const GitUtils = require('../utils/git');
const SymlinkUtils = require('../utils/symlinks');
const UI = require('../utils/ui');
const State = require('../utils/state');
const Hooks = require('../utils/hooks');
const UpdateNotifier = require('../utils/update-notifier');
const { captureAndVerify } = require('../utils/overlay-capture');

function readEnabledTools(cwd = process.cwd()) {
  try {
    const fs = require('fs');
    const path = require('path');
    const yaml = require('js-yaml');
    const cfgPath = path.join(cwd, 'baldart.config.yml');
    if (!fs.existsSync(cfgPath)) return ['claude'];
    const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8'));
    if (Array.isArray(cfg?.tools?.enabled) && cfg.tools.enabled.length) {
      return cfg.tools.enabled;
    }
  } catch (_) { /* fall through */ }
  return ['claude'];
}

// Allowed values for `--on-divergence` — the non-interactive escape hatch that
// lets an agent / CI complete (or deliberately stop) an update when the
// consumer has local commits on `.framework/`. Without it a non-TTY caller has
// no non-destructive path: `--yes` refuses, the interactive prompt needs a TTY,
// and `--reset` is gated on a clean working tree (see Finding 2 in the
// agent-overlay feedback that prompted this).
const DIVERGENCE_STRATEGIES = ['abort', 'pull', 'scaffold-overlays'];

// Machine-readable result channel (v3.32.0+). When --json is active, STDOUT
// must carry exactly ONE JSON object and nothing else — UI.setJsonMode() has
// already redirected every human line to STDERR. Every terminal point in the
// standard flow routes through `emitUpdateJson` so the contract holds: one
// object, then exit. `ok: true` means "framework is now at the remote version"
// (action `updated` or `already-current`); everything else is `ok: false`.
let JSON_MODE = false;
function emitUpdateJson(obj, exitCode) {
  if (JSON_MODE) {
    process.stdout.write(JSON.stringify({ schema: 'baldart.update/1', ...obj }) + '\n');
  }
  process.exit(exitCode);
}

// Map a touched framework path to an `overlay scaffold` target string.
//   .framework/framework/.claude/agents/coder.md     → 'agents/coder'
//   .framework/framework/.claude/commands/check.md    → 'commands/check'
//   .framework/framework/.claude/skills/bug/SKILL.md  → 'skill/bug'
// Returns null for anything that isn't an overlay-able framework primitive.
function frameworkPathToOverlayTarget(p) {
  const m = /(?:^|\/)\.framework\/framework\/\.claude\/(agents|commands|skills)\/(.+)$/.exec(p);
  if (!m) return null;
  const [, dir, rest] = m;
  if (dir === 'skills') {
    const name = rest.split('/')[0];
    return name ? `skill/${name}` : null;
  }
  const name = rest.replace(/\.md$/, '');
  if (!name || name.includes('/')) return null;
  return `${dir}/${name}`;
}

// Scaffold overlay skeletons for every overlay-able framework file touched by
// the given commits. Reuses overlay.scaffoldFile (the CLI is the source of
// truth — we never re-implement frontmatter/path logic here). Idempotent:
// existing overlays are left untouched.
function scaffoldOverlaysForCommits(commits) {
  const overlay = require('./overlay');
  const cwd = process.cwd();
  const targets = new Set();
  for (const c of commits) {
    for (const p of (c.touched || [])) {
      const t = frameworkPathToOverlayTarget(p);
      if (t) targets.add(t);
    }
  }
  const created = [];
  const skipped = [];
  if (targets.size === 0) {
    UI.warning('Could not derive any overlay target from the divergent commits.');
    UI.info('They may touch files outside `.claude/{agents,skills,commands}/`. Use `/baldart-push` or `--on-divergence pull`.');
    return { created, skipped };
  }
  for (const t of [...targets].sort()) {
    const r = overlay.scaffoldFile(cwd, t);
    if (r.ok) {
      created.push(r.overlayRel);
      UI.success(`Scaffolded overlay: ${r.overlayRel}  (base: ${r.baseRel})`);
    } else if (r.reason === 'exists') {
      skipped.push(r.overlayRel);
      UI.info(`Overlay already exists, left as-is: ${r.overlayRel}`);
    } else {
      UI.warning(`Could not scaffold ${t}: ${r.reason}${r.baseRel ? ` (${r.baseRel})` : ''}`);
    }
  }
  return { created, skipped };
}

// Path prefixes BALDART itself writes during install/update. Anything outside
// these patterns is treated as user-owned and never auto-staged.
const BALDART_MANAGED_PATTERNS = [
  /^\.framework(\/|$)/,
  /^AGENTS\.md$/,
  /^agents(\.backup)?(\/|$)/,
  /^\.claude\/(agents|commands|skills|workflows|hooks|routines|settings\.json)(\/|$)/,
  /^\.baldart(\/|$)/,
  /^baldart\.config\.yml$/,
  /^docs\/references\/(ui-guidelines\.template\.md|brand-guidelines\.md)$/,
  /^templates(\/|$)/,
  /^\.github\/workflows\/baldart-/,
  /^scripts\/routines(\/|$)/
];

function isBaldartManagedPath(p) {
  return BALDART_MANAGED_PATTERNS.some((rx) => rx.test(p));
}

// Real preview (v4.8.0+) — the old `git log HEAD..FETCH_HEAD -- .framework/`
// is always empty under subtree-squash, so the user never saw what changed.
// Instead, read the UPSTREAM CHANGELOG and return every entry above the
// version we already have. Best-effort; returns null when unavailable.
async function changelogSinceInstalled(git, installedVersion) {
  let text;
  try { text = await git.git.raw(['show', 'FETCH_HEAD:CHANGELOG.md']); }
  catch (_) { return null; }
  const out = [];
  let started = false;
  for (const line of text.split('\n')) {
    const m = /^##\s+\[?v?(\d+\.\d+\.\d+)\]?/.exec(line);
    if (m) {
      if (m[1] === installedVersion) break; // reached the version already on disk
      started = true;
    }
    if (started) out.push(line);
    if (out.length > 120) { out.push('  … (truncated — see .framework/CHANGELOG.md)'); break; }
  }
  return out.join('\n').trim() || null;
}

async function postUpdateAutoCommit(git, newVersion, options) {
  if (options && options.commit === false) {
    UI.info('Auto-commit skipped (--no-commit).');
    return;
  }
  let status;
  try {
    status = await git.git.status();
  } catch (err) {
    UI.warning(`Could not read git status for auto-commit: ${err.message}`);
    return;
  }
  // Collect every dirty path simple-git reports, classify against the allowlist.
  const allDirty = [
    ...status.not_added,
    ...status.modified,
    ...status.created,
    ...status.deleted,
    ...status.renamed.map((r) => r.to),
    ...status.staged
  ];
  const seen = new Set();
  const managed = [];
  const userOwned = [];
  for (const p of allDirty) {
    if (!p || seen.has(p)) continue;
    seen.add(p);
    (isBaldartManagedPath(p) ? managed : userOwned).push(p);
  }
  if (managed.length === 0) {
    return;
  }
  UI.newline();
  UI.section('Auto-commit BALDART-managed changes');
  UI.info(`${managed.length} BALDART-managed file(s) changed during this update:`);
  const preview = managed.slice(0, 10);
  UI.list(preview, 'cyan');
  if (managed.length > preview.length) {
    UI.info(`…and ${managed.length - preview.length} more.`);
  }
  if (userOwned.length > 0) {
    UI.warning(`${userOwned.length} other dirty path(s) will be left untouched (not BALDART-managed).`);
  }
  const autoYes = options && options.yes === true;
  const ok = autoYes
    ? true
    : await UI.confirm(`Commit them as \`chore(baldart): post-update reconcile to v${newVersion}\`?`, true);
  if (!ok) {
    UI.info('Auto-commit declined. Your worktree keeps the changes for manual review.');
    return;
  }
  try {
    await git.git.add(managed);
    const subject = `chore(baldart): post-update reconcile to v${newVersion}`;
    const body = [
      'Automated by `npx baldart update`.',
      '',
      'Reconciles BALDART-managed files after framework upgrade:',
      ...managed.map((p) => `  - ${p}`),
      '',
      'Revert this commit to undo the local reconcile without rolling back the framework subtree.'
    ].join('\n');
    await git.git.commit(`${subject}\n\n${body}`);
    UI.success(`Committed ${managed.length} file(s).`);
  } catch (err) {
    UI.warning(`Auto-commit failed: ${err.message}`);
    UI.info('Your changes are still in the worktree — commit manually when ready.');
  }
}

