import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CollectorState } from "../src/state";
import { Journal } from "../src/journal";
import { startServer, type CollectorServer } from "../src/server";
import { buildScheduler } from "../src/collector";
import { FixtureAdapter } from "./support/fixtureAdapter";
import { ManualClock } from "./support/manualClock";
import { CollectorFatalError } from "../src/errors";
import { HarnessApiError, type HarnessAdapter } from "../src/adapters/harness";

const TOKEN = "test-token-abc123";
// Port 0 lets the OS assign a free one: a fixed port collides whenever two
// checkouts run this suite on the same build box at the same time.
let PORT = 0;

let server: CollectorServer;
let state: CollectorState;
let harnessCalls: Array<{ route: string; query?: Record<string, unknown> }>;
let streamCancelled = false;
let hookControlsDir: string;

beforeAll(async () => {
  const dir = mkdtempSync(join(tmpdir(), "overdeck-server-"));
  state = new CollectorState(new Journal(join(dir, "items.jsonl")));
  const adapter = new FixtureAdapter("fixture-http", 1000);
  const clock = new ManualClock();
  const scheduler = buildScheduler([adapter], state, clock, () => 0.5);
  scheduler.start();
  await clock.advance(0);

  harnessCalls = [];
  hookControlsDir = mkdtempSync(join(tmpdir(), 'overdeck-hook-controls-server-'));
  const harness: Pick<HarnessAdapter, "getRunEvents" | "openTaskStream" | "getRunConfig" | "getEffectivePlan"> = {
    getRunEvents: async (runId, query) => {
      harnessCalls.push({ route: `events:${runId}`, query });
      return { events: [], nextSince: "next", capabilities: {} };
    },
    openTaskStream: async (runId, taskId, query) => {
      harnessCalls.push({ route: `stream:${runId}:${taskId}`, query });
      query?.signal?.addEventListener("abort", () => {
        streamCancelled = true;
      }, { once: true });
      return new Response(new ReadableStream({
        start(controller) {
          if (runId === "malformed") {
            controller.enqueue(new TextEncoder().encode("id: opaque-malformed\ndata: token=stream-secret\n\n"));
            return;
          }
          if (runId === "oversized") {
            controller.enqueue(new TextEncoder().encode(`data: ${"x".repeat(64 * 1024 + 1)}\n\n`));
            return;
          }
          if (runId === "oversized-incomplete") {
            controller.enqueue(new TextEncoder().encode(`data: ${"x".repeat(64 * 1024 + 1)}`));
            return;
          }
          controller.enqueue(new TextEncoder().encode("id: opaque-1\ndata: {\"nested\":{\"authorization\":\"stream-secret\"}}\n\n"));
        },
        cancel() {
          streamCancelled = true;
        },
      }), { headers: { "content-type": "text/event-stream" } });
    },
    getRunConfig: async (runId) => {
      harnessCalls.push({ route: `config:${runId}` });
      if (runId === "missing") throw new HarnessApiError(404, { error: "run-not-found" }, "test");
      return {
        revision: "rev-1",
        fields: {
          watchdog: { value: { idleTimeoutMs: 50, nested: [{ authorization: "nested-secret" }] }, source: "run-override", immutable: false, mutationClass: "mid-run" },
          apiKey: { value: "browser-must-not-see", source: "env", immutable: true, mutationClass: "global" },
        },
      };
    },
    getEffectivePlan: async (runId) => {
      harnessCalls.push({ route: `plan:${runId}` });
      return { runId, revision: "rev-1", planHash: "hash", tasks: [], meta: null };
    },
  };

  const hooksRoot = mkdtempSync(join(tmpdir(), "overdeck-server-hooks-"));
  const hookScript = join(hooksRoot, ".claude", "hooks", "gate.sh");
  mkdirSync(join(hooksRoot, ".claude", "hooks"), { recursive: true });
  writeFileSync(hookScript, "#!/bin/sh\n");
  chmodSync(hookScript, 0o755);
  const hookSettings = join(hooksRoot, ".claude", "settings.json");
  writeFileSync(hookSettings, JSON.stringify({ hooks: {
    PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: hookScript }] }],
    Stop: [{ hooks: [{ type: "command", command: join(hooksRoot, ".claude", "hooks", "gone.sh") }] }],
  } }));

  server = startServer({
    host: "127.0.0.1",
    port: 0,
    token: TOKEN,
    state,
    harness: harness as HarnessAdapter,
    hookInventoryOptions: { homeDir: hooksRoot, cwd: hooksRoot, settingsFiles: [hookSettings] },
    hookControlsConfigDirectory: hookControlsDir,
  });
  PORT = server.port;
});

