/**
 * Git-hooks health check — **assert-active**, not read-only.
 *
 * NOT to be confused with `src/utils/hooks.js`: that module manages the Claude
 * Code hooks registered in `.claude/settings.json`. THIS module inspects the
 * repo's own *git* hooks (pre-commit, pre-push, …) and asserts they are
 * actually being executed by git — i.e. that `core.hooksPath` resolves to the
 * directory where the repo's versioned hooks live.
 *
 * **Why it exists** (since v3.34.0)
 *
 * A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the
 * repo's real, versioned hooks lived in `scripts/git-hooks/`. Result: the
 * pre-commit schema guard and pre-push were silently inactive for days, and
 * the worktree-manager verify step (`git config core.hooksPath || true`) only
 * *read* the value without asserting anything — so it never caught the drift.
 * Same class as the LSP bug: the framework "verifies" by reading a proxy
 * instead of asserting the real state. This moves it from read-only to
 * assert-active.
 *
 * **Resolution of the active hooks dir** (the crux)
 *
 * `git rev-parse --git-path hooks` is NOT used: empirically it returns the
 * configured value *relative and unresolved*, and historically some git
 * versions ignored `core.hooksPath` for `--git-path` entirely. Instead we
 * resolve explicitly:
 *   - `core.hooksPath` set      → relative resolves against the working-tree
 *                                 top (`git rev-parse --show-toplevel`),
 *                                 absolute is used verbatim.
 *   - `core.hooksPath` unset    → `<git rev-parse --git-dir>/hooks` (handles
 *                                 the worktree case where `.git` is a file).
 *
 * **Design invariants**
 *
 *   - Fail-safe: every probe is wrapped; any internal error yields
 *     `{ checked: false, ok: true }` so the check NEVER blocks legitimate work
 *     and never produces a false WARN.
 *   - Silent when nothing to assert: a repo with no versioned-hooks directory
 *     returns `checked: false` (most consumers have no managed hooks).
 *   - No config flag: this is a universal, always-on health check auto-detected
 *     from the filesystem, so the schema-change-propagation rule does not apply.
 *   - WARN only: this module never mutates `core.hooksPath`. It reports a
 *     `fixCommand` string; applying it is the caller's (user's) decision.
 */

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

// Standard git hook filenames. A managed-hooks directory is recognized by
// containing at least one of these (husky places them at `.husky/<name>`,
// raw dirs at `<dir>/<name>`).
const STANDARD_HOOKS = [
  'pre-commit', 'pre-push', 'commit-msg', 'prepare-commit-msg',
  'pre-rebase', 'pre-merge-commit', 'post-checkout', 'post-commit',
  'post-merge', 'post-rewrite', 'pre-applypatch', 'applypatch-msg',
];

// Candidate versioned-hooks directories, in priority order. The first that
// exists AND contains a standard-named hook wins. `scripts/git-hooks` and
// `githooks` are conventions (not universal) — we enumerate a small known set
// rather than guess.
const MANAGED_DIRS = [
  { dir: '.husky',            source: 'husky' },
  { dir: '.githooks',         source: 'githooks' },
  { dir: 'scripts/git-hooks', source: 'scripts' },
  { dir: 'githooks',          source: 'githooks' },
];

function git(cwd, args) {
  return execFileSync('git', args, {
    cwd,
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'ignore'],
  }).trim();
}

/**
 * Find the repo's versioned-hooks directory, or null when none is managed.
 * @returns {{ dir: string, abs: string, source: string, hooks: string[] } | null}
 */
function detectExpected(cwd) {
  for (const cand of MANAGED_DIRS) {
    const abs = path.join(cwd, cand.dir);
    let names = [];
    try {
      if (!fs.statSync(abs).isDirectory()) continue;
      names = fs.readdirSync(abs).filter((f) => STANDARD_HOOKS.includes(f));
    } catch (_) { continue; }
    if (names.length) return { dir: cand.dir, abs, source: cand.source, hooks: names };
  }
  return null;
}

/**
 * Resolve the directory git will actually look in for hooks.
 * @returns {{ configured: string | null, abs: string }}
 */
function resolveActiveDir(cwd) {
  let configured = null;
  try { configured = git(cwd, ['config', '--get', 'core.hooksPath']) || null; } catch (_) {}

  if (configured) {
    if (path.isAbsolute(configured)) return { configured, abs: configured };
    // Relative core.hooksPath resolves against the working-tree top, not cwd.
    let top = cwd;
    try { top = git(cwd, ['rev-parse', '--show-toplevel']); } catch (_) {}
    return { configured, abs: path.resolve(top, configured) };
  }

  // Unset → <git-dir>/hooks. git-dir may be relative or a worktree gitdir.
  let gitDir;
  try { gitDir = git(cwd, ['rev-parse', '--git-dir']); } catch (_) { gitDir = path.join(cwd, '.git'); }
  if (!path.isAbsolute(gitDir)) gitDir = path.resolve(cwd, gitDir);
  return { configured: null, abs: path.join(gitDir, 'hooks') };
}

