import { afterEach, expect, test } from "bun:test";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { inventoryHooks } from "./hooks-inventory";

const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });

function tempRoot(prefix: string): string {
  const root = mkdtempSync(join(tmpdir(), prefix));
  roots.push(root);
  return root;
}

test("inventories healthy, missing, and non-executable configured hooks", async () => {
  const root = tempRoot("overdeck-hooks-inventory-");
  const claude = join(root, ".claude");
  const hooksDir = join(claude, "hooks");
  mkdirSync(hooksDir, { recursive: true });
  const healthy = join(hooksDir, "healthy.sh");
  const inert = join(hooksDir, "inert.sh");
  writeFileSync(healthy, "#!/bin/sh\n");
  writeFileSync(inert, "#!/bin/sh\n");
  chmodSync(healthy, 0o755);
  chmodSync(inert, 0o644);
  const settings = join(claude, "settings.json");
  writeFileSync(settings, JSON.stringify({ hooks: {
    PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: healthy }] }],
    Stop: [{ hooks: [{ type: "command", command: join(hooksDir, "missing.sh") }] }],
    SessionStart: [{ hooks: [{ type: "command", command: inert }] }],
  } }));

  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings], now: () => new Date("2026-08-08T00:00:00Z") });
  expect(result.generatedAt).toBe("2026-08-08T00:00:00.000Z");
  expect(result.sources).toEqual([settings]);
  expect(result.hooks.map(({ event, matcher, exists, executable, runtime, runtimeResolves, status, problem }) => ({ event, matcher, exists, executable, runtime, runtimeResolves, status, problem }))).toEqual([
    { event: "PreToolUse", matcher: "Bash", exists: true, executable: true, runtime: "sh", runtimeResolves: true, status: "OK", problem: null },
    { event: "SessionStart", matcher: null, exists: true, executable: false, runtime: null, runtimeResolves: null, status: "DEAD", problem: "hook target is not executable" },
    { event: "Stop", matcher: null, exists: false, executable: false, runtime: null, runtimeResolves: null, status: "DEAD", problem: "hook target is missing" },
  ]);
});

test("accepts a readable script launched by an executable interpreter", async () => {
  const root = tempRoot("overdeck-hooks-interpreter-");
  const script = join(root, "hook.sh");
  writeFileSync(script, "echo ok\n");
  chmodSync(script, 0o644);
  const registry = join(root, "tools.json");
  writeFileSync(registry, JSON.stringify({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ command: `/usr/bin/env bash ${script}` }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [registry] });
  expect(result.hooks[0]).toMatchObject({ commandPath: script, exists: true, executable: true, runtime: "bash", runtimeResolves: true, status: "OK" });
});

test("flags an executable script whose shebang runtime does not resolve", async () => {
  const root = tempRoot("overdeck-hooks-runtime-");
  const script = join(root, "hook.py");
  writeFileSync(script, "#!/opt/nonexistent/python9\n");
  chmodSync(script, 0o755);
  const settings = join(root, ".claude", "settings.json");
  mkdirSync(join(root, ".claude"), { recursive: true });
  writeFileSync(settings, JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ command: script }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings] });
  expect(result.hooks[0]).toMatchObject({
    exists: true,
    executable: true,
    runtime: "python9",
    runtimeResolves: false,
    status: "DEAD",
    problem: "runtime python9 does not resolve to an executable",
  });
});

test("resolves a runtime named through an env shebang", async () => {
  const root = tempRoot("overdeck-hooks-env-shebang-");
  const script = join(root, "hook");
  writeFileSync(script, "#!/usr/bin/env sh\necho ok\n");
  chmodSync(script, 0o755);
  const settings = join(root, ".claude", "settings.json");
  mkdirSync(join(root, ".claude"), { recursive: true });
  writeFileSync(settings, JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: script }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings] });
  expect(result.hooks[0]).toMatchObject({ runtime: "sh", runtimeResolves: true, status: "OK", problem: null });
});

test("treats an executable without a shebang as self-contained", async () => {
  const root = tempRoot("overdeck-hooks-binary-");
  const binary = join(root, "hook-bin");
  writeFileSync(binary, "not a script\n");
  chmodSync(binary, 0o755);
  const settings = join(root, ".claude", "settings.json");
  mkdirSync(join(root, ".claude"), { recursive: true });
  writeFileSync(settings, JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ command: binary }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings] });
  expect(result.hooks[0]).toMatchObject({ runtime: null, runtimeResolves: null, status: "OK", problem: null });
});

test("records source errors for unparseable registries", async () => {
  const root = tempRoot("overdeck-hooks-badjson-");
  const settings = join(root, ".claude", "settings.json");
  mkdirSync(join(root, ".claude"), { recursive: true });
  writeFileSync(settings, "{ not json");
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings] });
  expect(result.sources).toEqual([]);
  expect(result.sourceErrors).toHaveLength(1);
  expect(result.sourceErrors[0]!.source).toBe(settings);
  expect(result.hooks).toEqual([]);
});

test("substitutes CLAUDE_PLUGIN_ROOT from the plugin manifest next to the registry", async () => {
  const root = tempRoot("overdeck-hooks-pluginroot-");
  const plugin = join(root, ".claude", "plugins", "sources", "demo");
  mkdirSync(join(plugin, ".claude-plugin"), { recursive: true });
  writeFileSync(join(plugin, ".claude-plugin", "plugin.json"), "{}");
  mkdirSync(join(plugin, "hooks"), { recursive: true });
  const script = join(plugin, "hooks", "gate.sh");
  writeFileSync(script, "#!/bin/sh\n");
  chmodSync(script, 0o755);
  const registry = join(plugin, "hooks", "hooks.json");
  writeFileSync(registry, JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ command: 'sh "${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh"' }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [registry] });
  expect(result.hooks[0]).toMatchObject({ commandPath: script, exists: true, executable: true, status: "OK", problem: null });
});

test("marks commands with unresolvable environment variables UNOBSERVED, never DEAD", async () => {
  const root = tempRoot("overdeck-hooks-envvar-");
  const settings = join(root, ".claude", "settings.json");
  mkdirSync(join(root, ".claude"), { recursive: true });
  writeFileSync(settings, JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ command: 'node "${SOME_RUNTIME_DIR}/hook.mjs"' }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [settings] });
  expect(result.hooks[0]).toMatchObject({
    commandPath: null,
    status: "UNOBSERVED",
    problem: "command references ${SOME_RUNTIME_DIR}, which cannot be resolved outside a live session",
  });
});

test("resolves PLUGIN_ROOT to the dot-dir manifest's parent when the target lives there", async () => {
  const root = tempRoot("overdeck-hooks-dotplugin-");
  const plugin = join(root, ".claude", "plugins", "marketplaces", "demo");
  mkdirSync(join(plugin, ".codex-plugin"), { recursive: true });
  writeFileSync(join(plugin, ".codex-plugin", "plugin.json"), "{}");
  mkdirSync(join(plugin, "hooks", "codex"), { recursive: true });
  const script = join(plugin, "hooks", "codex", "stop.mjs");
  writeFileSync(script, "export {}\n");
  chmodSync(script, 0o644);
  const registry = join(plugin, ".codex-plugin", "hooks.json");
  writeFileSync(registry, JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/codex/stop.mjs"' }] }] } }));
  const result = await inventoryHooks({ homeDir: root, cwd: root, settingsFiles: [registry] });
  expect(result.hooks[0]).toMatchObject({ commandPath: script, exists: true, status: "OK" });
});
