import { access, open, readFile, readdir } from "node:fs/promises";
import { constants } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";

export interface HookInventoryItem {
  id: string;
  source: string;
  scope: string;
  event: string;
  matcher: string | null;
  command: string;
  commandPath: string | null;
  exists: boolean;
  executable: boolean;
  /** Interpreter the hook needs (explicit in the command, or from the target's shebang); null = self-contained or undetermined. */
  runtime: string | null;
  /** Whether the runtime resolves to an executable; null = no runtime needed or check impossible. */
  runtimeResolves: boolean | null;
  status: "OK" | "DEAD" | "UNOBSERVED";
  problem: string | null;
  lastExecutionAt: string | null;
  evidenceSource: string | null;
}

export interface HookInventoryResponse {
  generatedAt: string;
  sources: string[];
  sourceErrors: Array<{ source: string; error: string }>;
  hooks: HookInventoryItem[];
}

export interface HookInventoryOptions {
  homeDir?: string;
  cwd?: string;
  settingsFiles?: string[];
  now?: () => Date;
}

const INTERPRETERS = new Set(["bash", "sh", "zsh", "node", "bun", "python", "python3", "ruby", "perl"]);

function shellWords(command: string): string[] {
  const words: string[] = [];
  let word = "";
  let quote: "'" | '"' | null = null;
  let escaped = false;
  for (const char of command.trim()) {
    if (escaped) { word += char; escaped = false; continue; }
    if (char === "\\" && quote !== "'") { escaped = true; continue; }
    if (quote) { if (char === quote) quote = null; else word += char; continue; }
    if (char === "'" || char === '"') { quote = char; continue; }
    if (/\s/.test(char)) { if (word) { words.push(word); word = ""; } continue; }
    word += char;
  }
  if (word) words.push(word);
  return words;
}

function expandPath(value: string, home: string, source: string): string {
  const expanded = value === "~" ? home : value.startsWith("~/") ? join(home, value.slice(2)) : value;
  return isAbsolute(expanded) ? expanded : resolve(dirname(source), expanded);
}

function executablePath(value: string, home: string, source: string): string {
  if (value.includes("/") || value.startsWith("~")) return expandPath(value, home, source);
  return Bun.which(value) ?? value;
}

function hookTarget(command: string, home: string, source: string): { commandPath: string | null; interpreter: string | null } {
  const words = shellWords(command);
  if (words.length === 0) return { commandPath: null, interpreter: null };
  let commandIndex = 0;
  let first = executablePath(words[commandIndex]!, home, source);
  if (basename(first) === "env") {
    commandIndex = words.findIndex((word, index) => index > 0 && !word.startsWith("-") && !word.includes("="));
    if (commandIndex < 0) return { commandPath: first, interpreter: null };
    first = executablePath(words[commandIndex]!, home, source);
  }
  if (!INTERPRETERS.has(basename(first))) return { commandPath: first, interpreter: null };
  const script = words.slice(commandIndex + 1).find((word) => !word.startsWith("-") && (word.includes("/") || /\.(?:m?js|cjs|ts|sh|py|rb)$/.test(word)));
  return { commandPath: script ? expandPath(script, home, source) : first, interpreter: first };
}

async function canAccess(path: string, mode: number): Promise<boolean> {
  try { await access(path, mode); return true; } catch { return false; }
}

/** First shebang word chain of a script; null = no shebang; undefined = unreadable. */
async function readShebang(path: string): Promise<string[] | null | undefined> {
  let handle;
  try { handle = await open(path, "r"); } catch { return undefined; }
  try {
    const buffer = Buffer.alloc(256);
    const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
    const head = buffer.subarray(0, bytesRead).toString("utf8");
    if (!head.startsWith("#!")) return null;
    const line = head.slice(2).split(/\r?\n/, 1)[0]!.trim();
    return line.length === 0 ? null : line.split(/\s+/);
  } catch {
    return undefined;
  } finally {
    await handle.close();
  }
}

interface RuntimeVerdict {
  runtime: string | null;
  runtimeResolves: boolean | null;
  unobserved: string | null;
}

