/**
 * Consumer-side hook registration for `.claude/settings.json`.
 *
 * BALDART ships one or more Claude Code hooks (PreToolUse, Stop, SessionStart,
 * …) that are auto-registered in the consumer's `.claude/settings.json` on
 * `baldart add` and re-checked on `baldart update` / `baldart doctor`.
 *
 * **Multi-hook architecture (since v3.18.0)**
 *
 * Hook definitions live in `HOOK_REGISTRY` below — adding a hook is a matter
 * of appending one entry. Every register/unregister/status operation iterates
 * the registry, so call sites don't change when we ship a new hook.
 *
 * Each hook entry carries:
 *   - `id`          stable marker stored in settings.json under `hooks[].id`
 *   - `event`       Claude Code event (`PreToolUse`, `Stop`, `SessionStart`, …)
 *   - `matcher`     tool-name regex (use '*' for events that don't take one)
 *   - `command`     shell command Claude Code runs
 *   - `order`       sort key when this hook coexists with other BALDART hooks
 *                   on the same event (lower runs first)
 *   - `legacyMatch` (optional) substring that identifies pre-v3.18 entries
 *                   when migrating consumers from the single-hook layout
 *
 * **Drift policy**
 *
 * When an existing entry matches our `id` but its `command` differs, we don't
 * silently rewrite it — that would obliterate user customizations. Instead we
 * call back to `onDrift({ id, event, matcher, expected, actual })` and expect
 * `'keep'` or `'replace'`. Callers wire `onDrift` to an inquirer prompt
 * (with a `'diff'` option that shows the diff and re-prompts). The default
 * when `onDrift` is not provided is `'keep'` — safe for non-interactive runs.
 *
 * **Failure isolation**
 *
 * Claude Code's runtime executes hooks on the same event in array order and
 * isolates failures itself (a non-zero exit from a Stop/SessionStart hook
 * does not block the others). Our only responsibility is to never corrupt
 * the array of hooks the user has set up themselves — entries we don't own
 * (no `id` of ours, no `legacyMatch` of ours) are preserved verbatim and
 * stay in their original position.
 */

const fs = require('fs');
const path = require('path');

const SETTINGS_FILE = path.join('.claude', 'settings.json');

/** @typedef {Object} HookDef
 *  @property {string} id
 *  @property {string} event       Claude Code event name
 *  @property {string} matcher     tool-name regex, or '*' for matcherless events
 *  @property {string} command     shell command
 *  @property {number} order       ascending sort key among BALDART hooks on same event
 *  @property {string} [legacyMatch] substring marker for pre-v3.18 entries to migrate
 */

/** @type {HookDef[]} */
const HOOK_REGISTRY = [
  {
    id: 'baldart-framework-edit-gate',
    event: 'PreToolUse',
    matcher: 'Edit|Write|MultiEdit|NotebookEdit',
    command: 'node .framework/framework/.claude/hooks/framework-edit-gate.js',
    order: 100,
    legacyMatch: 'framework-edit-gate.js',
  },
  {
    // Denies `Agent` tool calls whose subagent_type matches a BALDART agent
    // name but the consumer's .claude/agents/<name>.md is missing or its
    // symlink is broken. Mitigates anthropics/claude-code#56869 (silent
    // sub-agent fallback) and #20931 (broken file-based discovery) for the
    // filesystem-detectable subset.
    id: 'baldart-agent-discovery-gate',
    event: 'PreToolUse',
    matcher: 'Agent',
    command: 'node .framework/framework/.claude/hooks/agent-discovery-gate.js',
    order: 200,
  },
  {
    // Injects an additionalContext warning at session start listing BALDART
    // agents whose .claude/agents/<name>.md is missing. Best-effort early
    // signal (Claude Code #10373 may suppress injection on new
    // conversations) — the blocking counterpart is agent-discovery-gate.
    id: 'baldart-agent-discovery-info',
    event: 'SessionStart',
    matcher: '*',
    command: 'bash .framework/framework/.claude/hooks/agent-discovery-info.sh',
    order: 100,
  },
  {
    // Observation-only telemetry: records every Read of a
    // `.baldart/overlays/*.md` file to `.baldart/telemetry/overlay-loads.jsonl`.
    // Measures, objectively, whether skill overlays are actually loaded at
    // runtime (skill overlays use runtime-concat, which is NOT structurally
    // enforced — unlike agent/command overlays, which are compile-time merged).
    // Numerator-only by design: skill invocation is prompt-expansion, not a
    // tool call, so the denominator is not hook-observable (claude-code #43630,
    // #22655). bash, not node — it fires on EVERY Read (hottest tool) in the
    // critical path, so a per-read node cold-start would tax every file read.
    // Fail-safe — never blocks. See framework/docs/OVERLAY-TELEMETRY.md.
    id: 'baldart-overlay-telemetry',
    event: 'PostToolUse',
    matcher: 'Read',
    command: 'bash .framework/framework/.claude/hooks/overlay-telemetry.sh',
    order: 100,
  },
  // Future hooks (T1.3, T1.4, T2.3) will be appended here, e.g.:
  // {
  //   id: 'baldart-capture-detect',
  //   event: 'Stop',
  //   matcher: '*',
  //   command: 'bash .framework/framework/.claude/hooks/capture-detect.sh',
  //   order: 100,
  // },
];

