/**
 * `baldart doctor` — smart diagnostic + suggested action.
 *
 * Invoked with no arguments (`npx baldart`) or explicitly (`npx baldart doctor`).
 * Inspects the consumer repo, prints a status table, then proposes the next
 * sensible action(s) and (with confirmation) runs them.
 *
 * Detection priority (precondition order, lowest-permissive first):
 *
 *   1.  no git repo                  → suggest `git init`, abort.
 *   2.  no .framework/               → suggest `add`.
 *   3.  legacy v2.0.x bulk skills    → suggest `migrate`.
 *   4.  no baldart.config.yml        → suggest `configure`.
 *   5.  config schema drift          → suggest `configure` (idempotent merge).
 *   6.  remote ahead AND local changes → suggest `update` then `push` (two prompts).
 *   7.  remote ahead                 → suggest `update`.
 *   8.  local changes                → suggest `push`.
 *   9.  all clean                    → print extended status + hints.
 *
 * Flags:
 *   --auto       run with no confirmation prompts (CI-friendly). Errors out
 *                on multi-choice ambiguity rather than guessing.
 *   --offline    skip the upstream fetch (no remote drift check).
 */

const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const GitUtils = require('../utils/git');
const UI = require('../utils/ui');
const State = require('../utils/state');
const Hooks = require('../utils/hooks');
const GitHooks = require('../utils/githooks');
const LspInstaller = require('../utils/lsp-installer');
const GraphifyInstaller = require('../utils/graphify-installer');
const UpdateNotifier = require('../utils/update-notifier');
const cliPackageJson = require('../../package.json');

const FRAMEWORK_DIR = '.framework';
const CONFIG_FILE = 'baldart.config.yml';

// ──────────────────────────────────────────────────────────────────────
// State detection
// ──────────────────────────────────────────────────────────────────────

function fileExists(p) {
  try { return fs.existsSync(p); } catch (_) { return false; }
}

function isLegacySkillsLayout(cwd) {
  const skills = path.join(cwd, '.claude', 'skills');
  if (!fileExists(skills)) return false;
  try { return fs.lstatSync(skills).isSymbolicLink(); }
  catch (_) { return false; }
}

function loadConfig(cwd) {
  const full = path.join(cwd, CONFIG_FILE);
  if (!fileExists(full)) return null;
  try { return yaml.load(fs.readFileSync(full, 'utf8')); }
  catch (_) { return { __malformed: true }; }
}

function loadConfigTemplate(cwd) {
  const tpl = path.join(cwd, FRAMEWORK_DIR, 'framework', 'templates', 'baldart.config.template.yml');
  if (!fileExists(tpl)) return null;
  try { return yaml.load(fs.readFileSync(tpl, 'utf8')); }
  catch (_) { return null; }
}

function configSchemaDrift(config, template) {
  if (!config || !template || config.__malformed) return [];
  const drift = [];
  for (const section of ['paths', 'features']) {
    const tplKeys = Object.keys(template[section] || {});
    const cfgKeys = Object.keys(config[section] || {});
    for (const k of tplKeys) {
      if (!cfgKeys.includes(k)) drift.push(`${section}.${k}`);
    }
  }
  return drift;
}

function countOverlays(cwd) {
  const root = path.join(cwd, '.baldart', 'overlays');
  if (!fileExists(root)) return 0;
  // Skill overlays at root, agent overlays under agents/, command under commands/.
  const counts = (sub) => {
    const d = sub ? path.join(root, sub) : root;
    if (!fileExists(d)) return 0;
    try {
      return fs.readdirSync(d).filter((f) => f.endsWith('.md') && !f.endsWith('.example.md')).length;
    } catch (_) { return 0; }
  };
  return counts('') + counts('agents') + counts('commands');
}

// SHA-first drift detection for skill overlays, mirroring `overlay drift`
// (overlay.js): a pinned `base_file_sha` is the source of truth — an overlay is
// drifted only when the base SKILL.md content actually changed. The version is
// a fallback ONLY when no `base_file_sha` is present, because VERSION bumps on
// every release regardless of whether THIS base file changed; comparing it
// directly flagged every unchanged overlay as drifted after any update (v4.11.0).
function overlayDrift(cwd, frameworkVersion) {
  if (!frameworkVersion) return [];
  const dir = path.join(cwd, '.baldart', 'overlays');
  if (!fileExists(dir)) return [];
  let computeBaseFileSha;
  try { ({ computeBaseFileSha } = require('../utils/overlay-merger')); }
  catch (_) { computeBaseFileSha = null; }
  const drifted = [];
  try {
    for (const f of fs.readdirSync(dir)) {
      if (!f.endsWith('.md') || f.endsWith('.example.md')) continue;
      const txt = fs.readFileSync(path.join(dir, f), 'utf8');
      const fm = txt.match(/^---\n([\s\S]*?)\n---/);
      if (!fm) continue;
      try {
        const meta = yaml.load(fm[1]) || {};
        const name = f.replace(/\.md$/, '');
        const baseAbs = path.join(cwd, '.framework', 'framework', '.claude', 'skills', name, 'SKILL.md');
        // SHA-first: trust the pinned base_file_sha when we can read the base.
        if (meta.base_file_sha && computeBaseFileSha && fileExists(baseAbs)) {
          const currentSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
          if (String(meta.base_file_sha) !== currentSha) {
            drifted.push({ name: f, target: meta.base_skill_version || 'sha', installed: frameworkVersion });
          }
          continue;
        }
        // Fallback: no base_file_sha (or base unreadable) → version comparison.
        if (meta.base_skill_version && meta.base_skill_version !== frameworkVersion) {
          drifted.push({ name: f, target: meta.base_skill_version, installed: frameworkVersion });
        }
      } catch (_) { /* ignore unparseable */ }
    }
  } catch (_) { /* ignore */ }
  return drifted;
}