async function resolveRuntime(target: { commandPath: string; interpreter: string | null }): Promise<RuntimeVerdict> {
  if (target.interpreter !== null) {
    const name = basename(target.interpreter);
    const resolved = target.interpreter.includes("/") ? target.interpreter : Bun.which(target.interpreter);
    return { runtime: name, runtimeResolves: resolved !== null && await canAccess(resolved, constants.X_OK), unobserved: null };
  }
  const shebang = await readShebang(target.commandPath);
  if (shebang === undefined) return { runtime: null, runtimeResolves: null, unobserved: "hook target is unreadable, so its runtime cannot be verified" };
  if (shebang === null) return { runtime: null, runtimeResolves: null, unobserved: null };
  let word = shebang[0]!;
  if (basename(word) === "env") {
    const next = shebang.slice(1).find((part) => !part.startsWith("-") && !part.includes("="));
    if (!next) return { runtime: "env", runtimeResolves: null, unobserved: "shebang uses env with no interpreter, so the runtime cannot be verified" };
    word = next;
  }
  const runtime = basename(word);
  const resolved = word.includes("/") ? word : Bun.which(word);
  return { runtime, runtimeResolves: resolved !== null && await canAccess(resolved, constants.X_OK), unobserved: null };
}

const PLUGIN_ROOT_VAR = /\$\{(?:CLAUDE_)?PLUGIN_ROOT\}/g;
const ENV_VAR = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;

/** Candidate plugin roots for a registry file; a manifest inside a dot-dir (e.g. .codex-plugin)
 * means the actual plugin root is usually the dot-dir's parent, so both are returned. */
async function pluginRootsFor(source: string): Promise<string[]> {
  let dir = dirname(source);
  while (true) {
    if (await canAccess(join(dir, "plugin.json"), constants.F_OK) || await canAccess(join(dir, ".claude-plugin", "plugin.json"), constants.F_OK)) {
      return basename(dir).startsWith(".") ? [dirname(dir), dir] : [dir];
    }
    const parent = dirname(dir);
    if (parent === dir) return [];
    dir = parent;
  }
}

async function candidateFiles(home: string, cwd: string): Promise<string[]> {
  const candidates = [join(home, ".claude", "settings.json"), join(home, ".claude", "settings.local.json"), join(home, ".claude", "tools.json")];
  let current = resolve(cwd);
  while (true) {
    candidates.push(join(current, ".claude", "settings.json"), join(current, ".claude", "settings.local.json"), join(current, ".claude", "tools.json"), join(current, "tools.json"));
    const parent = dirname(current);
    if (parent === current) break;
    current = parent;
  }
  const pluginRoot = join(home, ".claude", "plugins");
  async function scanPluginRegistries(directory: string, depth: number): Promise<void> {
    if (depth > 6) return;
    let entries;
    try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; }
    for (const entry of entries) {
      const path = join(directory, entry.name);
      if (entry.isDirectory()) await scanPluginRegistries(path, depth + 1);
      else if (entry.isFile() && (entry.name === "hooks.json" || entry.name === "tools.json")) candidates.push(path);
    }
  }
  await scanPluginRegistries(pluginRoot, 0);
  return [...new Set(candidates)];
}

function hooksObject(value: unknown): Record<string, unknown> | null {
  if (!value || typeof value !== "object") return null;
  const hooks = (value as { hooks?: unknown }).hooks;
  return hooks && typeof hooks === "object" && !Array.isArray(hooks) ? hooks as Record<string, unknown> : null;
}

function verdict(input: {
  commandPath: string | null;
  exists: boolean;
  executable: boolean;
  runtime: RuntimeVerdict | null;
  unresolvedVar: string | null;
}): { status: HookInventoryItem["status"]; problem: string | null } {
  if (input.unresolvedVar !== null) return { status: "UNOBSERVED", problem: `command references ${input.unresolvedVar}, which cannot be resolved outside a live session` };
  if (input.commandPath === null) return { status: "UNOBSERVED", problem: "could not determine the hook target from the command" };
  if (!input.exists) return { status: "DEAD", problem: "hook target is missing" };
  if (!input.executable) return { status: "DEAD", problem: "hook target is not executable" };
  if (input.runtime?.runtimeResolves === false) return { status: "DEAD", problem: `runtime ${input.runtime.runtime} does not resolve to an executable` };
  if (input.runtime?.unobserved) return { status: "UNOBSERVED", problem: input.runtime.unobserved };
  return { status: "OK", problem: null };
}

