import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express from 'express';
import request from 'supertest';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountShareApi } from './share-api.js';

// 共有された 1 タスクに、output あり worktree を持つサブジョブ 1 件を仕込む。
// 親ジョブの repo/issue は getLatestJobForIssue が引けるよう localTaskRepoName に揃える。
async function seedSharedTaskWithSubtask(
  ctx: { repo: Repository },
  tempDir: string,
): Promise<{ token: string; subJobId: string; taskWs: string }> {
  const task = await ctx.repo.createLocalTask({ title: 'parent', body: 'b' });
  const taskWs = join(tempDir, 'ws');
  mkdirSync(taskWs, { recursive: true });
  await ctx.repo.updateLocalTask(task.id, { workspacePath: taskWs });

  const repoName = localTaskRepoName(task.id);
  const parent = await ctx.repo.createJob({ repo: repoName, issueNumber: task.id, instruction: 'parent' });
  const sub = await ctx.repo.createJob({ repo: repoName, issueNumber: 1, instruction: 'sub', parentJobId: parent.id });

  // サブジョブの worktree は親タスクの workspacePath 配下に置く（封じ込めOK）。
  const subWs = join(taskWs, 'subtasks', '1');
  mkdirSync(join(subWs, 'output'), { recursive: true });
  mkdirSync(join(subWs, 'logs'), { recursive: true });
  writeFileSync(join(subWs, 'output', 'sub-result.md'), '# sub');
  writeFileSync(join(subWs, 'logs', 'activity.log'), 'sub activity line');
  writeFileSync(join(subWs, 'secret-sibling.txt'), 'do-not-leak');
  await ctx.repo.updateJob(sub.id, { worktreePath: subWs });

  const token = (await ctx.repo.shareLocalTask(task.id)) as string;
  return { token, subJobId: sub.id, taskWs };
}

