// Programmatic single-file write endpoint (POST /files/write).
//
// Backs the workspace-apps AppRunner postMessage `writeFile` bridge. Unlike
// /files/upload (O_EXCL, collision-rename), this OVERWRITES the target path so
// an app can update its own data file. Same write gate as upload/delete
// (canEditInSpace) and same ensurePathWithin traversal guard.

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
import AdmZip from 'adm-zip';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';

function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
  return {
    id,
    email: `${id}@x.com`,
    name: id,
    avatarUrl: null,
    role,
    status: 'active',
    orgIds,
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
  } as unknown as Express.User;
}

describe('space /files/write endpoint', () => {
  let dir = '';
  let repo: Repository;
  let ownerId = '';
  let editorId = '';
  let viewerId = '';
  let strangerId = '';
  let spaceId = '';
  let worktreeDir = '';

  function appAs(u: Express.User | null, authActive = true): express.Application {
    const app = express();
    if (u) {
      app.use((req, _res, next) => {
        (req as unknown as { user: Express.User }).user = u;
        next();
      });
    }
    const deps: SpaceApiDeps = {
      repo,
      dataRoot: join(dir, 'data', 'users'),
      worktreeDir,
      authActive,
    };
    app.use('/api/local/spaces', createSpaceApi(deps));
    return app;
  }

  beforeEach(async () => {
    dir = mkdtempSync(join(tmpdir(), 'space-write-'));
    worktreeDir = join(dir, 'workspaces');
    repo = new Repository(join(dir, 'db.sqlite'));
    ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
    editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
    viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
    strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;

    const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
    spaceId = space.id;
    await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
    await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
  });

  afterEach(() => {
    repo.close();
    rmSync(dir, { recursive: true, force: true });
  });

  it('editor writes text content under output/, file lands on disk', async () => {
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/report.txt', content: 'hello world' });
    expect(res.status).toBe(200);
    const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'report.txt');
    expect(readFileSync(abs, 'utf-8')).toBe('hello world');
  });

  it('OVERWRITES an existing file (not collision-renamed like upload)', async () => {
    const app = appAs(user(editorId));
    await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":1}' });
    const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":2}' });
    expect(res.status).toBe(200);
    const abs = join(spaceFilesDir(worktreeDir, spaceId), 'apps', 'foo', 'data', 'state.json');
    expect(readFileSync(abs, 'utf-8')).toBe('{"v":2}');
  });

  it('accepts base64 (binary) content', async () => {
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/blob.bin', contentBase64: Buffer.from([0, 1, 2, 255]).toString('base64') });
    expect(res.status).toBe(200);
    const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'blob.bin');
    expect(Array.from(readFileSync(abs))).toEqual([0, 1, 2, 255]);
  });

  it('viewer member → 403 (read-only)', async () => {
    const res = await request(appAs(user(viewerId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/x.txt', content: 'no' });
    expect(res.status).toBe(403);
  });

  it('non-member → 404 (read-gate, never reaches write gate)', async () => {
    const res = await request(appAs(user(strangerId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/x.txt', content: 'leak' });
    expect(res.status).toBe(404);
  });

  it('path traversal → 400', async () => {
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: '../../escape.txt', content: 'pwn' });
    expect(res.status).toBe(400);
  });

  it('missing path → 400; missing body content → 400', async () => {
    const app = appAs(user(editorId));
    expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ content: 'x' })).status).toBe(400);
    expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'output/x.txt' })).status).toBe(400);
  });

  it('writing onto an existing directory path → 400 (does not clobber dir)', async () => {
    mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'output', 'adir'), { recursive: true });
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/adir', content: 'x' });
    expect(res.status).toBe(400);
  });

  it('write outside apps/ or output/ → 403 (no arbitrary .html / AGENTS.md overwrite)', async () => {
    const app = appAs(user(editorId));
    // top-level file, AGENTS.md, source index, and a sneaky normalized path all rejected
    for (const path of ['evil.html', 'AGENTS.md', 'source/index.jsonl', 'apps/../sneaky.html']) {
      const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path, content: 'x' });
      expect([400, 403]).toContain(res.status); // apps/../ escapes-normalizes → not under apps/output → 403 (or 400 if guard trips)
      expect(res.status).not.toBe(200);
    }
    // sanity: a legit apps/ path still works
    expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/x/index.html', content: '<html></html>' })).status).toBe(200);
  });

  it('content over the 10MB cap → 400', async () => {
    const big = 'a'.repeat(10 * 1024 * 1024 + 1);
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/files/write`)
      .send({ path: 'output/big.txt', content: big });
    expect(res.status).toBe(400);
  });

  // ── /files/download-zip（read ゲート: メンバーなら viewer でも可、非メンバーは 404）──
  describe('download-zip', () => {
    const zipReq = (app: express.Application, body: unknown) =>
      request(app)
        .post(`/api/local/spaces/${spaceId}/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)));
        });

    beforeEach(() => {
      const filesDir = spaceFilesDir(worktreeDir, spaceId);
      mkdirSync(join(filesDir, 'sub'), { recursive: true });
      writeFileSync(join(filesDir, 'a.txt'), 'alpha');
      writeFileSync(join(filesDir, 'sub', 'b.txt'), 'bravo');
    });

    it('owner zips selected files with relative entry names', async () => {
      const res = await zipReq(appAs(user(ownerId)), { paths: ['a.txt', 'sub/b.txt'] });
      expect(res.status).toBe(200);
      expect(res.headers['content-type']).toContain('application/zip');
      const zip = new AdmZip(res.body as Buffer);
      expect(zip.getEntries().map((e) => e.entryName).sort()).toEqual(['a.txt', 'sub/b.txt']);
      expect(zip.getEntry('a.txt')?.getData().toString()).toBe('alpha');
    });

    it('a read-only viewer member CAN download (read-gated, not edit)', async () => {
      const res = await zipReq(appAs(user(viewerId)), { paths: ['a.txt'] });
      expect(res.status).toBe(200);
    });

    it('a non-member stranger gets 404 (no read access)', async () => {
      const res = await zipReq(appAs(user(strangerId)), { paths: ['a.txt'] });
      expect(res.status).toBe(404);
    });

    it('rejects traversal with 400', async () => {
      const res = await request(appAs(user(ownerId)))
        .post(`/api/local/spaces/${spaceId}/files/download-zip`)
        .send({ paths: ['../../etc/passwd'] });
      expect(res.status).toBe(400);
      expect(res.body.error).toBe('Path escapes workspace');
    });

    it('returns 404 when nothing matches', async () => {
      const res = await zipReq(appAs(user(ownerId)), { paths: ['nope.txt'] });
      expect(res.status).toBe(404);
    });
  });

  // ── /files/mkdir + /files/move（Box ライクなフォルダ作成・リネーム・移動）──
  describe('mkdir', () => {
    const mkdir = (u: Express.User, body: unknown) =>
      request(appAs(u)).post(`/api/local/spaces/${spaceId}/files/mkdir`).send(body as object);

    it('editor creates a folder; it lands on disk', async () => {
      const res = await mkdir(user(editorId), { path: 'notes' });
      expect(res.status).toBe(200);
      expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes'))).toBe(true);
    });

    it('reserved name readonly is allowed (existing-space backfill, idempotent)', async () => {
      expect((await mkdir(user(ownerId), { path: 'readonly' })).status).toBe(200);
      expect((await mkdir(user(ownerId), { path: 'readonly' })).status).toBe(200); // idempotent
      expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'readonly'))).toBe(true);
    });

    it('viewer → 403, non-member → 404, traversal → 400, missing path → 400', async () => {
      expect((await mkdir(user(viewerId), { path: 'x' })).status).toBe(403);
      expect((await mkdir(user(strangerId), { path: 'x' })).status).toBe(404);
      expect((await mkdir(user(editorId), { path: '../escape' })).status).toBe(400);
      expect((await mkdir(user(editorId), {})).status).toBe(400);
    });

    it('refuses to create over an existing file → 409', async () => {
      const filesDir = spaceFilesDir(worktreeDir, spaceId);
      mkdirSync(join(filesDir, 'output'), { recursive: true });
      writeFileSync(join(filesDir, 'output', 'a.txt'), 'x');
      // mkdir at a path that already exists as a FILE
      const res = await mkdir(user(editorId), { path: 'output/a.txt' });
      expect(res.status).toBe(409);
    });
  });

  describe('move (rename / move)', () => {
    const move = (u: Express.User, body: unknown) =>
      request(appAs(u)).post(`/api/local/spaces/${spaceId}/files/move`).send(body as object);

    beforeEach(() => {
      const filesDir = spaceFilesDir(worktreeDir, spaceId);
      mkdirSync(join(filesDir, 'notes'), { recursive: true });
      writeFileSync(join(filesDir, 'notes', 'a.txt'), 'alpha');
    });

    it('renames a file in place', async () => {
      const res = await move(user(editorId), { from: 'notes/a.txt', to: 'notes/b.txt' });
      expect(res.status).toBe(200);
      const filesDir = spaceFilesDir(worktreeDir, spaceId);
      expect(existsSync(join(filesDir, 'notes', 'a.txt'))).toBe(false);
      expect(existsSync(join(filesDir, 'notes', 'b.txt'))).toBe(true);
    });

    it('renames a user folder', async () => {
      const res = await move(user(editorId), { from: 'notes', to: 'memo' });
      expect(res.status).toBe(200);
      expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'memo', 'a.txt'))).toBe(true);
    });

    it('protects structural dirs (input/output/logs/apps/readonly/source/skills) → 400', async () => {
      for (const dir of ['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills']) {
        mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), dir), { recursive: true });
        const res = await move(user(editorId), { from: dir, to: `${dir}-renamed` });
        expect(res.status, dir).toBe(400);
      }
    });

    it('allows moving / renaming FILES inside structural dirs (only the dir itself is locked)', async () => {
      // ヘルプの約束「構造フォルダの中のファイルは自由に整理できる」を守る回帰ガード。
      // dddc3aff で先頭セグメント判定にした際、output/x.txt も巻き込んで弾いていた。
      const filesDir = spaceFilesDir(worktreeDir, spaceId);
      mkdirSync(join(filesDir, 'output'), { recursive: true });
      writeFileSync(join(filesDir, 'output', 'report.txt'), 'r');
      // rename in place inside output/
      const renamed = await move(user(editorId), { from: 'output/report.txt', to: 'output/final.txt' });
      expect(renamed.status).toBe(200);
      expect(existsSync(join(filesDir, 'output', 'final.txt'))).toBe(true);
      // move from output/ to a user folder
      const moved = await move(user(editorId), { from: 'output/final.txt', to: 'notes/final.txt' });
      expect(moved.status).toBe(200);
      expect(existsSync(join(filesDir, 'notes', 'final.txt'))).toBe(true);
      expect(existsSync(join(filesDir, 'output', 'final.txt'))).toBe(false);
      // structural dir itself is still untouched
      expect(existsSync(join(filesDir, 'output'))).toBe(true);
    });

    it('protection cannot be bypassed with dot-prefixed / non-canonical from', async () => {
      mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'input'), { recursive: true });
      mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'output'), { recursive: true });
      mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'source'), { recursive: true });
      mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'skills'), { recursive: true });
      // resolve 後の実体は input/ なのに生文字列が "/" を含む dot 形。すべて 400 でなければならない。
      for (const from of ['./input', 'input/.', './/output', './readonly', './.git', './source', 'skills/.']) {
        const res = await move(user(editorId), { from, to: 'pwned' });
        expect(res.status, from).toBe(400);
      }
      // input/ は無傷（リネームされていない）
      expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'input'))).toBe(true);
      expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'pwned'))).toBe(false);
    });

    it('cannot move a file ONTO a reserved top-level name (no fake structural dir)', async () => {
      // to が root 直下の予約名そのもの → 400。配下への移動は許可。
      const res = await move(user(editorId), { from: 'notes/a.txt', to: 'output' });
      expect(res.status).toBe(400);
      const ok = await move(user(editorId), { from: 'notes/a.txt', to: 'output/a.txt' });
      expect(ok.status).toBe(200);
    });

    it('cannot move a file ONTO the reserved names source / skills (incl. dot form)', async () => {
      for (const to of ['source', 'skills', './source', './skills']) {
        const res = await move(user(editorId), { from: 'notes/a.txt', to });
        expect(res.status, to).toBe(400);
      }
      // 配下への移動は許可（source/x.txt 等）
      const ok = await move(user(editorId), { from: 'notes/a.txt', to: 'source/a.txt' });
      expect(ok.status).toBe(200);
    });

    it('auto-renames on collision instead of clobbering', async () => {
      writeFileSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes', 'c.txt'), 'keep');
      const res = await move(user(editorId), { from: 'notes/a.txt', to: 'notes/c.txt' });
      expect(res.status).toBe(200);
      expect(res.body.to).toBe('notes/c (2).txt');
      // original c.txt untouched
      expect(readFileSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes', 'c.txt'), 'utf-8')).toBe('keep');
    });

    it('refuses to move a folder into itself → 400', async () => {
      const res = await move(user(editorId), { from: 'notes', to: 'notes/sub/notes' });
      expect(res.status).toBe(400);
    });

    it('viewer → 403, non-member → 404, traversal → 400, missing args → 400', async () => {
      expect((await move(user(viewerId), { from: 'notes/a.txt', to: 'notes/b.txt' })).status).toBe(403);
      expect((await move(user(strangerId), { from: 'notes/a.txt', to: 'notes/b.txt' })).status).toBe(404);
      expect((await move(user(editorId), { from: 'notes/a.txt', to: '../escape.txt' })).status).toBe(400);
      expect((await move(user(editorId), { from: 'notes/a.txt' })).status).toBe(400);
    });
  });
});