export async function inventoryHooks(options: HookInventoryOptions = {}): Promise<HookInventoryResponse> {
  const home = options.homeDir ?? homedir();
  const cwd = options.cwd ?? process.cwd();
  const candidates = options.settingsFiles ?? await candidateFiles(home, cwd);
  const sources: string[] = [];
  const sourceErrors: HookInventoryResponse["sourceErrors"] = [];
  const hooks: HookInventoryItem[] = [];

  for (const source of candidates) {
    let parsed: unknown;
    try { parsed = JSON.parse(await readFile(source, "utf8")); }
    catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") sourceErrors.push({ source, error: String((error as Error).message ?? error) });
      continue;
    }
    sources.push(source);
    const registry = hooksObject(parsed);
    if (!registry) continue;
    let pluginRoots: string[] | undefined;
    for (const [event, groups] of Object.entries(registry)) {
      if (!Array.isArray(groups)) continue;
      for (const [groupIndex, group] of groups.entries()) {
        if (!group || typeof group !== "object") continue;
        const matcher = typeof (group as { matcher?: unknown }).matcher === "string" ? (group as { matcher: string }).matcher : null;
        const registrations = (group as { hooks?: unknown }).hooks;
        if (!Array.isArray(registrations)) continue;
        for (const [hookIndex, registration] of registrations.entries()) {
          const command = registration && typeof registration === "object" && typeof (registration as { command?: unknown }).command === "string"
            ? (registration as { command: string }).command.trim() : "";
          if (!command) continue;
          let expanded = command;
          if (PLUGIN_ROOT_VAR.test(expanded)) {
            PLUGIN_ROOT_VAR.lastIndex = 0;
            if (pluginRoots === undefined) pluginRoots = await pluginRootsFor(source);
            for (const [index, root] of pluginRoots.entries()) {
              const candidate = command.replace(PLUGIN_ROOT_VAR, () => root);
              const candidateTarget = hookTarget(candidate, home, source);
              if (index === 0 || (candidateTarget.commandPath !== null && await canAccess(candidateTarget.commandPath, constants.F_OK))) expanded = candidate;
              if (candidateTarget.commandPath !== null && await canAccess(candidateTarget.commandPath, constants.F_OK)) break;
            }
          }
          const unresolvedVar = expanded.match(ENV_VAR)?.[0] ?? null;
          const target = unresolvedVar === null ? hookTarget(expanded, home, source) : { commandPath: null, interpreter: null };
          const exists = target.commandPath !== null && await canAccess(target.commandPath, constants.F_OK);
          const targetRunnable = target.commandPath !== null && await canAccess(target.commandPath, target.interpreter ? constants.R_OK : constants.X_OK);
          const executable = exists && targetRunnable;
          const runtime = exists && executable && target.commandPath !== null
            ? await resolveRuntime({ commandPath: target.commandPath, interpreter: target.interpreter })
            : null;
          const { status, problem } = verdict({ commandPath: target.commandPath, exists, executable, runtime, unresolvedVar });
          hooks.push({
            id: `${source}:${event}:${groupIndex}:${hookIndex}`,
            source,
            scope: source.startsWith(join(home, ".claude")) ? "user" : "project",
            event,
            matcher,
            command,
            commandPath: target.commandPath,
            exists,
            executable,
            runtime: runtime?.runtime ?? null,
            runtimeResolves: runtime?.runtimeResolves ?? null,
            status,
            problem,
            lastExecutionAt: null,
            evidenceSource: null,
          });
        }
      }
    }
  }

  hooks.sort((a, b) => a.event.localeCompare(b.event) || (a.matcher ?? "").localeCompare(b.matcher ?? "") || a.command.localeCompare(b.command));
  return { generatedAt: (options.now ?? (() => new Date()))().toISOString(), sources, sourceErrors, hooks };
}
