import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } 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 — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => {
    // In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir.
    // Those files are resolved as source='builtin' and are non-deletable.
    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: [], default_next: 'COMPLETE', rules: [] }],
    });
    const res = await request(app).delete('/api/pieces/deleteme');
    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/cannot delete a built-in/i);
  });

  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 and built-in both appear when same name (no hiding)", 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 generals = res.body.pieces.filter((p: any) => p.name === 'general');
    // Both builtin and user-custom should appear
    expect(generals).toHaveLength(2);
    const customGeneral = generals.find((p: any) => p.source === 'user-custom');
    const builtinGeneral = generals.find((p: any) => p.source === 'builtin');
    expect(customGeneral).toBeDefined();
    expect(customGeneral.description).toBe('alice override');
    expect(builtinGeneral).toBeDefined();
  });

  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 user-custom dir (not piecesDir)', async () => {
    // POST always targets user-custom dir regardless of admin role.
    // Admins edit built-ins via PUT on existing ones.
    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);
    // Piece lands in admin's user-custom dir, NOT in piecesDir.
    expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-piece.yaml'))).toBe(true);
    expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(false);
  });

  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 built-in pieces (PUT 200) but NOT delete them (DELETE 403)', async () => {
    writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
    const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });

    // Admin CAN edit (PUT) a built-in
    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);

    // Admin CANNOT delete a built-in — 403 for everyone
    const delRes = await request(adminApp).delete('/api/pieces/admin-target');
    expect(delRes.status).toBe(403);
    expect(delRes.body.ok).toBe(false);
    expect(delRes.body.error).toMatch(/cannot delete a built-in/i);
    // File must still exist
    expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(true);
  });

  // --- Task 2A: built-in must not be hidden by same-named custom ---
  it("GET /api/pieces — built-in is NOT hidden when user has a same-named custom", async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice chat override'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces');
    expect(res.status).toBe(200);
    // Both the user-custom AND the builtin should appear
    const chatPieces = res.body.pieces.filter((p: any) => p.name === 'chat');
    expect(chatPieces).toHaveLength(2);
    const sources = chatPieces.map((p: any) => p.source).sort();
    expect(sources).toEqual(['builtin', 'user-custom']);
    // Custom has the custom description, builtin has the original
    const customChat = chatPieces.find((p: any) => p.source === 'user-custom');
    const builtinChat = chatPieces.find((p: any) => p.source === 'builtin');
    expect(customChat.description).toBe('alice chat override');
    expect(builtinChat.description).toBe('built-in chat');
  });

  // --- Task 2B: CreatePiece rejects name collision with built-in ---
  it('POST /api/pieces rejects custom creation with a built-in name', async () => {
    // 'general' and 'chat' are in piecesDir (built-in)
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .post('/api/pieces')
      .send({
        name: 'chat',
        description: 'my chat',
        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(409);
    expect(res.body.error).toMatch(/built-in/i);
  });

  it('POST /api/pieces with a fresh custom name still works for non-admin', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .post('/api/pieces')
      .send({
        name: 'fresh-custom',
        description: 'fresh',
        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);
  });

  // --- Task 2C: regression — non-admin DELETE of built-in → 403 ---
  it('non-admin DELETE of built-in returns 403', async () => {
    writeFileSync(join(piecesDir, 'deletable-builtin.yaml'), makeMinimalPieceYaml('deletable-builtin', 'del'));
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/deletable-builtin');
    expect(res.status).toBe(403);
    expect(existsSync(join(piecesDir, 'deletable-builtin.yaml'))).toBe(true);
  });

  // --- Fix 2: GET /api/pieces/:name?source=builtin fetches the specific source ---
  it('GET /api/pieces/:name?source=builtin returns builtin even when user-custom exists with same name', async () => {
    // Alice has a user-custom 'chat' that overrides by default priority
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces/chat?source=builtin');
    expect(res.status).toBe(200);
    expect(res.body.source).toBe('builtin');
    expect(res.body.piece.description).toBe('built-in chat');
  });

  it('GET /api/pieces/:name without ?source uses priority resolution (user-custom first)', async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces/chat');
    expect(res.status).toBe(200);
    expect(res.body.source).toBe('user-custom');
    expect(res.body.piece.description).toBe('alice custom chat');
  });

  // --- Fix 3: DELETE of a user-custom named 'chat' by its owner must succeed ---
  it("DELETE /api/pieces/chat on owner's user-custom named 'chat' returns 200 (guard only blocks builtin)", async () => {
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/chat');
    expect(res.status).toBe(200);
    // User-custom file removed; built-in chat still present
    expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(false);
    expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
  });

  it('DELETE /api/pieces/chat on builtin by non-admin returns 403', async () => {
    // No user-custom for alice, so findPieceForCaller resolves to builtin
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/chat');
    expect(res.status).toBe(403);
    expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
  });

  // --- Fix source param for PUT/DELETE (P1-b) ---

  it('PUT /api/pieces/chat?source=builtin by admin updates the builtin, not a same-named custom', async () => {
    // Alice (admin) has a user-custom chat AND there is a builtin chat.
    mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom chat'));
    const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });

    const res = await request(adminApp).put('/api/pieces/chat?source=builtin').send({
      name: 'chat',
      description: 'builtin updated',
      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);
    // Builtin was updated
    const builtinContent = readFileSync(join(piecesDir, 'chat.yaml'), 'utf-8');
    expect(builtinContent).toContain('builtin updated');
    // Custom is untouched
    const customContent = readFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), 'utf-8');
    expect(customContent).toContain('admin custom chat');
  });

  it('DELETE /api/pieces/chat?source=builtin by admin deletes the builtin, not same-named custom', async () => {
    // admin1 has a user-custom chat AND builtin chat
    mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom'));
    const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });

    const res = await request(adminApp).delete('/api/pieces/chat?source=builtin');
    // Built-in pieces are non-deletable for everyone (including admins)
    expect(res.status).toBe(403);
    // Both still intact
    expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
    expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'))).toBe(true);
  });

  it('DELETE /api/pieces/extra?source=user-custom targets user-custom, not builtin', async () => {
    writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'builtin extra'));
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'), makeMinimalPieceYaml('extra', 'alice extra'));

    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .delete('/api/pieces/extra?source=user-custom');
    expect(res.status).toBe(200);
    // User-custom removed
    expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'))).toBe(false);
    // Builtin untouched
    expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
  });

  // P2 fix: GET /api/pieces/:name (no ?source) includes source in response body
  // so the UI can derive the correct read-only state regardless of URL params.
  it('GET /api/pieces/general (no ?source) returns source: builtin in the body', async () => {
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
      .get('/api/pieces/general');
    expect(res.status).toBe(200);
    expect(res.body.piece.name).toBe('general');
    expect(res.body.source).toBe('builtin');
    expect(res.body.custom).toBe(false);
  });

  it('POST /api/pieces creates in user-custom dir for admin (not piecesDir)', async () => {
    // Regression for P2: POST always targets user-custom, admin is no exception.
    const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
      .post('/api/pieces')
      .send({
        name: 'admin-new-custom',
        description: 'admin custom 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, 'admin1', 'pieces', 'admin-new-custom.yaml'))).toBe(true);
    expect(existsSync(join(piecesDir, 'admin-new-custom.yaml'))).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Finding 2 regression: authenticated POST with userPiecesRootDir UNSET → 503
// Never falls through to shared/builtin dirs for an authenticated user.
// ---------------------------------------------------------------------------

describe('Pieces API (Finding 2: POST auth fallback hole)', () => {
  let piecesDir: string;

  function makeAppWithAuthButNoUserPiecesRoot(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();
    });
    // Intentionally: no userPiecesRootDir configured, no customPiecesDir.
    mountPiecesApi(app, { piecesDir });
    return app;
  }

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

  it('authenticated non-admin POST with userPiecesRootDir unset returns 503 (does not write shared/builtin)', async () => {
    const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'alice', role: 'user' });
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'should-not-exist',
        description: 'fallback hole test',
        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(503);
    expect(res.body.ok).toBe(false);
    expect(res.body.error).toMatch(/not configured/i);
    // Must NOT have written anything to the builtin dir.
    expect(existsSync(join(piecesDir, 'should-not-exist.yaml'))).toBe(false);
  });

  it('authenticated admin POST with userPiecesRootDir unset also returns 503', async () => {
    const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'admin1', role: 'admin' });
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'admin-fallback-hole',
        description: 'admin hole test',
        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(503);
    expect(res.body.ok).toBe(false);
    // Must NOT have written anything to the builtin dir.
    expect(existsSync(join(piecesDir, 'admin-fallback-hole.yaml'))).toBe(false);
  });

  it('unauthenticated POST with no userPiecesRootDir falls back to piecesDir (legacy no-auth mode)', async () => {
    // No user: genuine no-auth legacy mode — should still work (existing behavior).
    const app = makeAppWithAuthButNoUserPiecesRoot(null);
    const res = await request(app)
      .post('/api/pieces')
      .send({
        name: 'legacy-fallback',
        description: 'legacy',
        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, 'legacy-fallback.yaml'))).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Fix 1: invalid ?source param → 400 (no fallback to priority resolution)
// ---------------------------------------------------------------------------

describe('Pieces API (Fix 1: invalid ?source → 400, no destructive fallback)', () => {
  let piecesDir: string;
  let userPiecesRootDir: string;

  beforeEach(() => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix1-'));
    piecesDir = join(tempDir, 'pieces');
    userPiecesRootDir = join(tempDir, 'users');
    mkdirSync(piecesDir);
    mkdirSync(userPiecesRootDir);
    // A builtin and a user-custom with the same name
    writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'builtin chat'));
    // Alice has a user-custom 'chat'
    mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
    writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
  });

  function makeApp(user: UserShape | null) {
    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;
  }

  it('GET /api/pieces/:name?source=builtinn (typo) → 400 (not priority-fallback)', async () => {
    const res = await request(makeApp({ id: 'alice', role: 'user' }))
      .get('/api/pieces/chat?source=builtinn');
    expect(res.status).toBe(400);
    expect(res.body.ok).toBe(false);
    expect(res.body.error).toMatch(/invalid source/i);
  });

  it('DELETE /api/pieces/chat?source=builtinn (typo) → 400, does NOT delete user-custom', async () => {
    const res = await request(makeApp({ id: 'alice', role: 'user' }))
      .delete('/api/pieces/chat?source=builtinn');
    expect(res.status).toBe(400);
    expect(res.body.ok).toBe(false);
    expect(res.body.error).toMatch(/invalid source/i);
    // User-custom must be untouched
    expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(true);
  });

  it('PUT /api/pieces/chat?source=user_custom (underscore typo) → 400, does NOT mutate user-custom', async () => {
    const res = await request(makeApp({ id: 'alice', role: 'user' }))
      .put('/api/pieces/chat?source=user_custom')
      .send({
        name: 'chat',
        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(400);
    expect(res.body.ok).toBe(false);
    expect(res.body.error).toMatch(/invalid source/i);
    // User-custom description must be unchanged
    const content = readFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), 'utf-8');
    expect(content).toContain('alice custom chat');
    expect(content).not.toContain('hijacked');
  });

  it('valid ?source=builtin still works (not rejected)', async () => {
    const res = await request(makeApp({ id: 'alice', role: 'user' }))
      .get('/api/pieces/chat?source=builtin');
    expect(res.status).toBe(200);
    expect(res.body.source).toBe('builtin');
  });

  it('absent ?source still does priority resolution (not rejected)', async () => {
    const res = await request(makeApp({ id: 'alice', role: 'user' }))
      .get('/api/pieces/chat');
    expect(res.status).toBe(200);
    // priority: user-custom wins
    expect(res.body.source).toBe('user-custom');
  });
});

