/**
 * Registrar that reconciles `.goat-flow/config.yaml` hook truth to detected
 * hook-capable agent surfaces in the selected project.
 */
import {
  chmodSync,
  existsSync,
  mkdirSync,
  readFileSync,
  unlinkSync,
} from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { getAgentProfiles } from "../agents/registry.js";
import {
  readHookEnabled,
  removeHookConfig,
  removeTopLevelConfigBlock,
  setHookEnabled,
} from "../config/writer.js";
import { getTemplatePath } from "../paths.js";
import type { AgentId, AgentProfile } from "../types.js";
import {
  getHookSpec,
  isValidHookIdShape,
  listHookSpecs,
  type HookSpec,
} from "./hooks-registry.js";
import {
  readAgentHookState,
  writeAgentHookState,
} from "./agent-hook-writer.js";
import { writeFileAtomic } from "./safe-exec.js";

const DENY_DANGEROUS_POLICY_FILES = [
  "patterns-shell.sh",
  "patterns-paths.sh",
  "patterns-writes.sh",
  "deny-dangerous-self-test.sh",
];
const LEGACY_AGENT_HOOK_DIRS = [
  ".claude/hooks",
  ".codex/hooks",
  ".agents/hooks",
  ".github/hooks",
];
const LEGACY_DENY_DANGEROUS_SCRIPT_NAMES = [
  "guard-common.sh",
  "guard-destructive-shell.sh",
  "guard-secret-paths.sh",
  "guard-repository-writes.sh",
  "guardrails-self-test.sh",
  "deny-dangerous.self-test.sh",
];
const REMOVED_HOOK_TOMBSTONES: HookSpec[] = [
  {
    id: "plan-checkbox-guard",
    displayName: "Removed plan checkbox guard",
    description:
      "Legacy cleanup tombstone for stale plan checkbox guard installs.",
    event: "Stop",
    matcher: "",
    scriptFiles: ["plan-checkbox-guard.sh"],
    primaryScript: "plan-checkbox-guard.sh",
    togglable: false,
    defaultEnabled: false,
    requiresConfirmDialog: false,
  },
];

type HookDrift = "desired-on-actual-off" | "desired-off-actual-on";

/** Per-agent hook installation/config state for one registry hook. */
interface HookAgentState extends Record<"supported", boolean> {
  installed: boolean;
  scriptPath: string | null;
  configPath: string | null;
  drift?: HookDrift;
  reason?: string;
}

/** Dashboard-facing hook state including defaults, drift, and per-agent registration status. */
export interface HookState extends Record<"togglable" | "enabled", boolean> {
  id: string;
  name: string;
  description: string;
  defaultEnabled: boolean;
  requiresConfirmDialog: boolean;
  agents: Record<AgentId, HookAgentState>;
}

/** HTTP-safe hook registrar failure with the status code routes should return. */
class HookRegistrarError extends Error {
  constructor(
    message: string,
    readonly statusCode: number,
  ) {
    super(message);
    this.name = "HookRegistrarError";
  }
}

export { HookRegistrarError };

/** Validate and resolve a hook id into the registry spec; bad ids throw 400 and unknown ids throw 404. Throws on invalid input. */
function resolveSpec(hookId: string): HookSpec {
  if (!isValidHookIdShape(hookId)) {
    throw new HookRegistrarError("Invalid hook id", 400);
  }
  const spec = getHookSpec(hookId);
  if (!spec) throw new HookRegistrarError(`Unknown hook: ${hookId}`, 404);
  return spec;
}

/** Confirm an agent profile has all manifest paths needed for hook registration. */
function isSupportedAgent(agent: AgentProfile): boolean {
  return (
    agent.hooksDir !== null &&
    agent.hookConfigFile !== null &&
    agent.hookEvents !== null
  );
}

function unsupportedReasonForSpec(
  spec: HookSpec,
  agent: AgentProfile,
): string | null {
  return spec.unsupportedAgents?.[agent.id] ?? null;
}