// ---------------------------------------------------------------------------
// settings.json I/O
// ---------------------------------------------------------------------------

function readSettings(cwd = process.cwd()) {
  const full = path.join(cwd, SETTINGS_FILE);
  if (!fs.existsSync(full)) return { path: full, settings: null, malformed: false };
  try {
    const raw = fs.readFileSync(full, 'utf8');
    if (!raw.trim()) return { path: full, settings: {}, malformed: false };
    return { path: full, settings: JSON.parse(raw), malformed: false };
  } catch (_) {
    return { path: full, settings: null, malformed: true };
  }
}

function writeSettings(settingsPath, settings) {
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
}

// ---------------------------------------------------------------------------
// Internal: locate an entry that owns a given hook definition
// ---------------------------------------------------------------------------

/**
 * Walks `settings.hooks[event]` and returns the first entry/hook pair whose
 * `id` matches, or whose `command` contains `legacyMatch` (pre-v3.18 layout).
 * Returns `null` when the hook is not yet registered.
 */
function findOwnedEntry(eventList, def) {
  if (!Array.isArray(eventList)) return null;
  for (let i = 0; i < eventList.length; i++) {
    const entry = eventList[i];
    if (!entry || !Array.isArray(entry.hooks)) continue;
    for (let j = 0; j < entry.hooks.length; j++) {
      const h = entry.hooks[j];
      if (!h) continue;
      if (h.id === def.id) return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: false };
      if (def.legacyMatch && h.type === 'command'
          && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
        return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: true };
      }
    }
  }
  return null;
}

// ---------------------------------------------------------------------------
// Status — read-only inspection of every hook in the registry
// ---------------------------------------------------------------------------

/**
 * @typedef {Object} HookStatus
 * @property {string} id
 * @property {string} event
 * @property {boolean} registered  hook with this id exists in settings.json
 * @property {boolean} drift       registered but command differs from expected
 * @property {boolean} legacy      matched only by legacyMatch (id is missing)
 * @property {string} expected     command BALDART wants
 * @property {string} [actual]     command currently in settings.json (when registered)
 */

/**
 * Returns the status of every hook in the registry. Pure read; never writes.
 *
 * @param {string} cwd
 * @returns {{ malformed: boolean, path: string, hooks: HookStatus[] }}
 */
function getStatus(cwd = process.cwd()) {
  const { path: settingsPath, settings, malformed } = readSettings(cwd);
  if (malformed) {
    return { malformed: true, path: settingsPath, hooks: [] };
  }
  const allHooks = (settings && settings.hooks) || {};
  const hooks = HOOK_REGISTRY.map((def) => {
    const found = findOwnedEntry(allHooks[def.event], def);
    if (!found) {
      return {
        id: def.id, event: def.event,
        registered: false, drift: false, legacy: false,
        expected: def.command,
      };
    }
    const actual = found.hook.command;
    return {
      id: def.id, event: def.event,
      registered: true,
      drift: actual !== def.command,
      legacy: found.byLegacy,
      expected: def.command,
      actual,
    };
  });
  return { malformed: false, path: settingsPath, hooks };
}