async function describeRemote(git, repo, offline) {
  // SSOT (v3.25.0+): version-compare authority + diagnostic counts.
  // The legacy { ahead, behind, fetched } shape is preserved for downstream
  // callers that read it; the new authoritative fields are `isAligned`,
  // `installedVersion`, `remoteVersion`.
  if (offline) return { fetched: false, isAligned: false, installedVersion: 'unknown', remoteVersion: 'unknown', aheadScoped: 0 };
  const status = await git.getUpdateStatus(repo);
  return {
    fetched: status.fetched,
    ahead: status.commitCount.ahead,
    behind: status.commitCount.behind,
    isAligned: status.isAligned,
    installedVersion: status.installedVersion,
    remoteVersion: status.remoteVersion,
    aheadScoped: status.aheadScoped,
    hasFetchHead: status.hasFetchHead,
  };
}

async function localFrameworkChanges(git) {
  // Files modified vs HEAD inside .framework/. Distinct from "ahead of remote"
  // (which counts commits). This is the working-tree dirty count.
  try {
    const out = await git.git.raw(['status', '--porcelain', '--', FRAMEWORK_DIR]);
    return out.split('\n').filter((l) => l.trim()).length;
  } catch (_) { return 0; }
}

async function commitsAheadOfRemote(git, remote) {
  // Pre-v3.25.0 this re-ran the git query; now we read from the SSOT
  // result populated by describeRemote(). Kept as a helper for readability
  // in detectState().
  if (remote && typeof remote.aheadScoped === 'number') return remote.aheadScoped;
  return 0;
}

