import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { mountPiecesApi } from './pieces-api.js';

function makeGeneralPieceYaml(): string {
  return [
    'name: general',
    'description: 汎用タスク',
    'max_movements: 25',
    'initial_movement: understand',
    'movements:',
    '  - name: understand',
    '    edit: false',
    '    persona: analyst',
    '    instruction: |',
    '      タスクを確認する。',
    '    allowed_tools: [Read, Glob]',
    '    default_next: execute',
    '    rules:',
    '      - condition: 方針が立った',
    '        next: execute',
    '  - name: execute',
    '    edit: true',
    '    persona: worker',
    '    instruction: |',
    '      作業を実行する。',
    '    allowed_tools: [Read, Write]',
    '    default_next: COMPLETE',
    '    rules:',
    '      - condition: 完了',
    '        next: COMPLETE',
  ].join('\n');
}

function makeMinimalPieceYaml(name: string, description = 'x'): string {
  return [
    `name: ${name}`,
    `description: ${description}`,
    'max_movements: 1',
    'initial_movement: only',
    'movements:',
    '  - name: only',
    '    edit: false',
    '    persona: p',
    '    instruction: i',
    '    allowed_tools: [Read]',
    '    default_next: COMPLETE',
    '    rules: []',
  ].join('\n');
}

describe('Pieces API (no auth — legacy behavior)', () => {
  let app: express.Application;
  let piecesDir: string;

  beforeEach(() => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
    piecesDir = join(tempDir, 'pieces');
    mkdirSync(piecesDir);
    writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
    app = express();
    app.use(express.json());
    mountPiecesApi(app, { piecesDir });
  });

  it('GET /api/pieces returns piece list', async () => {
    const res = await request(app).get('/api/pieces');
    expect(res.status).toBe(200);
    expect(res.body.pieces).toHaveLength(1);
    expect(res.body.pieces[0].name).toBe('general');
    expect(res.body.pieces[0].source).toBe('builtin');
    expect(res.body.pieces[0].custom).toBe(false);
  });

  it('GET /api/pieces/:name returns full piece', async () => {
    const res = await request(app).get('/api/pieces/general');
    expect(res.status).toBe(200);
    expect(res.body.piece.name).toBe('general');
    expect(res.body.piece.movements).toHaveLength(2);
    expect(res.body.source).toBe('builtin');
  });

  it('GET /api/pieces/:name returns 404 for unknown', async () => {
    const res = await request(app).get('/api/pieces/nonexistent');
    expect(res.status).toBe(404);
  });

  it('PUT /api/pieces/:name updates piece', async () => {
    const res = await request(app)
      .put('/api/pieces/general')
      .send({
        name: 'general',
        description: '更新済み',
        max_movements: 30,
        initial_movement: 'understand',
        movements: [
          { name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
        ],
      });
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
  });

  it('POST /api/pieces creates new piece', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'custom',
        description: 'カスタム',
        max_movements: 10,
        initial_movement: 'work',
        movements: [
          { name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
        ],
      });
    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);
  });

  it('POST /api/pieces rejects duplicate name', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
    expect(res.status).toBe(409);
  });

  it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'phase6b-reject',
        description: 'should be rejected',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          { name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
            rules: [{ condition: 'ok', next: 'COMPLETE' }] },
        ],
      });
    expect(res.status).toBe(400);
    expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
  });

  it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'phase6b-default-ok',
        description: 'default_next is fine',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          { name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
            default_next: 'COMPLETE', rules: [] },
        ],
      });
    expect(res.status).toBe(201);
  });

  // Phase 4: per-movement SSH connection allowlist validation.
  it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-missing-allowlist',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          { name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            default_next: 'COMPLETE', rules: [] },
        ],
      });
    expect(res.status).toBe(400);
    expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
  });

  it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-uuid-ok',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(201);
  });

  it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-wildcard-ok',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            allowed_ssh_connections: ['*'],
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(201);
  });

  it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-empty-ok',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            allowed_ssh_connections: [],
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(201);
  });

  it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-bad-format',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(400);
    expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
  });

  it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-non-array',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
            allowed_ssh_connections: 'not-an-array',
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(400);
    expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
  });

  it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'ssh-noop',
        description: 'x',
        max_movements: 1,
        initial_movement: 'only',
        movements: [
          {
            name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
            allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
            default_next: 'COMPLETE', rules: [],
          },
        ],
      });
    expect(res.status).toBe(201);
  });

  it('DELETE /api/pieces/:name deletes piece', async () => {
    await request(app).post('/api/pieces').send({
      name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
      movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
    });
    const res = await request(app).delete('/api/pieces/deleteme');
    expect(res.status).toBe(200);
  });

  it('DELETE /api/pieces/general is forbidden', async () => {
    const res = await request(app).delete('/api/pieces/general');
    expect(res.status).toBe(403);
  });
});

