import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { createSpaceApi } from './space-api.js';

function makeApp(repo: Repository, dataRoot: string, worktreeDir: string) {
  const app = express();
  app.use(express.json());
  // テスト用の固定ユーザーを注入（認証 OFF 環境の synthetic user と同形）
  app.use((req, _res, next) => {
    (req as any).user = { id: 'user-1', role: 'user', orgIds: [] };
    next();
  });
  app.use('/api/local/spaces', createSpaceApi({ repo, dataRoot, worktreeDir, authActive: false }));
  return app;
}

// 本番（修正後の server.ts）と同形: グローバル express.json() を付けずに mount する。
// これでルート自前の json parser（最大 50mb）だけが効くことを検証できる。
// 旧バグでは default 100kb のグローバル json が body を先に消費し >100kb で 413 になった。
function makeAppNoGlobalJson(repo: Repository, dataRoot: string, worktreeDir: string) {
  const app = express();
  app.use((req, _res, next) => {
    (req as any).user = { id: 'user-1', role: 'user', orgIds: [] };
    next();
  });
  app.use('/api/local/spaces', createSpaceApi({ repo, dataRoot, worktreeDir, authActive: false }));
  return app;
}

describe('space-api', () => {
  let repo: Repository; let dir: string; let app: express.Express;
  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'space-api-'));
    repo = new Repository(join(dir, `${randomUUID()}.db`));
    // dataRoot は本番同様 userFolderRoot（data/users）を渡す。space-api は
    // dirname(dataRoot)=data を scaffold に渡すので実体は data/spaces/{id} になる。
    app = makeApp(repo, join(dir, 'data', 'users'), join(dir, 'wt'));
  });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('GET returns the auto-created personal space', async () => {
    const res = await request(app).get('/api/local/spaces');
    expect(res.status).toBe(200);
    expect(res.body.some((s: any) => s.kind === 'personal')).toBe(true);
  });

  it('POST creates a case space and scaffolds its dirs', async () => {
    const res = await request(app).post('/api/local/spaces').send({ title: '案件A' });
    expect(res.status).toBe(201);
    expect(res.body.kind).toBe('case');
    expect(res.body.ownerId).toBe('user-1');
    expect(existsSync(join(dir, 'data', 'spaces', res.body.id, 'AGENTS.md'))).toBe(true);
    expect(existsSync(join(dir, 'wt', 'space', res.body.id, 'files'))).toBe(true);
  });

  it('PATCH updates title; POST archive archives', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: 'old' })).body;
    const patched = await request(app).patch(`/api/local/spaces/${created.id}`).send({ title: 'new' });
    expect(patched.body.title).toBe('new');
    const archived = await request(app).post(`/api/local/spaces/${created.id}/archive`);
    expect(archived.status).toBe(200);
    const list = (await request(app).get('/api/local/spaces')).body;
    expect(list.find((s: any) => s.id === created.id)).toBeUndefined(); // archived hidden by default
  });

  it('lists + serves files from the space persistent workspace and rejects traversal', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件F' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    writeFileSync(join(filesDir, 'note.md'), '# hello space');

    // list
    const list = await request(app).get(`/api/local/spaces/${created.id}/files`);
    expect(list.status).toBe(200);
    expect(list.body.basePath).toBe('space');
    expect(list.body.entries.some((e: any) => e.name === 'note.md' && e.kind === 'file')).toBe(true);

    // raw content
    const raw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=note.md`);
    expect(raw.status).toBe(200);
    expect(raw.text).toContain('# hello space');

    // text content endpoint
    const content = await request(app).get(`/api/local/spaces/${created.id}/files/content?path=note.md`);
    expect(content.status).toBe(200);
    expect(content.text).toContain('# hello space');

    // path traversal is rejected (confined to spaceFilesDir)
    const escape = await request(app).get(`/api/local/spaces/${created.id}/files?path=../../../../etc`);
    expect(escape.status).toBe(400);
    expect(escape.body.error).toBe('Path escapes workspace');

    const escapeRaw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=..%2F..%2F..%2Fpasswd`);
    expect(escapeRaw.status).toBe(400);
  });

  it('returns 404 (not 500) for a missing file/dir — e.g. source/index.jsonl before any source exists', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件Z' })).body;
    // content of a file that does not exist (the Source Library index of an
    // empty space) must be a quiet 404 the frontend treats as "no content",
    // not a 500 that floods the log with ERROR.
    const content = await request(app).get(`/api/local/spaces/${created.id}/files/content?path=source/index.jsonl`);
    expect(content.status).toBe(404);
    // raw of a missing file → 404 too
    const raw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=output/nope.png`);
    expect(raw.status).toBe(404);
    // listing a missing subdir → 404 (root is auto-created, subdirs are not)
    const list = await request(app).get(`/api/local/spaces/${created.id}/files?path=output/missing`);
    expect(list.status).toBe(404);
  });

  it('serves trusted HTML (no CSP sandbox) to a non-owner viewer of a public space', async () => {
    // user-1 owns a PUBLIC space with an HTML app inside it.
    const created = (await request(app)
      .post('/api/local/spaces')
      .send({ title: '案件T', visibility: 'public' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    writeFileSync(join(filesDir, 'app.html'), '<h1>hi</h1>');

    // A different, authenticated viewer (auth ON) opens it with trusted=1.
    const viewerApp = express();
    viewerApp.use((req, _res, next) => {
      (req as any).user = { id: 'user-2', role: 'user', orgIds: [] };
      next();
    });
    viewerApp.use(
      '/api/local/spaces',
      createSpaceApi({
        repo,
        dataRoot: join(dir, 'data', 'users'),
        worktreeDir: join(dir, 'wt'),
        authActive: true,
      }),
    );

    const res = await request(viewerApp).get(
      `/api/local/spaces/${created.id}/files/raw?path=app.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 trusted HTML for an unauthenticated request when auth is ON', async () => {
    const created = (await request(app)
      .post('/api/local/spaces')
      .send({ title: '案件U', visibility: 'public' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    writeFileSync(join(filesDir, 'app.html'), '<h1>hi</h1>');

    const anonApp = express();
    anonApp.use(
      '/api/local/spaces',
      createSpaceApi({
        repo,
        dataRoot: join(dir, 'data', 'users'),
        worktreeDir: join(dir, 'wt'),
        authActive: true,
      }),
    );
    const res = await request(anonApp).get(
      `/api/local/spaces/${created.id}/files/raw?path=app.html&trusted=1`,
    );
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('file window excludes logs/, .conflict/ and dotfiles (plan 5, Phase C)', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件N' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    mkdirSync(join(filesDir, 'logs'), { recursive: true });
    mkdirSync(join(filesDir, '.conflict'), { recursive: true });
    mkdirSync(join(filesDir, 'output'), { recursive: true });
    writeFileSync(join(filesDir, '.hidden'), 'secret');
    writeFileSync(join(filesDir, 'output', 'x.txt'), 'visible');

    const list = await request(app).get(`/api/local/spaces/${created.id}/files`);
    expect(list.status).toBe(200);
    const names = list.body.entries.map((e: any) => e.name);
    expect(names).toContain('output');
    expect(names).not.toContain('logs');
    expect(names).not.toContain('.conflict');
    expect(names).not.toContain('.hidden');
  });

  it('returns 404 for files of a non-existent / non-visible space', async () => {
    const res = await request(app).get('/api/local/spaces/does-not-exist/files');
    expect(res.status).toBe(404);
  });

  const b64 = (s: string) => Buffer.from(s, 'utf-8').toString('base64');

  it('uploads a file into a viewable space and writes it to disk', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件U' })).body;
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [{ name: 'a.txt', contentBase64: b64('hi') }] });
    expect(res.status).toBe(200);
    expect(res.body.uploaded).toEqual([{ name: 'a.txt', path: 'a.txt' }]);
    const abs = join(dir, 'wt', 'space', created.id, 'files', 'a.txt');
    expect(existsSync(abs)).toBe(true);
    expect(readFileSync(abs, 'utf-8')).toBe('hi');
  });

  it('accepts a >150kb upload body when no global json parser is mounted (P1 413 regression)', async () => {
    // server.ts と同形: グローバル json 無し。ルート自前の 50mb parser が効くはず。
    const noGlobal = makeAppNoGlobalJson(repo, join(dir, 'data', 'users'), join(dir, 'wt'));
    const created = (await request(noGlobal).post('/api/local/spaces').send({ title: '案件L' })).body;
    // ~200kb の生データ → base64 で ~270kb の JSON body。旧バグでは 413 になる。
    const big = 'a'.repeat(200 * 1024);
    const res = await request(noGlobal)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [{ name: 'big.txt', contentBase64: b64(big) }] });
    expect(res.status).toBe(200);
    expect(res.body.uploaded).toEqual([{ name: 'big.txt', path: 'big.txt' }]);
    const abs = join(dir, 'wt', 'space', created.id, 'files', 'big.txt');
    expect(existsSync(abs)).toBe(true);
    expect(readFileSync(abs, 'utf-8').length).toBe(200 * 1024);
  });

  it('renames a duplicate upload to "name (2).ext" without overwriting the original', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件D' })).body;
    await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [{ name: 'dup.txt', contentBase64: b64('first') }] });
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [{ name: 'dup.txt', contentBase64: b64('second') }] });
    expect(res.status).toBe(200);
    expect(res.body.uploaded).toEqual([{ name: 'dup (2).txt', path: 'dup (2).txt' }]);
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    expect(readFileSync(join(filesDir, 'dup.txt'), 'utf-8')).toBe('first');
    expect(readFileSync(join(filesDir, 'dup (2).txt'), 'utf-8')).toBe('second');
  });

  it('rejects an upload whose path escapes the workspace with 400', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '案件E' })).body;
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '../escape', files: [{ name: 'x.txt', contentBase64: b64('x') }] });
    expect(res.status).toBe(400);
  });

  it('returns 404 when uploading to a space not visible to the viewer', async () => {
    // user-1 (注入された viewer) には見えない、別 owner の private スペース。
    const other = await repo.createSpace({
      kind: 'case',
      title: 'secret',
      description: '',
      ownerId: 'user-2',
      visibility: 'private',
      visibilityScopeOrgId: null,
      brandColor: null,
    });
    const res = await request(app)
      .post(`/api/local/spaces/${other.id}/files/upload`)
      .send({ path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }] });
    expect(res.status).toBe(404);
  });

  // ─── ファイル削除（POST /files/delete）────────────────────────────────
  it('deletes a file (single path) and the file is gone afterward', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '削除案件' })).body;
    await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [{ name: 'gone.txt', contentBase64: b64('bye') }] });
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    expect(existsSync(join(filesDir, 'gone.txt'))).toBe(true);

    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ path: 'gone.txt' });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['gone.txt']);
    expect(existsSync(join(filesDir, 'gone.txt'))).toBe(false);
  });

  it('deletes multiple files in one batch (paths array)', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '一括削除' })).body;
    await request(app)
      .post(`/api/local/spaces/${created.id}/files/upload`)
      .send({ path: '', files: [
        { name: 'a.txt', contentBase64: b64('a') },
        { name: 'b.txt', contentBase64: b64('b') },
        { name: 'keep.txt', contentBase64: b64('k') },
      ] });
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');

    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ paths: ['a.txt', 'b.txt'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted.sort()).toEqual(['a.txt', 'b.txt']);
    expect(existsSync(join(filesDir, 'a.txt'))).toBe(false);
    expect(existsSync(join(filesDir, 'b.txt'))).toBe(false);
    expect(existsSync(join(filesDir, 'keep.txt'))).toBe(true);
  });

  it('skips (does not error) on a missing path — idempotent', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: '冪等' })).body;
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ paths: ['nope.txt'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual([]);
    expect(res.body.skipped).toEqual(['nope.txt']);
  });

  it('rejects a delete whose path escapes the workspace with 400', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: 'traversal' })).body;
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ paths: ['../../etc/passwd'] });
    expect(res.status).toBe(400);
  });

  it('400 when neither path nor paths is provided', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: 'empty' })).body;
    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({});
    expect(res.status).toBe(400);
  });

  // メンバーロール起点の権限テスト。space_members.user_id は users(id) に FK するので、
  // 招待するメンバーと owner を実ユーザーとして作成し、そのメンバーを viewer として注入する
  // 専用 app を組む（authActive=true）。
  function makeAppAs(userId: string) {
    const a = express();
    a.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user', orgIds: [] }; next(); });
    a.use('/api/local/spaces', createSpaceApi({
      repo, dataRoot: join(dir, 'data', 'users'), worktreeDir: join(dir, 'wt'), authActive: true,
    }));
    return a;
  }

  it('viewer-role member cannot delete (403)', async () => {
    const owner = repo.createUser({ email: 'o1@b.com', name: 'Owner', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'v1@b.com', name: 'Viewer', role: 'user', status: 'active' });
    const owned = await repo.createSpace({
      kind: 'case', title: '閲覧者テスト', description: '', ownerId: owner.id,
      visibility: 'public', visibilityScopeOrgId: null, brandColor: null,
    });
    const filesDir = join(dir, 'wt', 'space', owned.id, 'files');
    mkdirSync(filesDir, { recursive: true });
    writeFileSync(join(filesDir, 'locked.txt'), 'x');
    await repo.addSpaceMember({ spaceId: owned.id, userId: member.id, role: 'viewer', invitedBy: owner.id });

    const res = await request(makeAppAs(member.id))
      .post(`/api/local/spaces/${owned.id}/files/delete`)
      .send({ paths: ['locked.txt'] });
    expect(res.status).toBe(403);
    expect(existsSync(join(filesDir, 'locked.txt'))).toBe(true);
  });

  it('editor-role member can delete a file', async () => {
    const owner = repo.createUser({ email: 'o2@b.com', name: 'Owner2', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'e1@b.com', name: 'Editor', role: 'user', status: 'active' });
    const owned = await repo.createSpace({
      kind: 'case', title: '編集者テスト', description: '', ownerId: owner.id,
      visibility: 'public', visibilityScopeOrgId: null, brandColor: null,
    });
    const filesDir = join(dir, 'wt', 'space', owned.id, 'files');
    mkdirSync(filesDir, { recursive: true });
    writeFileSync(join(filesDir, 'edit.txt'), 'x');
    await repo.addSpaceMember({ spaceId: owned.id, userId: member.id, role: 'editor', invitedBy: owner.id });

    const res = await request(makeAppAs(member.id))
      .post(`/api/local/spaces/${owned.id}/files/delete`)
      .send({ paths: ['edit.txt'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['edit.txt']);
    expect(existsSync(join(filesDir, 'edit.txt'))).toBe(false);
  });

  it('returns 404 when deleting in a space not visible to the viewer', async () => {
    const other = await repo.createSpace({
      kind: 'case',
      title: 'secret-del',
      description: '',
      ownerId: 'user-2',
      visibility: 'private',
      visibilityScopeOrgId: null,
      brandColor: null,
    });
    const res = await request(app)
      .post(`/api/local/spaces/${other.id}/files/delete`)
      .send({ paths: ['x.txt'] });
    expect(res.status).toBe(404);
  });

  it('deletes a user-created directory recursively (folder delete)', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-del' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    mkdirSync(join(filesDir, 'sub'), { recursive: true });
    writeFileSync(join(filesDir, 'sub', 'f.txt'), 'x');

    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ paths: ['sub'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual(['sub']);
    expect(res.body.skipped).toEqual([]);
    expect(existsSync(join(filesDir, 'sub'))).toBe(false);
  });

  it('does not delete a structural workspace dir (skips it, leaving it intact)', async () => {
    const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-protect' })).body;
    const filesDir = join(dir, 'wt', 'space', created.id, 'files');
    // `output` is a reserved structural dir (isProtectedWorkspaceDir) and must
    // survive an API-direct delete, since the UI guard alone can be bypassed.
    mkdirSync(join(filesDir, 'output'), { recursive: true });
    writeFileSync(join(filesDir, 'output', 'f.txt'), 'x');

    const res = await request(app)
      .post(`/api/local/spaces/${created.id}/files/delete`)
      .send({ paths: ['output'] });
    expect(res.status).toBe(200);
    expect(res.body.deleted).toEqual([]);
    expect(res.body.skipped).toEqual(['output']);
    expect(existsSync(join(filesDir, 'output', 'f.txt'))).toBe(true);
  });
});