async function detectState(cwd, opts = {}) {
  const git = new GitUtils(cwd);
  const state = {
    cwd,
    cliVersion: cliPackageJson.version,
    cliUpdate: UpdateNotifier.hint({ currentVersion: cliPackageJson.version }),
    isGitRepo: await git.isGitRepo(),
    frameworkPresent: await git.frameworkExists(),
    legacyLayout: false,
    frameworkVersion: null,
    configPresent: false,
    configMalformed: false,
    configSchemaMissing: [],
    statePresent: false,
    stateInstalled: null,
    stateInstallDate: null,
    stateLastUpdate: null,
    stateLastPush: null,
    remote: { fetched: false },
    commitsAhead: 0,
    workingTreeDirty: 0,
    overlays: 0,
    overlaysDrifted: [],
  };

  if (!state.isGitRepo) return state;

  if (state.frameworkPresent) {
    state.legacyLayout = isLegacySkillsLayout(cwd);
    try { state.frameworkVersion = await git.getFrameworkVersion(); } catch (_) {}

    const config = loadConfig(cwd);
    state.configPresent = !!config && !config.__malformed;
    state.configMalformed = !!(config && config.__malformed);
    if (state.configPresent) {
      const tpl = loadConfigTemplate(cwd);
      state.configSchemaMissing = configSchemaDrift(config, tpl);
    }

    const ledger = State.load(cwd);
    state.statePresent = !!ledger.installed_version;
    state.stateInstalled = ledger.installed_version;
    state.stateInstallDate = ledger.install_date;
    state.stateLastUpdate = ledger.last_update_date;
    state.stateLastPush = ledger.last_pushed_version
      ? `v${ledger.last_pushed_version} (${(ledger.last_push_date || '').slice(0, 10) || '—'})`
      : null;

    // Ledger drift detection (v3.13.0+): the framework on disk says one thing,
    // the ledger says another. Caused by interrupted updates, manual commits
    // that excluded state.json, or filesystem write failures the old silent
    // catch in update.js used to hide. Self-healable via `reconcile-state-ledger`.
    state.stateLedgerDrift = null;
    if (state.frameworkVersion && ledger.installed_version &&
        state.frameworkVersion !== ledger.installed_version) {
      state.stateLedgerDrift = {
        payload: state.frameworkVersion,
        ledger: ledger.installed_version
      };
    }

    state.remote = await describeRemote(git, ledger.framework_repo || State.DEFAULT_REPO, !!opts.offline);
    state.commitsAhead = await commitsAheadOfRemote(git, state.remote);
    state.workingTreeDirty = await localFrameworkChanges(git);

    state.overlays = countOverlays(cwd);
    state.overlaysDrifted = overlayDrift(cwd, state.frameworkVersion);

    state.hooksStatus = { malformed: false, hooks: [] };
    state.hooksMissing = [];
    state.hooksDrifted = [];
    state.hooksLegacy = [];
    try {
      const status = Hooks.getStatus(cwd);
      state.hooksStatus = status;
      if (!status.malformed) {
        state.hooksMissing  = status.hooks.filter((h) => !h.registered).map((h) => h.id);
        state.hooksDrifted  = status.hooks.filter((h) => h.registered && h.drift && !h.legacy).map((h) => h.id);
        state.hooksLegacy   = status.hooks.filter((h) => h.legacy).map((h) => h.id);
      }
    } catch (_) {}

    // ---- Git-hooks health (since v3.34.0) ------------------------------
    // Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
    // actually being run — i.e. `core.hooksPath` resolves to the directory
    // that holds them. A drift here (e.g. core.hooksPath=.git/hooks while the
    // hooks live in scripts/git-hooks/) silently disables every hook. Distinct
    // from the BALDART `.claude/settings.json` hooks probed above. Fully
    // fail-safe: `checked:false` when no managed hooks exist or on any error.
    state.gitHooks = { checked: false, ok: true };
    try { state.gitHooks = GitHooks.getStatus(cwd); } catch (_) {}

    // ---- Agent symlink integrity (since v3.20.0) -----------------------
    // Source of truth for the BALDART agent list is the framework filesystem
    // directory itself (`.framework/framework/.claude/agents/*.md`). For each
    // name, the consumer must have a working `.claude/agents/<name>.md`
    // (symlink to the framework copy, possibly merged with an overlay).
    // A missing or broken symlink would otherwise be detected at runtime by
    // `agent-discovery-gate.js`, blocking Agent calls. This probe lets the
    // doctor heal it offline before the user hits the gate.
    state.agentSymlinksBroken = [];
    try {
      if (state.frameworkPresent) {
        const frameworkAgentsDir = path.join(cwd, '.framework', 'framework', '.claude', 'agents');
        const consumerAgentsDir = path.join(cwd, '.claude', 'agents');
        if (fs.existsSync(frameworkAgentsDir)) {
          const baldartAgents = fs.readdirSync(frameworkAgentsDir)
            .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md')
            .map((f) => f.replace(/\.md$/, ''));
          for (const name of baldartAgents) {
            try {
              fs.statSync(path.join(consumerAgentsDir, `${name}.md`));
            } catch (_) {
              state.agentSymlinksBroken.push(name);
            }
          }
        }
      }
    } catch (_) { /* never block doctor on probe */ }

    // ---- Workflow symlink integrity (since v4.14.0) --------------------
    // Dynamic workflows (.js) are Claude-only and per-item symlinked into
    // `.claude/workflows/`. Unlike agents there is NO hard discovery gate —
    // a missing workflow just makes the consuming skill fall back to its
    // inline path (e.g. /new's Final Review) — so this is advisory self-heal,
    // not a blocker. Gated on `claude` being enabled (workflows have no Codex
    // equivalent, so a Codex-only install must not be flagged).
    state.workflowSymlinksBroken = [];
    try {
      if (state.frameworkPresent) {
        const toolsEnabled = (config && !config.__malformed && config.tools && Array.isArray(config.tools.enabled))
          ? config.tools.enabled : ['claude'];
        if (toolsEnabled.includes('claude')) {
          const frameworkWorkflowsDir = path.join(cwd, '.framework', 'framework', '.claude', 'workflows');
          const consumerWorkflowsDir = path.join(cwd, '.claude', 'workflows');
          if (fs.existsSync(frameworkWorkflowsDir)) {
            const baldartWorkflows = fs.readdirSync(frameworkWorkflowsDir)
              .filter((f) => f.endsWith('.js') && !f.startsWith('.'))
              .map((f) => f.replace(/\.js$/, ''));
            for (const name of baldartWorkflows) {
              try {
                fs.statSync(path.join(consumerWorkflowsDir, `${name}.js`));
              } catch (_) {
                state.workflowSymlinksBroken.push(name);
              }
            }
          }
        }
      }
    } catch (_) { /* never block doctor on probe */ }

    // `.framework/` is a git subtree — gitignore matching it breaks every
    // future `git add` under that path (the failure mode that hit v3.13.0
    // consumers during push). Detect any gitignore rule that catches the
    // path so the planner can heal it before any flow needs to stage files.
    state.frameworkIgnored = { ignored: false };
    try { state.frameworkIgnored = await git.checkFrameworkIgnored(); } catch (_) {}

    // ---- LSP layer (since v3.10.0) -------------------------------------
    state.lspEnabled = false;
    state.lspInstalled = [];
    state.lspBroken = [];
    state.lspMissingClaudePlugins = []; // [{ name, pluginId }] — since v3.14.0
    try {
      // Reuse the `config` already parsed above instead of re-reading the file.
      if (config && !config.__malformed && config.features && config.features.has_lsp_layer === true) {
        state.lspEnabled = true;
        const installed = (config.lsp && config.lsp.installed_servers) || [];
        state.lspInstalled = installed;
        const autoVerify = !(config.lsp && config.lsp.auto_verify === false);
        if (autoVerify && installed.length) {
          const installer = new LspInstaller(cwd);
          const results = installer.verifyServers(installed);
          state.lspBroken = results.filter(r => !r.ok).map(r => r.name);

          // Companion Claude plugin check (v3.14.0+). Gated on `claude` being
          // in tools.enabled AND the `claude` CLI being available; otherwise
          // we don't report missing plugins (manual install hint would be
          // misleading without the CLI).
          const toolsEnabled = (config.tools && Array.isArray(config.tools.enabled))
            ? config.tools.enabled
            : ['claude'];
          if (toolsEnabled.includes('claude') && installer.isClaudeCliAvailable()) {
            const checks = installer.verifyClaudePlugins(installed);
            state.lspMissingClaudePlugins = checks
              .filter(c => c.installed === false)
              .map(c => ({ name: c.name, pluginId: c.pluginId }));
          }
        }
      }
    } catch (_) { /* never block doctor on LSP probe */ }

    // ---- Code graph layer (Graphify, since v4.21.0) --------------------
    state.graphEnabled = false;
    state.graphBinaryMissing = false;
    state.graphStale = false;
    state.graphNotBuilt = false;
    state.graphHookMissing = false;
    try {
      if (config && !config.__malformed && config.features && config.features.has_code_graph === true) {
        state.graphEnabled = true;
        const gi = new GraphifyInstaller(cwd);
        if (!gi.detect()) {
          state.graphBinaryMissing = true;
        } else {
          const outDir = (config.graph && config.graph.output_dir) || 'graphify-out';
          const st = gi.isStale(cwd, outDir);
          state.graphNotBuilt = !st.built;
          state.graphStale = st.stale === true;
          if (config.graph && config.graph.auto_rebuild_hook !== false) {
            state.graphHookMissing = gi.hookStatus(cwd).installed === false;
          }
        }
      }
    } catch (_) { /* never block doctor on graph probe */ }
  }

  return state;
}

// ──────────────────────────────────────────────────────────────────────
// Action planner
// ──────────────────────────────────────────────────────────────────────

/**
 * Returns an array of action proposals. Each item:
 *   { key, label, why, run() }
 *
 * Order matters: actions are presented and (with confirmation) executed in
 * the order returned. Empty array means "nothing to do".
 */
