import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { mountLocalTasksApi } from './local-tasks-api.js';

describe('POST /api/local/tasks with visibility', () => {
  let tempDir = '';
  let repo: Repository;
  let app: express.Application;
  let aliceUser: Express.User;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-api-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    aliceUser = {
      ...real,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = aliceUser;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
  });

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

  it('creates task with owner_id and visibility=org', async () => {
    const res = await request(app).post('/api/local/tasks').send({
      body: 'hello',
      piece: 'auto',
      visibility: 'org',
      visibilityScopeOrgId: '10',
    });
    expect(res.status).toBe(201);
    expect(res.body.task.visibility).toBe('org');
    expect(res.body.task.visibilityScopeOrgId).toBe('10');
    expect(res.body.task.ownerId).toBe(aliceUser.id);
  });

  it('rejects visibility=org with org not in user orgs', async () => {
    const res = await request(app).post('/api/local/tasks').send({
      body: 'hello',
      piece: 'auto',
      visibility: 'org',
      visibilityScopeOrgId: '99',
    });
    expect(res.status).toBe(400);
  });

  it('defaults visibility to private when not provided', async () => {
    const res = await request(app).post('/api/local/tasks').send({
      body: 'hello',
      piece: 'auto',
    });
    expect(res.status).toBe(201);
    expect(res.body.task.visibility).toBe('private');
    expect(res.body.task.visibilityScopeOrgId).toBeNull();
    expect(res.body.task.ownerId).toBe(aliceUser.id);
  });

  it('rejects invalid visibility enum values', async () => {
    const res = await request(app).post('/api/local/tasks').send({
      body: 'hello',
      piece: 'auto',
      visibility: 'bogus',
    });
    expect(res.status).toBe(400);
  });

  it('clears visibilityScopeOrgId when visibility is public', async () => {
    const res = await request(app).post('/api/local/tasks').send({
      body: 'hello',
      piece: 'auto',
      visibility: 'public',
      visibilityScopeOrgId: '10',
    });
    expect(res.status).toBe(201);
    expect(res.body.task.visibility).toBe('public');
    expect(res.body.task.visibilityScopeOrgId).toBeNull();
  });
});

describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
  let tempDir = '';
  let repo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
    return app;
  }

  it('non-owner non-admin gets 404 on DELETE (even when visibility=public)', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      ownerId: alice.id,
      visibility: 'public',
    });

    const bobUser: Express.User = {
      id: 'bob-id',
      email: 'b@x.com',
      name: 'b',
      avatarUrl: null,
      role: 'user',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const app = buildAppForUser(bobUser);

    // Bob CANNOT delete task owned by alice
    const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
    expect(delRes.status).toBe(404);

    // Task still exists (Bob's DELETE was rejected)
    const after = await repo.getLocalTask(task.id);
    expect(after).not.toBeNull();
  });

  it('admin can DELETE any task', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      ownerId: alice.id,
      visibility: 'private',
    });

    const adminUser: Express.User = {
      id: 'admin-id',
      email: 'admin@x.com',
      name: 'admin',
      avatarUrl: null,
      role: 'admin',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const app = buildAppForUser(adminUser);

    const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
    expect(delRes.status).toBe(200);
    expect(delRes.body.ok).toBe(true);

    const after = await repo.getLocalTask(task.id);
    expect(after).toBeNull();
  });

  it('owner can DELETE own task', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      ownerId: alice.id,
      visibility: 'private',
    });

    const app = buildAppForUser(aliceUser);
    const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
    expect(delRes.status).toBe(200);
    expect(delRes.body.ok).toBe(true);
  });
});