// ---------------------------------------------------------------------------
// Auth-aware tests (per-user custom pieces + non-admin write authz)
// ---------------------------------------------------------------------------

type UserShape = { id: string; role: 'admin' | 'user' };

function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
  const app = express();
  app.use(express.json());
  app.use((req, _res, next) => {
    if (user) (req as any).user = user;
    next();
  });
  mountPiecesApi(app, { piecesDir, userPiecesRootDir });
  return app;
}

describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
  let piecesDir: string;
  let userPiecesRootDir: string;

  beforeEach(() => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
    piecesDir = join(tempDir, 'pieces');
    userPiecesRootDir = join(tempDir, 'users');
    mkdirSync(piecesDir);
    mkdirSync(userPiecesRootDir);
    writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
    writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
  });

  it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces');
    expect(res.status).toBe(200);
    expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
    for (const p of res.body.pieces) {
      expect(p.source).toBe('builtin');
      expect(p.custom).toBe(false);
    }
  });

  it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
    // Alice has my-tool, Bob has bob-tool
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
    mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces');
    expect(res.status).toBe(200);
    const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
    expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
    expect(byName['my-tool'].source).toBe('user-custom');
    expect(byName['my-tool'].ownerId).toBe('alice');
    expect(byName['my-tool'].custom).toBe(true);
    // Bob's piece must not appear for Alice
    expect(byName['bob-tool']).toBeUndefined();
  });

  it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces');
    expect(res.status).toBe(200);
    const general = res.body.pieces.find((p: any) => p.name === 'general');
    expect(general.source).toBe('user-custom');
    expect(general.description).toBe('alice override');
  });

  it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .post('/api/pieces')
      .send({
        name: 'alice-custom',
        description: 'alice piece',
        max_movements: 1,
        initial_movement: 'only',
        movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
      });
    expect(res.status).toBe(201);
    expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
    // Built-in dir untouched
    expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
  });

  it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
      .post('/api/pieces')
      .send({
        name: 'admin-piece',
        description: 'admin piece',
        max_movements: 1,
        initial_movement: 'only',
        movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
      });
    expect(res.status).toBe(201);
    expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
  });

  it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .put('/api/pieces/general')
      .send({
        name: 'general',
        description: 'should not write',
        max_movements: 1,
        initial_movement: 'only',
        movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
      });
    expect(res.status).toBe(403);
  });

  it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .put('/api/pieces/my-piece')
      .send({
        name: 'my-piece',
        description: 'v2',
        max_movements: 1,
        initial_movement: 'only',
        movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
      });
    expect(res.status).toBe(200);
  });

  it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
    // Bob has a piece. Alice tries to edit it via the same name.
    mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));

    // Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .put('/api/pieces/bob-only')
      .send({
        name: 'bob-only',
        description: 'hijacked',
        max_movements: 1,
        initial_movement: 'only',
        movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
      });
    expect(res.status).toBe(404);
  });

  it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
    // Use a deletable built-in (not general/chat which are protected separately)
    writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/extra');
    expect(res.status).toBe(403);
    expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
  });

  it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/goner');
    expect(res.status).toBe(200);
    expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
  });

  it('admin can edit and delete built-in pieces', async () => {
    writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
    const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });

    const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
      name: 'admin-target',
      description: 'after',
      max_movements: 1,
      initial_movement: 'only',
      movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
    });
    expect(putRes.status).toBe(200);

    const delRes = await request(adminApp).delete('/api/pieces/admin-target');
    expect(delRes.status).toBe(200);
    expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
  });
});
