import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { z } from "zod";
import { configDir } from "./paths";
import { redactBrowserValue } from "./redact";
import type { Item, Panel } from "./schema";
import type { CollectorState } from "./state";

/** Reconciliation owner id for every queued permission request. */
export const PERMISSION_SOURCE = "permissions";
export const PERMISSION_ID_PREFIX = "permission:";
export const PERMISSION_PANEL_ID = "permission-gate";
export const PERMISSION_ANSWER_VERB = "permission.answer";

/** A runaway session cannot grow the queue past this — further requests fall straight back to the terminal. */
export const MAX_PENDING = 64;
export const DEFAULT_WAIT_MS = 30_000;
export const MAX_WAIT_MS = 120_000;
/**
 * The arm file self-expires: a closed browser tab stops refreshing it and every
 * session returns to normal terminal behaviour within one window.
 */
export const ARM_WINDOW_MS = 60_000;
const MAX_INPUT_PREVIEW_CHARS = 2_000;

export type PermissionDecision = "allow" | "deny" | "ask";

export const PermissionChoiceSchema = z.enum(["allow", "deny"]);

export const PermissionRequestSchema = z.object({
  sessionId: z.string().min(1).max(200),
  toolName: z.string().min(1).max(200),
  toolInput: z.unknown().optional(),
  cwd: z.string().max(4096).optional(),
  waitMs: z.number().int().positive().max(MAX_WAIT_MS).optional(),
});
export type PermissionRequestInput = z.infer<typeof PermissionRequestSchema>;

export interface PermissionVerdict {
  decision: PermissionDecision;
  reason: string;
}

/** The Claude Code PreToolUse hook envelope — the one feeder shape live today. */
export const ClaudeCodeHookInputSchema = z.object({
  session_id: z.string().max(200).optional(),
  tool_name: z.string().min(1).max(200),
  tool_input: z.unknown().optional(),
  cwd: z.string().max(4096).optional(),
});

/**
 * Maps the Claude Code hook envelope onto the queue's feeder-neutral request.
 * A second feeder (ACP) supplies its own mapping; the queue itself stays neutral.
 */
export function claudeCodeRequest(
  input: z.infer<typeof ClaudeCodeHookInputSchema>,
  waitMs: number,
): PermissionRequestInput {
  return {
    sessionId: input.session_id || "unknown session",
    toolName: input.tool_name,
    toolInput: input.tool_input,
    cwd: input.cwd ?? "",
    waitMs,
  };
}

/** Renders a verdict as the PreToolUse hook contract; `ask` yields no directive. */
export function claudeCodeHookOutput(verdict: PermissionVerdict): Record<string, unknown> {
  if (verdict.decision === "ask") return {};
  return {
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: verdict.decision,
      permissionDecisionReason: `Overdeck: ${verdict.reason}`,
    },
  };
}

export interface PermissionArmState {
  /** epoch ms the arm window lapses at; null when disarmed. */
  until: number | null;
}

interface PendingRequest {
  id: string;
  sessionId: string;
  toolName: string;
  cwd: string;
  inputPreview: string;
  createdAt: number;
  settle(verdict: PermissionVerdict): void;
}

export interface PermissionQueueDeps {
  state: CollectorState;
  /** Collector base URL the hook posts back to; embedded in the arm file. */
  collectorUrl: string;
  /** Collector bearer token; embedded in the arm file (mode 600, same dir as the token). */
  token: string;
  now?: () => number;
  armFilePath?: string;
}

export function armFile(): string {
  return join(configDir(), "browser-approvals.json");
}

function previewToolInput(toolInput: unknown): string {
  if (toolInput === undefined) return "";
  let text: string;
  try {
    text = JSON.stringify(redactBrowserValue(toolInput), null, 2) ?? String(toolInput);
  } catch {
    text = "[unserialisable tool input]";
  }
  return text.length > MAX_INPUT_PREVIEW_CHARS
    ? `${text.slice(0, MAX_INPUT_PREVIEW_CHARS)}\n… truncated`
    : text;
}

function toItem(pending: PendingRequest): Item {
  const waitingSince = new Date(pending.createdAt).toISOString();
  const where = pending.cwd || "unknown working directory";
  return {
    id: pending.id,
    source: PERMISSION_SOURCE,
    severity: "act",
    kind: "decision",
    title: `${pending.toolName} — awaiting approval`,
    detail: where,
    ts: waitingSince,
    actions: [
      { verb: PERMISSION_ANSWER_VERB, args: { requestId: pending.id, choice: "allow" }, label: "Allow" },
      { verb: PERMISSION_ANSWER_VERB, args: { requestId: pending.id, choice: "deny" }, label: "Deny" },
    ],
    decision: {
      question: `Allow ${pending.toolName} in ${where}?`,
      options: [{ label: "Allow" }, { label: "Deny" }],
      freeText: false,
      context: JSON.stringify({
        dataBlock: pending.inputPreview,
        kvs: [
          { label: "tool", value: pending.toolName },
          { label: "session", value: pending.sessionId },
          { label: "cwd", value: where },
        ],
      }),
      waitingSince,
    },
  };
}

