import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import { mountLocalFilesApi } from './local-files-api.js';
import type { Repository } from '../db/repository.js';

let ws: string;

function makeRepo(overrides: Partial<Repository> = {}): Repository {
  return {
    getLocalTask: vi.fn().mockResolvedValue({
      id: 1,
      ownerId: 'user-1',
      visibility: 'private',
      workspacePath: ws,
    }),
    getLatestJobForIssue: vi.fn().mockResolvedValue(null),
    ...overrides,
  } as unknown as Repository;
}

function makeUser(overrides: Partial<Express.User> = {}): Express.User {
  return {
    id: 'user-1',
    email: 'u@example.com',
    name: 'User One',
    avatarUrl: null,
    role: 'user',
    status: 'active',
    orgIds: [],
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
    ...overrides,
  };
}

function makeApp(
  repo: Repository,
  user?: Express.User,
  opts: { authActive?: boolean } = {},
): express.Application {
  const app = express();
  if (user) {
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
  }
  mountLocalFilesApi(app, repo, { authActive: opts.authActive ?? true });
  return app;
}

beforeEach(() => {
  ws = mkdtempSync(join(tmpdir(), 'local-files-api-'));
  mkdirSync(join(ws, 'input'), { recursive: true });
  mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
  writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
  writeFileSync(join(ws, 'output', 'report.md'), '# report');
  writeFileSync(join(ws, 'output', 'report.html'), '<!doctype html><script>window.__ran = true</script><h1>report</h1>');
  writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
  // A file just outside the workspace that traversal must never reach.
  writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
});

afterEach(() => {
  rmSync(ws, { recursive: true, force: true });
  rmSync(join(ws, '..', `outside-${process.pid}.txt`), { force: true });
});

describe('GET /api/local/tasks/:taskId/files (listing)', () => {
  it('lists the input section by default', async () => {
    const res = await request(makeApp(makeRepo(), makeUser())).get('/api/local/tasks/1/files');
    expect(res.status).toBe(200);
    expect(res.body.basePath).toBe('input');
    expect(res.body.entries.map((e: { name: string }) => e.name)).toEqual(['data.csv']);
  });

  it('lists a subdirectory via the path query', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=output&path=sub');
    expect(res.status).toBe(200);
    expect(res.body.entries.map((e: { name: string }) => e.name)).toEqual(['nested.txt']);
  });

  it('rejects an unknown section with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=secrets');
    expect(res.status).toBe(400);
  });

  it('rejects an invalid task id with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser())).get('/api/local/tasks/-1/files');
    expect(res.status).toBe(400);
  });

  it('hides a private task from a non-owner with 404', async () => {
    const res = await request(makeApp(makeRepo(), makeUser({ id: 'stranger' })))
      .get('/api/local/tasks/1/files');
    expect(res.status).toBe(404);
  });

  it('rejects directory traversal on listing with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=input&path=..%2F..');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(JSON.stringify(res.body)).not.toContain('outside-');
  });

  // 計画5: logs section は logRoot(task) で解決する。
  it('reads the logs section from runtime_dir when set', async () => {
    const runtimeDir = mkdtempSync(join(tmpdir(), 'local-files-rt-'));
    try {
      writeFileSync(join(runtimeDir, 'activity.log'), 'from-runtime');
      // workspace/logs intentionally contains a different marker so we can
      // prove the read went to runtime_dir, not workspace_path/logs.
      mkdirSync(join(ws, 'logs'), { recursive: true });
      writeFileSync(join(ws, 'logs', 'stale.log'), 'from-workspace');
      const repo = makeRepo({
        getLocalTask: vi.fn().mockResolvedValue({
          id: 1, ownerId: 'user-1', visibility: 'private', workspacePath: ws, runtimeDir,
        }),
      });
      const res = await request(makeApp(repo, makeUser())).get('/api/local/tasks/1/files?section=logs');
      expect(res.status).toBe(200);
      const names = res.body.entries.map((e: { name: string }) => e.name);
      expect(names).toContain('activity.log');
      expect(names).not.toContain('stale.log');
    } finally {
      rmSync(runtimeDir, { recursive: true, force: true });
    }
  });

  it('reads the logs section from workspace_path/logs when runtime_dir is null (regression)', async () => {
    mkdirSync(join(ws, 'logs'), { recursive: true });
    writeFileSync(join(ws, 'logs', 'activity.log'), 'legacy');
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1, ownerId: 'user-1', visibility: 'private', workspacePath: ws, runtimeDir: null,
      }),
    });
    const res = await request(makeApp(repo, makeUser())).get('/api/local/tasks/1/files?section=logs');
    expect(res.status).toBe(200);
    expect(res.body.entries.map((e: { name: string }) => e.name)).toContain('activity.log');
  });
});