describe('PATCH /api/local/tasks/:id visibility', () => {
  let tempDir = '';
  let repo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
    return app;
  }

  it('owner can change visibility from private to org with valid orgId', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
    });

    const app = buildAppForUser(aliceUser);
    const res = await request(app)
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'org', visibilityScopeOrgId: '10' });
    expect(res.status).toBe(200);
    expect(res.body.task.visibility).toBe('org');
    expect(res.body.task.visibilityScopeOrgId).toBe('10');
  });

  it('rejects invalid visibility enum with 400', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
    });

    const app = buildAppForUser(aliceUser);
    const res = await request(app)
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'bogus' });
    expect(res.status).toBe(400);
  });

  it('rejects visibility=org without a scope org the user belongs to', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
    });

    const app = buildAppForUser(aliceUser);
    const res = await request(app)
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'org', visibilityScopeOrgId: '99' });
    expect(res.status).toBe(400);
  });

  it('non-owner non-admin gets 404 on PATCH', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'public',
    });

    const bobUser: Express.User = {
      id: 'bob-id',
      email: 'b@x.com',
      name: 'b',
      avatarUrl: null,
      role: 'user',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const app = buildAppForUser(bobUser);

    const res = await request(app)
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'private' });
    expect(res.status).toBe(404);

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

  it('cascades visibility change to the spawn job and its subtask descendants', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
    });
    const spawnJob = await repo.createJob({
      repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
      ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
    });
    const subJob = await repo.createJob({
      repo: `subtask/${spawnJob.id}`, issueNumber: 1, instruction: 'sub',
      parentJobId: spawnJob.id, subtaskDepth: 1,
      ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
    });
    const grandSubJob = await repo.createJob({
      repo: `subtask/${subJob.id}`, issueNumber: 1, instruction: 'sub-sub',
      parentJobId: subJob.id, subtaskDepth: 2,
      ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
    });

    const app = buildAppForUser(aliceUser);
    const res = await request(app)
      .patch(`/api/local/tasks/${task.id}`)
      .send({ visibility: 'org', visibilityScopeOrgId: '10' });
    expect(res.status).toBe(200);

    for (const id of [spawnJob.id, subJob.id, grandSubJob.id]) {
      const after = await repo.getJob(id);
      expect(after!.visibility).toBe('org');
      expect(after!.visibilityScopeOrgId).toBe('10');
    }
  });

  it('nulls the scope on descendants when visibility moves back to public', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));

    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
    });
    const spawnJob = await repo.createJob({
      repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
      ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
    });

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

    const after = await repo.getJob(spawnJob.id);
    expect(after!.visibility).toBe('public');
    expect(after!.visibilityScopeOrgId).toBeNull();
  });
});

describe('GET /api/local/tasks visibility filter', () => {
  let tempDir = '';
  let repo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
    return app;
  }

  async function seedThreeTasks(aliceId: string) {
    const priv = await repo.createLocalTask({ title: 'priv', body: 'b', ownerId: aliceId, visibility: 'private' });
    const org = await repo.createLocalTask({ title: 'org', body: 'b', ownerId: aliceId, visibility: 'org', visibilityScopeOrgId: '10' });
    const pub = await repo.createLocalTask({ title: 'pub', body: 'b', ownerId: aliceId, visibility: 'public' });
    return { priv, org, pub };
  }

  it('owner sees all three visibilities', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    await seedThreeTasks(alice.id);

    const res = await request(buildAppForUser(aliceUser)).get('/api/local/tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'priv', 'pub']);
  });

  it('admin sees all three', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const admin: Express.User = {
      id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null,
      role: 'admin', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    await seedThreeTasks(alice.id);

    const res = await request(buildAppForUser(admin)).get('/api/local/tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks).toHaveLength(3);
  });

  it('same-org bystander sees org + public (not private)', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const bob = repo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
    const bobUser: Express.User = {
      ...bob,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    await seedThreeTasks(alice.id);

    const res = await request(buildAppForUser(bobUser)).get('/api/local/tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'pub']);
  });

  it('different-org bystander sees only public', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const carol = repo.createUser({ email: 'c@x.com', name: 'c', role: 'user', status: 'active' });
    const carolUser: Express.User = {
      ...carol,
      orgIds: ['20'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    await seedThreeTasks(alice.id);

    const res = await request(buildAppForUser(carolUser)).get('/api/local/tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title)).toEqual(['pub']);
  });
});

describe('PUT /api/local/tasks/:taskId/feedback ownership', () => {
  let tempDir = '';
  let repo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
    return app;
  }

  it('non-owner non-admin gets 404 on feedback (even when visibility=public)', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });

    const bobUser: Express.User = {
      id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const res = await request(buildAppForUser(bobUser))
      .put(`/api/local/tasks/${task.id}/feedback`)
      .send({ rating: 'good', tags: [] });
    expect(res.status).toBe(404);
  });

  it('owner can submit feedback on own task', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });

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

  it('admin can submit feedback on any task', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });

    const adminUser: Express.User = {
      id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null,
      role: 'admin', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const res = await request(buildAppForUser(adminUser))
      .put(`/api/local/tasks/${task.id}/feedback`)
      .send({ rating: 'good', tags: [] });
    expect(res.status).toBe(200);
  });
});

describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
  let tempDir = '';
  let repo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
    });
    return app;
  }

  function makeBob(): Express.User {
    return {
      id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
  }

  it('non-owner non-admin gets 404 on POST /comments (private task)', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });

    const res = await request(buildAppForUser(makeBob()))
      .post(`/api/local/tasks/${task.id}/comments`)
      .send({ body: 'hi from bob', author: 'user' });
    expect(res.status).toBe(404);

    // Comment was NOT recorded
    const comments = await repo.listLocalTaskComments(task.id);
    expect(comments).toHaveLength(0);
  });

  it('non-owner non-admin gets 404 on POST /comments even when task is public', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });

    const res = await request(buildAppForUser(makeBob()))
      .post(`/api/local/tasks/${task.id}/comments`)
      .send({ body: 'hi from bob', author: 'user' });
    expect(res.status).toBe(404);
  });

  it('non-owner non-admin gets 404 on POST /cancel', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-cancel-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });

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

  it('owner can post comments to own task', async () => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice, orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws-alice') });

    const res = await request(buildAppForUser(aliceUser))
      .post(`/api/local/tasks/${task.id}/comments`)
      .send({ body: 'hi from alice', author: 'user' });
    expect(res.status).toBe(201);
  });
});