// Single auto-stash engine (v4.8.0+). The ONE place that protects a dirty
// working tree before a `git subtree pull` (normal flow) OR a `--reset`
// reinstall. Uses a blanket `git stash push -u` with NO pathspec — a pathspec
// that lands on a `.gitignore`d directory is exactly what made the previous
// manual-stash workaround fail. Tracked-but-ignored files (a `.gitignore` that
// post-dates the file) are still stashed by plain stash; purely-ignored
// untracked files don't block the pull and aren't added by `git add -A`, so
// leaving them in place is harmless.
//
// Returns { stashRef } on success (or { stashRef: null } when already clean),
// { aborted: true } when the user declined, or { error } when the stash failed.
async function autoStashNonFramework(git, { autoStash, interactive }) {
  const isClean = await git.hasCleanWorkingTree();
  if (isClean) return { stashRef: null };

  UI.newline();
  UI.warning('Working tree has uncommitted changes — they will be stashed and re-applied after the update.');
  const dirty = await git.git.status();
  const dirtyPaths = [
    ...dirty.not_added, ...dirty.modified, ...dirty.created,
    ...dirty.deleted, ...dirty.renamed.map((r) => r.to), ...dirty.staged
  ];
  const unique = [...new Set(dirtyPaths)];
  UI.info(`${unique.length} dirty path(s):`);
  UI.list(unique.slice(0, 8), 'yellow');
  if (unique.length > 8) UI.info(`…and ${unique.length - 8} more.`);

  let action = 'stash';
  if (!autoStash && interactive) {
    action = await UI.select('How would you like to proceed?', [
      { name: 'Auto-stash now and re-apply after the update (recommended)', value: 'stash' },
      { name: 'Abort — I want to commit or stash manually first', value: 'abort' }
    ]);
  } else {
    UI.info('Auto-stashing dirty paths.');
  }
  if (action === 'abort') return { aborted: true };

  const stashRef = `baldart-pre-update-${new Date().toISOString().replace(/[:.]/g, '-')}`;
  try {
    await git.git.stash(['push', '-u', '-m', stashRef]);
    UI.success(`Stashed working tree as "${stashRef}".`);
    return { stashRef };
  } catch (err) {
    return { error: err.message };
  }
}

// Seamless resolution of `overlay-able` divergence (v4.8.0+). The user's intent
// is always the same — update and preserve overlays — so this never dead-ends:
//   - every divergent edit verifiably preserved by an overlay (existing or
//     freshly captured) → reset-and-reapply (the `.framework/` commits are
//     redundant; the overlays survive and re-apply on reinstall).
//   - anything that can't be captured+verified (stale overlay, section deletion,
//     frontmatter/preamble change, skill with no overlay, unmappable path) →
//     stop ONCE with the exact list, so nothing is silently lost.
// On success this calls runReset (which exits the process). On an unrecoverable
// blocker it emits the single-pause refuse and exits.
//
// Pass the FULL touched set to captureAndVerify — never a pre-filtered subset.
// An overlay-able commit (per classifyCommitByPaths) only touches files under
// `.claude/{agents,commands,skills}/`, but some (nested agent files, etc.) are
// not overlay-mappable; pre-filtering them out would let the reset wipe them
// with no overlay to preserve them. captureAndVerify turns those into blockers.
async function resolveOverlayDivergenceSeamlessly(git, status, options, autoYes, overlayCommits, remoteVersion) {
  const cwd = process.cwd();
  const touched = [...new Set(overlayCommits.flatMap((c) => c.touched || []))];

  UI.newline();
  UI.info(`Verifying ${touched.length} divergent edit(s) are preserved by overlays (auto-capture + verify)…`);
  const { captured, blockers } = await captureAndVerify({
    cwd, git, files: touched, frameworkVersion: remoteVersion,
  });
  for (const o of captured) UI.success(`Overlay verified: ${o}`);

  // Commit captured overlays NOW, before the reset. The reset auto-stashes the
  // working tree, and the symlink/merge step reads overlays FROM DISK — an
  // untracked, stashed overlay would be invisible to the merge and the
  // regenerated agent would silently drop the customization. Committing pins
  // them into HEAD so they survive the reset and feed the merge. (Already-
  // committed, unchanged overlays stage to nothing and are skipped.)
  if (captured.length) {
    try {
      await git.git.add(captured);
      const st = await git.git.status();
      if (st.staged.length) {
        await git.git.commit(
          'chore(baldart): capture/verify overlays for divergent framework edits\n\n'
          + captured.map((c) => `  - ${c}`).join('\n')
        );
        UI.success(`Committed ${st.staged.length} overlay file(s).`);
      }
    } catch (err) {
      UI.warning(`Could not commit overlays: ${err.message} — they remain in the working tree.`);
    }
  }

  if (blockers.length) {
    UI.newline();
    UI.error(`${blockers.length} edit(s) could not be preserved+verified — stopping so nothing is lost:`);
    UI.list(blockers.map((b) => `${b.file} — ${b.reason}`), 'yellow');
    UI.newline();
    UI.info('Resolve these, then re-run `npx baldart update`:');
    UI.list([
      'Author / reconcile the overlay with `/overlay` (Claude Code) or `npx baldart overlay scaffold`.',
      'Or contribute the edit upstream with `/baldart-push` if it is a generic improvement.',
      'Or keep the commits and merge with `npx baldart update --on-divergence pull`.',
    ], 'cyan');
    emitUpdateJson({ ok: false, action: 'divergence-capture-incomplete',
      divergence_class: status.divergenceClass,
      captured, blockers,
      installed_before: status.installedVersion, remote_version: status.remoteVersion,
      reason: `Preserved ${captured.length} edit(s); ${blockers.length} could not be captured+verified — stopped to avoid data loss.`,
      next_command: 'npx baldart update --on-divergence pull' }, 1);
  }

  UI.newline();
  UI.success('All divergent edits are preserved & verified in `.baldart/overlays/` — resolving via clean reinstall (overlays are re-applied).');
  // iKnow is implied: the only files reset deletes inside `.framework/` are
  // framework artifacts that get reinstalled, and every edit is now overlaid.
  await runReset(git, { ...options, reset: true, iKnow: true }, autoYes);
}