/** Block hook script writes that would escape the selected project root; throws a 400 registrar error. */
function assertWithinProject(projectPath: string, targetPath: string): void {
  const root = resolve(projectPath);
  const target = resolve(targetPath);
  const fromRoot = relative(root, target);
  if (
    fromRoot === "" ||
    (fromRoot !== ".." &&
      !fromRoot.startsWith(`..${String.fromCharCode(47)}`) &&
      !fromRoot.startsWith(`..${String.fromCharCode(92)}`) &&
      !isAbsolute(fromRoot))
  ) {
    return;
  }
  throw new HookRegistrarError("Refusing to write outside project path", 400);
}

function scriptTarget(
  projectPath: string,
  agent: AgentProfile,
  script: string,
) {
  if (!agent.hooksDir) throw new Error(`${agent.id} has no hooks dir`);
  const target = join(projectPath, agent.hooksDir, script);
  assertWithinProject(projectPath, target);
  return target;
}

function scriptExists(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
): boolean {
  const agentScriptsExist = spec.scriptFiles.every((script) =>
    existsSync(scriptTarget(projectPath, agent, script)),
  );
  if (!agentScriptsExist) return false;
  if (spec.id !== "deny-dangerous") return true;
  return DENY_DANGEROUS_POLICY_FILES.every((file) =>
    existsSync(
      join(projectPath, ".goat-flow", "hooks", "deny-dangerous", file),
    ),
  );
}

type AgentProfilePathKey =
  | "instructionFile"
  | "skillsDir"
  | "settingsFile"
  | "hookConfigFile"
  | "hooksDir";

function profilePathIsUnique(
  profiles: AgentProfile[],
  key: AgentProfilePathKey,
  path: string | null,
): boolean {
  if (!path) return false;
  return profiles.filter((profile) => profile[key] === path).length === 1;
}

function agentInstalledSurfaceExists(
  projectPath: string,
  agent: AgentProfile,
  profiles: AgentProfile[],
): boolean {
  const uniqueOptionalMarkers = [
    profilePathIsUnique(profiles, "instructionFile", agent.instructionFile)
      ? agent.instructionFile
      : null,
    profilePathIsUnique(profiles, "skillsDir", agent.skillsDir)
      ? agent.skillsDir
      : null,
  ];
  const markers = [
    agent.settingsFile,
    agent.hookConfigFile,
    profilePathIsUnique(profiles, "hooksDir", agent.hooksDir)
      ? agent.hooksDir
      : null,
    ...uniqueOptionalMarkers,
  ].filter((marker): marker is string => typeof marker === "string");
  return markers.some((marker) => existsSync(join(projectPath, marker)));
}

function hookScriptResidueExists(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
  profiles: AgentProfile[],
): boolean {
  const scriptFiles =
    spec.id === "deny-dangerous"
      ? [...spec.scriptFiles, ...LEGACY_DENY_DANGEROUS_SCRIPT_NAMES]
      : spec.scriptFiles;
  if (
    agent.hooksDir &&
    profilePathIsUnique(profiles, "hooksDir", agent.hooksDir) &&
    scriptFiles.some((script) =>
      existsSync(scriptTarget(projectPath, agent, script)),
    )
  ) {
    return true;
  }
  return LEGACY_AGENT_HOOK_DIRS.some((hooksDir) =>
    scriptFiles.some((script) =>
      existsSync(join(projectPath, hooksDir, script)),
    ),
  );
}

function shouldReconcileAgent(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
  profiles: AgentProfile[],
): boolean {
  return (
    agentInstalledSurfaceExists(projectPath, agent, profiles) ||
    hookScriptResidueExists(projectPath, agent, spec, profiles)
  );
}

