import { describe, expect, test } from "bun:test";
import type { FetchLike } from "./kanboard-client";
import { createKanboardClient } from "./kanboard-client";
import { IncidentOptionsError } from "./dispatch-options";
import { IncidentMutationError } from "./incident-service";
import { IncidentsUnavailableError, createIncidentsProvider } from "./provider";

const BASE_URL = "http://127.0.0.1:31339/jsonrpc.php";
const TOKEN = "incident-provider-token";

const PROJECT = {
  id: 11,
  identifier: "OVERDECKINCIDENTS",
  name: "Overdeck Incidents",
  is_public: false,
  is_private: false,
  is_active: true,
  per_swimlane_task_limits: false,
  enable_global_tags: false,
  hide_in_dashboard: false,
  priority_start: 0,
  priority_end: 3,
  priority_default: 2,
  description: null,
  email: null,
  token: "project-token",
  url: {},
  last_modified: 0,
};

const COLUMNS = [
  { id: 101, title: "Filed", position: 1 },
  { id: 102, title: "Dispatching", position: 2 },
  { id: 103, title: "Running", position: 3 },
  { id: 104, title: "Needs attention", position: 4 },
  { id: 105, title: "Resolved", position: 5 },
];

const SWIMLANES = [
  { id: 201, name: "P0 · Critical", position: 1, is_active: true },
  { id: 202, name: "P1 · High", position: 2, is_active: true },
  { id: 203, name: "P2 · Normal", position: 3, is_active: true },
  { id: 204, name: "P3 · Low", position: 4, is_active: true },
];

const FILED_TASK = {
  id: 9001,
  title: "Filed for provider seam",
  description: "Dispatch authority load path",
  date_creation: 1710000000,
  color_id: "yellow",
  project_id: PROJECT.id,
  column_id: 101,
  owner_id: 0,
  position: 1,
  is_active: true,
  date_completed: null,
  score: 0,
  date_due: 0,
  category_id: 0,
  creator_id: 0,
  date_modification: 1710000000,
  reference: "provider-filed-1",
  date_started: 1710000000,
  time_spent: null,
  time_estimated: null,
  swimlane_id: 202,
  date_moved: 1710000000,
  recurrence_status: null,
  recurrence_counter: null,
  recurrence_parent: 0,
  priority: 1,
  external_provider: null,
  external_uri: null,
  url: "https://example.test/task/9001",
  color: { name: "yellow", background: "#123456", border: "#654321" },
};

const FILED_METADATA = {
  "overdeck.schema": "incident/v1",
  "overdeck.incident_id": "provider-filed-1",
  "overdeck.request_sha256": "abc",
  "overdeck.cli": "codex",
  "overdeck.model": "gpt-5.6-sol",
  "overdeck.wrapper_model": "gpt-5.6-sol-high",
  "overdeck.reasoning_effort": "high",
  "overdeck.account": "main",
  "overdeck.unsafe": "0",
  "overdeck.priority": "P1",
};

interface Harness {
  fetchImpl: FetchLike;
  bootstrapCalls: () => number;
  failBootstrap: (fail: boolean) => void;
  seedFiledIncident: () => void;
}

function createHarness(): Harness {
  let bootstrapCalls = 0;
  let fail = false;
  let filed = false;
  const results: Record<string, unknown> = {
    getProjectByIdentifier: PROJECT,
    getColumns: COLUMNS,
    getAllSwimlanes: SWIMLANES,
    getUserByName: { id: 501 },
    getAllTaskIds: [],
    "overdeck.listTasksBounded": { tasks: [], high_water: 0, next_cursor: null, exhausted: true, complete: true, order: "id_desc" },
    getAllTasks: [],
    getTaskByReference: null,
  };

  const fetchImpl: FetchLike = async (_input, init) => {
    const request = JSON.parse(String(init?.body)) as { id: string; method: string; params?: Record<string, unknown> };
    if (request.method === "getProjectByIdentifier") {
      bootstrapCalls += 1;
      if (fail) throw new Error("kanboard unreachable");
    }
    if (filed) {
      if (request.method === "getTaskByReference" || request.method === "getTask") {
        return new Response(
          JSON.stringify({ jsonrpc: "2.0", id: request.id, result: FILED_TASK }),
          { status: 200 },
        );
      }
      if (request.method === "getTaskMetadata") {
        return new Response(
          JSON.stringify({ jsonrpc: "2.0", id: request.id, result: FILED_METADATA }),
          { status: 200 },
        );
      }
      if (request.method === "getAllTasks") {
        return new Response(
          JSON.stringify({ jsonrpc: "2.0", id: request.id, result: [FILED_TASK] }),
          { status: 200 },
        );
      }
    }
    if (!(request.method in results)) {
      throw new Error(`Unexpected method ${request.method}`);
    }
    return new Response(
      JSON.stringify({ jsonrpc: "2.0", id: request.id, result: results[request.method] }),
      { status: 200 },
    );
  };

  return {
    fetchImpl,
    bootstrapCalls: () => bootstrapCalls,
    failBootstrap: (next) => { fail = next; },
    seedFiledIncident: () => { filed = true; },
  };
}