/**
 * Holds agent permission requests until a browser answers them. Feeders (the
 * Claude Code PreToolUse hook today, an ACP adapter later) only ever call
 * `request`; the queue neither knows nor cares who enqueued.
 */
export class PermissionQueue {
  private readonly state: CollectorState;
  private readonly collectorUrl: string;
  private readonly token: string;
  private readonly now: () => number;
  private readonly armPath: string;
  private readonly pending = new Map<string, PendingRequest>();
  private readonly bootId: string;
  private sequence = 0;
  private armedUntil: number | null = null;

  constructor(deps: PermissionQueueDeps) {
    this.state = deps.state;
    this.collectorUrl = deps.collectorUrl;
    this.token = deps.token;
    this.now = deps.now ?? Date.now;
    this.armPath = deps.armFilePath ?? armFile();
    this.bootId = this.now().toString(36);
    // A previous process may have left an arm file pointing at a collector that no
    // longer exists, and journalled pendings whose agents are long gone. Both are
    // stale by construction — clear them before accepting the first request.
    this.disarm();
    this.publish();
  }

  /** Blocks until a browser verdict, the wait bound lapses, or the caller disconnects. */
  request(input: PermissionRequestInput, signal?: AbortSignal): Promise<PermissionVerdict> {
    if (this.pending.size >= MAX_PENDING) {
      return Promise.resolve({
        decision: "ask",
        reason: `permission queue is full (${MAX_PENDING} pending)`,
      });
    }
    if (signal?.aborted) {
      return Promise.resolve({ decision: "ask", reason: "caller disconnected" });
    }

    this.sequence += 1;
    const id = `${PERMISSION_ID_PREFIX}${this.bootId}:${this.sequence}`;
    const waitMs = Math.min(input.waitMs ?? DEFAULT_WAIT_MS, MAX_WAIT_MS);

    return new Promise<PermissionVerdict>((resolve) => {
      let settled = false;
      const settle = (verdict: PermissionVerdict) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        signal?.removeEventListener("abort", onAbort);
        this.pending.delete(id);
        this.publish();
        resolve(verdict);
      };
      const onAbort = () => settle({ decision: "ask", reason: "caller disconnected" });
      const timer = setTimeout(
        () => settle({ decision: "ask", reason: `no browser verdict within ${waitMs}ms` }),
        waitMs,
      );
      // Bun keeps the process alive for pending timers; this one must not.
      timer.unref?.();
      signal?.addEventListener("abort", onAbort, { once: true });

      this.pending.set(id, {
        id,
        sessionId: input.sessionId,
        toolName: input.toolName,
        cwd: input.cwd ?? "",
        inputPreview: previewToolInput(input.toolInput),
        createdAt: this.now(),
        settle,
      });
      this.publish();
    });
  }

  /** Resolves a pending request with a human verdict. False when it already lapsed. */
  answer(requestId: string, choice: z.infer<typeof PermissionChoiceSchema>, decidedBy: string): boolean {
    const entry = this.pending.get(requestId);
    if (!entry) return false;
    entry.settle({ decision: choice, reason: `${choice} by ${decidedBy || "browser"}` });
    return true;
  }

  /** Opens a rolling arm window; the browser refreshes it while the page is open. */
  arm(): PermissionArmState {
    const until = this.now() + ARM_WINDOW_MS;
    this.armedUntil = until;
    mkdirSync(dirname(this.armPath), { recursive: true, mode: 0o700 });
    writeFileSync(
      this.armPath,
      `${JSON.stringify({ until, waitMs: DEFAULT_WAIT_MS, url: this.collectorUrl, token: this.token })}\n`,
      { mode: 0o600 },
    );
    chmodSync(this.armPath, 0o600);
    this.publish();
    return { until };
  }

  disarm(): PermissionArmState {
    this.armedUntil = null;
    rmSync(this.armPath, { force: true });
    this.publish();
    return { until: null };
  }

  pendingCount(): number {
    return this.pending.size;
  }

  armedUntilMs(): number | null {
    return this.armedUntil;
  }

  private publish(): void {
    const items = [...this.pending.values()].map(toItem);
    const panel: Panel = {
      id: PERMISSION_PANEL_ID,
      ts: new Date(this.now()).toISOString(),
      data: {
        armedUntil: this.armedUntil,
        armWindowMs: ARM_WINDOW_MS,
        waitMs: DEFAULT_WAIT_MS,
        pending: items.length,
        maxPending: MAX_PENDING,
      },
    };
    this.state.publish(PERMISSION_SOURCE, items, [panel]);
  }
}