/** Check for an existing hook config before writing disabled state for optional hooks. */
function hookConfigExists(projectPath: string, agent: AgentProfile): boolean {
  return (
    agent.hookConfigFile !== null &&
    existsSync(join(projectPath, agent.hookConfigFile))
  );
}

function ensureGoatFlowGitignoreEntry(
  projectPath: string,
  entry: string,
): void {
  const gitignorePath = join(projectPath, ".goat-flow", ".gitignore");
  assertWithinProject(projectPath, gitignorePath);
  mkdirSync(join(projectPath, ".goat-flow"), { recursive: true });

  const original = existsSync(gitignorePath)
    ? readFileSync(gitignorePath, "utf-8")
    : "";
  const hasFinalNewline = original.length === 0 || original.endsWith("\n");
  const lines = original.split(/\r?\n/u).filter((line, index, all) => {
    return index < all.length - 1 || line.length > 0;
  });
  if (lines.includes(entry)) return;

  const next = `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}${entry}\n`;
  writeFileAtomic(
    gitignorePath,
    hasFinalNewline ? next : next.trimEnd(),
    projectPath,
  );
}

function removeGoatFlowGitignoreEntry(
  projectPath: string,
  entry: string,
): void {
  const gitignorePath = join(projectPath, ".goat-flow", ".gitignore");
  assertWithinProject(projectPath, gitignorePath);
  if (!existsSync(gitignorePath)) return;
  const original = readFileSync(gitignorePath, "utf-8");
  const hasFinalNewline = original.endsWith("\n");
  const lines = original.split(/\r?\n/u);
  if (hasFinalNewline) lines.pop();
  const nextLines = lines.filter((line) => line !== entry);
  if (nextLines.length === lines.length) return;
  const next = `${nextLines.join("\n")}${hasFinalNewline ? "\n" : ""}`;
  writeFileAtomic(gitignorePath, next, projectPath);
}

/**
 * Keep the shared `.goat-flow/hooks/deny-dangerous/` policy store tracked by Git.
 *
 * Adds both `!hooks/` and `!hooks/**` negations to `.goat-flow/.gitignore` so the
 * deny-dangerous policy modules survive a fresh clone; without them a gitignored
 * `.goat-flow/` drops the store and the guard fails closed on checkout. Idempotent -
 * each entry is appended only when absent (writes `.goat-flow/.gitignore`).
 *
 * @param projectPath - target project root whose `.goat-flow/.gitignore` is updated
 */
function ensureHookGitignoreEntries(projectPath: string): void {
  ensureGoatFlowGitignoreEntry(projectPath, "!hooks/");
  ensureGoatFlowGitignoreEntry(projectPath, "!hooks/**");
}

function removeLegacyAgentScriptIfPresent(
  projectPath: string,
  hooksDir: string,
  script: string,
): void {
  const target = join(projectPath, hooksDir, script);
  assertWithinProject(projectPath, target);
  try {
    unlinkSync(target);
  } catch {
    /* target already gone - stale script pruning is idempotent */
  }
}

function removeLegacyAgentHookScripts(
  projectPath: string,
  spec: HookSpec,
): void {
  for (const hooksDir of LEGACY_AGENT_HOOK_DIRS) {
    for (const script of spec.scriptFiles) {
      removeLegacyAgentScriptIfPresent(projectPath, hooksDir, script);
    }
    if (spec.id === "deny-dangerous") {
      for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) {
        removeLegacyAgentScriptIfPresent(projectPath, hooksDir, script);
      }
    }
  }
}

function hookScriptContent(script: string): string {
  const source = readFileSync(
    getTemplatePath(`workflow/hooks/${script}`),
    "utf-8",
  );
  return source;
}

