import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import postgres from 'postgres';
import { registerTaskRoutes } from '../tasks.js';

/**
 * F1 — POST /api/tasks/:id/cancel route wiring.
 *
 * The route's job: reach the in-flight external run via `cancelExternal(taskId)`
 * (the new abort hook), keep cancelling native inference for open chats unchanged,
 * and land the task row in 'cancelled'. The streaming assistant message is
 * finalized by the dispatcher's run-function, not here — that path is covered by
 * finalize-message.test.ts. This suite pins the route's behavior against a real DB.
 */
describe.runIf(!!process.env.DATABASE_URL)('POST /api/tasks/:id/cancel (route, F1)', () => {
  let sql: ReturnType<typeof postgres>;
  let app: FastifyInstance;
  let projectId: string;
  let sessionId: string;
  let chatId: string;

  const externalCancelCalls: string[] = [];
  const inferenceCancelCalls: Array<[string, string]> = [];
  let externalReturns = true;

  beforeAll(async () => {
    sql = postgres(process.env.DATABASE_URL!, { max: 3 });
    const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql');
    const coderSchema = resolve(__dirname, '../../schema.sql');
    await sql.unsafe(readFileSync(serverSchema, 'utf8'));
    await sql.unsafe(readFileSync(coderSchema, 'utf8'));

    const [p] = await sql<{ id: string }[]>`
      INSERT INTO projects (name, path, status) VALUES ('f1-cancel-route', '/tmp/f1-cancel-route', 'open') RETURNING id
    `;
    projectId = p!.id;
    const [s] = await sql<{ id: string }[]>`
      INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'f1', 'm', 'open') RETURNING id
    `;
    sessionId = s!.id;
    const [c] = await sql<{ id: string }[]>`
      INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id
    `;
    chatId = c!.id;

    app = Fastify();
    registerTaskRoutes(
      app,
      sql,
      {
        cancel: async (sid: string, cid: string) => {
          inferenceCancelCalls.push([sid, cid]);
          return false;
        },
      },
      (taskId: string) => {
        externalCancelCalls.push(taskId);
        return externalReturns;
      },
    );
    await app.ready();
  });

  afterAll(async () => {
    if (app) await app.close();
    if (!sql) return;
    await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.catch(() => {});
    await sql`DELETE FROM tasks WHERE project_id = ${projectId}`.catch(() => {});
    await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {});
    await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {});
    await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {});
    await sql.end({ timeout: 5 });
  });

  async function insertTask(agent: string | null, state: string): Promise<string> {
    const [t] = await sql<{ id: string }[]>`
      INSERT INTO tasks (project_id, input, agent, session_id, state, started_at)
      VALUES (${projectId}, 'do a thing', ${agent}, ${sessionId}, ${state}, clock_timestamp())
      RETURNING id
    `;
    return t!.id;
  }

  it('reaches cancelExternal and lands the task cancelled for a running external task', async () => {
    externalReturns = true;
    externalCancelCalls.length = 0;
    const taskId = await insertTask('opencode', 'running');

    const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ cancelled: true });

    expect(externalCancelCalls).toContain(taskId);

    const [row] = await sql<{ state: string; ended_at: Date | null }[]>`
      SELECT state, ended_at FROM tasks WHERE id = ${taskId}
    `;
    expect(row!.state).toBe('cancelled');
    expect(row!.ended_at).not.toBeNull();
  });

  it('still cancels a native boocode task (cancelExternal returns false → inference.cancel path unchanged)', async () => {
    externalReturns = false; // native task: no controller registered
    externalCancelCalls.length = 0;
    inferenceCancelCalls.length = 0;
    const taskId = await insertTask(null, 'running');

    const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
    expect(res.statusCode).toBe(200);

    // The route calls cancelExternal unconditionally (cheap, returns false here)...
    expect(externalCancelCalls).toContain(taskId);
    // ...and the native inference.cancel path still fires for the open chat.
    expect(inferenceCancelCalls).toContainEqual([sessionId, chatId]);

    const [row] = await sql<{ state: string }[]>`SELECT state FROM tasks WHERE id = ${taskId}`;
    expect(row!.state).toBe('cancelled');
  });

  it('rejects cancelling an already-terminal task with 409 and never touches the abort hook', async () => {
    externalCancelCalls.length = 0;
    const taskId = await insertTask('opencode', 'completed');

    const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
    expect(res.statusCode).toBe(409);
    expect(externalCancelCalls).not.toContain(taskId);
  });

  it('returns 404 for an unknown task', async () => {
    const res = await app.inject({
      method: 'POST',
      url: `/api/tasks/00000000-0000-0000-0000-000000000000/cancel`,
    });
    expect(res.statusCode).toBe(404);
  });
});
