import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { Server } from "bun";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Journal } from "../journal";
import { CollectorState } from "../state";
import { startServer } from "../server";
import { IncidentsUnavailableError, type IncidentsProvider } from "./provider";
import type { Incident, ListIncidentsQuery, ListIncidentsResult } from "./incident-service";
import type { LoadedIncidentOptions } from "./dispatch-options";

const TOKEN = "collector-incidents-token";

function optionsFixture(): LoadedIncidentOptions {
  const internal = new Map<string, { wrapperModel: string; permissionMode: "safe" | "unsafe"; fixed: boolean }>();
  internal.set(["claude", "fable", "high", "main", "safe"].join("\0"), { wrapperModel: "fable-high", permissionMode: "safe", fixed: false });
  return {
    types: [{ id: "resource-overload", title: "Resource overload", keywords: ["cpu", "load"] }],
    clis: [{ id: "claude", label: "Claude", models: [{ id: "fable", efforts: ["high"] }], accounts: [{ slug: "main", label: "Main", ready: true, fixed: false }], permissionModes: ["safe"] }],
    sourcePaths: [],
    internal,
  };
}

function incidentFixture(): Incident {
  return {
    id: "incident-1",
    kanboardTaskId: 42,
    title: "Collector wedged",
    description: "It stopped emitting deltas.",
    priority: "P1",
    incidentType: null,
    dispatchBrief: null,
    dispatchBriefProvenance: null,
    state: "running",
    active: true,
    createdAt: "2026-08-08T00:00:00.000Z",
    updatedAt: "2026-08-08T00:05:00.000Z",
    resolvedAt: null,
    dispatch: {
      state: "running",
      dispatchId: null,
      cli: "codex",
      model: null,
      wrapperModel: null,
      reasoningEffort: null,
      account: null,
      requestSha256: null,
      statusRevision: null,
      startedAt: null,
      heartbeatAt: null,
      completedAt: null,
      exitCode: null,
      failureClass: null,
      resultSummary: null,
    },
    activity: [],
    coverage: { stale: false },
  };
}

interface Recorder {
  queries: ListIncidentsQuery[];
  ids: string[];
}

function providerStub(overrides: Partial<IncidentsProvider>, recorder: Recorder): IncidentsProvider {
  return {
    async listIncidents(query) {
      recorder.queries.push(query);
      return { incidents: [incidentFixture()], coverage: { stale: false }, page: { highWater: 100, nextCursor: null, exhausted: true, order: 'id_desc' } } satisfies ListIncidentsResult;
    },
    async getIncident(incidentId) {
      recorder.ids.push(incidentId);
      return incidentId === "incident-1" ? incidentFixture() : null;
    },
    async fileIncident() {
      return incidentFixture();
    },
    async dispatchIncident() { return { ...incidentFixture(), state: "dispatching", dispatch: { ...incidentFixture().dispatch, state: "dispatching" } }; },
    async resolveIncident() { return { ...incidentFixture(), state: "resolved" }; },
    getDispatchLauncher() { return null; },
    ...overrides,
  };
}