function copyHookScripts(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
): void {
  if (!agent.hooksDir) return;
  mkdirSync(join(projectPath, agent.hooksDir), { recursive: true });
  for (const script of spec.scriptFiles) {
    const target = scriptTarget(projectPath, agent, script);
    writeFileAtomic(target, hookScriptContent(script), projectPath);
    chmodSync(target, 0o755);
  }
  ensureHookGitignoreEntries(projectPath);
  if (spec.id === "deny-dangerous") {
    const targetDir = join(
      projectPath,
      ".goat-flow",
      "hooks",
      "deny-dangerous",
    );
    mkdirSync(targetDir, { recursive: true });
    for (const file of DENY_DANGEROUS_POLICY_FILES) {
      const source = getTemplatePath(`workflow/hooks/deny-dangerous/${file}`);
      const target = join(targetDir, file);
      assertWithinProject(projectPath, target);
      writeFileAtomic(target, readFileSync(source, "utf-8"), projectPath);
      chmodSync(target, 0o755);
    }
    for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) {
      removeScriptIfPresent(projectPath, agent, script);
    }
  }
  removeLegacyAgentHookScripts(projectPath, spec);
}

function removeScriptIfPresent(
  projectPath: string,
  agent: AgentProfile,
  script: string,
): void {
  const target = scriptTarget(projectPath, agent, script);
  try {
    unlinkSync(target);
  } catch {
    /* target already gone - script removal is idempotent, missing file is fine */
  }
}

function removeHookScripts(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
): void {
  removeScriptIfPresent(projectPath, agent, spec.primaryScript);
  if (spec.id === "deny-dangerous") {
    for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) {
      removeScriptIfPresent(projectPath, agent, script);
    }
  }
  removeLegacyAgentHookScripts(projectPath, spec);
}

/** Build the state payload for an agent that cannot host the requested hook. */
function unsupportedAgentHookState(reason: string): HookAgentState {
  return {
    supported: false,
    installed: false,
    scriptPath: null,
    configPath: null,
    reason,
  };
}

function hookDrift(
  desired: boolean,
  installed: boolean,
): HookDrift | undefined {
  if (desired && !installed) return "desired-on-actual-off";
  if (!desired && installed) return "desired-off-actual-on";
  return undefined;
}

function supportedAgentHookState(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
  desired: boolean,
): HookAgentState {
  const read = readAgentHookState(projectPath, agent, spec);
  const installed = read.installed && scriptExists(projectPath, agent, spec);
  const drift = hookDrift(desired, installed);
  return {
    supported: true,
    installed,
    scriptPath: agent.hooksDir
      ? `${agent.hooksDir}/${spec.primaryScript}`.replace(/\/+/gu, "/")
      : null,
    configPath: agent.hookConfigFile,
    ...(drift ? { drift } : {}),
    ...(read.configMissing ? { reason: "Hook config file is missing." } : {}),
    ...(read.configInvalid
      ? { reason: "Hook config file is invalid JSON." }
      : {}),
  };
}

function agentHookState(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
  desired: boolean,
): HookAgentState {
  const unsupportedReason = unsupportedReasonForSpec(spec, agent);
  if (unsupportedReason) return unsupportedAgentHookState(unsupportedReason);
  if (!isSupportedAgent(agent)) {
    return unsupportedAgentHookState(
      "Agent manifest has no hook directory or hook config file.",
    );
  }
  return supportedAgentHookState(projectPath, agent, spec, desired);
}

/** Read persisted desired hook state, falling back to the registry default. */
function readDesired(projectPath: string, spec: HookSpec): boolean {
  return readHookEnabled(projectPath, spec.id, spec.defaultEnabled);
}

/**
 * Remove leftover hook config entries from an agent the registry now marks
 * unsupported for this spec. Without this, flipping an
 * agent to unsupported strands dead registrations that agents may still
 * attempt to run. Cleanup intentionally does not trust current manifest event
 * metadata: a manifest can be corrected to remove a bogus event while stale
 * managed entries for that same event still exist on disk.
 * Scripts are shared across agents and stay untouched.
 */