function createProvider(harness: Harness, now: () => number, readinessTtlMs = 60_000) {
  const client = createKanboardClient({ baseUrl: BASE_URL, token: TOKEN, fetchImpl: harness.fetchImpl });
  return createIncidentsProvider({ client, readinessTtlMs, now, loadIncidentOptions: async () => ({ types: [], clis: [], sourcePaths: [], internal: new Map() }) });
}

describe("createIncidentsProvider", () => {
  test("resolves readiness lazily", async () => {
    const harness = createHarness();
    createProvider(harness, () => 0);

    expect(harness.bootstrapCalls()).toBe(0);
  });

  test("caches readiness inside the ttl and re-resolves after it", async () => {
    const harness = createHarness();
    let clock = 0;
    const provider = createProvider(harness, () => clock, 60_000);

    await provider.listIncidents({ scope: "active" });
    clock = 59_999;
    await provider.listIncidents({ scope: "active" });
    expect(harness.bootstrapCalls()).toBe(1);

    clock = 60_000;
    await provider.listIncidents({ scope: "active" });
    expect(harness.bootstrapCalls()).toBe(2);
  });

  test("shares a single in-flight resolution across concurrent callers", async () => {
    const harness = createHarness();
    const provider = createProvider(harness, () => 0);

    await Promise.all([
      provider.listIncidents({ scope: "active" }),
      provider.listIncidents({ scope: "active" }),
      provider.getIncident("missing"),
    ]);

    expect(harness.bootstrapCalls()).toBe(1);
  });

  test("does not cache a failed resolution", async () => {
    const harness = createHarness();
    const provider = createProvider(harness, () => 0);

    harness.failBootstrap(true);
    await expect(provider.listIncidents({ scope: "active" })).rejects.toBeInstanceOf(IncidentsUnavailableError);
    expect(harness.bootstrapCalls()).toBe(1);

    harness.failBootstrap(false);
    const result = await provider.listIncidents({ scope: "active" });
    expect(result.incidents).toEqual([]);
    expect(harness.bootstrapCalls()).toBe(2);
  });

  test("preserves named mutation errors instead of wrapping them as unavailable", async () => {
    const harness = createHarness();
    const provider = createIncidentsProvider({
      client: createKanboardClient({ baseUrl: BASE_URL, token: TOKEN, fetchImpl: harness.fetchImpl }),
      now: () => 0,
      loadIncidentOptions: async () => ({ types: [], clis: [], sourcePaths: [], internal: new Map() }),
    });

    const error = await provider.dispatchIncident("missing").then(
      () => undefined,
      (caught: unknown) => caught,
    );

    expect(error).toBeInstanceOf(IncidentMutationError);
    expect((error as IncidentMutationError).code).toBe("not-found");
    expect(error).not.toBeInstanceOf(IncidentsUnavailableError);
  });

  test("rethrows IncidentOptionsError from loadIncidentOptions during dispatch with same kind/detail", async () => {
    const harness = createHarness();
    harness.seedFiledIncident();
    const optionsError = new IncidentOptionsError("assets", "manifest broken for provider seam");
    const provider = createIncidentsProvider({
      client: createKanboardClient({ baseUrl: BASE_URL, token: TOKEN, fetchImpl: harness.fetchImpl }),
      now: () => 0,
      loadIncidentOptions: async () => {
        throw optionsError;
      },
    });

    const error = await provider.dispatchIncident("provider-filed-1").then(
      () => undefined,
      (caught: unknown) => caught,
    );

    expect(error).toBe(optionsError);
    expect(error).toBeInstanceOf(IncidentOptionsError);
    expect((error as IncidentOptionsError).kind).toBe("assets");
    expect((error as IncidentOptionsError).detail).toBe("manifest broken for provider seam");
    expect(error).not.toBeInstanceOf(IncidentsUnavailableError);
  });

  test("wraps unrelated loadIncidentOptions failures as IncidentsUnavailableError", async () => {
    const harness = createHarness();
    harness.seedFiledIncident();
    const provider = createIncidentsProvider({
      client: createKanboardClient({ baseUrl: BASE_URL, token: TOKEN, fetchImpl: harness.fetchImpl }),
      now: () => 0,
      loadIncidentOptions: async () => {
        throw new Error("disk chewed the manifest");
      },
    });

    const error = await provider.dispatchIncident("provider-filed-1").then(
      () => undefined,
      (caught: unknown) => caught,
    );

    expect(error).toBeInstanceOf(IncidentsUnavailableError);
    expect((error as Error).message).toBe("incidents store unavailable");
    expect(`${(error as Error).message} ${(error as Error).stack ?? ""}`).not.toContain("disk chewed");
  });

  test("never leaks the token or store address through the failure message", async () => {
    const harness = createHarness();
    const provider = createProvider(harness, () => 0);
    harness.failBootstrap(true);

    const error = await provider.getIncident("anything").then(
      () => undefined,
      (caught: unknown) => caught,
    );

    expect(error).toBeInstanceOf(IncidentsUnavailableError);
    const text = `${(error as Error).message} ${(error as Error).stack ?? ""}`;
    expect(text).not.toContain(TOKEN);
    expect(text).not.toContain("31339");
  });
});