// ---------------------------------------------------------------------------
// Fix 2: POST /api/pieces returns actual source in response body
// ---------------------------------------------------------------------------

describe('Pieces API (Fix 2: POST returns actual source)', () => {
  let piecesDir: string;
  let userPiecesRootDir: string;

  function minimalPieceBody(name: string) {
    return {
      name,
      description: 'test',
      max_movements: 1,
      initial_movement: 'only',
      movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
    };
  }

  beforeEach(() => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-'));
    piecesDir = join(tempDir, 'pieces');
    userPiecesRootDir = join(tempDir, 'users');
    mkdirSync(piecesDir);
    mkdirSync(userPiecesRootDir);
  });

  it('authenticated POST returns source: user-custom', async () => {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => { (req as any).user = { id: 'alice', role: 'user' }; next(); });
    mountPiecesApi(app, { piecesDir, userPiecesRootDir });
    const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-piece'));
    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);
    expect(res.body.source).toBe('user-custom');
  });

  it('legacy (no-auth) POST with customPiecesDir returns source: global-custom', async () => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-gc-'));
    const gcDir = join(tempDir, 'custom');
    mkdirSync(join(tempDir, 'pieces'));
    mkdirSync(gcDir);
    const app = express();
    app.use(express.json());
    // No user set — legacy no-auth mode
    mountPiecesApi(app, { piecesDir: join(tempDir, 'pieces'), customPiecesDir: gcDir });
    const res = await request(app).post('/api/pieces').send(minimalPieceBody('gc-piece'));
    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);
    expect(res.body.source).toBe('global-custom');
  });

  it('legacy (no-auth) POST without customPiecesDir returns source: builtin', async () => {
    const app = express();
    app.use(express.json());
    // No user, no customPiecesDir
    mountPiecesApi(app, { piecesDir });
    const res = await request(app).post('/api/pieces').send(minimalPieceBody('legacy-piece'));
    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);
    expect(res.body.source).toBe('builtin');
  });

  it('no-auth POST with userPiecesRootDir returns source: user-custom (regression fix)', async () => {
    const app = express();
    app.use(express.json());
    // No user, but userPiecesRootDir is configured (the fixed path)
    mountPiecesApi(app, { piecesDir, userPiecesRootDir });
    const res = await request(app).post('/api/pieces').send(minimalPieceBody('noauth-custom'));
    expect(res.status).toBe(201);
    expect(res.body.ok).toBe(true);
    expect(res.body.source).toBe('user-custom');
    // Piece is in data/users/local/pieces, NOT in piecesDir
    expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'noauth-custom.yaml'))).toBe(true);
    expect(existsSync(join(piecesDir, 'noauth-custom.yaml'))).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Regression fix: no-auth + userPiecesRootDir → 'local' user-custom namespace