function planActions(state) {
  const actions = [];

  if (!state.isGitRepo) {
    actions.push({
      key: 'git-init',
      label: 'Initialize a git repo',
      why: 'BALDART requires a git repository to install (it uses git subtree).',
      run: () => {
        UI.error('Run `git init` first, then re-run `npx baldart`.');
        process.exit(1);
      },
    });
    return actions;
  }

  // `autoOk` policy:
  //   - true  → safe & idempotent action that has its own internal prompts
  //             (update, configure, migrate, hook register). The outer doctor
  //             prompt would be a redundant double-confirmation, so we skip it.
  //   - false → state-changing action that warrants explicit intent at the
  //             doctor level (add = first install, push = writes to remote
  //             and triggers a version-classification dialog).
  if (!state.frameworkPresent) {
    actions.push({
      key: 'add',
      label: 'Install BALDART framework',
      why: 'No .framework/ directory found in this repo.',
      autoOk: false,
      run: async () => {
        const add = require('./add');
        // Fork-aware: resolve from the ledger (preserves a consumer's fork)
        // rather than hardcoding the upstream default.
        await add(State.resolveRepo(state.cwd), { branch: 'main' });
      },
    });
    return actions;
  }

  if (state.legacyLayout) {
    actions.push({
      key: 'migrate',
      label: 'Migrate legacy v2.0.x layout',
      why: '.claude/skills/ is a bulk symlink — v2.0.x layout. v2.1.1+ uses per-item merge.',
      autoOk: true,
      run: () => require('./migrate')(),
    });
    return actions;
  }

  if (state.configMalformed) {
    actions.push({
      key: 'configure-malformed',
      label: 'Rewrite baldart.config.yml (current file is malformed)',
      why: 'Existing baldart.config.yml is not valid YAML — re-running configure will rebuild it.',
      autoOk: true,
      run: () => require('./configure')(),
    });
    return actions;
  }

  if (!state.configPresent) {
    actions.push({
      key: 'configure',
      label: 'Generate baldart.config.yml',
      why: 'Skills require a config to resolve project-specific paths/identity/stack. Without it they will prompt on every invocation.',
      autoOk: true,
      run: () => require('./configure')(),
    });
    return actions;
  }

  if (state.configSchemaMissing.length > 0) {
    actions.push({
      key: 'configure-drift',
      label: `Refresh baldart.config.yml (${state.configSchemaMissing.length} new key(s) from framework template)`,
      why: 'Framework added config keys this consumer doesn\'t yet declare: ' + state.configSchemaMissing.join(', '),
      autoOk: true,
      run: () => require('./configure')(),
    });
    // Drift is non-blocking — keep checking for other actions after.
  }

  if (state.stateLedgerDrift) {
    actions.push({
      key: 'reconcile-state-ledger',
      label: `Reconcile state ledger (${state.stateLedgerDrift.ledger} → ${state.stateLedgerDrift.payload})`,
      why: `.framework/VERSION is v${state.stateLedgerDrift.payload} but .baldart/state.json records installed_version=${state.stateLedgerDrift.ledger}. Typical causes: an update flow that exited mid-way (configure declined, stash conflict), a manual commit that excluded .baldart/state.json, or a write failure that the old silent catch in update.js used to hide (fixed in v3.13.0).`,
      autoOk: true, // pure local file write, idempotent, fully reversible
      run: () => {
        const res = State.reconcileInstalledVersion({
          to: state.stateLedgerDrift.payload,
          reason: 'doctor reconcile — payload/ledger drift'
        });
        if (res.changed) {
          UI.success(`Ledger updated: installed_version ${res.from} → ${res.to}`);
          UI.info('History entry "ledger-reconcile" recorded for audit. Commit .baldart/state.json when ready.');
        } else {
          UI.info('No change needed — ledger already matches payload.');
        }
      },
    });
    // Drift is non-blocking — keep checking for other actions after.
  }

  // Heal `.gitignore` BEFORE any other action that may stage files under
  // `.framework/`. Without this, every downstream `git add` (push, auto-commit
  // during update, lsp-install commits, …) would fail with "paths are ignored
  // by one of your .gitignore files". (since v3.14.0)
  if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
    actions.push({
      key: 'unignore-framework',
      label: 'Heal .gitignore so .framework/ is trackable',
      why: `.framework/ is a git subtree managed by BALDART — it MUST be tracked. Currently ignored by ${state.frameworkIgnored.source}:${state.frameworkIgnored.line} (pattern: ${state.frameworkIgnored.pattern}). Any subsequent \`git add\` under that path would fail.`,
      autoOk: true, // pure local file rewrite, idempotent, fully reversible
      run: async () => {
        const git = new GitUtils(state.cwd);
        const heal = await git.ensureFrameworkNotIgnored();
        if (!heal.wasFixed) {
          UI.info('.gitignore already healthy — nothing to do.');
          return;
        }
        UI.success('.gitignore healed:');
        if (heal.removedLines > 0) {
          UI.info(`  Removed ${heal.removedLines} stale .framework line(s).`);
        }
        if (heal.appendedNegation) {
          UI.info('  Appended `!.framework` negation block (idempotent).');
        }
        if (heal.stillIgnored) {
          UI.warning('  .framework/ is STILL ignored — likely a parent-dir .gitignore outside this repo.');
          UI.info(`  Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
          UI.info('  Resolve by removing that rule manually.');
        } else {
          UI.info('  Commit the .gitignore change when you next push.');
        }
      },
    });
    // Non-blocking — keep looking for other actions to run in the same pass.
  }

  // BALDART hooks (multi-hook registry since v3.18.0).
  // Three buckets, three actions: missing → propose; legacy → propose
  // (silent migration to current id); drifted → require interactive prompt.
  if ((state.hooksMissing && state.hooksMissing.length > 0)
      || (state.hooksLegacy && state.hooksLegacy.length > 0)) {
    const ids = [...(state.hooksMissing || []), ...(state.hooksLegacy || [])];
    actions.push({
      key: 'register-hooks',
      label: `Register/migrate BALDART hook(s) in .claude/settings.json (${ids.join(', ')})`,
      why: 'BALDART hooks are missing or registered under a pre-v3.18 layout. They run on PreToolUse/Stop/SessionStart to gate edits and feed the auto-learning loop. Re-registration is idempotent and never overwrites user-customized commands without asking.',
      autoOk: true,
      run: async () => {
        const Hooks = require('../utils/hooks');
        const res = await Hooks.registerAll(process.cwd(), {
          onDrift: Hooks.createDriftPrompt(UI, { autoYes: state.autoMode }),
        });
        if (res.malformed) {
          UI.warning('.claude/settings.json is malformed; fix it manually then re-run `npx baldart doctor`.');
          return;
        }
        for (const r of res.results) {
          if (r.status === 'created') UI.success(`Registered hook \`${r.id}\``);
          else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
        }
      },
    });
  }
  if (state.hooksDrifted && state.hooksDrifted.length > 0) {
    actions.push({
      key: 'reconcile-hooks-drift',
      label: `Reconcile drifted BALDART hook(s) (${state.hooksDrifted.join(', ')})`,
      why: 'These hooks are registered but their command differs from the BALDART default. This is typically a sign of a manual customization or a stale framework version on disk. Reconciliation prompts you per hook (Keep / Replace / Show diff).',
      autoOk: false, // drift reconciliation is the kind of decision --auto must NOT make
      run: async () => {
        const Hooks = require('../utils/hooks');
        const res = await Hooks.registerAll(process.cwd(), {
          onDrift: Hooks.createDriftPrompt(UI), // always interactive here
        });
        for (const r of res.results) {
          if (r.status === 'drift-replaced') UI.success(`Replaced \`${r.id}\` (was: ${r.previousCommand})`);
          else if (r.status === 'drift-kept') UI.info(`Kept \`${r.id}\` as-is.`);
        }
      },
    });
  }

  // Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
  // mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
  // original silent-inactive bug). It surfaces the WARN + the exact remediation
  // command so the user applies it deliberately. Present as an action (rather
  // than a bare table WARN) so `doctor` doesn't contradict it with "Nothing to
  // do". Safe to run in --auto since it only prints.
  if (state.gitHooks && state.gitHooks.checked && !state.gitHooks.ok) {
    actions.push({
      key: 'githooks-health',
      label: state.gitHooks.inactive
        ? `Git hooks INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} are not being run`
        : `Git hooks not executable — ${state.gitHooks.nonExecutable.join(', ')}`,
      why: `${state.gitHooks.detail}. Fix: ${state.gitHooks.fixCommand}`,
      autoOk: true, // advisory print-only; run() does NOT mutate config
      run: () => {
        UI.warning(state.gitHooks.detail);
        UI.info('Run to re-activate the repo\'s versioned git hooks:');
        console.log(`    ${state.gitHooks.fixCommand}`);
      },
    });
  }

  // Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
  // recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
  // directly rather than dispatching to `update` — we want only the symlink
  // pass, not a full update (which would also pull, merge overlays, register
  // hooks, and verify config drift).
  if (state.agentSymlinksBroken && state.agentSymlinksBroken.length > 0) {
    actions.push({
      key: 'repair-agent-symlinks',
      label: `Repair ${state.agentSymlinksBroken.length} broken agent symlink(s)`,
      why: `BALDART expects these agents to be reachable via .claude/agents/<name>.md but the symlinks are missing or broken: ${state.agentSymlinksBroken.join(', ')}. Without these, the agent-discovery-gate PreToolUse hook will deny Agent calls targeting these names.`,
      autoOk: true,
      run: async () => {
        const SymlinkUtils = require('../utils/symlinks');
        // Re-read the config here (not stashed on state) to pick up
        // `tools.enabled`. Falls back to ['claude'] if absent/malformed.
        const cfg = loadConfig(process.cwd());
        const enabledTools = (cfg && !cfg.__malformed
          && cfg.tools && Array.isArray(cfg.tools.enabled))
          ? cfg.tools.enabled
          : ['claude'];
        const symlinks = new SymlinkUtils();
        symlinks.mergeAgents({ tools: enabledTools });
        UI.success(`Re-merged agent symlinks for tools: ${enabledTools.join(', ')}`);
      },
    });
  }

  // Workflow symlink integrity (since v4.14.0). Advisory — a missing workflow
  // symlink degrades the consuming skill to its inline path, it does not block.
  // Re-runs only the workflow merge pass (same rationale as the agent repair).
  if (state.workflowSymlinksBroken && state.workflowSymlinksBroken.length > 0) {
    actions.push({
      key: 'repair-workflow-symlinks',
      label: `Link ${state.workflowSymlinksBroken.length} framework workflow(s)`,
      why: `These framework dynamic workflows are not linked into .claude/workflows/: ${state.workflowSymlinksBroken.join(', ')}. Skills that delegate to them (e.g. /new's Final Review) fall back to their inline path until the symlinks exist — not a failure, but you lose the workflow acceleration.`,
      autoOk: true,
      run: async () => {
        const SymlinkUtils = require('../utils/symlinks');
        const cfg = loadConfig(process.cwd());
        const enabledTools = (cfg && !cfg.__malformed
          && cfg.tools && Array.isArray(cfg.tools.enabled))
          ? cfg.tools.enabled
          : ['claude'];
        const symlinks = new SymlinkUtils();
        symlinks.mergeWorkflows({ tools: enabledTools });
        UI.success(`Linked framework workflows for tools: ${enabledTools.join(', ')}`);
      },
    });
  }

  if (state.lspEnabled && state.lspBroken && state.lspBroken.length > 0) {
    actions.push({
      key: 'lsp-fix',
      label: `Reinstall ${state.lspBroken.length} broken LSP server(s)`,
      why: `LSP layer is enabled but these language servers are not reachable on $PATH: ${state.lspBroken.join(', ')}. The Claude Code LSP tool spawns each server by name from $PATH, so a binary that is missing OR only present in node_modules/.bin (a legacy devDep install) makes symbol search silently fall back to Grep. Reinstalling runs the global install so the binary lands on $PATH.`,
      autoOk: false, // runs `npm install -g` and is the kind of action a user
      // may have made deliberate (switching from npm pyright to system pyright);
      // keep the outer prompt + the per-server confirm both alive.
      run: async () => {
        const installer = new LspInstaller(state.cwd);
        const { installed, manual, skipped } = await installer.installServers(state.lspBroken);
        if (installed.length) {
          UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
          // If a reinstall keeps coming back broken, npm's global bin dir is
          // likely not on $PATH — surface the escape so the user doesn't loop.
          const recheck = installer.verifyServers(installed.map(i => i.name));
          if (recheck.some(r => !r.ok)) {
            UI.warning('Some servers are still unreachable after reinstall.');
            UI.info('Check that npm\'s global bin dir is on $PATH: `npm prefix -g` → add its `/bin` to PATH.');
          }
        }
        for (const m of manual) {
          UI.warning(`System-level reinstall required for ${m.label}:`);
          console.log(`    ${m.command}`);
        }
        if (skipped.length) UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
      },
    });
  }

  // Native Claude LSP plugins (v3.14.0+). The server binary alone is not
  // enough — without the matching `<lang>-lsp@claude-plugins-official` plugin
  // installed, Claude Code never invokes the LSP and agents silently fall back
  // to Grep. This action backfills the plugins for installs predating v3.14.0
  // and recovers from manual plugin uninstalls.
  if (state.lspEnabled && state.lspMissingClaudePlugins && state.lspMissingClaudePlugins.length > 0) {
    const missing = state.lspMissingClaudePlugins;
    actions.push({
      key: 'lsp-install-claude-plugins',
      label: `Install ${missing.length} native Claude LSP plugin(s)`,
      why: `LSP layer is enabled and the language server(s) are present, but the matching Claude Code plugin(s) are not installed: ${missing.map(m => m.pluginId).join(', ')}. Without them Claude never sees the LSP and the layer is effectively dead. Standard since v3.14.0; missing on installs predating that version.`,
      autoOk: true, // user-scope plugin install, idempotent, fully reversible via `claude plugin uninstall`
      run: async () => {
        const installer = new LspInstaller(state.cwd);
        const res = await installer.installClaudePlugins(missing.map(m => m.name), {
          toolsEnabled: ['claude'], // we already checked tools.enabled in detectState
          interactive: false,
          scope: 'user',
        });
        if (res.installed.length) {
          UI.success(`Installed: ${res.installed.map(i => i.pluginId).join(', ')}`);
        }
        for (const m of res.manual || []) {
          UI.warning(`Could not auto-install ${m.label}:`);
          console.log(`    ${m.command}`);
        }
        if (res.skipped.length) {
          const real = res.skipped.filter(s => s.reason !== 'plugin already installed');
          if (real.length) UI.warning(`Skipped: ${real.map(s => `${s.name} (${s.reason})`).join(', ')}`);
        }
      },
    });
  }

  // ---- Code graph layer (Graphify, since v4.21.0) ----------------------
  // The install side effects of `configure` only run interactively (a Python
  // tool is never installed silently in CI). So doctor is the backfill: it
  // installs the binary, (re)builds a stale/missing graph, and restores the
  // native auto-rebuild git hook.
  if (state.graphEnabled && state.graphBinaryMissing) {
    actions.push({
      key: 'graph-install',
      label: 'Install Graphify code-graph tool',
      why: 'features.has_code_graph is true but the `graphify` CLI is not on $PATH. Agents fall back to LSP/Grep and the wiki feed is dormant until it is installed (`pipx install graphifyy`).',
      autoOk: false, // installs a Python package; let the user confirm
      run: async () => {
        const gi = new GraphifyInstaller(state.cwd);
        const res = gi.install();
        if (res.ok) {
          UI.success(`Graphify installed (${res.method}).`);
          const b = gi.buildGraph(state.cwd);
          if (!b.ok) UI.warning(`Initial graph build failed: ${b.error}`);
        } else {
          UI.warning(`Install failed: ${res.error}`);
        }
      },
    });
  }
  if (state.graphEnabled && !state.graphBinaryMissing && (state.graphStale || state.graphNotBuilt)) {
    actions.push({
      key: 'graph-rebuild',
      label: state.graphNotBuilt ? 'Build the code graph (not built yet)' : 'Rebuild the stale code graph',
      why: state.graphNotBuilt
        ? 'features.has_code_graph is true but no graph.json exists yet. Build it (`graphify update .`, offline, no API cost).'
        : 'The code graph was built from an older commit than HEAD (built_at_commit ≠ HEAD). Rebuild so structural queries and the wiki feed reflect current code.',
      autoOk: true, // offline, idempotent, no API cost
      run: async () => {
        const gi = new GraphifyInstaller(state.cwd);
        const b = gi.buildGraph(state.cwd);
        if (!b.ok) UI.warning(`Graph build failed: ${b.error}`);
      },
    });
  }
  if (state.graphEnabled && !state.graphBinaryMissing && state.graphHookMissing) {
    actions.push({
      key: 'graph-hook',
      label: 'Install Graphify auto-rebuild git hook',
      why: 'graph.auto_rebuild_hook is true but Graphify\'s native post-commit hook is not installed, so the graph will drift after commits. `graphify hook install` restores it.',
      autoOk: true,
      run: async () => {
        const gi = new GraphifyInstaller(state.cwd);
        const h = gi.installGitHook(state.cwd);
        if (h.ok) UI.success('Graphify git hook installed.');
        else UI.warning(`Hook install failed: ${h.error}`);
      },
    });
  }

  // v3.25.0+: drift detection is authoritative via VERSION compare (isAligned).
  // The HEAD...FETCH_HEAD commit count is subtree-merge noise and never reaches
  // 0, so we MUST NOT use it as the "needs update" signal.
  const remoteAhead = state.remote.fetched && state.remote.isAligned === false;
  // Local work detection: working-tree dirty is real; commitsAhead (aheadScoped)
  // includes subtree-merge noise — only count it when NOT aligned (i.e. there's
  // a real divergence to investigate). C2 adds classifier to filter noise.
  const hasLocalWork = state.workingTreeDirty > 0 ||
    (state.commitsAhead > 0 && state.remote.fetched && state.remote.isAligned === false);

  if (remoteAhead) {
    const fromTo = (state.remote.installedVersion && state.remote.remoteVersion)
      ? `v${state.remote.installedVersion} → v${state.remote.remoteVersion}`
      : 'update available';
    actions.push({
      key: 'update',
      label: `Update framework (${fromTo})`,
      why: 'Remote framework version differs from local. Updating first keeps your contributions on a fresh base.',
      autoOk: true, // update has its own internal "Proceed?" prompt + pre-flight stash
      run: () => require('./update')(),
    });
  }

  if (hasLocalWork) {
    actions.push({
      key: 'push',
      label: 'Push local framework improvements upstream',
      why: state.workingTreeDirty
        ? `${state.workingTreeDirty} uncommitted file(s) in .framework/ — commit them through \`baldart push\`.`
        : `${state.commitsAhead} commit(s) on .framework/ not yet pushed (may include subtree-merge noise; \`baldart push\` will classify).`,
      autoOk: false, // push writes to remote and asks for version classification — keep explicit intent
      run: () => require('./push')(),
    });
  }

  return actions;
}