describe('POST /api/local/tasks browserSessionProfileId owner check', () => {
  let tempDir = '';
  let repo: Repository;
  let sessRepo: BrowserSessionRepo;
  let alice: { id: string };
  let bob: { id: string };
  let aliceProfileId: number;
  let bobProfileId: number;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-bsp-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    sessRepo = new BrowserSessionRepo(repo.getDb());
    alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    bob = repo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
    aliceProfileId = sessRepo.createProfile({
      ownerId: alice.id,
      label: 'alice-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
    bobProfileId = sessRepo.createProfile({
      ownerId: bob.id,
      label: 'bob-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
  });

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

  function buildAppForUser(user: Express.User): express.Application {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
      sessRepo,
    });
    return app;
  }

  function asUser(u: { id: string }, email: string): Express.User {
    return {
      id: u.id, email, name: 'x', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
  }

  it('accepts a valid profile owned by the requesting user (201)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/local/tasks')
      .send({ body: 'hello', piece: 'auto', browserSessionProfileId: aliceProfileId });
    expect(res.status).toBe(201);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('rejects a profile owned by a different user (400)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/local/tasks')
      .send({ body: 'hello', piece: 'auto', browserSessionProfileId: bobProfileId });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/not owned by you|not found/i);
  });

  it('rejects a positive integer that does not match any profile (400)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/local/tasks')
      .send({ body: 'hello', piece: 'auto', browserSessionProfileId: 999999 });
    expect(res.status).toBe(400);
  });

  it('rejects a non-positive-integer profile id (400)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/local/tasks')
      .send({ body: 'hello', piece: 'auto', browserSessionProfileId: -3 });
    expect(res.status).toBe(400);
  });

  it('without the field, behavior is unchanged (201, profile null)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/local/tasks')
      .send({ body: 'hello', piece: 'auto' });
    expect(res.status).toBe(201);
    expect(res.body.task.browserSessionProfileId ?? null).toBeNull();
  });
});

describe('POST /api/local/tasks/:id/continue', () => {
  let tempDir = '';
  let repo: Repository;
  let app: express.Application;
  let aliceUser: Express.User;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-cont-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    aliceUser = {
      ...real,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = aliceUser;
      next();
    });
    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
      pieceExists: (name: string) => name === 'manual-writer' || name === 'ssh-ops',
    });
  });

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

  async function setupTaskWithTerminalJob(opts: { status?: string } = {}) {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    const prev = await repo.createJob({
      repo: localTaskRepoName(task.id),
      issueNumber: task.id,
      instruction: 'go',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    await repo.updateJob(prev.id, { status: opts.status ?? 'succeeded' });
    await repo.addLocalTaskComment(task.id, 'agent', '✅ 完了\n\nmanual at output/manual.md', 'result');
    return { task, prev };
  }

  it('happy path: terminal succeeded job + valid piece + instruction → 201', async () => {
    const { task, prev } = await setupTaskWithTerminalJob();
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
    expect(res.status).toBe(201);
    expect(res.body.jobId).toBeTruthy();
    const newJob = await repo.getJob(res.body.jobId);
    expect(newJob?.pieceName).toBe('ssh-ops');
    expect(newJob?.continuedFromJobId).toBe(prev.id);
    const updatedTask = await repo.getLocalTask(task.id);
    expect(updatedTask?.pieceName).toBe('ssh-ops');
  });

  it('posts a handoff-kind comment naming both pieces', async () => {
    const { task } = await setupTaskWithTerminalJob();
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'ssh-ops', instruction: 'go' });
    expect(res.status).toBe(201);
    const comments = await repo.listLocalTaskComments(task.id);
    const handoff = comments.find((c) => c.kind === 'handoff');
    expect(handoff).toBeTruthy();
    expect(handoff?.body).toContain('manual-writer');
    expect(handoff?.body).toContain('ssh-ops');
  });

  it('returns 409 job_in_progress when prev job is running', async () => {
    const { task } = await setupTaskWithTerminalJob({ status: 'running' });
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'ssh-ops', instruction: 'go' });
    expect(res.status).toBe(409);
    expect(res.body.error).toBe('job_in_progress');
    expect(res.body.currentStatus).toBe('running');
  });

  it('returns 409 no_previous_job when no jobs exist for the task', async () => {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'ssh-ops', instruction: 'go' });
    expect(res.status).toBe(409);
    expect(res.body.error).toBe('no_previous_job');
  });

  it('returns 400 piece_not_found for unknown piece', async () => {
    const { task } = await setupTaskWithTerminalJob();
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'no-such-piece', instruction: 'go' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('piece_not_found');
  });

  it('returns 400 instruction_required for whitespace-only instruction', async () => {
    const { task } = await setupTaskWithTerminalJob();
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'ssh-ops', instruction: '   ' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('instruction_required');
  });

  it('returns 400 piece_required when piece field is missing', async () => {
    const { task } = await setupTaskWithTerminalJob();
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ instruction: 'go' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('piece_required');
  });

  it('all DB-valid terminal states allow continuation', async () => {
    // jobs.status CHECK constraint permits these four terminal states.
    // 'aborted' is intentionally absent — the worker maps abort outcomes to
    // 'failed' (see worker.ts handlePieceResult), so the endpoint's TERMINAL
    // list also excludes it (see commit 974ef89).
    for (const status of ['succeeded', 'failed', 'waiting_human', 'cancelled']) {
      const { task } = await setupTaskWithTerminalJob({ status });
      const res = await request(app)
        .post(`/api/local/tasks/${task.id}/continue`)
        .send({ piece: 'ssh-ops', instruction: `from ${status}` });
      expect(res.status, `status=${status}`).toBe(201);
    }
  });
});