function setup(user?: { id: string; role: 'admin' | 'user' }) {
  const tempDir = mkdtempSync(join(tmpdir(), 'share-api-'));
  const repo = new Repository(join(tempDir, 'test.db'));
  const app = express();
  app.use(express.json());

  // Mock user middleware (simulate authenticated user)
  const effectiveUser = user ?? { id: 'user-1', role: 'admin' as const };
  app.use((req, _res, next) => {
    (req as any).user = {
      ...effectiveUser,
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    (req as any).isAuthenticated = () => true;
    next();
  });

  mountShareApi(app, repo);
  return { app, repo, tempDir };
}

describe('Share API', () => {
  let tempDir = '';

  afterEach(() => {
    if (tempDir) {
      rmSync(tempDir, { recursive: true, force: true });
      tempDir = '';
    }
  });

  it('POST /api/local/tasks/:id/share generates token', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(200);
    expect(res.body.shareToken).toBeTruthy();
    expect(res.body.shareUrl).toContain(res.body.shareToken);
  });

  it('DELETE /api/local/tasks/:id/share removes token', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);

    const updated = await ctx.repo.getLocalTask(task.id);
    expect(updated?.shareToken).toBeNull();
  });

  it('GET /api/shared/:token returns task info', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'shared task', body: 'body' });
    const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const token = shareRes.body.shareToken;

    const res = await request(ctx.app).get(`/api/shared/${token}`);
    expect(res.status).toBe(200);
    expect(res.body.task.title).toBe('shared task');
    // ownerId と workspacePath は非公開
    expect(res.body.task.ownerId).toBeUndefined();
    expect(res.body.task.workspacePath).toBeUndefined();
  });

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

  it('GET /api/shared/:token/comments returns comments', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    await ctx.repo.addLocalTaskComment(task.id, 'user', 'hello', 'comment');
    const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const token = shareRes.body.shareToken;

    const res = await request(ctx.app).get(`/api/shared/${token}/comments`);
    expect(res.status).toBe(200);
    expect(res.body.comments.length).toBe(1);
  });

  it('GET /api/shared/:token/files lists output files', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    const wsPath = join(tempDir, 'ws');
    mkdirSync(join(wsPath, 'output'), { recursive: true });
    writeFileSync(join(wsPath, 'output', 'result.md'), '# Result');
    await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });

    const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const token = shareRes.body.shareToken;

    const res = await request(ctx.app).get(`/api/shared/${token}/files`);
    expect(res.status).toBe(200);
    expect(res.body.entries.length).toBe(1);
    expect(res.body.entries[0].name).toBe('result.md');
  });

  it('GET /api/shared/:token/files/raw serves file content', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    const wsPath = join(tempDir, 'ws');
    mkdirSync(join(wsPath, 'output'), { recursive: true });
    writeFileSync(join(wsPath, 'output', 'result.md'), '# Hello');
    await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });

    const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const token = shareRes.body.shareToken;

    const res = await request(ctx.app).get(`/api/shared/${token}/files/raw?path=result.md`);
    expect(res.status).toBe(200);
  });

  it('rejects path traversal on shared file endpoints with 400', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
    const wsPath = join(tempDir, 'ws');
    mkdirSync(join(wsPath, 'output'), { recursive: true });
    // A file outside output/ that traversal must never reach.
    writeFileSync(join(wsPath, 'secret.txt'), 'do-not-leak');
    await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });

    const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    const token = shareRes.body.shareToken;

    for (const ep of ['files?path=..', 'files/content?path=..%2Fsecret.txt', 'files/raw?path=..%2Fsecret.txt']) {
      const res = await request(ctx.app).get(`/api/shared/${token}/${ep}`);
      expect(res.status, ep).toBe(400);
      expect(res.body.error, ep).toBe('Path escapes workspace');
      expect(res.text, ep).not.toContain('do-not-leak');
    }
  });

  // --- Cross-user authorization ---

  it('POST /share by non-owner non-admin returns 404', async () => {
    const ctx = setup({ id: 'bob', role: 'user' });
    tempDir = ctx.tempDir;
    // Alice owns the task
    const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });

    const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(404);

    // Token was NOT generated
    const after = await ctx.repo.getLocalTask(task.id);
    expect(after?.shareToken).toBeFalsy();
  });

  it('DELETE /share by non-owner non-admin returns 404', async () => {
    const ctx = setup({ id: 'bob', role: 'user' });
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
    // Alice already shared the task (simulate by setting token directly via repo)
    await ctx.repo.shareLocalTask(task.id);

    const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(404);

    // Token still exists (Bob's DELETE was rejected)
    const after = await ctx.repo.getLocalTask(task.id);
    expect(after?.shareToken).toBeTruthy();
  });

  it('POST /share by owner succeeds', async () => {
    const ctx = setup({ id: 'alice', role: 'user' });
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });

    const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(200);
    expect(res.body.shareToken).toBeTruthy();
  });

  it('POST /share by admin succeeds for any task', async () => {
    const ctx = setup({ id: 'admin-1', role: 'admin' });
    tempDir = ctx.tempDir;
    const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });

    const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
    expect(res.status).toBe(200);
    expect(res.body.shareToken).toBeTruthy();
  });

  // --- Shared individual subtask endpoints (read-only, containment) ---

  it('GET /api/shared/:token/subtasks/:jobId/activity returns the activity log', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);

    const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/activity`);
    expect(res.status).toBe(200);
    expect(res.body.activityLog).toContain('sub activity line');
  });

  it('GET /api/shared/:token/subtasks/:jobId/files lists subtask files by category', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);

    const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files`);
    expect(res.status).toBe(200);
    expect(res.body.files).toEqual(['sub-result.md']);
    expect(res.body.categories.output).toEqual(['sub-result.md']);
    expect(res.body.categories.logs).toEqual(['activity.log']);
  });

  it('GET /api/shared/:token/subtasks/:jobId/files/* serves an individual subtask file', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);

    const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/output/sub-result.md`);
    expect(res.status).toBe(200);
    expect(res.text).toContain('# sub');
  });

  it('rejects traversal out of the subtask worktree with 403 and no leak', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);

    const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/..%2Fsecret-sibling.txt`);
    expect(res.status).toBe(403);
    expect(res.text).not.toContain('do-not-leak');
  });

  it('returns 404 for a subtask outside the shared task workspace (containment)', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const { token } = await seedSharedTaskWithSubtask(ctx, tempDir);

    // A second, unshared task with its own subtask. Reaching its subtask via the
    // first task's share token must fail (the jobId is not under task.workspacePath).
    const other = await ctx.repo.createLocalTask({ title: 'other', body: 'b' });
    const otherWs = join(tempDir, 'other-ws');
    mkdirSync(join(otherWs, 'output'), { recursive: true });
    await ctx.repo.updateLocalTask(other.id, { workspacePath: otherWs });
    const oRepo = localTaskRepoName(other.id);
    const oParent = await ctx.repo.createJob({ repo: oRepo, issueNumber: other.id, instruction: 'p' });
    const oSub = await ctx.repo.createJob({ repo: oRepo, issueNumber: 1, instruction: 's', parentJobId: oParent.id });
    await ctx.repo.updateJob(oSub.id, { worktreePath: otherWs });

    for (const ep of ['activity', 'files']) {
      const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${oSub.id}/${ep}`);
      expect(res.status, ep).toBe(404);
    }
  });

  it('returns 404 for a sibling task whose workspace is a string prefix of the shared one (prefix-collision containment)', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    // Shared task workspace is `<tempDir>/ws`. A sibling at `<tempDir>/ws-evil`
    // shares that string prefix but is a different directory — a bare
    // `startsWith` containment check would wrongly admit it (the /wt/local/12 vs
    // /wt/local/123 越境). The separator-bounded check must reject it.
    const { token } = await seedSharedTaskWithSubtask(ctx, tempDir);

    const evil = await ctx.repo.createLocalTask({ title: 'evil', body: 'b' });
    const evilWs = join(tempDir, 'ws-evil');
    mkdirSync(join(evilWs, 'output'), { recursive: true });
    mkdirSync(join(evilWs, 'logs'), { recursive: true });
    writeFileSync(join(evilWs, 'output', 'leak.md'), 'secret-from-other-task');
    writeFileSync(join(evilWs, 'logs', 'activity.log'), 'victim activity');
    const eRepo = localTaskRepoName(evil.id);
    const eParent = await ctx.repo.createJob({ repo: eRepo, issueNumber: evil.id, instruction: 'p' });
    const eSub = await ctx.repo.createJob({ repo: eRepo, issueNumber: 1, instruction: 's', parentJobId: eParent.id });
    await ctx.repo.updateJob(eSub.id, { worktreePath: evilWs });

    for (const ep of ['activity', 'files', 'files/output/leak.md']) {
      const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${eSub.id}/${ep}`);
      expect(res.status, ep).toBe(404);
    }
  });

  it('returns 404 for shared subtask endpoints with an unknown token', async () => {
    const ctx = setup();
    tempDir = ctx.tempDir;
    const a = await request(ctx.app).get('/api/shared/nope/subtasks/job-x/activity');
    expect(a.status).toBe(404);
    const f = await request(ctx.app).get('/api/shared/nope/subtasks/job-x/files');
    expect(f.status).toBe(404);
  });
});