// --reset nuclear option (v3.26.0+).
//
// When the standard update keeps failing due to subtree divergence the user
// doesn't want to investigate, --reset wipes .framework/ and reinstalls
// cleanly. The user keeps every customization that lives OUTSIDE .framework/:
//
//   - baldart.config.yml          (real file, root of consumer)
//   - .baldart/                   (whole dir: overlays/, state.json, ...)
//   - .claude/settings.json
//   - .claude/{agents,skills,commands}/<*.md>  ONLY if NOT a symlink into
//                                              .framework/ (those are
//                                              regenerated by `add`)
//
// Safety:
//   - Refuses if the working tree is dirty (commit/stash first).
//   - Refuses --yes unless --i-know is ALSO passed: --reset rm -rf's
//     untracked & .gitignored files inside .framework/ that the user
//     might not realize exist.
//   - Always creates a backup tag before deleting anything.
async function runReset(git, options, autoYes) {
  const fs = require('fs');
  const path = require('path');
  const { spawnSync } = require('child_process');

  UI.newline();
  UI.header('BALDART RESET (NUCLEAR OPTION)');
  UI.warning('This will rm -rf .framework/ and reinstall from upstream.');
  UI.info('What is preserved (NEVER touched):');
  UI.list([
    'baldart.config.yml',
    '.baldart/ (overlays, state.json, skill-conflicts.json, …)',
    '.claude/settings.json',
    '.claude/{agents,skills,commands}/*.md authored by you (non-framework symlinks)',
  ], 'cyan');

  // Safety gate #1: protect any non-framework work. The reset commits a
  // `git add -A` (the .framework/ deletion), so a dirty tree would otherwise be
  // swept into that commit — auto-stash it instead and re-apply at the end.
  // Same engine the normal flow uses; honours the user's stash authorization
  // instead of hard-refusing (the v4.8.0 fix).
  const stashOutcome = await autoStashNonFramework(git, {
    autoStash: autoYes || options.autoStash === true,
    interactive: !autoYes,
  });
  if (stashOutcome.aborted) { UI.info('Reset aborted.'); process.exit(0); }
  if (stashOutcome.error) {
    UI.error(`Could not stash working tree before reset: ${stashOutcome.error}`);
    if (JSON_MODE) {
      emitUpdateJson({ ok: false, action: 'failed',
        reason: `Could not stash working tree before reset: ${stashOutcome.error}` }, 1);
    }
    process.exit(1);
  }
  const resetStashRef = stashOutcome.stashRef;

  // Safety gate #2: any untracked / ignored files inside .framework/?
  let untrackedInsideFramework = '';
  try {
    untrackedInsideFramework = await git.git.raw([
      'ls-files', '--others', '--ignored', '--exclude-standard', '--', '.framework/'
    ]).catch(() => '');
    // Some git versions return non-zero when --ignored has no matches; treat as empty.
    if (typeof untrackedInsideFramework !== 'string') untrackedInsideFramework = '';
  } catch (_) { untrackedInsideFramework = ''; }
  const ignoredLines = untrackedInsideFramework.split('\n').filter((l) => l.trim());

  if (ignoredLines.length) {
    UI.newline();
    UI.warning(`${ignoredLines.length} untracked / ignored file(s) inside .framework/ will be DELETED:`);
    UI.list(ignoredLines.slice(0, 10), 'yellow');
    if (ignoredLines.length > 10) UI.info(`…and ${ignoredLines.length - 10} more.`);
    if (autoYes && options.iKnow !== true) {
      UI.error('--reset --yes refuses to delete untracked/ignored files without --i-know.');
      UI.info('Run: npx baldart update --reset --yes --i-know');
      process.exit(1);
    }
    if (!autoYes) {
      const ok = await UI.confirm('Proceed and DELETE these files permanently?', false);
      if (!ok) { UI.info('Reset aborted.'); process.exit(0); }
    }
  }

  // Resolve the upstream repo BEFORE anything destructive. The reinstall hands
  // off to add() programmatically (no bin-layer default), so an unresolved repo
  // would surface as "Downloading from undefined" / "Cannot read properties of
  // undefined" AFTER .framework/ was already wiped and committed. Resolving here
  // (state.framework_repo → default, never undefined) makes that unreachable and
  // preserves a consumer's fork.
  const repo = State.resolveRepo();
  UI.info(`Reinstall source: ${repo}`);

  // Safety gate #3: backup tag before doing anything destructive.
  const backupTag = await git.createBackupTag();
  UI.success(`Backup tag created: ${backupTag}`);

  // Snapshot user-owned files OUTSIDE .framework/ for restore-after-add.
  // All these live OUTSIDE .framework/ so `rm -rf .framework/` doesn't touch
  // them — but we record what was present so a post-install sanity check can
  // confirm nothing got clobbered by the fresh install flow.
  const preserved = {
    baldartConfig: fs.existsSync('baldart.config.yml'),
    baldartDir: fs.existsSync('.baldart'),
    claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
    customAgents: snapshotCustomEntries('.claude/agents'),
    customSkills: snapshotCustomEntries('.claude/skills'),
    customCommands: snapshotCustomEntries('.claude/commands'),
  };
  UI.info(`Preserved snapshot: config=${preserved.baldartConfig}, .baldart=${preserved.baldartDir}, settings.json=${preserved.claudeSettings}, custom files: ${preserved.customAgents.length + preserved.customSkills.length + preserved.customCommands.length}`);

  // Wipe .framework/. The `git rm` first ensures git's index reflects the
  // deletion (otherwise a stray subtree commit might recreate refs); then
  // rm -rf removes any untracked leftovers.
  UI.newline();
  UI.info('Removing .framework/…');
  try {
    spawnSync('git', ['rm', '-rf', '--ignore-unmatch', '.framework'], { stdio: 'pipe' });
  } catch (_) { /* fall through to rm -rf */ }
  try {
    fs.rmSync('.framework', { recursive: true, force: true });
  } catch (err) {
    UI.error(`Could not remove .framework/: ${err.message}`);
    UI.info(`Rollback with: git reset --hard ${backupTag}`);
    process.exit(1);
  }
  UI.success('.framework/ removed.');

  // Commit the deletion so `subtree add` has a clean tree to land on.
  try {
    await git.git.add(['-A']);
    const idx = await git.git.status();
    if (idx.staged.length || idx.modified.length || idx.deleted.length) {
      await git.git.commit(`chore(baldart): reset .framework/ (pre-reinstall, backup ${backupTag})`);
      UI.success('Committed reset.');
    }
  } catch (err) {
    UI.warning(`Could not commit reset (${err.message}). Continuing — subtree add may still succeed.`);
  }

  // Hand off to `add` for the fresh install. `add` will:
  //   - `git subtree add --squash` → fresh subtree, no divergence history.
  //   - Reconcile symlinks (custom files preserved by existing collision logic).
  //   - Run configure if missing baldart.config.yml (we preserved it, so skip).
  //   - Register hooks (will see existing settings.json, idempotent).
  UI.newline();
  UI.info('Re-installing framework via `baldart add`…');
  const addCmd = require('./add');
  // `add(repo, options)` — the repo MUST be threaded explicitly: the bin-layer
  // default ('antbald/BALDART') only applies when add is invoked from the CLI,
  // not on this programmatic path (the v3.28.1 comment claiming "undefined →
  // default from package.json" was wrong and caused the undefined-repo crash).
  // `throwOnError` makes add() re-throw instead of process.exit(1), so a failed
  // reinstall lands in the catch below and we can roll back to the backup tag —
  // the consumer is NEVER left committed-without-.framework/.
  try {
    await addCmd(repo, { yes: true, throwOnError: true });
  } catch (err) {
    UI.newline();
    UI.error(`Reinstall failed: ${err.message}`);
    UI.warning('Rolling back to pre-reset state…');
    try {
      await git.git.raw(['reset', '--hard', backupTag]);
      // The clean-tree gate (hasCleanWorkingTree → status.isClean(), which counts
      // untracked) guaranteed ZERO untracked files at backup-tag time, so every
      // untracked file now is partial-install cruft created by the failed add()
      // (symlinks, copied templates) — safe to sweep. `-d` dirs, no `-x` so
      // gitignored paths are left untouched. Non-fatal: .framework/ is already
      // restored by the reset above regardless.
      try {
        await git.git.raw(['clean', '-fd']);
      } catch (cleanErr) {
        UI.warning(`Could not sweep partial-install leftovers (${cleanErr.message}). .framework/ is restored; run \`git clean -fd\` to tidy.`);
      }
      UI.success(`Restored .framework/ from ${backupTag}. Working tree back to pre-reset state.`);
    } catch (rollbackErr) {
      UI.error(`Auto-rollback FAILED: ${rollbackErr.message}`);
      UI.info(`Recover manually: git reset --hard ${backupTag}`);
    }
    UI.newline();
    UI.info('Then retry, or reinstall explicitly:');
    UI.info(`  npx baldart add ${repo} --yes`);
    process.exit(1);
  }

  // Post-restore sanity check — confirm nothing user-owned got clobbered.
  const stillPresent = {
    baldartConfig: fs.existsSync('baldart.config.yml'),
    baldartDir: fs.existsSync('.baldart'),
    claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
  };
  const lost = Object.entries(stillPresent).filter(([k, v]) => preserved[k] && !v).map(([k]) => k);
  if (lost.length) {
    UI.error(`Reset DROPPED user-owned files: ${lost.join(', ')}.`);
    UI.info(`Rollback IMMEDIATELY: git reset --hard ${backupTag}`);
    process.exit(1);
  }

  const newVersion = await git.getFrameworkVersion();

  // Finalizer (v4.8.0+): re-apply the stash and commit BALDART-managed
  // artifacts so the reset path leaves a clean tree — exactly like the normal
  // update flow. Without this, the freshly-regenerated agents + state.json were
  // left uncommitted (the half-done handoff the seamless rewrite removes).
  let stashConflict = false;
  if (resetStashRef) {
    try {
      await git.git.stash(['pop']);
      UI.success(`Re-applied pre-reset stash "${resetStashRef}".`);
    } catch (err) {
      stashConflict = true;
      UI.warning(`Could not auto-re-apply stash "${resetStashRef}": ${err.message}`);
      UI.info('Recover with: git stash list && git stash pop');
    }
  }
  try {
    await postUpdateAutoCommit(git, newVersion, options);
  } catch (err) {
    UI.warning(`Auto-commit step skipped: ${err.message}`);
  }

  if (JSON_MODE) {
    emitUpdateJson({ ok: true, action: 'reset',
      installed_after: newVersion, remote_version: newVersion,
      backup_tag: backupTag, stash_conflict: stashConflict }, 0);
  }

  UI.header('✓ RESET COMPLETE');
  UI.box('RESET RESULT', [
    `Framework: fresh install (${newVersion})`,
    `Backup tag: ${backupTag}`,
    `Rollback if needed: git reset --hard ${backupTag}`,
    '',
    'All user-owned files preserved.',
  ]);
  process.exit(0);
}