// ---------------------------------------------------------------------------

describe('Pieces API (regression: no-auth uses local user-custom, not piecesDir)', () => {
  let piecesDir: string;
  let userPiecesRootDir: string;

  function minimalPieceBody(name: string) {
    return {
      name,
      description: 'test',
      max_movements: 1,
      initial_movement: 'only',
      movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
    };
  }

  beforeEach(() => {
    const tempDir = mkdtempSync(join(tmpdir(), 'pieces-noauth-local-'));
    piecesDir = join(tempDir, 'pieces');
    userPiecesRootDir = join(tempDir, 'users');
    mkdirSync(piecesDir);
    mkdirSync(userPiecesRootDir);
    writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
  });

  function makeNoAuthApp() {
    const app = express();
    app.use(express.json());
    // No user middleware — simulates no-auth mode
    mountPiecesApi(app, { piecesDir, userPiecesRootDir });
    return app;
  }

  it('no-auth POST creates under local user-custom dir, NOT in piecesDir', async () => {
    const app = makeNoAuthApp();
    const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-noauth-piece'));
    expect(res.status).toBe(201);
    expect(res.body.source).toBe('user-custom');
    // Must be in the 'local' user-custom dir
    expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'my-noauth-piece.yaml'))).toBe(true);
    // Must NOT be in bundled piecesDir
    expect(existsSync(join(piecesDir, 'my-noauth-piece.yaml'))).toBe(false);
  });

  it('no-auth created piece (with userPiecesRootDir) is DELETABLE — DELETE returns 200', async () => {
    const app = makeNoAuthApp();
    // Create it first
    await request(app).post('/api/pieces').send(minimalPieceBody('deletable-noauth'));
    expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(true);

    // Now delete — must succeed (not 403)
    const delRes = await request(app).delete('/api/pieces/deletable-noauth');
    expect(delRes.status).toBe(200);
    expect(delRes.body.ok).toBe(true);
    expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(false);
  });

  it('bundled built-in piece is still non-deletable (403) for no-auth callers', async () => {
    const app = makeNoAuthApp();
    const res = await request(app).delete('/api/pieces/general');
    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/cannot delete a built-in/i);
    expect(existsSync(join(piecesDir, 'general.yaml'))).toBe(true);
  });

  it('no-auth LIST includes the local user-custom piece (source=user-custom)', async () => {
    // Pre-create a piece in the 'local' user-custom dir
    mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
    writeFileSync(
      join(userPiecesRootDir, 'local', 'pieces', 'local-custom.yaml'),
      makeMinimalPieceYaml('local-custom', 'no-auth local piece'),
    );

    const app = makeNoAuthApp();
    const res = await request(app).get('/api/pieces');
    expect(res.status).toBe(200);
    const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
    expect(byName['local-custom']).toBeDefined();
    expect(byName['local-custom'].source).toBe('user-custom');
    expect(byName['local-custom'].ownerId).toBe('local');
    // Built-in also visible
    expect(byName['general']).toBeDefined();
    expect(byName['general'].source).toBe('builtin');
  });

  it('no-auth GET resolves user-custom piece from local dir by priority', async () => {
    mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
    writeFileSync(
      join(userPiecesRootDir, 'local', 'pieces', 'my-piece.yaml'),
      makeMinimalPieceYaml('my-piece', 'local custom'),
    );

    const app = makeNoAuthApp();
    const res = await request(app).get('/api/pieces/my-piece');
    expect(res.status).toBe(200);
    expect(res.body.source).toBe('user-custom');
    expect(res.body.ownerId).toBe('local');
  });
});
