// Space-scoped task operations exercised through the HTTP API.
//
// The personal-task auth matrix (owner/admin/non-member) is already covered in
// local-tasks-api.test.ts. Here we prove the SAME routes behave correctly when
// the task lives INSIDE a case space (space_id set): the operation succeeds for
// owner/admin, is denied (404) for a non-member, and — critically — that the
// HTTP /continue route propagates space_id onto the spawned child job (the
// regression that would silently route a space job through the wrong
// MCP/SSH/folder set).

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountLocalTasksApi } from './local-tasks-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,
  };
}

describe('space-scoped task operations (HTTP)', () => {
  let dir = '';
  let repo: Repository;
  let ownerId = '';

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'lt-space-'));
    repo = new Repository(join(dir, 'db.sqlite'));
    ownerId = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' }).id;
  });

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

  function appAs(u: Express.User, extra: Record<string, unknown> = {}): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = u;
      next();
    });
    mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces'), ...extra });
    return app;
  }

  async function spaceTaskWithTerminalJob(spaceId: string, status = 'succeeded') {
    const task = await repo.createLocalTask({
      title: 't', body: 'b', pieceName: 'manual-writer', ownerId, spaceId,
    });
    const prev = await repo.createJob({
      repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
      pieceName: 'manual-writer', ownerId, spaceId,
    });
    await repo.updateJob(prev.id, { status: status as never });
    return { task, prev };
  }

  // ── DELETE ──────────────────────────────────────────────────────────────────

  it('owner can DELETE a private space task; it disappears', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user(ownerId))).delete(`/api/local/tasks/${task.id}`);
    expect(res.status).toBe(200);
    expect(await repo.getLocalTask(task.id)).toBeNull();
  });

  it('a non-member (non-owner, non-admin) gets 404 on DELETE of a private space task; task survives', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user('intruder'))).delete(`/api/local/tasks/${task.id}`);
    expect(res.status).toBe(404);
    expect(await repo.getLocalTask(task.id)).not.toBeNull();
  });

  it('admin can DELETE any space task', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user('root', 'admin'))).delete(`/api/local/tasks/${task.id}`);
    expect(res.status).toBe(200);
    expect(await repo.getLocalTask(task.id)).toBeNull();
  });

  // ── CANCEL ──────────────────────────────────────────────────────────────────

  it('owner can cancel a running space task; the job goes cancelled and keeps its space_id', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const { prev } = await spaceTaskWithTerminalJob(space.id, 'running');

    const res = await request(appAs(user(ownerId))).post(`/api/local/tasks/${prev.issueNumber}/cancel`);
    expect(res.status).toBe(200);
    expect(res.body.jobId).toBe(prev.id);

    const after = await repo.getJob(prev.id);
    expect(after?.status).toBe('cancelled');
    expect(after?.spaceId).toBe(space.id);
  });

  it('non-member gets 404 on cancel of a private space task (job left running)', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'running');

    const res = await request(appAs(user('intruder'))).post(`/api/local/tasks/${task.id}/cancel`);
    expect(res.status).toBe(404);
    expect((await repo.getJob(prev.id))?.status).toBe('running');
  });

  // ── CONTINUE-WITH-PIECE: space_id propagation through the HTTP route ─────────

  it('continue: the HTTP route spawns a child job carrying the parent space_id', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'succeeded');

    const app = appAs(user(ownerId), { pieceExists: (n: string) => n === 'research' || n === 'manual-writer' });
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'research', instruction: 'keep going in this space' });
    expect(res.status).toBe(201);

    const child = await repo.getJob(res.body.jobId);
    expect(child?.pieceName).toBe('research');
    expect(child?.continuedFromJobId).toBe(prev.id);
    // The whole point: a space task's continuation must stay in the same space.
    expect(child?.spaceId).toBe(space.id);
  });

  it('continue: a non-member gets 404 and no child job is created', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const { task } = await spaceTaskWithTerminalJob(space.id, 'succeeded');

    const app = appAs(user('intruder'), { pieceExists: () => true });
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'research', instruction: 'sneak in' });
    expect(res.status).toBe(404);

    // Only the original starter job exists (no continuation spawned).
    const jobCount = (repo.getDb()
      .prepare('SELECT COUNT(*) AS c FROM jobs WHERE repo = ?')
      .get(localTaskRepoName(task.id)) as { c: number }).c;
    expect(jobCount).toBe(1);
  });

  // ── CREATE: space tasks are FORCED private (no org/public leak) ──────────────

  it('create: a task with spaceId set is stored private even when body asks for public', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });

    const res = await request(app)
      .post('/api/local/tasks')
      .send({ body: 'hello space', spaceId: space.id, visibility: 'public' });
    expect(res.status).toBe(201);

    const created = await repo.getLocalTask(res.body.task.id);
    expect(created?.spaceId).toBe(space.id);
    // body said 'public' but a space task is always private — access is membership-only.
    expect(created?.visibility).toBe('private');
    expect(created?.visibilityScopeOrgId).toBeNull();
  });

  it('create: spaceId set + body org → still private (org scope ignored)', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const app = appAs(user(ownerId, 'user', ['acme']), { selectPiece: async () => 'chat' });

    const res = await request(app)
      .post('/api/local/tasks')
      .send({ body: 'hi', spaceId: space.id, visibility: 'org', visibilityScopeOrgId: 'acme' });
    expect(res.status).toBe(201);

    const created = await repo.getLocalTask(res.body.task.id);
    expect(created?.visibility).toBe('private');
    expect(created?.visibilityScopeOrgId).toBeNull();
  });

  it('create: a non-space (personal) task keeps the requested public visibility', async () => {
    const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });
    const res = await request(app)
      .post('/api/local/tasks')
      .send({ body: 'personal', visibility: 'public' });
    expect(res.status).toBe(201);
    const created = await repo.getLocalTask(res.body.task.id);
    expect(created?.spaceId).toBeNull();
    expect(created?.visibility).toBe('public');
  });

  it('end-to-end: a forced-private space chat is invisible to non-members, visible to members', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const memberId = repo.createUser({ email: 'm@x.com', name: 'm', role: 'user', status: 'active' }).id;
    const strangerId = repo.createUser({ email: 's@x.com', name: 's', role: 'user', status: 'active' }).id;
    await repo.addSpaceMember({ spaceId: space.id, userId: memberId, role: 'editor', invitedBy: ownerId });

    // Create through the API as the owner, asking (futilely) for public.
    const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });
    const cres = await request(app)
      .post('/api/local/tasks')
      .send({ body: 'space chat', spaceId: space.id, visibility: 'public' });
    expect(cres.status).toBe(201);
    const taskId = cres.body.task.id;
    expect((await repo.getLocalTask(taskId))?.visibility).toBe('private');

    // Access control runs through buildVisibilityWhere (the membership OR-branch),
    // which is where space membership grants/denies read. Assert it directly at
    // the repository layer — the single source of truth for who can see a row.
    // Member (non-owner) of the space CAN see the private space chat.
    const seenByMember = await repo.getLocalTask(taskId, { viewer: user(memberId) });
    expect(seenByMember).not.toBeNull();
    expect(seenByMember?.spaceId).toBe(space.id);

    // Non-member CANNOT (private + not a member → denied → null).
    const seenByStranger = await repo.getLocalTask(taskId, { viewer: user(strangerId) });
    expect(seenByStranger).toBeNull();

    // And the same denial surfaces end-to-end through the HTTP GET for a stranger.
    const strangerRes = await request(appAs(user(strangerId))).get(`/api/local/tasks/${taskId}`);
    expect(strangerRes.status).toBe(404);

    // The task list (also membership-gated via buildVisibilityWhere) includes it
    // for the member and excludes it for the stranger.
    const memberList = await repo.listLocalTasks({ viewer: user(memberId) });
    expect(memberList.some(t => t.id === taskId)).toBe(true);
    const strangerList = await repo.listLocalTasks({ viewer: user(strangerId) });
    expect(strangerList.some(t => t.id === taskId)).toBe(false);
  });

  // ── VISIBILITY ──────────────────────────────────────────────────────────────

  it('owner can PATCH visibility of a space task; persists and keeps the space tag', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user(ownerId)))
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'public' });
    expect(res.status).toBe(200);
    expect(res.body.task.visibility).toBe('public');

    const after = await repo.getLocalTask(task.id);
    expect(after?.visibility).toBe('public');
    expect(after?.spaceId).toBe(space.id);
  });

  it('non-member gets 404 on PATCH visibility of a private space task', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user('intruder')))
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'public' });
    expect(res.status).toBe(404);
    expect((await repo.getLocalTask(task.id))?.visibility).toBe('private');
  });

  // ── FEEDBACK ──────────────────────────────────────────────────────────────

  it('owner can submit feedback on a space task; it persists', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user(ownerId)))
      .put(`/api/local/tasks/${task.id}/feedback`)
      .send({ rating: 'good', tags: ['useful'] });
    expect(res.status).toBe(200);

    const after = await repo.getLocalTask(task.id);
    expect(after?.feedbackRating).toBe('good');
    expect(after?.feedbackTags).toEqual(['useful']);
    expect(after?.spaceId).toBe(space.id);
  });

  it('non-member gets 404 on feedback for a private space task', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const res = await request(appAs(user('intruder')))
      .put(`/api/local/tasks/${task.id}/feedback`)
      .send({ rating: 'bad', tags: [] });
    expect(res.status).toBe(404);
    expect((await repo.getLocalTask(task.id))?.feedbackRating).toBeNull();
  });

  // ── TITLE REGENERATE ────────────────────────────────────────────────────────

  it('owner can regenerate the title of a space task; updateLocalTask persists agent title', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({
      title: 'old', titleSource: 'auto', body: 'long body to summarize', ownerId, spaceId: space.id,
    });

    const app = appAs(user(ownerId), { generateTitle: async () => 'A Crisp New Title' });
    const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`);
    expect(res.status).toBe(200);
    expect(res.body.title).toBe('A Crisp New Title');

    const after = await repo.getLocalTask(task.id);
    expect(after?.title).toBe('A Crisp New Title');
    expect(after?.titleSource).toBe('agent');
    expect(after?.spaceId).toBe(space.id);
  });

  it('non-member gets 404 on title regeneration of a private space task', async () => {
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
    const task = await repo.createLocalTask({ title: 'old', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });

    const app = appAs(user('intruder'), { generateTitle: async () => 'nope' });
    const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`);
    expect(res.status).toBe(404);
    expect((await repo.getLocalTask(task.id))?.title).toBe('old');
  });
});