// ---------------------------------------------------------------------------
// Register — write path with drift callback
// ---------------------------------------------------------------------------

/**
 * Idempotently register every hook in the registry.
 *
 * @param {string} [cwd]
 * @param {Object} [options]
 * @param {(info: { id: string, event: string, matcher: string,
 *                  expected: string, actual: string })
 *           => Promise<'keep'|'replace'>|('keep'|'replace')} [options.onDrift]
 *        Called when a hook with our `id` exists but command differs.
 *        Default: `'keep'` (safe for non-interactive / CI).
 * @returns {Promise<{ malformed: boolean, path: string,
 *                     created: boolean, updated: boolean,
 *                     results: Array<{ id: string, status:
 *                       'created'|'already'|'drift-replaced'|'drift-kept'|'legacy-migrated',
 *                       previousCommand?: string }> }>}
 */
async function registerAll(cwd = process.cwd(), options = {}) {
  const onDrift = typeof options.onDrift === 'function'
    ? options.onDrift
    : () => 'keep';

  const { path: settingsPath, settings: existing, malformed } = readSettings(cwd);
  if (malformed) {
    return { malformed: true, path: settingsPath, created: false, updated: false, results: [] };
  }

  const wasNew = !existing;
  const settings = existing && typeof existing === 'object' ? structuredClone(existing) : {};
  settings.hooks = settings.hooks && typeof settings.hooks === 'object'
    ? settings.hooks
    : {};

  const results = [];
  let modified = false;

  for (const def of HOOK_REGISTRY) {
    const list = Array.isArray(settings.hooks[def.event])
      ? settings.hooks[def.event]
      : [];

    const found = findOwnedEntry(list, def);

    if (!found) {
      list.push({
        matcher: def.matcher,
        hooks: [{ id: def.id, type: 'command', command: def.command }],
      });
      settings.hooks[def.event] = list;
      results.push({ id: def.id, status: 'created' });
      modified = true;
      continue;
    }

    if (found.byLegacy) {
      // Pre-v3.18 entry: stamp our id on it and align the command.
      const previousCommand = found.hook.command;
      found.hook.id = def.id;
      found.hook.command = def.command;
      results.push({ id: def.id, status: 'legacy-migrated', previousCommand });
      modified = true;
      continue;
    }

    if (found.hook.command === def.command) {
      results.push({ id: def.id, status: 'already' });
      continue;
    }

    // Drift: same id, different command. Ask the caller.
    const decision = await onDrift({
      id: def.id,
      event: def.event,
      matcher: def.matcher,
      expected: def.command,
      actual: found.hook.command,
    });

    if (decision === 'replace') {
      const previousCommand = found.hook.command;
      found.hook.command = def.command;
      results.push({ id: def.id, status: 'drift-replaced', previousCommand });
      modified = true;
    } else {
      results.push({
        id: def.id, status: 'drift-kept', previousCommand: found.hook.command,
      });
    }
  }

  if (modified) writeSettings(settingsPath, settings);

  return {
    malformed: false,
    path: settingsPath,
    created: modified && wasNew,
    updated: modified && !wasNew,
    results,
  };
}

// ---------------------------------------------------------------------------
// Unregister — remove every BALDART hook from settings.json
// ---------------------------------------------------------------------------

/**
 * Remove every hook defined in the registry from settings.json. Idempotent.
 *
 * @param {string} [cwd]
 * @returns {{ malformed: boolean, path: string,
 *             results: Array<{ id: string, status: 'removed'|'not-found' }> }}
 */