describe("incident read routes", () => {
  let server: Server<undefined> | undefined;
  let dir: string;
  let state: CollectorState;
  let recorder: Recorder;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "collector-incident-routes-"));
    state = new CollectorState(new Journal(join(dir, "items.jsonl")));
    recorder = { queries: [], ids: [] };
  });

  afterEach(() => {
    server?.stop(true);
    server = undefined;
    rmSync(dir, { recursive: true, force: true });
  });

  function start(incidents?: IncidentsProvider, briefAssets?: Record<string, string>, loadIncidentOptions?: () => Promise<LoadedIncidentOptions>): string {
    const assets = briefAssets ?? {
      "taxonomy.json": '[{"id":"resource-overload","title":"Resource overload","absorbs":[],"skill":"od-overload","doctrine":"measure first","keywords":["cpu","load"]}]',
      "dispatch-template.md": "Incident {{incident_id}}\n{{situation}}\n{{placement_map}}\n{{never_touch}}",
      "placement-map.md": "placement",
      "never-touch.md": "never touch",
    };
    server?.stop(true);
    server = startServer({
      host: "127.0.0.1",
      port: 0,
      token: TOKEN,
      state,
      incidents,
      incidentBriefAssets: { readAsset: (name) => assets[name] ?? null, listKb: () => [], skillExists: () => true },
      incidentBriefProvenance: () => '{"sha":"route-fixture-sha"}',
      ...(loadIncidentOptions ? { loadIncidentOptions } : {}),
    });
    return `http://127.0.0.1:${server.port}`;
  }

  function get(origin: string, path: string): Promise<Response> {
    return fetch(`${origin}${path}`, { headers: { authorization: `Bearer ${TOKEN}` } });
  }

  function post(origin: string, path: string, body: unknown): Promise<Response> {
    return fetch(`${origin}${path}`, { method: "POST", headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: JSON.stringify(body) });
  }

  test("returns 404 for both routes when no incident store is configured", async () => {
    const origin = start(undefined);

    expect((await get(origin, "/incidents")).status).toBe(404);
    expect((await get(origin, "/incidents/incident-1")).status).toBe(404);
  });

  test("requires the bearer token", async () => {
    const origin = start(providerStub({}, recorder));

    expect((await fetch(`${origin}/incidents`)).status).toBe(401);
    expect(recorder.queries).toEqual([]);
  });

  test("defaults scope to active and forwards query and priority", async () => {
    const origin = start(providerStub({}, recorder));

    const defaulted = await get(origin, "/incidents");
    expect(defaulted.status).toBe(200);
    expect(await defaulted.json()).toEqual({ incidents: [incidentFixture()], coverage: { stale: false }, page: { highWater: 100, nextCursor: null, exhausted: true, order: "id_desc" } });

    await get(origin, "/incidents?scope=all&query=wedged&priority=P1");
    expect(recorder.queries).toEqual([
      { scope: "active", limit: 50 },
      { scope: "all", query: "wedged", priority: "P1", limit: 50 },
    ]);
  });

  test("rejects an unknown scope or priority without reaching the store", async () => {
    const origin = start(providerStub({}, recorder));

    const badScope = await get(origin, "/incidents?scope=everything");
    expect(badScope.status).toBe(400);
    expect(await badScope.json()).toEqual({ error: "invalid-query" });

    const badPriority = await get(origin, "/incidents?priority=P9");
    expect(badPriority.status).toBe(400);
    expect(recorder.queries).toEqual([]);
  });

  test("defaults pagination and rejects unbounded list requests before reaching the store", async () => {
    const origin = start(providerStub({}, recorder));

    expect((await get(origin, "/incidents")).status).toBe(200);
    expect(recorder.queries).toEqual([{ scope: "active", limit: 50 }]);

    expect((await get(origin, `/incidents?query=${"x".repeat(257)}`)).status).toBe(400);
    expect((await get(origin, "/incidents?limit=101")).status).toBe(400);
    expect((await get(origin, "/incidents?limit=25&cursor=-1")).status).toBe(400);
    expect(recorder.queries).toHaveLength(1);

    expect((await get(origin, "/incidents?limit=25&cursor=50")).status).toBe(200);
    expect(recorder.queries[1]).toEqual({ scope: "active", limit: 25, cursor: 50 });
  });

  test("returns a single incident and 404 for an unknown id", async () => {
    const origin = start(providerStub({}, recorder));

    const found = await get(origin, "/incidents/incident-1");
    expect(found.status).toBe(200);
    expect(await found.json()).toEqual(incidentFixture());

    const missing = await get(origin, "/incidents/nope");
    expect(missing.status).toBe(404);
    expect(await missing.json()).toEqual({ error: "not-found" });
    expect(recorder.ids).toEqual(["incident-1", "nope"]);
  });

  test("decodes the incident id segment", async () => {
    const origin = start(providerStub({}, recorder));

    await get(origin, `/incidents/${encodeURIComponent("incident 1/2")}`);
    expect(recorder.ids).toEqual(["incident 1/2"]);
  });

  test("surfaces an unavailable store as 503 on both routes", async () => {
    const unavailable = () => Promise.reject(new IncidentsUnavailableError("incidents store unavailable"));
    const origin = start(providerStub({ listIncidents: unavailable, getIncident: unavailable }, recorder));

    const list = await get(origin, "/incidents");
    expect(list.status).toBe(503);
    expect(await list.json()).toEqual({ error: "incidents-store-unavailable" });

    const detail = await get(origin, "/incidents/incident-1");
    expect(detail.status).toBe(503);
    expect(await detail.json()).toEqual({ error: "incidents-store-unavailable" });
  });

  test("files and dispatches through dedicated mutation routes", async () => {
    let fileCalls = 0;
    const blockedOrigin = start(providerStub({}, recorder));
    const blocked = await post(blockedOrigin, "/incidents", { requestId: "6ba7b810-9dad-41d1-80b4-00c04fd430c8", title: "Wedged", description: "No deltas", cli: "claude", model: "fable", reasoningEffort: "high", account: "main", unsafe: false, priority: "P1" });
    expect(blocked.status).toBe(503);
    expect(fileCalls).toBe(0);

    const dispatchBlocked = await post(blockedOrigin, "/incidents/incident-1/dispatch", {});
    expect(dispatchBlocked.status).toBe(503);
    expect(await dispatchBlocked.json()).toEqual({ error: "incident-assets-unavailable" });

    const origin = start(providerStub({
      async fileIncident() {
        fileCalls += 1;
        return incidentFixture();
      },
    }, recorder), undefined, async () => optionsFixture());
    const unsafe = await post(origin, "/incidents", { requestId: "d165300e-0503-4afe-9793-a6d9553d66b2", title: "Wedged", description: "No deltas", cli: "claude", model: "fable", reasoningEffort: "high", account: "main", unsafe: true, priority: "P1" });
    expect(unsafe.status).toBe(400);
    expect(fileCalls).toBe(0);

    const filed = await post(origin, "/incidents", { requestId: "6ba7b810-9dad-41d1-80b4-00c04fd430c8", title: "Wedged", description: "No deltas", cli: "claude", model: "fable", reasoningEffort: "high", account: "main", unsafe: false, priority: "P1" });
    expect(filed.status).toBe(201);
    expect(fileCalls).toBe(1);
    expect((await filed.json() as { incident: { id: string } }).incident.id).toBe("incident-1");

    const dispatched = await post(origin, "/incidents/incident-1/dispatch", {});
    expect(dispatched.status).toBe(200);
    expect((await dispatched.json() as { incident: { state: string } }).incident.state).toBe("dispatching");
  });

  test("rejects an oversized dispatch body before allocation", async () => {
    const origin = start(providerStub({}, recorder), undefined, async () => optionsFixture());
    const response = await fetch(`${origin}/incidents/incident-1/dispatch`, {
      method: "POST",
      headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
      body: "x".repeat(64 * 1024 + 1),
    });
    expect(response.status).toBe(413);
    expect(await response.json()).toEqual({ error: "payload-too-large" });
  });

  test("serves taxonomy options and reports missing assets as unavailable", async () => {
    const origin = start(providerStub({}, recorder));

    const options = await get(origin, "/incidents/options");
    expect(options.status).toBe(200);
    expect(await options.json()).toEqual({ types: [{ id: "resource-overload", title: "Resource overload", keywords: ["cpu", "load"] }] });

    const bare = start(providerStub({}, recorder), {});
    const missing = await get(bare, "/incidents/options");
    expect(missing.status).toBe(503);
    expect((await missing.json() as { error: string }).error).toBe("incident-assets-unavailable");
  });

  test("requires auth and returns 404 for an unknown incident on the brief route", async () => {
    const origin = start(providerStub({}, recorder));

    expect((await fetch(`${origin}/incidents/incident-1/brief`)).status).toBe(401);

    const missing = await get(origin, "/incidents/nope/brief");
    expect(missing.status).toBe(404);
    expect(await missing.json()).toEqual({ error: "not-found" });
  });

  test("assembles a fresh brief and prefers the persisted one", async () => {
    const origin = start(providerStub({}, recorder));
    const fresh = await get(origin, "/incidents/incident-1/brief");
    expect(fresh.status).toBe(200);
    const freshBody = await fresh.json() as { persisted: boolean; provenance: string; brief: { text: string } };
    expect(freshBody.persisted).toBe(false);
    expect(freshBody.provenance).toBe('{"sha":"route-fixture-sha"}');
    expect(freshBody.brief.text).toContain("Incident incident-1");

    const persistedOrigin = start(providerStub({
      async getIncident() {
        return { ...incidentFixture(), dispatchBrief: "stored brief text", dispatchBriefProvenance: '{"sha":"older"}' };
      },
    }, recorder));
    const persisted = await get(persistedOrigin, "/incidents/incident-1/brief");
    expect(persisted.status).toBe(200);
    const persistedBody = await persisted.json() as { persisted: boolean; brief: { text: string }; provenance: string };
    expect(persistedBody.persisted).toBe(true);
    expect(persistedBody.brief.text).toBe("stored brief text");
    expect(persistedBody.provenance).toBe('{"sha":"older"}');
  });

  test("surfaces a brief assembly failure as a named refusal, never a bare 500", async () => {
    const origin = start(providerStub({}, recorder), {});
    const blocked = await get(origin, "/incidents/incident-1/brief");
    expect(blocked.status).toBe(422);
    const body = await blocked.json() as { error: string; detail: string };
    expect(body.error).toBe("brief-assembly-failed");
    expect(body.detail).toContain("dispatch blocked: brief assembly failed —");
  });
});