// ──────────────────────────────────────────────────────────────────────
// Diagnostic table
// ──────────────────────────────────────────────────────────────────────

function fmtDate(iso) {
  if (!iso) return '—';
  try { return iso.slice(0, 10); } catch (_) { return iso; }
}

function statusLine(label, value, severity) {
  const symbol = severity === 'ok' ? '✓' : severity === 'warn' ? '⚠' : severity === 'err' ? '✗' : '·';
  return `${symbol}  ${label.padEnd(15)} ${value}`;
}

function renderDiagnostic(state) {
  UI.header('BALDART DOCTOR');
  console.log(`Repository:    ${state.cwd}`);
  console.log();

  // CLI version line — always shown (independent of git repo state). When a
  // newer baldart npm package was detected on a previous run, surface it here
  // so the doctor table is the single pane of glass for "what's stale".
  console.log(statusLine(
    'CLI',
    state.cliUpdate
      ? `v${state.cliVersion} → v${state.cliUpdate.latest} available (npm i -g baldart@latest)`
      : `v${state.cliVersion}`,
    state.cliUpdate ? 'warn' : 'ok'
  ));

  console.log(statusLine('Git repo', state.isGitRepo ? 'yes' : 'no', state.isGitRepo ? 'ok' : 'err'));

  if (!state.isGitRepo) return;

  console.log(statusLine(
    'Framework',
    state.frameworkPresent ? `v${state.frameworkVersion}` : 'not installed',
    state.frameworkPresent ? 'ok' : 'warn'
  ));

  if (!state.frameworkPresent) return;

  console.log(statusLine(
    'Layout',
    state.legacyLayout ? 'legacy v2.0.x (migration needed)' : 'current',
    state.legacyLayout ? 'warn' : 'ok'
  ));

  console.log(statusLine(
    'Config',
    state.configMalformed
      ? 'malformed YAML'
      : state.configPresent
        ? (state.configSchemaMissing.length
            ? `${state.configSchemaMissing.length} key(s) missing from current schema`
            : 'present')
        : 'missing',
    state.configMalformed || !state.configPresent ? 'err'
      : state.configSchemaMissing.length ? 'warn'
        : 'ok'
  ));

  console.log(statusLine(
    'State ledger',
    state.statePresent
      ? (state.stateLedgerDrift
          ? `drift: ledger=v${state.stateLedgerDrift.ledger}, payload=v${state.stateLedgerDrift.payload}`
          : `installed v${state.stateInstalled} (${fmtDate(state.stateInstallDate)})`)
      : 'not seeded',
    state.stateLedgerDrift ? 'warn' : (state.statePresent ? 'ok' : 'warn')
  ));

  if (state.stateLastUpdate) {
    console.log(statusLine('Last update', fmtDate(state.stateLastUpdate), 'ok'));
  }
  if (state.stateLastPush) {
    console.log(statusLine('Last push', state.stateLastPush, 'ok'));
  }

  if (state.remote.fetched) {
    // v3.25.0+: messaging based on isAligned (version-compare), not on
    // commit count which is structurally noisy on subtree pulls.
    if (state.remote.isAligned) {
      console.log(statusLine(
        'Remote',
        `v${state.remote.remoteVersion} (aligned)`,
        'ok'
      ));
    } else if (state.remote.remoteVersion && state.remote.remoteVersion !== 'unknown') {
      console.log(statusLine(
        'Remote',
        `v${state.remote.remoteVersion} — update available (installed v${state.remote.installedVersion})`,
        'warn'
      ));
    } else {
      console.log(statusLine('Remote', 'upstream VERSION unreadable', 'warn'));
    }
  } else {
    console.log(statusLine('Remote', 'offline / not fetched', 'warn'));
  }

  // Local changes: working-tree dirty is real signal. Commit count is shown
  // ONLY if not aligned (otherwise it's subtree-merge noise — see Fix 2 for
  // the classifier that further filters this).
  const showCommitsAhead = state.commitsAhead > 0 &&
    state.remote.fetched && state.remote.isAligned === false;
  const localStatusText = state.workingTreeDirty > 0
    ? `${state.workingTreeDirty} file(s) in .framework/`
    : showCommitsAhead
      ? `${state.commitsAhead} unpushed commit(s)`
      : 'clean';
  const localSeverity = (state.workingTreeDirty > 0 || showCommitsAhead) ? 'warn' : 'ok';
  console.log(statusLine('Local changes', localStatusText, localSeverity));

  console.log(statusLine(
    'Overlays',
    state.overlays === 0 ? 'none authored' : `${state.overlays} authored`,
    state.overlaysDrifted.length ? 'warn' : 'ok'
  ));
  if (state.overlaysDrifted.length) {
    state.overlaysDrifted.forEach((o) =>
      console.log(`   • ${o.name} targets v${o.target}, installed v${o.installed}`));
  }

  {
    const total = state.hooksStatus && state.hooksStatus.hooks ? state.hooksStatus.hooks.length : 0;
    const missing = state.hooksMissing ? state.hooksMissing.length : 0;
    const drifted = state.hooksDrifted ? state.hooksDrifted.length : 0;
    const legacy  = state.hooksLegacy  ? state.hooksLegacy.length  : 0;
    const registered = total - missing;
    let summary;
    let status;
    if (state.hooksStatus && state.hooksStatus.malformed) {
      summary = '.claude/settings.json is malformed';
      status = 'warn';
    } else if (missing === 0 && drifted === 0 && legacy === 0) {
      summary = `${registered}/${total} registered`;
      status = 'ok';
    } else {
      const parts = [`${registered}/${total} registered`];
      if (missing) parts.push(`${missing} missing`);
      if (drifted) parts.push(`${drifted} drifted`);
      if (legacy)  parts.push(`${legacy} pre-v3.18`);
      summary = parts.join(', ');
      status = 'warn';
    }
    console.log(statusLine('BALDART hooks', summary, status));
    if (state.hooksMissing && state.hooksMissing.length)
      console.log(`   • missing:   ${state.hooksMissing.join(', ')}`);
    if (state.hooksDrifted && state.hooksDrifted.length)
      console.log(`   • drifted:   ${state.hooksDrifted.join(', ')}`);
    if (state.hooksLegacy && state.hooksLegacy.length)
      console.log(`   • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
  }

  // Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
  // (`checked:false`) — most consumers have no managed git hooks and must see
  // zero noise here.
  if (state.gitHooks && state.gitHooks.checked) {
    if (state.gitHooks.ok) {
      console.log(statusLine(
        'Git hooks',
        `${state.gitHooks.source} active (${state.gitHooks.expectedHooks.join(', ')})`,
        'ok'
      ));
    } else if (state.gitHooks.inactive) {
      console.log(statusLine(
        'Git hooks',
        `INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} not run by core.hooksPath`,
        'warn'
      ));
      console.log(`   • fix: ${state.gitHooks.fixCommand}`);
    } else {
      console.log(statusLine('Git hooks', state.gitHooks.detail, 'warn'));
      console.log(`   • fix: ${state.gitHooks.fixCommand}`);
    }
  }

  if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
    console.log(statusLine(
      '.gitignore',
      `IGNORES .framework/ (${state.frameworkIgnored.source}:${state.frameworkIgnored.line}) — will be auto-healed`,
      'warn'
    ));
  }

  if (state.lspEnabled) {
    const total = state.lspInstalled.length;
    const broken = state.lspBroken.length;
    const missingPlugins = (state.lspMissingClaudePlugins || []).length;
    let value;
    if (total === 0) {
      value = 'enabled but no servers installed';
    } else if (broken > 0) {
      value = `${broken}/${total} broken (${state.lspBroken.join(', ')})`;
    } else if (missingPlugins > 0) {
      value = `${total} server(s) verified, ${missingPlugins} Claude plugin(s) missing (${state.lspMissingClaudePlugins.map(m => m.pluginId).join(', ')})`;
    } else {
      value = `${total} server(s) verified (${state.lspInstalled.join(', ')})`;
    }
    const severity = total === 0 || broken > 0 || missingPlugins > 0 ? 'warn' : 'ok';
    console.log(statusLine('LSP layer', value, severity));
  } else {
    console.log(statusLine('LSP layer', 'disabled', 'ok'));
  }

  if (state.graphEnabled) {
    let value, severity;
    if (state.graphBinaryMissing) { value = 'enabled but graphify CLI missing'; severity = 'warn'; }
    else if (state.graphNotBuilt) { value = 'enabled, graph not built yet'; severity = 'warn'; }
    else if (state.graphStale) { value = 'enabled, graph stale (behind HEAD)'; severity = 'warn'; }
    else if (state.graphHookMissing) { value = 'enabled, verified (auto-rebuild hook missing)'; severity = 'warn'; }
    else { value = 'enabled, graph fresh'; severity = 'ok'; }
    console.log(statusLine('Code graph', value, severity));
  } else {
    console.log(statusLine('Code graph', 'disabled', 'ok'));
  }

  console.log();
}

// ──────────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────────

async function doctor(opts = {}) {
  const cwd = process.cwd();
  const auto = !!opts.auto;
  const offline = !!opts.offline;

  const state = await detectState(cwd, { offline });
  state.autoMode = auto;
  renderDiagnostic(state);

  const actions = planActions(state);

  if (actions.length === 0) {
    UI.success('Nothing to do — framework is healthy and in sync.');
    UI.newline();
    UI.info('Available actions:');
    UI.list([
      '`npx baldart push`      — contribute local improvements upstream',
      '`npx baldart configure` — re-run config prompts (e.g. after enabling a feature)',
      '`npx baldart status`    — symlink + overlay validity check',
      '`npx baldart version`   — installed version + remote drift',
    ], 'cyan');
    return;
  }

  UI.section('Recommended action(s)');
  actions.forEach((a, i) =>
    console.log(`  ${i + 1}. ${a.label}\n     ${a.why}`));
  UI.newline();

  // Multi-action ambiguity check in --auto mode.
  if (auto && actions.length > 1 && actions.some((a) => a.key !== 'configure-drift')) {
    // Drift refresh is a no-op-style action; pairs with other actions safely.
    // Anything else that yields >1 step is genuine ambiguity in CI.
    const ambiguous = actions.filter((a) => a.key !== 'configure-drift').length > 1;
    if (ambiguous) {
      UI.error('Multiple actions proposed; --auto refuses to chain in ambiguous state.');
      UI.info('Run them explicitly with the corresponding `baldart <subcommand>` calls.');
      process.exit(2);
    }
  }

  for (const action of actions) {
    let proceed = auto;
    if (!auto) {
      // Safe & idempotent actions (`autoOk: true`) run without an outer
      // confirmation — they have their own internal prompts. Actions that
      // change external state or require explicit version classification
      // (add, push) still ask here so the user signals intent.
      if (action.autoOk) {
        UI.info(`→ Running: ${action.label}`);
        proceed = true;
      } else {
        proceed = await UI.confirm(`Run: ${action.label}?`, true);
      }
    }
    if (!proceed) {
      UI.info(`Skipped: ${action.label}`);
      break;
    }
    try {
      await action.run();
    } catch (err) {
      UI.error(`Action "${action.key}" failed: ${err.message}`);
      process.exit(1);
    }
  }
}

module.exports = doctor;
module.exports.detectState = detectState;
module.exports.planActions = planActions;