/**
 * Resolve a path to its canonical (symlink-free) form so two paths computed
 * from different bases compare correctly. The active dir (from git's
 * realpath-resolved toplevel/git-dir) and the expected dir (from cwd) can
 * otherwise differ purely by a symlinked ancestor — e.g. macOS `/var` →
 * `/private/var`, or a symlinked repo/home path — yielding a false INACTIVE.
 *
 * Handles non-existent leaves (the active `.git/hooks` may not exist on a
 * misconfigured repo): realpath the deepest existing ancestor, re-append the
 * rest. Falls back to a plain resolve if nothing along the path is realpath-able.
 */
function canonicalize(p) {
  let dir = path.resolve(p);
  const tail = [];
  for (;;) {
    try {
      const real = fs.realpathSync(dir);
      return tail.length ? path.join(real, ...tail) : real;
    } catch (_) {
      const parent = path.dirname(dir);
      if (parent === dir) return path.resolve(p); // reached fs root
      tail.unshift(path.basename(dir));
      dir = parent;
    }
  }
}

/**
 * PURE decision: is the active hooks dir serving the versioned hooks?
 *
 * True when the two dirs are identical OR one is nested within the other.
 * The nesting case covers husky v9, which sets `core.hooksPath=.husky/_`
 * (wrappers) while the user hooks live in `.husky/`. Exported for unit tests —
 * this is the one decision that, if wrong, silently invalidates the whole check.
 */
function isActiveServing(activeAbs, expectedAbs) {
  const a = path.resolve(activeAbs);
  const e = path.resolve(expectedAbs);
  if (a === e) return true;
  return a.startsWith(e + path.sep) || e.startsWith(a + path.sep);
}

/**
 * Inspect the repo's git-hooks health. Fail-safe.
 * @returns {{
 *   checked: boolean, ok: boolean, source: string|null, configured: string|null,
 *   activeDir: string|null, expectedDir: string|null, expectedHooks: string[],
 *   inactive: boolean, nonExecutable: string[], detail: string, fixCommand: string|null
 * }}
 */
function getStatus(cwd = process.cwd()) {
  const result = {
    checked: false,
    ok: true,
    source: null,
    configured: null,
    activeDir: null,
    expectedDir: null,
    expectedHooks: [],
    inactive: false,
    nonExecutable: [],
    detail: '',
    fixCommand: null,
  };

  try {
    const expected = detectExpected(cwd);
    if (!expected) return result; // no managed hooks → silent, ok

    const active = resolveActiveDir(cwd);
    result.checked = true;
    result.source = expected.source;
    result.configured = active.configured;
    result.activeDir = active.abs;
    result.expectedDir = expected.dir;
    result.expectedHooks = expected.hooks;

    // Canonicalize both ends before comparing — they're computed from different
    // bases (git realpath vs cwd) and a symlinked ancestor would otherwise
    // produce a false INACTIVE.
    if (!isActiveServing(canonicalize(active.abs), canonicalize(expected.abs))) {
      result.inactive = true;
      result.ok = false;
      result.fixCommand = `git config core.hooksPath ${expected.dir}`;
      result.detail = `core.hooksPath risolve a ${active.abs} ma gli hook versionati sono in ${expected.dir}`;
      return result;
    }

    // Executability assert — only for raw dirs git execs directly. Husky sources
    // user hooks via its `_` wrappers and v9 does NOT require them to be +x, so
    // asserting -x there would manufacture false positives.
    if (expected.source !== 'husky') {
      for (const h of expected.hooks) {
        try { fs.accessSync(path.join(expected.abs, h), fs.constants.X_OK); }
        catch (_) { result.nonExecutable.push(h); }
      }
      if (result.nonExecutable.length) {
        result.ok = false;
        result.detail = `hook non eseguibili (manca il bit -x): ${result.nonExecutable.join(', ')}`;
        result.fixCommand = `chmod +x ${result.nonExecutable.map((h) => path.join(expected.dir, h)).join(' ')}`;
      }
    }

    return result;
  } catch (_) {
    // Never block doctor / worktree verify on a probe failure.
    return { ...result, checked: false, ok: true };
  }
}

module.exports = { getStatus, isActiveServing };
// Exposed for completeness / advanced callers and unit tests.
module.exports.detectExpected = detectExpected;
module.exports.resolveActiveDir = resolveActiveDir;
module.exports.STANDARD_HOOKS = STANDARD_HOOKS;
module.exports.MANAGED_DIRS = MANAGED_DIRS;