function snapshotCustomEntries(dir) {
  const fs = require('fs');
  const path = require('path');
  if (!fs.existsSync(dir)) return [];
  try {
    return fs.readdirSync(dir)
      .filter((name) => {
        // Symlinks pointing into .framework/ are framework-owned (will be
        // recreated by `add`). Real files / dirs are user-authored.
        try {
          const lst = fs.lstatSync(path.join(dir, name));
          if (!lst.isSymbolicLink()) return true;
          const target = fs.readlinkSync(path.join(dir, name));
          return !target.includes('.framework/');
        } catch (_) { return false; }
      });
  } catch (_) { return []; }
}

// CLI self-upgrade auto-relaunch (v3.25.0+).
// When the globally-installed `baldart` CLI is older than the latest on npm,
// transparently re-invoke `npx baldart@latest update <flags>` so the user
// gets the fix without having to remember `npm i -g baldart@latest`. The
// child inherits stdio (preserves prompts), and BALDART_RELAUNCHED=1 in env
// is a loop guard: if npm cache somehow serves an older version, the child
// sees the flag and skips its own relaunch check.
// `extra.rawArgs` — when set, forward these verbatim to the child instead of
// reconstructing flags from the parsed `options`. Used by the unknown-flag path
// (v3.35.2+): an option this CLI doesn't recognize never lands in `options`, so
// reconstruction would silently drop it — only the raw argv carries it intact.
// The caller MUST have already stripped the leading `update` subcommand token.
// `extra.unknown` — true when triggered by an unknown flag rather than a plain
// stale-CLI check. In that case the interactive "proceed with current CLI"
// branch is pointless (the current CLI literally cannot honor the flag), so we
// relaunch whenever a newer version exists and skip the prompt.
async function maybeRelaunchUnderLatest(options, extra = {}) {
  if (process.env.BALDART_RELAUNCHED === '1') return false;
  if (process.env.BALDART_NO_RELAUNCH === '1') return false;

  const cliVersion = require('../../package.json').version;
  let info = UpdateNotifier.hint({ currentVersion: cliVersion });
  if (!info) {
    // No cached hint — force a refresh so a brand-new `baldart update`
    // invocation can still benefit. Best-effort, network-bounded.
    try {
      await UpdateNotifier.refresh({ currentVersion: cliVersion, timeoutMs: 3000, force: true });
      info = UpdateNotifier.hint({ currentVersion: cliVersion });
    } catch (_) { /* offline / blocked — fall through */ }
  }
  if (!info) return false;

  const autoYes = options.yes === true;
  let proceed = autoYes;
  if (extra.unknown) {
    // Unknown flag + a newer CLI exists: relaunch unconditionally. Asking
    // "proceed with current CLI?" makes no sense — it can't parse the flag.
    proceed = true;
    UI.newline();
    UI.warning(`Unknown option for the installed CLI (v${cliVersion}); a newer release (v${info.latest}) may support it.`);
  } else if (!autoYes) {
    UI.newline();
    UI.warning(`The installed CLI is older than npm latest: v${cliVersion} → v${info.latest}.`);
    const choice = await UI.select('How would you like to proceed?', [
      { name: `Auto-relaunch via npx baldart@${info.latest} (recommended)`, value: 'relaunch' },
      { name: 'Proceed with the current CLI (you may hit fixed bugs)', value: 'current' },
      { name: 'Abort and upgrade manually (`npm i -g baldart@latest`)', value: 'abort' },
    ]);
    if (choice === 'abort') {
      UI.info(`Run: npm i -g baldart@latest && npx baldart update${options.yes ? ' --yes' : ''}`);
      process.exit(0);
    }
    proceed = (choice === 'relaunch');
  }
  if (!proceed) return false;

  // Build the child argv. The unknown-flag path forwards the raw argv (already
  // stripped of the `update` token); the normal stale-CLI path reconstructs
  // from parsed options.
  let flags;
  if (Array.isArray(extra.rawArgs)) {
    flags = extra.rawArgs;
  } else {
    flags = [];
    if (options.yes === true) flags.push('--yes');
    if (options.autoStash === true && options.yes !== true) flags.push('--auto-stash');
    if (options.commit === false) flags.push('--no-commit');
    if (options.reset === true) flags.push('--reset');
    if (options.iKnow === true) flags.push('--i-know');
    // Forward the non-interactive resolution + machine-readable flags too, else
    // a stale-CLI self-relaunch would silently drop them (v3.32.0+).
    if (options.onDivergence) flags.push('--on-divergence', options.onDivergence);
    if (options.json === true) flags.push('--json');
  }

  const spawn = require('child_process').spawnSync;
  UI.info(`Auto-relaunching via npx baldart@${info.latest}…`);
  const res = spawn('npx', ['-y', `baldart@${info.latest}`, 'update', ...flags], {
    stdio: 'inherit',
    env: { ...process.env, BALDART_RELAUNCHED: '1' },
    timeout: 600000, // mirror the longest legitimate flow downstream
  });
  if (res.error || res.status === null) {
    UI.warning(`Auto-relaunch failed (${res.error ? res.error.message : 'no exit'}). Falling back to current CLI.`);
    return false;
  }
  process.exit(res.status || 0);
}