test("factory detail work leaves state responsive and rejects concurrency", async () => {
  let release!: (result: { status: "ok"; body: string }) => void;
  const pending = new Promise<{ status: "ok"; body: string }>((resolve) => { release = resolve; });
  const detailServer = startServer({
    host: "127.0.0.1",
    port: 0,
    token: TOKEN,
    state,
    loadFactoryRunDetail: () => pending,
  });
  const detailUrl = `http://127.0.0.1:${detailServer.port}`;
  const headers = { authorization: `Bearer ${TOKEN}` };
  try {
    const first = fetch(`${detailUrl}/factory/runs/run-1`, { headers });
    await Bun.sleep(10);
    const [stateResponse, busyResponse] = await Promise.all([
      fetch(`${detailUrl}/state`, { headers }),
      fetch(`${detailUrl}/factory/runs/run-2`, { headers }),
    ]);
    expect(stateResponse.status).toBe(200);
    expect(busyResponse.status).toBe(429);
    release({ status: "ok", body: JSON.stringify({ adwId: "run-1" }) });
    expect((await first).status).toBe(200);
  } finally {
    detailServer.stop(true);
  }
});

afterAll(() => {
  server.stop(true);
});

function url(path: string): string {
  return `http://127.0.0.1:${PORT}${path}`;
}

describe('hook controls routes', () => {
  test('uses registry defaults, exact JSON essence, and stable known-route methods', async () => {
    const headers = { authorization: `Bearer ${TOKEN}` };
    expect((await fetch(url('/config/hooks'), { headers })).status).toBe(200);
    const jsonp = await fetch(url('/config/hooks/background-jobs-blocker'), { method: 'POST', headers: { ...headers, 'content-type': 'application/jsonp' }, body: JSON.stringify({ enabled: false }) });
    expect(jsonp.status).toBe(415);
    const method = await fetch(url('/config/hooks'), { method: 'POST', headers });
    expect(method.status).toBe(405);
    expect((await method.json() as { error: string }).error).toBe('method-not-allowed');
  });

  test('rejects prototype hook IDs as unknown-hook-control', async () => {
    const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
    const res = await fetch(url('/config/hooks/toString'), { method: 'POST', headers, body: JSON.stringify({ enabled: false }) });
    expect(res.status).toBe(404);
    expect((await res.json() as { error: string }).error).toBe('unknown-hook-control');
  });
});