function unregisterAll(cwd = process.cwd()) {
  const { path: settingsPath, settings, malformed } = readSettings(cwd);
  if (malformed || !settings || !settings.hooks) {
    return {
      malformed,
      path: settingsPath,
      results: HOOK_REGISTRY.map((d) => ({ id: d.id, status: 'not-found' })),
    };
  }

  const next = structuredClone(settings);
  const results = [];
  let modified = false;

  for (const def of HOOK_REGISTRY) {
    const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
    if (!list) {
      results.push({ id: def.id, status: 'not-found' });
      continue;
    }
    let removed = false;
    const filtered = list
      .map((entry) => {
        if (!entry || !Array.isArray(entry.hooks)) return entry;
        const innerKept = entry.hooks.filter((h) => {
          if (!h) return true;
          if (h.id === def.id) { removed = true; return false; }
          if (def.legacyMatch && h.type === 'command'
              && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
            removed = true;
            return false;
          }
          return true;
        });
        if (innerKept.length === 0) return null;
        return { ...entry, hooks: innerKept };
      })
      .filter(Boolean);

    if (removed) {
      if (filtered.length === 0) {
        delete next.hooks[def.event];
      } else {
        next.hooks[def.event] = filtered;
      }
      modified = true;
      results.push({ id: def.id, status: 'removed' });
    } else {
      results.push({ id: def.id, status: 'not-found' });
    }
  }

  if (Object.keys(next.hooks).length === 0) delete next.hooks;
  if (modified) writeSettings(settingsPath, next);

  return { malformed: false, path: settingsPath, results };
}

// ---------------------------------------------------------------------------
// Verify — post-flight assertion that every active hook is registered
// ---------------------------------------------------------------------------

/**
 * Confirms that every active hook in HOOK_REGISTRY is present in
 * `.claude/settings.json`. Pure read (delegates to `getStatus`). Designed as
 * the post-flight gate for `add` / `update --reset`: the install must never
 * declare success while a hook is missing (the silent partial-install class,
 * v3.27.1 / mayo 3.28→3.31). Commented-out registry entries (e.g.
 * capture-detect) are excluded for free — `getStatus` only iterates the array.
 *
 * A malformed settings.json counts as a failure: registration was skipped, so
 * the hooks are not verifiably present.
 *
 * @param {string} [cwd]
 * @returns {{ ok: boolean, malformed: boolean, missing: string[], path: string }}
 */
function verifyAll(cwd = process.cwd()) {
  const status = getStatus(cwd);
  if (status.malformed) {
    return {
      ok: false,
      malformed: true,
      missing: HOOK_REGISTRY.map((d) => d.id),
      path: status.path,
    };
  }
  const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
  return { ok: missing.length === 0, malformed: false, missing, path: status.path };
}

// ---------------------------------------------------------------------------
// Drift prompt helper (interactive Keep / Replace / Show diff)
// ---------------------------------------------------------------------------

/**
 * Build an `onDrift` callback suitable for `registerAll`. Wraps inquirer via
 * the project's UI helper so the CLI output stays consistent.
 *
 * @param {Object} UI                 the `src/utils/ui.js` module (avoids a
 *                                    circular require by accepting it from
 *                                    the caller)
 * @param {Object} [options]
 * @param {boolean} [options.autoYes] non-interactive mode: skip prompt and
 *                                    always return 'keep' (safe default)
 * @returns {(info: {id:string, event:string, expected:string, actual:string})
 *           => Promise<'keep'|'replace'>}
 */
function createDriftPrompt(UI, { autoYes = false } = {}) {
  if (autoYes) {
    return async ({ id }) => {
      UI.warning(`Hook drift detected for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
      return 'keep';
    };
  }

  const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);

  return async function onDrift({ id, event, expected, actual }) {
    UI.warning(`Hook \`${id}\` (${event}) is registered with a non-default command.`);
    // Loop while the user keeps choosing 'diff'
    for (;;) {
      const choice = await UI.select(
        `What do you want to do with \`${id}\`?`,
        [
          { name: `Keep current        — leave: ${truncate(actual)}`,        value: 'keep' },
          { name: `Replace with BALDART — set:  ${truncate(expected)}`,      value: 'replace' },
          { name: `Show full diff and decide`,                               value: 'diff' },
        ],
      );
      if (choice !== 'diff') return choice;
      console.log(`  expected: ${expected}`);
      console.log(`  actual:   ${actual}`);
    }
  };
}

module.exports = {
  HOOK_REGISTRY,
  SETTINGS_FILE,
  getStatus,
  verifyAll,
  registerAll,
  unregisterAll,
  createDriftPrompt,
};