// Helpful terminal error when an unknown flag reached this CLI and a relaunch
// under a newer version was not possible (already on latest / offline / opted
// out, or we ARE the relaunched child). Replaces commander's cryptic
// `error: unknown option` with actionable guidance. (v3.35.2+)
function failOnUnknownArgs(unknownArgs, options) {
  const tokens = unknownArgs.filter((a) => typeof a === 'string');
  const firstOpt = tokens.find((a) => a.startsWith('-')) || tokens[0] || '(unknown)';
  // In the relaunched child we are already on @latest, so "upgrade" is wrong —
  // an unknown flag here means a genuine typo.
  const alreadyLatest = process.env.BALDART_RELAUNCHED === '1';
  const reason = alreadyLatest
    ? `Unknown option ${firstOpt}. Even the latest CLI does not recognize it — check for a typo.`
    : `Unknown option ${firstOpt}. If it is a recently-added flag, upgrade with \`npm i -g baldart@latest\`; otherwise check for a typo.`;
  if (options.json === true) {
    emitUpdateJson({ ok: false, action: 'usage-error', reason }, 2);
  }
  UI.error(reason);
  if (!alreadyLatest) {
    UI.info(`Then retry: npx baldart update ${tokens.join(' ')}`.trim());
  }
  process.exit(2);
}