describe('GET /api/local/tasks/:taskId/files/content', () => {
  it('serves file content as text/plain', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output&path=report.md');
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('text/plain');
    expect(res.text).toBe('# report');
  });

  it('requires a path', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('path is required');
  });

  it('returns 404 (not 500) for a missing file', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output&path=does-not-exist.md');
    expect(res.status).toBe(404);
  });

  it('rejects a directory path with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output&path=sub');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('path must point to a file');
  });

  it('rejects traversal reads with 400 and never serves outside files', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get(`/api/local/tasks/1/files/content?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('secret');
  });

  it('strips leading slashes so absolute paths stay inside the workspace', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=input&path=%2Fetc%2Fpasswd');
    expect([404, 500]).toContain(res.status);
    expect(res.text).not.toContain('root:');
  });
});

describe('GET /api/local/tasks/:taskId/files/raw', () => {
  it('serves bytes with a type derived from the extension', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.md');
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('markdown');
  });

  it('sandboxes raw HTML by default', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('allows the task OWNER to open trusted raw HTML without the sandbox header', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it('serves trusted raw HTML to an admin viewing another user\'s task (owner-trust policy)', async () => {
    const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it('serves trusted raw HTML to a NON-owner viewer of a shared task (visibility already gated)', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1,
        ownerId: 'user-1',
        visibility: 'public',
        workspacePath: ws,
      }),
    } as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it('serves trusted raw HTML for an ownerless task to an authenticated viewer', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1,
        ownerId: null,
        visibility: 'public',
        workspacePath: ws,
      }),
    } as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
  });

  it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
    const res = await request(makeApp(makeRepo()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('allows trusted raw HTML in no-auth mode (sole operator owns every task)', async () => {
    const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it('still sandboxes non-HTML in no-auth mode even with trusted=1', async () => {
    const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.md&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('rejects traversal reads with 400 and never serves outside files', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('secret');
  });
});

describe('PUT /api/local/tasks/:taskId/files/content', () => {
  const put = (app: express.Application, body: unknown) =>
    request(app).put('/api/local/tasks/1/files/content').send(body as object);

  it('writes an output file for the owner', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 'updated',
    });
    expect(res.status).toBe(200);
    expect(readFileSync(join(ws, 'output', 'report.md'), 'utf-8')).toBe('updated');
  });

  it('hides the task from a non-owner with 404', async () => {
    const res = await put(makeApp(makeRepo(), makeUser({ id: 'stranger' })), {
      section: 'output',
      path: 'report.md',
      content: 'x',
    });
    expect(res.status).toBe(404);
    expect(readFileSync(join(ws, 'output', 'report.md'), 'utf-8')).toBe('# report');
  });

  it('lets an admin edit another user\'s task', async () => {
    const res = await put(makeApp(makeRepo(), makeUser({ id: 'admin-1', role: 'admin' })), {
      section: 'output',
      path: 'report.md',
      content: 'admin edit',
    });
    expect(res.status).toBe(200);
  });

  it('refuses edits while a job is running', async () => {
    const repo = makeRepo({
      getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'running' }),
    } as Partial<Repository>);
    const res = await put(makeApp(repo, makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 'x',
    });
    expect(res.status).toBe(409);
  });

  it('only allows the output section', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'input',
      path: 'data.csv',
      content: 'x',
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Only output files can be edited');
  });

  it('requires string content', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 42,
    });
    expect(res.status).toBe(400);
  });

  it('rejects traversal writes with 400 and writes nothing', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: '../evil.txt',
      content: 'pwned',
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(existsSync(join(ws, 'evil.txt'))).toBe(false);
  });
});

describe('POST /api/local/tasks/:taskId/files/upload', () => {
  const upload = (app: express.Application, body: unknown) =>
    request(app).post('/api/local/tasks/1/files/upload').send(body as object);
  const b64 = (s: string) => Buffer.from(s, 'utf-8').toString('base64');

  it('uploads a file into the input section for the owner', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'input',
      path: '',
      files: [{ name: 'new.txt', contentBase64: b64('hello') }],
    });
    expect(res.status).toBe(200);
    expect(res.body.uploaded).toEqual([{ name: 'new.txt', path: 'new.txt' }]);
    expect(readFileSync(join(ws, 'input', 'new.txt'), 'utf-8')).toBe('hello');
  });

  it('uploads into a subdirectory via path', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: 'sub',
      files: [{ name: 'added.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(200);
    expect(res.body.uploaded[0].path).toBe('sub/added.txt');
    expect(existsSync(join(ws, 'output', 'sub', 'added.txt'))).toBe(true);
  });

  it('renames on collision instead of overwriting (O_EXCL)', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'input',
      path: '',
      files: [{ name: 'data.csv', contentBase64: b64('new') }],
    });
    expect(res.status).toBe(200);
    expect(res.body.uploaded[0].name).toBe('data (2).csv');
    // original untouched
    expect(readFileSync(join(ws, 'input', 'data.csv'), 'utf-8')).toBe('a,b\n1,2\n');
    expect(readFileSync(join(ws, 'input', 'data (2).csv'), 'utf-8')).toBe('new');
  });

  it('sanitizes path separators in the file name', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'input',
      path: '',
      files: [{ name: '../escape.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(200);
    // `/` and `\` become `_`, so it stays inside input/.
    expect(res.body.uploaded[0].name).toBe('.._escape.txt');
    expect(existsSync(join(ws, '..', 'escape.txt'))).toBe(false);
  });

  it('hides the task from a non-owner with 404', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser({ id: 'stranger' })), {
      section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(404);
  });

  it('lets an admin upload to another user\'s task', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser({ id: 'admin-1', role: 'admin' })), {
      section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(200);
  });

  it('refuses uploads while a job is running', async () => {
    const repo = makeRepo({
      getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'running' }),
    } as Partial<Repository>);
    const res = await upload(makeApp(repo, makeUser()), {
      section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(409);
  });

  it('rejects the logs section with 400', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'logs', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(400);
  });

  it('rejects the workspace section with 400', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'workspace', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(400);
  });

  it('rejects an empty files array with 400', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), { section: 'input', path: '', files: [] });
    expect(res.status).toBe(400);
  });

  it('rejects traversal in the path with 400 and writes nothing outside', async () => {
    const res = await upload(makeApp(makeRepo(), makeUser()), {
      section: 'input', path: '../..', files: [{ name: 'pwn.txt', contentBase64: b64('x') }],
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
  });
});

describe('POST /api/local/tasks/:taskId/files/delete', () => {
  const del = (app: express.Application, body: unknown) =>
    request(app).post('/api/local/tasks/1/files/delete').send(body as object);

  it('deletes input files for the owner', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: ['data.csv'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['data.csv']);
    expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(false);
  });

  it('accepts a single path', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'output', path: 'report.md' });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['report.md']);
  });

  it('skips missing files idempotently', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: ['nope.txt'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual([]);
    expect(res.body.skipped).toEqual(['nope.txt']);
  });

  it('deletes a directory recursively (with its contents)', async () => {
    expect(existsSync(join(ws, 'output', 'sub', 'nested.txt'))).toBe(true);
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['sub'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['sub']);
    expect(existsSync(join(ws, 'output', 'sub'))).toBe(false);
  });

  it('hides the task from a non-owner with 404', async () => {
    const res = await del(makeApp(makeRepo(), makeUser({ id: 'stranger' })), { section: 'input', paths: ['data.csv'] });
    expect(res.status).toBe(404);
    expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
  });

  it('refuses deletes while a job is running', async () => {
    const repo = makeRepo({
      getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'dispatching' }),
    } as Partial<Repository>);
    const res = await del(makeApp(repo, makeUser()), { section: 'input', paths: ['data.csv'] });
    expect(res.status).toBe(409);
    expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
  });

  it('rejects the logs section with 400', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'logs', paths: ['activity.log'] });
    expect(res.status).toBe(400);
  });

  it('rejects an empty paths array with 400', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: [] });
    expect(res.status).toBe(400);
  });

  it('rejects traversal with 400 and deletes nothing outside', async () => {
    const res = await del(makeApp(makeRepo(), makeUser()), {
      section: 'input', paths: [`../../outside-${process.pid}.txt`],
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(existsSync(join(ws, '..', `outside-${process.pid}.txt`))).toBe(true);
  });

  it('does not partially delete when a later path traverses (validate-all-first)', async () => {
    // valid path comes first, traversal second. The two-pass guard must validate
    // both before unlinking, so the valid file survives the 400.
    const res = await del(makeApp(makeRepo(), makeUser()), {
      section: 'input', paths: ['data.csv', `../../outside-${process.pid}.txt`],
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
  });
});

describe('POST /api/local/tasks/:taskId/files/download-zip', () => {
  // supertest doesn't buffer binary bodies by default — collect the raw zip.
  const zipReq = (app: express.Application, body: unknown) =>
    request(app)
      .post('/api/local/tasks/1/files/download-zip')
      .send(body as object)
      .buffer(true)
      .parse((res, cb) => {
        const chunks: Buffer[] = [];
        res.on('data', (c: Buffer) => chunks.push(c));
        res.on('end', () => cb(null, Buffer.concat(chunks)));
      });

  it('zips selected output files for the owner', async () => {
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['report.md', 'sub/nested.txt'] });
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('application/zip');
    expect(res.headers['content-disposition']).toContain('files.zip');
    const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName).sort();
    expect(names).toEqual(['report.md', 'sub/nested.txt']);
  });

  it('allows download from the logs section too (read, all sections)', async () => {
    mkdirSync(join(ws, 'logs'), { recursive: true });
    writeFileSync(join(ws, 'logs', 'activity.log'), 'hello-log');
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'logs', paths: ['activity.log'] });
    expect(res.status).toBe(200);
    const zip = new AdmZip(res.body as Buffer);
    expect(zip.getEntry('activity.log')?.getData().toString()).toBe('hello-log');
  });

  it('downloads for a NON-owner viewer of a public task (read-gated, not edit)', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({ id: 1, ownerId: 'user-1', visibility: 'public', workspacePath: ws }),
    } as Partial<Repository>);
    const res = await zipReq(makeApp(repo, makeUser({ id: 'user-2' })), { section: 'output', paths: ['report.md'] });
    expect(res.status).toBe(200);
  });

  it('hides a private task from a non-owner with 404', async () => {
    const res = await zipReq(makeApp(makeRepo(), makeUser({ id: 'stranger' })), { section: 'output', paths: ['report.md'] });
    expect(res.status).toBe(404);
  });

  it('zips a directory recursively (nested files keep their folder path)', async () => {
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['sub'] });
    expect(res.status).toBe(200);
    const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName).sort();
    expect(names).toEqual(['sub/nested.txt']);
  });

  it('returns 404 when nothing matches (only missing paths)', async () => {
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['nope.md'] });
    expect(res.status).toBe(404);
  });

  it('rejects traversal with 400 and never zips outside files', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .post('/api/local/tasks/1/files/download-zip')
      .send({ section: 'input', paths: [`../../outside-${process.pid}.txt`] });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
  });

  it('rejects an empty paths array with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .post('/api/local/tasks/1/files/download-zip')
      .send({ section: 'output', paths: [] });
    expect(res.status).toBe(400);
  });

  it('canonicalizes zip entry names so a backslash filename cannot zip-slip on Windows', async () => {
    // On POSIX `a\evil.txt` is a single literal filename inside output/ (passes
    // ensurePathWithin), but as a ZIP entry name `\` is a Windows separator.
    // The endpoint must emit a `/`-normalized entry name with no backslashes.
    writeFileSync(join(ws, 'output', 'a\\evil.txt'), 'x');
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['a\\evil.txt'] });
    expect(res.status).toBe(200);
    const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName);
    expect(names.some((n) => n.includes('\\'))).toBe(false);
    expect(names).toContain('a/evil.txt');
  });

  it('strips .. segments from a backslash filename so the entry cannot escape on extract', async () => {
    // `a\..\evil.txt` is one literal POSIX filename (passes ensurePathWithin).
    // After `\`→`/` normalization it would become `a/../evil.txt` (escapes the
    // extract dir); the `..` segment must be dropped → `a/evil.txt`.
    writeFileSync(join(ws, 'output', 'a\\..\\evil.txt'), 'x');
    const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['a\\..\\evil.txt'] });
    expect(res.status).toBe(200);
    const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName);
    expect(names.every((n) => !n.split('/').includes('..'))).toBe(true);
    expect(names.some((n) => n.includes('\\'))).toBe(false);
    expect(names).toContain('a/evil.txt');
  });
});

describe('file provenance', () => {
  it('records an uploaded input/ file as user_input', async () => {
    const recordProvenance = vi.fn();
    const repo = makeRepo({ recordProvenance } as unknown as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .post('/api/local/tasks/1/files/upload')
      .send({ section: 'input', files: [{ name: 'up.txt', contentBase64: Buffer.from('hi').toString('base64') }] });
    expect(res.status).toBe(200);
    expect(recordProvenance).toHaveBeenCalledTimes(1);
    expect(recordProvenance).toHaveBeenCalledWith(
      expect.objectContaining({ relPath: 'input/up.txt', event: 'create', sourceKind: 'user_input', taskId: 1 }),
    );
  });

  it('upload still succeeds when the ledger write throws (best-effort)', async () => {
    const recordProvenance = vi.fn(() => { throw new Error('db down'); });
    const repo = makeRepo({ recordProvenance } as unknown as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .post('/api/local/tasks/1/files/upload')
      .send({ section: 'input', files: [{ name: 'up2.txt', contentBase64: Buffer.from('hi').toString('base64') }] });
    expect(res.status).toBe(200);
    expect(existsSync(join(ws, 'input', 'up2.txt'))).toBe(true);
  });

  it('GET /files/provenance returns the safe subset only (no job UUIDs / checksum / note)', async () => {
    const getProvenance = vi.fn().mockReturnValue({
      relPath: 'output/report.md', sourceKind: 'agent_output', createdByTaskId: 1,
      createdByJobId: 'JOB-UUID-SECRET', createdByPiece: 'chat', createdByMovement: 'execute',
      firstSeenAt: '2026-07-01T00:00:00Z', lastModifiedByTaskId: 1,
      lastModifiedByJobId: 'JOB-UUID-2', lastModifiedAt: '2026-07-01T00:00:00Z',
      checksum: 'CHK-SECRET', note: 'PII-NOTE-SECRET',
    });
    const repo = makeRepo({ getProvenance } as unknown as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .get('/api/local/tasks/1/files/provenance?section=output&path=report.md');
    expect(res.status).toBe(200);
    expect(getProvenance).toHaveBeenCalledWith(ws, 'output/report.md');
    expect(res.body.provenance.sourceKind).toBe('agent_output');
    expect(res.body.provenance.createdByTaskId).toBe(1);
    // Adversarial-review D4: these must never reach a (shared-space) client.
    expect(res.body.provenance.createdByJobId).toBeUndefined();
    expect(res.body.provenance.lastModifiedByJobId).toBeUndefined();
    expect(res.body.provenance.checksum).toBeUndefined();
    expect(res.body.provenance.note).toBeUndefined();
    expect(JSON.stringify(res.body)).not.toContain('SECRET');
  });

  it('GET /files/provenance returns null when untracked', async () => {
    const getProvenance = vi.fn().mockReturnValue(null);
    const repo = makeRepo({ getProvenance } as unknown as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .get('/api/local/tasks/1/files/provenance?section=output&path=ghost.md');
    expect(res.status).toBe(200);
    expect(res.body.provenance).toBeNull();
  });
});