function pruneUnsupportedAgentHookEntries(
  projectPath: string,
  agent: AgentProfile,
  spec: HookSpec,
): void {
  if (!isSupportedAgent(agent)) return;
  if (!hookConfigExists(projectPath, agent)) return;
  writeAgentHookState(projectPath, agent, spec, false);
}

function reconcileHook(
  projectPath: string,
  spec: HookSpec,
  enabled: boolean,
): void {
  const profiles = getAgentProfiles();
  for (const agent of profiles) {
    if (unsupportedReasonForSpec(spec, agent)) {
      pruneUnsupportedAgentHookEntries(projectPath, agent, spec);
      continue;
    }
    if (!isSupportedAgent(agent)) continue;
    if (!shouldReconcileAgent(projectPath, agent, spec, profiles)) continue;
    if (enabled) copyHookScripts(projectPath, agent, spec);
    else removeHookScripts(projectPath, agent, spec);
    if (enabled || hookConfigExists(projectPath, agent)) {
      writeAgentHookState(projectPath, agent, spec, enabled);
    }
  }
}

function pruneRemovedHookTombstone(projectPath: string, spec: HookSpec): void {
  const profiles = getAgentProfiles();
  for (const agent of profiles) {
    if (isSupportedAgent(agent) && hookConfigExists(projectPath, agent)) {
      writeAgentHookState(projectPath, agent, spec, false);
    }
    if (agent.hooksDir) removeHookScripts(projectPath, agent, spec);
  }
}

function pruneRemovedHookTombstones(projectPath: string): void {
  for (const spec of REMOVED_HOOK_TOMBSTONES) {
    pruneRemovedHookTombstone(projectPath, spec);
    removeHookConfig(projectPath, spec.id);
  }
  removeTopLevelConfigBlock(projectPath, "plan-guard");
  removeGoatFlowGitignoreEntry(projectPath, "logs/plan-guard-state.json");
}

/** Snapshot one hook across all known agents for dashboard and CLI consumers. */
function readHookState(hookId: string, projectPath: string): HookState {
  const spec = resolveSpec(hookId);
  const enabled = readDesired(projectPath, spec);
  const agents = Object.fromEntries(
    getAgentProfiles().map((agent) => [
      agent.id,
      agentHookState(projectPath, agent, spec, enabled),
    ]),
  ) as Record<AgentId, HookAgentState>;
  return {
    id: spec.id,
    name: spec.displayName,
    description: spec.description,
    togglable: spec.togglable,
    enabled,
    defaultEnabled: spec.defaultEnabled,
    requiresConfirmDialog: spec.requiresConfirmDialog,
    agents,
  };
}

// Snapshots the current enabled/installed state of every known hook for one
// project; reads settings + script presence, so the result reflects on-disk
// reality, not the in-memory registry defaults.
export function readAllHookStates(projectPath: string): HookState[] {
  return listHookSpecs().map((spec) => readHookState(spec.id, projectPath));
}

export function applyHookState(
  hookId: string,
  enabled: boolean,
  projectPath: string,
): HookState {
  pruneRemovedHookTombstones(projectPath);
  const spec = resolveSpec(hookId);
  if (!spec.togglable) {
    throw new HookRegistrarError(`Hook is not togglable: ${hookId}`, 400);
  }
  setHookEnabled(projectPath, spec.id, enabled);
  reconcileHook(projectPath, spec, enabled);
  return readHookState(spec.id, projectPath);
}

// Side-effecting: rewrites each togglable hook's installed files to match its
// persisted desired state, repairing drift (e.g. after a manual settings edit),
// then returns the refreshed snapshot. Non-togglable hooks are left untouched.
export function syncHookStates(projectPath: string): HookState[] {
  pruneRemovedHookTombstones(projectPath);
  for (const spec of listHookSpecs()) {
    if (!spec.togglable) continue;
    reconcileHook(projectPath, spec, readDesired(projectPath, spec));
  }
  return readAllHookStates(projectPath);
}