// ---------------------------------------------------------------------------
// Regression: /continue must accept pieces that exist ONLY in the task
// owner's per-user dir.  The old pieceExists(name) callback never checked
// that dir and always returned false → 400 piece_not_found for user-custom
// pieces even though the worker would have loaded them successfully.
// ---------------------------------------------------------------------------
describe('POST /api/local/tasks/:id/continue — user-custom piece resolution', () => {
  let tempDir = '';
  let repo: Repository;
  let app: express.Application;
  let aliceUser: Express.User;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'lt-ucpiece-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    const real = repo.createUser({ email: 'uc@x.com', name: 'uc', role: 'user', status: 'active' });
    aliceUser = {
      ...real,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = aliceUser;
      next();
    });

    // Simulate the server.ts pieceExists implementation:
    //   per-user dir → (global-custom skipped here) → builtin dir.
    const builtinDir = join(tempDir, 'pieces');
    const userFolderRoot = join(tempDir, 'users');
    mkdirSync(builtinDir, { recursive: true });
    // 'manual-writer' only exists in the builtin dir (used for the starter job).
    writeFileSync(
      join(builtinDir, 'manual-writer.yaml'),
      'name: manual-writer\nmovements: []\n',
    );
    // 'my-custom-piece' exists ONLY in Alice's per-user dir.
    const userPiecesPath = join(userFolderRoot, aliceUser.id, 'pieces');
    mkdirSync(userPiecesPath, { recursive: true });
    writeFileSync(
      join(userPiecesPath, 'my-custom-piece.yaml'),
      'name: my-custom-piece\nmovements: []\n',
    );

    mountLocalTasksApi(app, {
      repo,
      worktreeDir: join(tempDir, 'workspaces'),
      pieceExists: (name: string, ownerId?: string) => {
        // Check owner's per-user dir first (mirrors worker resolution).
        if (ownerId) {
          if (existsSync(join(userFolderRoot, ownerId, 'pieces', `${name}.yaml`))) return true;
        }
        // Fall back to builtin dir.
        return existsSync(join(builtinDir, `${name}.yaml`));
      },
    });
  });

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

  it('accepts a piece that exists only in the owner per-user dir (regression: was 400)', async () => {
    // Set up a task owned by alice with a terminal starter job.
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    const prev = await repo.createJob({
      repo: localTaskRepoName(task.id),
      issueNumber: task.id,
      instruction: 'go',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    await repo.updateJob(prev.id, { status: 'succeeded' });

    // 'my-custom-piece' is ONLY in alice's per-user dir — should succeed.
    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'my-custom-piece', instruction: 'continue with user-custom piece' });
    expect(res.status).toBe(201);
    expect(res.body.jobId).toBeTruthy();
    const newJob = await repo.getJob(res.body.jobId);
    expect(newJob?.pieceName).toBe('my-custom-piece');
  });

  it('rejects a piece that exists in neither builtin nor user-custom dir', async () => {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    const prev = await repo.createJob({
      repo: localTaskRepoName(task.id),
      issueNumber: task.id,
      instruction: 'go',
      pieceName: 'manual-writer',
      ownerId: aliceUser.id,
    });
    await repo.updateJob(prev.id, { status: 'succeeded' });

    const res = await request(app)
      .post(`/api/local/tasks/${task.id}/continue`)
      .send({ piece: 'nonexistent-piece', instruction: 'go' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('piece_not_found');
  });
});
