import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Journal } from "./journal";
import {
  ARM_WINDOW_MS,
  claudeCodeHookOutput,
  claudeCodeRequest,
  MAX_PENDING,
  PERMISSION_ID_PREFIX,
  PERMISSION_PANEL_ID,
  PERMISSION_SOURCE,
  PermissionQueue,
} from "./permissions";
import { CollectorState } from "./state";

let dir: string;

function makeQueue(now: () => number = Date.now) {
  const state = new CollectorState(new Journal(join(dir, "items.jsonl")), now);
  const queue = new PermissionQueue({
    state,
    collectorUrl: "http://127.0.0.1:4980",
    token: "test-token",
    now,
    armFilePath: join(dir, "browser-approvals.json"),
  });
  return { state, queue };
}

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), "overdeck-permissions-"));
});

afterEach(() => {
  rmSync(dir, { recursive: true, force: true });
});

describe("PermissionQueue", () => {
  test("a pending request becomes a decision item and an allow verdict resolves it", async () => {
    const { state, queue } = makeQueue();
    const verdict = queue.request({ sessionId: "s-1", toolName: "Bash", toolInput: { command: "ls" }, cwd: "/repo" });

    const [item] = state.getItems("decision");
    expect(item?.id.startsWith(PERMISSION_ID_PREFIX)).toBe(true);
    expect(item?.source).toBe(PERMISSION_SOURCE);
    expect(item?.title).toBe("Bash — awaiting approval");
    expect(item?.actions.map((action) => action.args.choice)).toEqual(["allow", "deny"]);

    expect(queue.answer(item!.id, "allow", "deck-web")).toBe(true);
    expect((await verdict).decision).toBe("allow");
    expect(state.getItems("decision")).toHaveLength(0);
  });

  test("a deny verdict resolves with deny", async () => {
    const { state, queue } = makeQueue();
    const verdict = queue.request({ sessionId: "s-1", toolName: "Write", cwd: "/repo" });
    const [item] = state.getItems("decision");
    queue.answer(item!.id, "deny", "deck-web");
    expect((await verdict).decision).toBe("deny");
  });

  test("the wait bound lapses to ask and never to allow", async () => {
    const { state, queue } = makeQueue();
    const verdict = await queue.request({ sessionId: "s-1", toolName: "Read", waitMs: 5 });
    expect(verdict.decision).toBe("ask");
    expect(verdict.reason).toContain("no browser verdict");
    expect(state.getItems("decision")).toHaveLength(0);
  });

  test("a disconnected caller lapses to ask", async () => {
    const { queue } = makeQueue();
    const controller = new AbortController();
    const verdict = queue.request({ sessionId: "s-1", toolName: "Read", waitMs: 5_000 }, controller.signal);
    controller.abort();
    expect((await verdict).decision).toBe("ask");
  });

  test("answering an unknown or already-lapsed request reports not applied", async () => {
    const { queue } = makeQueue();
    expect(queue.answer("permission:nope:1", "allow", "deck-web")).toBe(false);
  });

  test("a full queue falls straight back to the terminal", async () => {
    const { queue } = makeQueue();
    const held = Array.from({ length: MAX_PENDING }, () =>
      queue.request({ sessionId: "s-1", toolName: "Read", waitMs: 50 }));
    expect(queue.pendingCount()).toBe(MAX_PENDING);

    const overflow = await queue.request({ sessionId: "s-1", toolName: "Read", waitMs: 50 });
    expect(overflow.decision).toBe("ask");
    expect(overflow.reason).toContain("full");
    await Promise.all(held);
  });

  test("credential-shaped tool input is redacted before it reaches the browser", async () => {
    const { state, queue } = makeQueue();
    const verdict = queue.request({
      sessionId: "s-1",
      toolName: "Bash",
      toolInput: { command: "deploy", authorization: "Bearer hunter2" },
    });
    const [item] = state.getItems("decision");
    expect(item?.decision?.context).toContain("[REDACTED]");
    expect(item?.decision?.context).not.toContain("hunter2");
    queue.answer(item!.id, "deny", "deck-web");
    await verdict;
  });

  test("arming writes a self-expiring 0600 arm file and disarming removes it", () => {
    const at = 1_800_000_000_000;
    const { queue } = makeQueue(() => at);
    const armPath = join(dir, "browser-approvals.json");

    const armed = queue.arm();
    expect(armed.until).toBe(at + ARM_WINDOW_MS);
    const parsed = JSON.parse(readFileSync(armPath, "utf8")) as { until: number; url: string; token: string };
    expect(parsed.until).toBe(at + ARM_WINDOW_MS);
    expect(parsed.url).toBe("http://127.0.0.1:4980");
    expect(parsed.token).toBe("test-token");

    expect(queue.disarm().until).toBeNull();
    expect(existsSync(armPath)).toBe(false);
  });

  test("the gate panel reports arm state and queue depth honestly", () => {
    const at = 1_800_000_000_000;
    const { state, queue } = makeQueue(() => at);
    const disarmed = state.getPanels().find((panel) => panel.id === PERMISSION_PANEL_ID);
    expect(disarmed?.data).toMatchObject({ armedUntil: null, pending: 0, maxPending: MAX_PENDING });

    queue.arm();
    const armedPanel = state.getPanels().find((panel) => panel.id === PERMISSION_PANEL_ID);
    expect(armedPanel?.data).toMatchObject({ armedUntil: at + ARM_WINDOW_MS });
  });

  test("the Claude Code feeder maps the hook envelope onto a neutral request", () => {
    expect(claudeCodeRequest({ session_id: "s-9", tool_name: "Bash", tool_input: { command: "ls" }, cwd: "/repo" }, 42))
      .toEqual({ sessionId: "s-9", toolName: "Bash", toolInput: { command: "ls" }, cwd: "/repo", waitMs: 42 });
    expect(claudeCodeRequest({ tool_name: "Read" }, 42).sessionId).toBe("unknown session");
  });

  test("only allow and deny render a PreToolUse directive", () => {
    expect(claudeCodeHookOutput({ decision: "allow", reason: "allow by deck-web" })).toEqual({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "allow",
        permissionDecisionReason: "Overdeck: allow by deck-web",
      },
    });
    expect(claudeCodeHookOutput({ decision: "deny", reason: "no" }).hookSpecificOutput).toBeDefined();
    expect(claudeCodeHookOutput({ decision: "ask", reason: "timed out" })).toEqual({});
  });

  test("startup clears journalled pendings and a stale arm file from a dead collector", () => {
    const journalPath = join(dir, "items.jsonl");
    const armPath = join(dir, "browser-approvals.json");
    // A crashed collector leaves a pending request in the journal and an arm file
    // pointing at a port nothing is listening on.
    writeFileSync(journalPath, `${JSON.stringify({
      id: "permission:dead:1",
      source: PERMISSION_SOURCE,
      severity: "act",
      kind: "decision",
      title: "Bash — awaiting approval",
      detail: "/repo",
      ts: new Date(0).toISOString(),
      actions: [],
    })}\n`);
    writeFileSync(armPath, JSON.stringify({ until: Date.now() + 60_000 }));

    const { state } = makeQueue();
    expect(state.getItems("decision")).toHaveLength(0);
    expect(existsSync(armPath)).toBe(false);
  });
});