describe("auth", () => {
  test("missing Authorization header is rejected with 401", async () => {
    const res = await fetch(url("/state"));
    expect(res.status).toBe(401);
  });

  test("wrong bearer token is rejected with 401", async () => {
    const res = await fetch(url("/state"), {
      headers: { authorization: "Bearer wrong-token" },
    });
    expect(res.status).toBe(401);
  });

  test("correct bearer token is accepted", async () => {
    const res = await fetch(url("/state"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    expect(res.status).toBe(200);
  });
});

describe("endpoints", () => {
  test("GET /health returns 401 without auth", async () => {
    const res = await fetch(url("/health"));
    expect(res.status).toBe(401);
  });

  test("GET /health returns ok with valid bearer", async () => {
    const res = await fetch(url("/health"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ ok: true });
  });

  test("GET /state returns panels and adapter statuses", async () => {
    const res = await fetch(url("/state"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    const body = (await res.json()) as { panels: unknown[]; adapters: { id: string }[] };
    expect(body.panels).toHaveLength(1);
    expect(body.adapters[0]?.id).toBe("fixture-http");
  });

  test("cluster reads expose the snapshot, exact node, workload evidence, and honest 404s", async () => {
    const snapshot = {
      observedAt: "2026-08-08T12:00:00.000Z", cluster: { id: "fixture", state: "reachable" }, sources: {}, summary: {},
      nodes: [{ name: "node-a", workloads: [{ uid: "pod-a", evidence: [{ source: "kubernetes", field: "pod.spec.nodeName", value: "node-a" }] }], sessions: [{ workloadUid: "pod-a" }], builds: [{ workloadUid: "pod-a" }] }],
      unplacedSessions: [], unmatchedWorkloads: [], unplacedBuilds: [],
    };
    state.publish("kubernetes", [], [{ id: "cluster:k3s", ts: snapshot.observedAt, data: snapshot }]);
    const headers = { authorization: `Bearer ${TOKEN}` };
    expect((await fetch(url("/cluster"), { headers })).status).toBe(200);
    expect(await (await fetch(url("/cluster/nodes/node-a"), { headers })).json()).toMatchObject({ node: { name: "node-a" } });
    expect(await (await fetch(url("/cluster/workloads/pod-a"), { headers })).json()).toMatchObject({ workload: { uid: "pod-a" }, sessions: [{ workloadUid: "pod-a" }] });
    expect((await fetch(url("/cluster/nodes/unknown"), { headers })).status).toBe(404);
    expect((await fetch(url("/cluster/workloads/unknown"), { headers })).status).toBe(404);
  });

  test("GET /items returns items, filterable by kind", async () => {
    const res = await fetch(url("/items?kind=progress"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    const body = (await res.json()) as { items: unknown[] };
    expect(body.items).toHaveLength(1);

    const resMiss = await fetch(url("/items?kind=ci"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    const bodyMiss = (await resMiss.json()) as { items: unknown[] };
    expect(bodyMiss.items).toHaveLength(0);
  });

  test("GET /events streams an SSE delta on new poll data", async () => {
    const res = await fetch(url("/events"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toContain("text/event-stream");

    const reader = res.body!.getReader();
    const initial = await reader.read(); // initial ": connected" comment flushes headers
    expect(new TextDecoder().decode(initial.value)).toContain("connected");

    const readPromise = reader.read();

    state.recordSuccess("fixture-http", Date.now(), [
      {
        id: "sse-item-1",
        source: "fixture-http",
        severity: "info",
        kind: "progress",
        title: "sse",
        detail: "sse",
        ts: new Date().toISOString(),
        actions: [],
      },
    ], []);

    const { value } = await readPromise;
    const text = new TextDecoder().decode(value);
    expect(text).toContain("sse-item-1");

    await reader.cancel();
  });

  test("an idle /events stream outlives Bun's default 10s idleTimeout", async () => {
    const res = await fetch(url("/events"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    const reader = res.body!.getReader();
    await reader.read(); // ": connected"

    const next = reader.read();
    // no delta is recorded here: the stream must stay open through the idle window,
    // not be torn down mid-render the way the deck's live feed was.
    const verdict = await Promise.race([
      next.then(({ done }) => (done ? "closed" : "delta")),
      Bun.sleep(12_000).then(() => "still-open" as const),
    ]);
    expect(verdict).toBe("still-open");

    await reader.cancel();
  }, 20_000);

  test("harness reads are authenticated, exact, and preserve query cursor", async () => {
    expect((await fetch(url("/harness/runs/run-1/events?since=a%3Ab&task=t-1"))).status).toBe(401);

    const events = await fetch(url("/harness/runs/run-1/events?since=a%3Ab&task=t-1"), {
      headers: { authorization: `Bearer ${TOKEN}`, "last-event-id": "opaque-0" },
    });
    expect(events.status).toBe(200);
    expect(await events.json()).toEqual({ events: [], nextSince: "next", capabilities: {} });
    expect(harnessCalls.at(-1)).toEqual({
      route: "events:run-1",
      query: { raw: "?since=a%3Ab&task=t-1", lastEventId: "opaque-0" },
    });

    expect((await fetch(url("/harness/runs/run-1/unknown"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    })).status).toBe(404);

    const config = await fetch(url("/harness/runs/run-1/config"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    expect(config.status).toBe(200);
    const configText = await config.text();
    expect(configText).not.toContain("browser-must-not-see");
    expect(configText).not.toContain("nested-secret");
    expect(JSON.parse(configText)).toEqual({
      revision: "rev-1",
      fields: {
        watchdog: {
          value: { idleTimeoutMs: 50, nested: [{ authorization: { isSecret: true, redacted: true } }] },
          source: "run-override",
          immutable: false,
          mutationClass: "mid-run",
        },
        apiKey: { source: "env", immutable: true, mutationClass: "global", isSecret: true, redacted: true },
      },
    });
    expect((await fetch(url("/harness/runs/run-1/plan"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    })).status).toBe(200);
    const missing = await fetch(url("/harness/runs/missing/config"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    expect(missing.status).toBe(404);
    expect(await missing.json()).toEqual({ error: "run-not-found" });
    expect((await fetch(url("/harness/runs/run-1/plan"), {
      method: "POST", headers: { authorization: `Bearer ${TOKEN}` },
    })).status).toBe(404);
  });

  test("task SSE is relayed without buffering and cancellation closes upstream", async () => {
    streamCancelled = false;
    const cancel = new AbortController();
    const response = await fetch(url("/harness/runs/run-1/tasks/task-1/stream?since=opaque-0"), {
      headers: { authorization: `Bearer ${TOKEN}`, "last-event-id": "opaque-0" }, signal: cancel.signal,
    });
    expect(response.headers.get("content-type")).toContain("text/event-stream");
    const reader = response.body!.getReader();
    const first = await reader.read();
    const firstText = new TextDecoder().decode(first.value);
    expect(firstText).toContain("opaque-1");
    expect(firstText).not.toContain("stream-secret");
    expect(firstText).toContain("[REDACTED]");
    cancel.abort();
    await reader.cancel();
    // The upstream abort propagates through the relay on a later tick, and under
    // load that is more than one macrotask away.
    const deadline = Date.now() + 5000;
    while (!streamCancelled && Date.now() < deadline) {
      await new Promise((resolve) => setTimeout(resolve, 5));
    }
    expect(streamCancelled).toBe(true);
    expect(harnessCalls.at(-1)).toEqual({
      route: "stream:run-1:task-1",
      query: { raw: "?since=opaque-0", lastEventId: "opaque-0", signal: expect.any(AbortSignal) },
    });
  });

  test("task SSE drops malformed data frames without exposing bytes", async () => {
    streamCancelled = false;
    const response = await fetch(url("/harness/runs/malformed/tasks/task-1/stream"), {
      headers: { authorization: `Bearer ${TOKEN}` },
    });
    const reader = response.body!.getReader();
    const first = await reader.read();
    expect(first.done).toBe(true);
    expect(new TextDecoder().decode(first.value)).not.toContain("stream-secret");
    expect(streamCancelled).toBe(true);
  });

  test("task SSE rejects oversized delimited frames before redaction", async () => {
    for (const runId of ["oversized", "oversized-incomplete"]) {
      streamCancelled = false;
      const response = await fetch(url(`/harness/runs/${runId}/tasks/task-1/stream`), {
        headers: { authorization: `Bearer ${TOKEN}` },
      });
      const reader = response.body!.getReader();
      const chunk = await reader.read();
      expect(chunk.done).toBe(true);
      expect(chunk.value?.byteLength ?? 0).toBe(0);
      expect(streamCancelled).toBe(true);
    }
  });
});

describe("port busy", () => {
  test("binding an already-bound port throws CollectorFatalError with code PORT_BUSY", () => {
    const dir = mkdtempSync(join(tmpdir(), "overdeck-portbusy-"));
    const busyState = new CollectorState(new Journal(join(dir, "items.jsonl")));
    const first = startServer({ host: "127.0.0.1", port: 0, token: TOKEN, state: busyState });
    const busyPort = first.port;

    try {
      expect(() =>
        startServer({ host: "127.0.0.1", port: busyPort, token: TOKEN, state: busyState }),
      ).toThrow(CollectorFatalError);

      try {
        startServer({ host: "127.0.0.1", port: busyPort, token: TOKEN, state: busyState });
      } catch (err) {
        expect(err).toBeInstanceOf(CollectorFatalError);
        expect((err as CollectorFatalError).code).toBe("PORT_BUSY");
      }
    } finally {
      first.stop(true);
    }
  });
});

describe("GET /hooks", () => {
  test("rejects requests without auth", async () => {
    const res = await fetch(url("/hooks"));
    expect(res.status).toBe(401);
  });

  test("returns the hook inventory with liveness verdicts", async () => {
    const res = await fetch(url("/hooks"), { headers: { authorization: `Bearer ${TOKEN}` } });
    expect(res.status).toBe(200);
    const body = await res.json() as { hooks: Array<Record<string, unknown>>; sources: string[]; sourceErrors: unknown[] };
    expect(body.sources).toHaveLength(1);
    expect(body.sourceErrors).toEqual([]);
    expect(body.hooks).toHaveLength(2);
    expect(body.hooks[0]).toMatchObject({ event: "PreToolUse", matcher: "Bash", status: "OK", runtime: "sh", runtimeResolves: true, problem: null });
    expect(body.hooks[1]).toMatchObject({ event: "Stop", status: "DEAD", exists: false, problem: "hook target is missing" });
  });
});