async function update(options = {}, unknownArgs = []) {
  const git = new GitUtils();
  const symlinks = new SymlinkUtils();
  const repo = 'antbald/BALDART'; // Default repo

  // Non-interactive flags (since v3.17.1). `--yes` implies `--auto-stash`.
  // When `yes` is set, every UI.confirm/UI.select downstream short-circuits
  // to its default — the same value an unattended user would have produced.
  const autoYes = options.yes === true;
  const autoStash = autoYes || options.autoStash === true;
  const confirm = async (msg, def = true) => (autoYes ? def : UI.confirm(msg, def));

  // Arm machine-readable mode FIRST (v3.35.2+). The unknown-flag handling below
  // can emit human lines (relaunch notice / helpful error); they must route to
  // STDERR before anything reaches STDOUT, or a `--json --yes --new-flag` run
  // would pollute the single-object stdout contract.
  JSON_MODE = options.json === true;
  if (JSON_MODE) UI.setJsonMode(true);

  // Unknown-flag self-upgrade (v3.35.2+). Commander now parses `update`
  // permissively (see bin/baldart.js), so a flag this CLI doesn't know lands
  // in `unknownArgs` instead of crashing the parser. That flag was almost
  // certainly introduced in a newer release — transparently relaunch under
  // `npx baldart@latest`, forwarding the RAW argv (the unknown token never made
  // it into `options`, so only raw argv carries it). If we can't relaunch
  // (already latest / offline / opted out / we ARE the child), fail with an
  // actionable message instead of a cryptic crash.
  if (Array.isArray(unknownArgs) && unknownArgs.length > 0) {
    // process.argv = [node, baldart, 'update', ...flags] — drop the first 3 so
    // the child isn't spawned as `update update …` (it already gets 'update').
    const rawArgs = process.argv.slice(3);
    await maybeRelaunchUnderLatest(options, { rawArgs, unknown: true });
    // Relaunch did not happen → this (latest-or-offline) CLI can't honor it.
    failOnUnknownArgs(unknownArgs, options);
  }

  // Machine-readable mode (v3.32.0+). Must be fully non-interactive: --json
  // requires --yes (so no UI.select/confirm can block) and rejects --reset
  // (the nuclear path is a deliberate destructive human escape hatch, not an
  // agent flow — see the autonomous /baldart-update skill, which never resets).
  if (JSON_MODE) {
    if (options.reset === true) {
      emitUpdateJson({ ok: false, action: 'usage-error',
        reason: '--json does not support --reset. Reset is an interactive destructive escape hatch; run it manually.' }, 2);
    }
    if (!autoYes) {
      emitUpdateJson({ ok: false, action: 'usage-error',
        reason: '--json requires --yes (non-interactive). Re-run: npx baldart update --json --yes [--on-divergence pull|scaffold-overlays]' }, 2);
    }
  }

  // Non-interactive divergence strategy (since v3.29.0). Validated up-front so
  // a typo fails fast instead of mid-flow after the network fetch.
  const divStrategy = options.onDivergence;
  if (divStrategy !== undefined && !DIVERGENCE_STRATEGIES.includes(divStrategy)) {
    if (JSON_MODE) {
      emitUpdateJson({ ok: false, action: 'usage-error',
        reason: `Invalid --on-divergence "${divStrategy}". Use one of: ${DIVERGENCE_STRATEGIES.join(', ')}.` }, 1);
    }
    UI.error(`Invalid --on-divergence "${divStrategy}". Use one of: ${DIVERGENCE_STRATEGIES.join(', ')}.`);
    process.exit(1);
  }

  try {
    // Step 1: Verify installation
    UI.header('STEP 1/5: Verify Installation');

    const exists = await git.frameworkExists();
    if (!exists) {
      UI.error('Framework not installed!');
      UI.newline();
      UI.box('ERROR', [
        'Framework directory .framework/ not found.',
        '',
        'Solution:',
        '  Install first: npx baldart add'
      ]);
      emitUpdateJson({ ok: false, action: 'framework-missing',
        reason: 'Framework directory .framework/ not found.',
        next_command: 'npx baldart add' }, 1);
    }

    // v3.26.0+ NUCLEAR OPTION: --reset rips out .framework/ and reinstalls
    // fresh, preserving everything user-owned that lives OUTSIDE .framework/.
    // Branches the flow entirely — never proceeds to the standard subtree
    // pull path.
    if (options.reset === true) {
      await runReset(git, options, autoYes);
      return; // runReset exits the process on success
    }

    // v3.25.0+: CLI self-upgrade — if a newer baldart is on npm, transparently
    // relaunch under `npx baldart@latest` so the user always runs the fixed
    // code path. Loop guard via BALDART_RELAUNCHED env. No-op when already on
    // latest or when the user opts out / network is down.
    await maybeRelaunchUnderLatest(options);

    const currentVersion = await git.getFrameworkVersion();
    UI.success(`Current version: ${currentVersion}`);

    // `.framework/` MUST be trackable — heal a stale `.gitignore` before
    // any `git subtree pull` / `git add` runs. Idempotent. (since v3.14.0)
    try {
      const heal = await git.ensureFrameworkNotIgnored();
      if (heal.wasFixed) {
        UI.warning(`.framework/ was ignored by ${heal.source || 'a gitignore'} — auto-healed.`);
        if (heal.removedLines > 0) {
          UI.info(`  Removed ${heal.removedLines} stale .framework line(s) from .gitignore.`);
        }
        if (heal.appendedNegation) {
          UI.info('  Appended `!.framework` negation block to .gitignore (idempotent).');
        }
        if (heal.stillIgnored) {
          UI.error('.framework/ is STILL ignored after auto-heal — 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, then re-run `baldart update`.');
          emitUpdateJson({ ok: false, action: 'failed',
            reason: `.framework/ is still ignored after auto-heal (${heal.recheck.source}:${heal.recheck.line}). Remove that .gitignore rule and re-run.` }, 1);
        }
      }
    } catch (err) {
      UI.warning(`Could not check .gitignore for .framework: ${err.message}`);
    }

    // Step 2: Check for updates
    UI.header('STEP 2/5: Check for Updates');

    const spinner = UI.spinner('Connecting to repository...').start();

    // SSOT (v3.25.0+): one query gives version compare + commit counts +
    // divergence class. Replaces hasChangesToPush() (which checked the wrong
    // remote AND wrong direction — see git.js history for context).
    let status;
    try {
      status = await git.getUpdateStatus(repo);
      spinner.succeed('Connected to repository');
    } catch (error) {
      spinner.fail();
      UI.error('Cannot connect to repository!');
      UI.newline();
      UI.box('ERROR', [
        'Possible causes:',
        '  • No internet connection',
        '  • Repository not accessible',
        '',
        'Solution:',
        '  • Check internet connection',
        '  • Try again later'
      ]);
      emitUpdateJson({ ok: false, action: 'connect-failed',
        reason: 'Cannot connect to repository (network / access).' }, 1);
    }

    if (!status.fetched || !status.hasFetchHead) {
      UI.error('Could not fetch upstream — try again with network access.');
      emitUpdateJson({ ok: false, action: 'fetch-failed',
        reason: 'Could not fetch upstream — try again with network access.' }, 1);
    }

    // VERSION-compare authority. Subtree-merge commit count is noise; never
    // use behind > 0 as the up-to-date signal (root cause of v3.24.x bug).
    if (status.isAligned) {
      UI.success(`Already up to date! (v${status.installedVersion})`);
      emitUpdateJson({ ok: true, action: 'already-current',
        installed_before: status.installedVersion,
        installed_after: status.installedVersion,
        remote_version: status.remoteVersion }, 0);
    }

    UI.warning(`Update available: v${status.installedVersion} → v${status.remoteVersion}`);

    // Divergence handling: when consumer has commits on .framework/ that
    // aren't upstream, classify before deciding. all-noise = subtree
    // plumbing autogenerated → auto-resolve. overlay-able = user-customised
    // a framework file → suggest /overlay. real-custom = prompt 3-way.
    if (status.hasDivergence) {
      UI.newline();
      if (status.divergenceClass === 'all-noise') {
        UI.info(`Detected ${status.divergenceCommits.length} subtree-merge / chore commit(s) (git plumbing noise — no real content drift). Auto-resolving…`);
      } else if (status.divergenceClass === 'overlay-able') {
        UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
        UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
        const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');
        for (const c of overlayCommits) {
          UI.info(`  • ${c.sha.slice(0, 7)} — ${c.subject}`);
        }

        // If every overlay-able commit only touches files that ALREADY have a
        // matching overlay in `.baldart/overlays/`, the divergent edit's intent
        // is already preserved — `--reset` is safe and `scaffold-overlays`
        // would only create redundant skeletons. Surface that so the user/agent
        // picks the cheap, correct resolution. (v3.33.0)
        const allCovered = overlayCommits.length > 0
          && overlayCommits.every((c) => c.overlayCovered === true);

        // Explicit, non-interactive overrides (agents / CI) via --on-divergence
        // still win. With NO strategy the DEFAULT is now seamless (v4.8.0+):
        // capture+verify any uncovered edit, then reset-and-reapply — no flag maze.
        if (divStrategy === 'scaffold-overlays') {
          UI.newline();
          scaffoldOverlaysForCommits(overlayCommits);
          UI.newline();
          UI.info('Next steps:');
          UI.list([
            'Fill in the overlay skeleton(s) above to capture your customisations (the base file shows what you changed).',
            'Then complete the update with `npx baldart update --reset --yes --i-know` — this discards the now-redundant `.framework/` edits and reinstalls clean; your `.baldart/overlays/` are preserved and re-applied.',
            'Alternatively, push the edits upstream with `/baldart-push` if they are generic improvements.',
          ], 'cyan');
          emitUpdateJson({ ok: false, action: 'divergence-scaffolded',
            divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
            installed_before: status.installedVersion, remote_version: status.remoteVersion,
            reason: 'Scaffolded overlay skeleton(s); fill them in, then re-run.',
            next_command: 'npx baldart update --reset --yes --i-know' }, 0);
        }
        if (divStrategy === 'abort') {
          UI.info('Update cancelled (--on-divergence abort).');
          emitUpdateJson({ ok: false, action: 'aborted',
            divergence_class: status.divergenceClass,
            reason: 'Update cancelled (--on-divergence abort).' }, 0);
        }
        if (divStrategy === 'pull') {
          UI.info('Keeping the custom commits (--on-divergence pull). The subtree pull will attempt a merge.');
          // fall through to the subtree pull below
        } else {
          // DEFAULT seamless resolution (v4.8.0+). Captures+verifies any
          // uncovered edit, then reset-and-reapply. Exits the process on
          // success (reset) or stops once on an unrecoverable blocker.
          await resolveOverlayDivergenceSeamlessly(
            git, status, options, autoYes, overlayCommits, status.remoteVersion
          );
        }
      } else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
        UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
        for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
          UI.info(`  • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
        }
        const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');

        // Non-interactive resolution (agents / CI) via --on-divergence.
        if (divStrategy === 'scaffold-overlays') {
          if (overlayCommits.length === 0) {
            UI.error('No overlay-able commits to scaffold — every custom commit touches non-overlayable paths (src/, CHANGELOG, …).');
            UI.info('Use `/baldart-push` to contribute them upstream, or `--on-divergence pull` to merge.');
            emitUpdateJson({ ok: false, action: 'divergence-refused',
              divergence_class: status.divergenceClass,
              installed_before: status.installedVersion, remote_version: status.remoteVersion,
              reason: 'No overlay-able commits to scaffold — every custom commit touches non-overlayable paths.',
              next_command: 'npx baldart update --json --yes --on-divergence pull' }, 1);
          }
          UI.newline();
          scaffoldOverlaysForCommits(overlayCommits);
          UI.newline();
          UI.warning('Scaffolded overlays for the overlay-able commits, but custom-other commit(s) remain — NOT pulling (would be destructive to those edits).');
          UI.info('Resolve the remaining commits (push upstream with `/baldart-push`, or revert), then re-run `npx baldart update`.');
          emitUpdateJson({ ok: false, action: 'divergence-scaffolded',
            divergence_class: status.divergenceClass,
            installed_before: status.installedVersion, remote_version: status.remoteVersion,
            reason: 'Scaffolded overlay-able subset; custom-other commit(s) still need handling before pulling.',
            next_command: 'npx baldart update --json --yes' }, 0);
        }
        if (divStrategy === 'abort') {
          UI.info('Update cancelled (--on-divergence abort).');
          emitUpdateJson({ ok: false, action: 'aborted',
            divergence_class: status.divergenceClass,
            reason: 'Update cancelled (--on-divergence abort).' }, 0);
        }
        if (divStrategy === 'pull') {
          UI.info('Pulling over the custom commits (--on-divergence pull). A merge commit will be created — resolve any conflicts that surface.');
          // fall through
        } else {
          // DEFAULT seamless (v4.8.0+): capture+verify the overlay-able subset
          // so those edits are preserved, then stop ONCE. The custom-other
          // commits touch non-overlayable paths (src/, CHANGELOG, …) and cannot
          // be auto-resolved without risking that work — this is the single
          // legitimate pause.
          const cwd = process.cwd();
          const overlayableTouched = [...new Set(overlayCommits.flatMap((c) => c.touched || []))];
          let captured = [];
          let blockers = [];
          if (overlayableTouched.length) {
            UI.newline();
            UI.info(`Capturing ${overlayableTouched.length} overlay-able edit(s) into overlays…`);
            ({ captured, blockers } = await captureAndVerify({
              cwd, git, files: overlayableTouched, frameworkVersion: status.remoteVersion,
            }));
            for (const o of captured) UI.success(`Overlay verified: ${o}`);
            for (const b of blockers) UI.warning(`Could not capture ${b.file} — ${b.reason}`);
            // Persist the verified overlays so the user keeps them when re-running.
            if (captured.length) {
              try {
                await git.git.add(captured);
                const st = await git.git.status();
                if (st.staged.length) {
                  await git.git.commit('chore(baldart): capture/verify overlays (overlay-able subset of mixed divergence)');
                }
              } catch (_) { /* non-fatal — overlays remain on disk */ }
            }
          }
          UI.newline();
          UI.error('Custom commits touch non-overlayable paths (src/, CHANGELOG, …) — these cannot be auto-resolved.');
          UI.info('Resolve them, then re-run `npx baldart update`:');
          UI.list([
            'Contribute generic improvements upstream with `/baldart-push` (recommended).',
            'Or keep the commits and merge with `npx baldart update --on-divergence pull`.',
          ], 'cyan');
          emitUpdateJson({ ok: false, action: 'divergence-capture-incomplete',
            divergence_class: status.divergenceClass, captured, blockers,
            installed_before: status.installedVersion, remote_version: status.remoteVersion,
            reason: 'Overlay-able subset captured; custom-other commit(s) touch non-overlayable paths and need /baldart-push or --on-divergence pull.',
            next_command: 'npx baldart update --on-divergence pull' }, 1);
        }
      }
      // 'unknown' → conservative: continue silently, the subtree pull will
      // surface any real conflict.
    }

    // Step 3: Show changes preview
    UI.header('STEP 3/5: Preview Changes');

    const changelog = await changelogSinceInstalled(git, status.installedVersion);
    if (changelog) {
      UI.info(`What's new (v${status.installedVersion} → v${status.remoteVersion}):`);
      // Human mode only — keep STDOUT clean in --json mode (UI already routes
      // its own lines to STDERR there; a raw console.log would break the contract).
      if (!JSON_MODE) console.log(changelog);
    } else {
      UI.info(`Updating v${status.installedVersion} → v${status.remoteVersion} (CHANGELOG preview unavailable).`);
    }

    const proceedUpdate = await confirm('Proceed with update?', true);
    if (!proceedUpdate) {
      UI.info('Update cancelled');
      process.exit(0);
    }

    // Pre-flight: git subtree pull --squash refuses a dirty worktree. Auto-stash
    // via the single shared engine and re-apply after the update (v4.8.0+).
    let stashConflict = false; // surfaced in the --json result (v3.32.0+)
    const stashOutcome = await autoStashNonFramework(git, { autoStash, interactive: !autoYes });
    if (stashOutcome.aborted) {
      UI.info('Aborted. Commit or `git stash -u`, then re-run `npx baldart update`.');
      process.exit(0);
    }
    if (stashOutcome.error) {
      UI.error(`Could not stash: ${stashOutcome.error}`);
      emitUpdateJson({ ok: false, action: 'failed',
        reason: `Could not stash working tree before update: ${stashOutcome.error}` }, 1);
    }
    const stashRef = stashOutcome.stashRef;

    // Step 4: Create backup
    UI.header('STEP 4/5: Create Backup');

    const backupSpinner = UI.spinner('Creating backup tag...').start();
    const backupTag = await git.createBackupTag();
    backupSpinner.succeed(`Backup created: ${backupTag}`);

    UI.newline();
    UI.box('ROLLBACK INFO', [
      'If something goes wrong, rollback with:',
      `  git checkout ${backupTag}`,
      '  git checkout -b recovery-branch'
    ]);

    // Step 5: Update framework
    UI.header('STEP 5/5: Update Framework');

    const updateSpinner = UI.spinner('Updating framework...').start();
    let newVersion;

    try {
      await git.updateSubtree(repo);
      updateSpinner.succeed('Framework updated');

      newVersion = await git.getFrameworkVersion();
      UI.success(`New version: ${newVersion}`);
      // Update the ledger and VERIFY the write landed. Prior to v3.13.0 this
      // call used a silent catch — write failures (permissions, EBUSY) were
      // hidden, and the user only noticed the drift weeks later via doctor.
      // Now: visible warning + always-actionable next step.
      try {
        const after = State.recordUpdate({ from: currentVersion, to: newVersion });
        if (after.installed_version !== newVersion) {
          UI.warning(`Ledger write returned installed_version=${after.installed_version}, expected ${newVersion}.`);
          UI.info('Run `baldart doctor` to reconcile the ledger.');
        }
      } catch (err) {
        UI.warning(`Could not write .baldart/state.json: ${err.message}`);
        UI.info(`Framework on disk is at v${newVersion} but the ledger still records v${currentVersion}.`);
        UI.info('Run `baldart doctor` to reconcile, or check filesystem permissions on .baldart/state.json.');
      }

      // Re-apply the pre-update stash (if any). Conflicts mean we leave the
      // stash intact and tell the user how to resolve — never silently drop.
      if (stashRef) {
        try {
          await git.git.stash(['pop']);
          UI.success(`Re-applied pre-update stash "${stashRef}".`);
        } catch (err) {
          stashConflict = true;
          UI.newline();
          UI.warning(`Could not auto-re-apply stash "${stashRef}" — conflicts with the framework update.`);
          UI.box('STASH CONFLICT', [
            'Your pre-update changes are preserved in the stash.',
            '',
            'Inspect:   git stash list',
            'Apply:     git stash pop      (resolve conflicts manually)',
            'Discard:   git stash drop     (only if you no longer need them)'
          ]);
        }
      }
    } catch (error) {
      updateSpinner.fail();
      UI.error('Update failed!');

      if (error.message.includes('conflict')) {
        UI.newline();
        UI.box('MERGE CONFLICT', [
          'Git found conflicts during update.',
          '',
          'Resolution:',
          '  1. Check conflicts: git status',
          '  2. For each conflict file:',
          '     • Keep yours: git checkout --ours <file>',
          '     • Use new: git checkout --theirs <file>',
          '     • Edit manually to resolve',
          '  3. After resolving:',
          '     git add <files>',
          '     git commit -m "Resolved update conflicts"',
          '  4. Retry: baldart update',
          '',
          'Or rollback:',
          `  git checkout ${backupTag}`
        ]);
      }

      // Subtree pull failed — restore the pre-update stash so the user's
      // dirty changes don't appear lost. Conflicts here are unlikely (we
      // never reached the modifying step), but we still report instead of
      // silently dropping.
      if (stashRef) {
        try {
          await git.git.stash(['pop']);
          UI.info(`Restored pre-update stash "${stashRef}".`);
        } catch (popErr) {
          UI.warning(`Could not auto-restore stash "${stashRef}": ${popErr.message}`);
          UI.info('Recover with: git stash list && git stash pop');
        }
      }

      throw error;
    }

    // Verify symlinks
    UI.newline();
    UI.section('Verifying Symlinks');

    // Read enabled tools from baldart.config.yml so updates respect the
    // user's per-tool selection (Claude only / Claude + Codex / …).
    const enabledTools = readEnabledTools();

    const symlinkValid = symlinks.verifySymlinks();
    if (!symlinkValid) {
      UI.info('Some symlinks are missing or out of date. The framework will:');
      UI.list([
        'Refuse to overwrite any file/dir you customised (you\'ll be asked first).',
        'Convert legacy .claude/skills/ bulk symlink (v2.0.x) into the new per-item layout.',
        `Merge framework skills into per-tool skill dirs (${enabledTools.join(', ')}) without touching your personal skills.`
      ]);
      const recreate = await confirm('Reconcile symlinks now?', true);
      if (recreate) {
        // In auto-yes mode pass 'force' instead of 'prompt' so per-conflict
        // prompts inside SymlinkUtils don't reintroduce blocking I/O.
        const mode = autoYes ? 'force' : 'prompt';
        await symlinks.createAllSymlinks({ mode, tools: enabledTools });
      }
    } else {
      // Re-run the per-item merges for every enabled tool so newly-shipped
      // skills/agents/commands are linked in each tool's expected directory.
      symlinks.mergeSkills({ tools: enabledTools });
      symlinks.mergeAgents({ tools: enabledTools });
      symlinks.mergeCommands({ tools: enabledTools });
      symlinks.mergeWorkflows({ tools: enabledTools });
    }

    // Routines wizard (since v2.1.0) — surfaces routines added in the new framework version
    try {
      const routinesCmd = require('./routines');
      await routinesCmd.postUpdateWizard();
    } catch (err) {
      UI.warning(`Routines wizard skipped: ${err.message}`);
    }

    // BALDART hooks (multi-hook registry since v3.18.0) — backfill for
    // consumers that predate any of the hooks shipped in the current
    // framework version. Idempotent: no-op when every hook is already in
    // place. Drift on an existing entry triggers an interactive prompt
    // (Keep / Replace / Show diff); on --auto / -y the default is 'keep'.
    try {
      const res = await Hooks.registerAll(process.cwd(), {
        onDrift: Hooks.createDriftPrompt(UI, { autoYes }),
      });
      if (res.malformed) {
        UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
      } else {
        // Aggregate output for missing-backfill (the common case after a major
        // bump) to keep the noise floor low. Drift events stay per-line since
        // they represent user-visible decisions.
        const created = res.results.filter((r) => r.status === 'created').map((r) => r.id);
        const legacy = res.results.filter((r) => r.status === 'legacy-migrated');
        const drifts = res.results.filter((r) => r.status === 'drift-replaced' || r.status === 'drift-kept');
        if (created.length === 1) {
          UI.success(`Registered hook \`${created[0]}\` in .claude/settings.json.`);
        } else if (created.length > 1) {
          UI.success(`Auto-registered ${created.length} missing hook(s): ${created.join(', ')}.`);
        }
        for (const r of legacy) UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
        for (const r of drifts) {
          if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
          else UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
        }
        // 'already' is silent on update to avoid noise
      }
    } catch (err) {
      UI.warning(`Hook registration skipped: ${err.message}`);
    }

    // Project configuration check (since v3.0.0)
    // Never overwrite baldart.config.yml on update. Offer to (re)run configure
    // if the schema has gained required keys, or if no config exists at all.
    try {
      const fs = require('fs');
      const path = require('path');
      const configPath = path.join(process.cwd(), 'baldart.config.yml');
      if (!fs.existsSync(configPath)) {
        UI.newline();
        UI.warning('No baldart.config.yml in this project — skills installed by this framework version require it.');
        const runConfigure = await confirm('Run `baldart configure` now?', true);
        if (runConfigure) {
          const configureCmd = require('./configure');
          await configureCmd(autoYes ? { nonInteractive: true } : undefined);
        }
      } else {
        // Schema migration: backfill `tools.enabled` for configs created
        // before v3.7.0 (the multi-tool release). Without this, a legacy
        // install that has Codex on the machine would never get .agents/skills/
        // populated, because update would keep defaulting to ['claude'].
        const yaml = require('js-yaml');
        let needsRewrite = false;
        let cur = {};
        try { cur = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}; } catch (_) { cur = null; }

        if (cur && (!cur.tools || !Array.isArray(cur.tools.enabled) || cur.tools.enabled.length === 0)) {
          const toolAdapters = require('../utils/tool-adapters');
          const suggested = toolAdapters.defaultEnabled();
          UI.newline();
          UI.warning('Pre-v3.7.0 config detected — `tools.enabled` is missing.');
          UI.info(`From v3.7.0, BALDART can install framework skills for multiple AI tools (Claude Code, OpenAI Codex CLI, …) from the same source.`);
          UI.info(`Autodetected on this machine: ${suggested.join(', ')}`);
          const ok = await confirm(`Backfill \`tools.enabled: [${suggested.join(', ')}]\` into baldart.config.yml?`, true);
          if (ok) {
            cur.tools = cur.tools || {};
            cur.tools.enabled = suggested;
            fs.writeFileSync(configPath, yaml.dump(cur, { lineWidth: 100, noRefs: true }));
            UI.success('Backfilled tools.enabled. Re-running the symlink reconcile to install per-tool skill dirs…');
            // Re-run merge so the just-backfilled tools get their skill dirs populated now.
            try { symlinks.mergeSkills({ tools: suggested }); }
            catch (err) { UI.warning(`Skill merge for new tools failed: ${err.message}`); }
            needsRewrite = true;
          } else {
            UI.info('Skipped. You can add it manually under `tools:` in baldart.config.yml or rerun `npx baldart configure`.');
          }
        }

        // Detect schema drift on other keys (features.*, paths.*).
        const templatePath = path.join('.framework', 'framework', 'templates', 'baldart.config.template.yml');
        if (fs.existsSync(templatePath)) {
          try {
            const tpl = yaml.load(fs.readFileSync(templatePath, 'utf8')) || {};
            const cur2 = needsRewrite ? cur : (yaml.load(fs.readFileSync(configPath, 'utf8')) || {});
            const missingFeatures = Object.keys(tpl.features || {})
              .filter((k) => !(k in (cur2.features || {})));
            const missingPaths = Object.keys(tpl.paths || {})
              .filter((k) => !(k in (cur2.paths || {})));
            const missingGit = Object.keys(tpl.git || {})
              .filter((k) => !(k in (cur2.git || {})));
            // Scalar top-level stack.* keys — e.g. stack.database,
            // stack.auth_provider, stack.framework, stack.deployment (strings) and
            // stack.schema_deploy_from_trunk_only (boolean, since v4.33.0). Sub-object
            // keys (charting, animation, testing) are intentionally skipped:
            // their drift is handled by individual sub-prompts in configure.
            const missingStack = Object.keys(tpl.stack || {})
              .filter((k) => ['string', 'boolean'].includes(typeof tpl.stack[k]))
              .filter((k) => !(k in (cur2.stack || {})));
            // Top-level nested config namespaces (e.g. `graph:` since v4.21.0).
            // Unlike `lsp:` (which propagates only via its `has_lsp_layer` flag),
            // diff the block's own scalar keys so a new sub-key surfaces too.
            const missingGraph = Object.keys(tpl.graph || {})
              .filter((k) => !(k in (cur2.graph || {})));
            const allMissing = [
              ...missingPaths,
              ...missingFeatures,
              ...missingGit.map((k) => `git.${k}`),
              ...missingStack.map((k) => `stack.${k}`),
              ...missingGraph.map((k) => `graph.${k}`),
            ];
            if (allMissing.length) {
              UI.newline();
              UI.warning(`New config keys in this version: ${allMissing.join(', ')}.`);
              // Auto-offer configure so the user actually answers, instead of
              // silently falling back to template defaults on first use.
              const runConfigure = await confirm('Run `baldart configure` now to populate them? (existing values are preserved)', true);
              if (runConfigure) {
                const configureCmd = require('./configure');
                await configureCmd(autoYes ? { nonInteractive: true } : undefined);
              } else {
                UI.info('Skipped. Skills will prompt for the missing keys on first use.');
                if (allMissing.includes('git.trunk_branch')) {
                  UI.warning('`git.trunk_branch` drives worktree creation in `/prd` and `/new`. '
                    + 'Until it is set, those skills autodetect the trunk (origin/HEAD → develop/main/master) '
                    + 'and warn on each run; run `npx baldart configure` to persist it and make it deterministic.');
                }
              }
            }
          } catch (_) {
            // Non-fatal — schema drift detection is best-effort.
          }
        }
      }
    } catch (err) {
      UI.warning(`Configuration check skipped: ${err.message}`);
    }

    // Post-update auto-commit (v3.5.0+) — keep the worktree clean and leave
    // a single revertable commit for the reconcile step.
    try {
      await postUpdateAutoCommit(git, newVersion, options);
    } catch (err) {
      UI.warning(`Auto-commit step skipped: ${err.message}`);
    }

    // Success
    UI.header('✓ UPDATE COMPLETE');

    UI.box('WHAT CHANGED', [
      `Version: ${currentVersion} → ${newVersion}`,
      '',
      'Review CHANGELOG:',
      '  cat .framework/CHANGELOG.md',
      '',
      'Check template updates:',
      '  diff .framework/.claude/hooks/*.template .claude/hooks/',
      '',
      'Review routines:',
      '  npx baldart routines list',
      '',
      `Backup available: ${backupTag}`,
      `Remove when confident: git tag -d ${backupTag}`
    ]);

    UI.newline();
    UI.success('Framework updated successfully!');

    emitUpdateJson({ ok: true, action: 'updated',
      installed_before: currentVersion, installed_after: newVersion,
      remote_version: status.remoteVersion, backup_tag: backupTag,
      stash_conflict: stashConflict }, 0);

  } catch (error) {
    if (JSON_MODE) {
      emitUpdateJson({ ok: false, action: 'failed', reason: error.message }, 1);
    }
    UI.error(`Update failed: ${error.message}`);
    process.exit(1);
  }
}

module.exports = update;
