/**
 * Mosaic update checker — compares the installed @mosaicstack/mosaic package
 * against the Gitea npm registry and reports when an upgrade is available.
 *
 * Used by:
 *  - CLI startup (non-blocking background check)
 *  - session-start.sh (via `mosaic update --check`)
 *  - install.sh (direct invocation)
 *
 * Design:
 *  - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour
 *  - Network call is fire-and-forget with a 5 s timeout
 *  - Never throws on failure — returns stale/unknown result
 */

import { execSync } from 'node:child_process';
import {
  closeSync,
  constants,
  copyFileSync,
  existsSync,
  fchmodSync,
  fsyncSync,
  linkSync,
  lstatSync,
  mkdirSync,
  openSync,
  readFileSync,
  readdirSync,
  renameSync,
  rmdirSync,
  unlinkSync,
  writeFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
import {
  assertCanonicalContainment,
  assertNoSymlinkAncestors,
  ensureManagedDirectory,
  readRegularFileSecure,
} from '../fleet/secure-file.js';
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
import {
  runInstallOrderingGuard,
  type InstallOrderingGuardDeps,
  type InstallOrderingGuardOptions,
  type RunInstallOrderingGuardResult,
} from '../commands/install-ordering-guard.js';

// ─── Types ──────────────────────────────────────────────────────────────────

export interface UpdateCheckResult {
  /** Currently installed version (empty if not found) */
  current: string;
  /** Currently installed package name */
  currentPackage?: string;
  /** Latest published version (empty if check failed) */
  latest: string;
  /** Package that should be installed for the latest version */
  targetPackage?: string;
  /** True when a newer version is available */
  updateAvailable: boolean;
  /** ISO timestamp of this check */
  checkedAt: string;
  /** Where the latest version was resolved from */
  registry: string;
}

// ─── Constants ──────────────────────────────────────────────────────────────

const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
const PKG = '@mosaicstack/mosaic';
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const NETWORK_TIMEOUT_MS = 5_000;

function isNodeErrorCode(error: unknown, code: string): boolean {
  return error instanceof Error && 'code' in error && error.code === code;
}

// ─── Helpers ────────────────────────────────────────────────────────────────

function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
  try {
    return execSync(`npm ${args}`, {
      encoding: 'utf-8',
      timeout: timeoutMs,
      stdio: ['ignore', 'pipe', 'ignore'],
    }).trim();
  } catch {
    return '';
  }
}

/**
 * Rudimentary semver "a < b" — handles pre-release tags.
 * Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence).
 */
export function semverLt(a: string, b: string): boolean {
  if (!a || !b) return false;

  const stripped = (v: string) => v.replace(/^v/, '');
  const splitPre = (v: string): [string, string | null] => {
    const idx = v.indexOf('-');
    return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)];
  };

  const sa = stripped(a);
  const sb = stripped(b);
  const [coreA, preA] = splitPre(sa);
  const [coreB, preB] = splitPre(sb);

  // Compare core version (major.minor.patch)
  const partsA = coreA.split('.').map(Number);
  const partsB = coreB.split('.').map(Number);
  const len = Math.max(partsA.length, partsB.length);

  for (let i = 0; i < len; i++) {
    const va = partsA[i] ?? 0;
    const vb = partsB[i] ?? 0;
    if (va < vb) return true;
    if (va > vb) return false;
  }

  // Core versions are equal — compare pre-release
  // No pre-release > any pre-release (1.0.0 > 1.0.0-alpha)
  if (preA !== null && preB === null) return true;
  if (preA === null && preB !== null) return false;
  if (preA === null && preB === null) return false;

  // Both have pre-release — compare dot-separated identifiers
  const idsA = preA!.split('.');
  const idsB = preB!.split('.');
  const preLen = Math.max(idsA.length, idsB.length);

  for (let i = 0; i < preLen; i++) {
    const ia = idsA[i];
    const ib = idsB[i];
    // Fewer fields = lower precedence
    if (ia === undefined && ib !== undefined) return true;
    if (ia !== undefined && ib === undefined) return false;
    const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null;
    const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null;
    // Numeric vs string: numeric < string
    if (na !== null && nb !== null) {
      if (na < nb) return true;
      if (na > nb) return false;
      continue;
    }
    if (na !== null && nb === null) return true;
    if (na === null && nb !== null) return false;
    // Both strings
    if (ia! < ib!) return true;
    if (ia! > ib!) return false;
  }
  return false;
}

// ─── Known packages for checkForAllUpdates() ──────────────────────────────

const KNOWN_PACKAGES = [
  '@mosaicstack/agent',
  '@mosaicstack/auth',
  '@mosaicstack/brain',
  '@mosaicstack/config',
  '@mosaicstack/coord',
  '@mosaicstack/db',
  '@mosaicstack/design-tokens',
  '@mosaicstack/discord-plugin',
  '@mosaicstack/forge',
  '@mosaicstack/gateway',
  '@mosaicstack/log',
  '@mosaicstack/macp',
  '@mosaicstack/memory',
  '@mosaicstack/mosaic',
  '@mosaicstack/oc-framework-plugin',
  '@mosaicstack/oc-macp-plugin',
  '@mosaicstack/prdy',
  '@mosaicstack/quality-rails',
  '@mosaicstack/queue',
  '@mosaicstack/storage',
  '@mosaicstack/telegram-plugin',
  '@mosaicstack/types',
];

// ─── Multi-package types ──────────────────────────────────────────────────

export interface PackageUpdateResult {
  /** Package name */
  package: string;
  /** Currently installed version (empty if not installed) */
  current: string;
  /** Latest published version (empty if check failed) */
  latest: string;
  /** True when a newer version is available */
  updateAvailable: boolean;
}

// ─── Cache ──────────────────────────────────────────────────────────────────

/** Cache stores the latest registry versions for all checked packages.
 *  The installed version is always checked fresh — it's a local `npm ls`. */
interface AllPackagesCache {
  packages: Record<string, { latest: string }>;
  checkedAt: string;
  registry: string;
}

function readAllCache(): AllPackagesCache | null {
  try {
    if (!existsSync(CACHE_FILE)) return null;
    const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as AllPackagesCache;
    const age = Date.now() - new Date(raw.checkedAt).getTime();
    if (age > CACHE_TTL_MS) return null;
    return raw;
  } catch {
    return null;
  }
}

function writeAllCache(entry: AllPackagesCache): void {
  try {
    mkdirSync(CACHE_DIR, { recursive: true });
    writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2) + '\n', 'utf-8');
  } catch {
    // Best-effort — cache is not critical
  }
}

// Legacy single-package cache (backward compat)
type RegistryCache = { latest: string; checkedAt: string; registry: string };

function readCache(): RegistryCache | null {
  const c = readAllCache();
  if (!c) return null;
  const mosaicLatest = c.packages[PKG]?.latest;
  if (!mosaicLatest) return null;
  return { latest: mosaicLatest, checkedAt: c.checkedAt, registry: c.registry };
}

function writeCache(entry: RegistryCache): void {
  const existing = readAllCache();
  const packages = { ...(existing?.packages ?? {}) };
  packages[PKG] = { latest: entry.latest };
  writeAllCache({ packages, checkedAt: entry.checkedAt, registry: entry.registry });
}

// ─── Public API ─────────────────────────────────────────────────────────────

/**
 * Get the currently installed @mosaicstack/mosaic version.
 */
export function getInstalledVersion(): { name: string; version: string } {
  try {
    const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
    if (raw) {
      const data = JSON.parse(raw) as {
        dependencies?: Record<string, { version?: string }>;
      };
      const version = data?.dependencies?.[PKG]?.version;
      if (version) {
        return { name: PKG, version };
      }
    }
  } catch {
    // fall through
  }
  return { name: '', version: '' };
}

/**
 * Fetch the latest published @mosaicstack/mosaic version from the Gitea npm registry.
 * Returns empty string on failure.
 */
export function getLatestVersion(): { name: string; version: string } {
  const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`);
  if (version) {
    return { name: PKG, version };
  }
  return { name: '', version: '' };
}

export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string {
  return `npm i -g ${result.targetPackage || PKG}@latest`;
}

/**
 * Perform an update check — uses registry cache when fresh, always checks
 * installed version fresh (local npm ls is cheap, caching it causes stale
 * "update available" banners after an upgrade).
 * Never throws.
 *
 * @deprecated Use checkForAllUpdates() for multi-package checking.
 *             This function is kept for backward compatibility.
 */
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
  const currentInfo = getInstalledVersion();
  const current = currentInfo.version;

  let latestInfo: { name: string; version: string };
  let checkedAt: string;

  if (!options?.skipCache) {
    const cached = readCache();
    if (cached) {
      latestInfo = {
        name: PKG,
        version: cached.latest,
      };
      checkedAt = cached.checkedAt;
    } else {
      latestInfo = getLatestVersion();
      checkedAt = new Date().toISOString();
      writeCache({
        latest: latestInfo.version,
        checkedAt,
        registry: REGISTRY,
      });
    }
  } else {
    latestInfo = getLatestVersion();
    checkedAt = new Date().toISOString();
    writeCache({
      latest: latestInfo.version,
      checkedAt,
      registry: REGISTRY,
    });
  }

  return {
    current,
    currentPackage: currentInfo.name || PKG,
    latest: latestInfo.version,
    targetPackage: latestInfo.name || PKG,
    updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
    checkedAt,
    registry: REGISTRY,
  };
}

/**
 * Format a human-readable update notice. Returns empty string if up-to-date.
 */
export function formatUpdateNotice(result: UpdateCheckResult): string {
  if (!result.updateAvailable) return '';

  const installCommand = getInstallCommand(result);

  const lines = [
    '',
    '╭─────────────────────────────────────────────────╮',
    '│                                                 │',
    `│   Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│',
    '│                                                 │',
    '│   Run:  bash tools/install.sh                   │',
    `│   Or:   ${installCommand}`.padEnd(50) + '│',
    '│                                                 │',
    '╰─────────────────────────────────────────────────╯',
    '',
  ];
  return lines.join('\n');
}

/**
 * Non-blocking update check that prints a notice to stderr if an update
 * is available. Designed to be called at CLI startup without delaying
 * the user.
 */
export function backgroundUpdateCheck(): void {
  try {
    const result = checkForUpdate();
    const notice = formatUpdateNotice(result);
    if (notice) {
      process.stderr.write(notice);
    }
  } catch {
    // Silently ignore — never block the user
  }
}

// ─── Multi-package update check ────────────────────────────────────────────

/**
 * Get the currently installed versions of all globally-installed @mosaicstack/* packages.
 */
function getInstalledVersions(): Record<string, string> {
  const result: Record<string, string> = {};
  try {
    const raw = npmExec('ls -g --depth=0 --json 2>/dev/null', 3000);
    if (raw) {
      const data = JSON.parse(raw) as {
        dependencies?: Record<string, { version?: string }>;
      };
      for (const [name, info] of Object.entries(data?.dependencies ?? {})) {
        if (name.startsWith('@mosaicstack/') && info.version) {
          result[name] = info.version;
        }
      }
    }
  } catch {
    // fall through
  }
  return result;
}

/**
 * Fetch the latest published version of a single package from the registry.
 */
function getLatestVersionFor(pkgName: string): string {
  return npmExec(`view ${pkgName} version --registry=${REGISTRY}`);
}

/**
 * Check all known @mosaicstack/* packages for updates.
 * Returns an array of per-package results, sorted by package name.
 * Never throws.
 */
export function checkForAllUpdates(options?: { skipCache?: boolean }): PackageUpdateResult[] {
  const installed = getInstalledVersions();
  const checkedAt = new Date().toISOString();

  // Resolve latest versions (from cache or network)
  let latestVersions: Record<string, string>;

  if (!options?.skipCache) {
    const cached = readAllCache();
    if (cached) {
      latestVersions = {};
      for (const pkg of KNOWN_PACKAGES) {
        const cachedLatest = cached.packages[pkg]?.latest;
        if (cachedLatest) {
          latestVersions[pkg] = cachedLatest;
        }
      }
    } else {
      latestVersions = {};
      for (const pkg of KNOWN_PACKAGES) {
        const v = getLatestVersionFor(pkg);
        if (v) latestVersions[pkg] = v;
      }
      writeAllCache({
        packages: Object.fromEntries(
          Object.entries(latestVersions).map(([k, v]) => [k, { latest: v }]),
        ),
        checkedAt,
        registry: REGISTRY,
      });
    }
  } else {
    latestVersions = {};
    for (const pkg of KNOWN_PACKAGES) {
      const v = getLatestVersionFor(pkg);
      if (v) latestVersions[pkg] = v;
    }
    writeAllCache({
      packages: Object.fromEntries(
        Object.entries(latestVersions).map(([k, v]) => [k, { latest: v }]),
      ),
      checkedAt,
      registry: REGISTRY,
    });
  }

  const results: PackageUpdateResult[] = [];
  for (const pkg of KNOWN_PACKAGES) {
    const current = installed[pkg] ?? '';
    const latest = latestVersions[pkg] ?? '';
    results.push({
      package: pkg,
      current,
      latest,
      updateAvailable: !!(current && latest && semverLt(current, latest)),
    });
  }

  return results.sort((a, b) => a.package.localeCompare(b.package));
}

/**
 * Get the install command for all outdated packages.
 */
export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
  const pkgs = outdated.filter((r) => r.updateAvailable).map((r) => `${r.package}@latest`);
  if (pkgs.length === 0) return '';
  return `npm i -g ${pkgs.join(' ')}`;
}

// ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ─────────────
//
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
// These helpers run the package's own install.sh in sync-only mode. The re-seed
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
// shared framework-manifest.txt and prunes only retired framework files inside
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
// roster + agents + backlog, and anything the manifest never anticipated) is
// left byte-identical. Contract files are still reconciled (overwrite +
// backup-once). Opt-in, this also relaunches durable agents.

/** Resolve the framework/ directory bundled in the installed package. */
export function resolveBundledFrameworkRoot(): string {
  // dist/runtime/update-checker.js → ../../framework (package files: dist + framework)
  return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
}

export const FRAMEWORK_RESEED_PACKAGE = PKG;

/**
 * Build the framework re-seed invocation: the package's install.sh in
 * sync-only mode (file phase only — no environment-touching post-install),
 * keep mode (never overwrite user files). Returned as data so it is unit
 * testable; `runFrameworkReseed` executes it.
 */
export function buildReseedCommand(
  frameworkRoot: string,
  mosaicHome: string,
): { installer: string; command: string; env: Record<string, string> } {
  const installer = join(frameworkRoot, 'install.sh');
  return {
    installer,
    command: `bash ${installer}`,
    env: {
      MOSAIC_SYNC_ONLY: '1',
      MOSAIC_INSTALL_MODE: 'keep',
      MOSAIC_HOME: mosaicHome,
    },
  };
}

export interface ToolsRepairResult {
  ok: boolean;
  changed: boolean;
  backupPath?: string;
  reason?: string;
}

export interface ToolsRepairHooks {
  beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
}

function optionalSecureFile(
  path: string,
  root: string,
): ReturnType<typeof readRegularFileSecure> | undefined {
  try {
    return readRegularFileSecure(path, { root });
  } catch (error) {
    if (isNodeErrorCode(error, 'ENOENT')) return undefined;
    throw error;
  }
}

function stageManagedFile(
  root: string,
  directory: string,
  target: string,
  content: Buffer,
  mode: number,
): string {
  assertCanonicalContainment(root, target);
  ensureManagedDirectory(root, directory);
  assertNoSymlinkAncestors(target);
  const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
  assertCanonicalContainment(root, staged);
  const fd = openSync(
    staged,
    constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
    0o600,
  );
  try {
    writeFileSync(fd, content);
    fchmodSync(fd, mode);
    fsyncSync(fd);
  } finally {
    closeSync(fd);
  }
  return staged;
}

function cryptoRandom(): string {
  return randomBytes(8).toString('hex');
}

interface ManagedOriginal {
  path: string;
  snapshot?: ReturnType<typeof readRegularFileSecure>;
}

function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
  if (!original.snapshot) {
    try {
      lstatSync(original.path);
      throw new Error(`repair destination appeared during staging: ${original.path}`);
    } catch (error) {
      if (isNodeErrorCode(error, 'ENOENT')) return;
      throw error;
    }
  }
  const current = readRegularFileSecure(original.path, { root });
  if (
    current.dev !== original.snapshot.dev ||
    current.ino !== original.snapshot.ino ||
    current.mode !== original.snapshot.mode ||
    !current.content.equals(original.snapshot.content)
  ) {
    throw new Error(`repair destination changed during staging: ${original.path}`);
  }
}

function installBackupNoClobber(staged: string, target: string, root: string): void {
  assertCanonicalContainment(root, target);
  assertNoSymlinkAncestors(target);
  try {
    lstatSync(target);
    throw new Error(`digest-qualified backup collision at ${target}`);
  } catch (error) {
    if (!isNodeErrorCode(error, 'ENOENT')) throw error;
  }
  linkSync(staged, target);
  unlinkSync(staged);
}

function atomicInstall(staged: string, target: string, root: string): void {
  assertCanonicalContainment(root, target);
  assertNoSymlinkAncestors(target);
  try {
    const current = lstatSync(target);
    if (current.isSymbolicLink() || !current.isFile()) {
      throw new Error(`repair destination is not a regular file: ${target}`);
    }
  } catch (error) {
    if (!isNodeErrorCode(error, 'ENOENT')) throw error;
  }
  renameSync(staged, target);
}

/**
 * Explicitly repair the user-owned TOOLS contract and required helper from the
 * bundled current framework. Existing divergent TOOLS content is preserved in
 * a digest-qualified no-clobber backup; repeated repairs are idempotent.
 */
export function repairFleetCommsTools(
  frameworkRoot = resolveBundledFrameworkRoot(),
  mosaicHome = join(homedir(), '.config', 'mosaic'),
  hooks: ToolsRepairHooks = {},
): ToolsRepairResult {
  const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
  const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
  const installedTools = join(mosaicHome, 'TOOLS.md');
  const helperDirectory = join(mosaicHome, 'tools', 'tmux');
  const installedHelper = join(helperDirectory, 'agent-send.sh');
  let stagedBackup: string | undefined;
  let stagedTools: string | undefined;
  let stagedHelper: string | undefined;
  let rollbackTools: string | undefined;
  let rollbackHelper: string | undefined;
  let committedBackup = false;
  let committedTools = false;
  let committedHelper = false;
  let createdHome = false;
  let createdToolsDirectory = false;
  let createdHelperDirectory = false;
  let backupPath: string | undefined;
  let toolsOriginal: ManagedOriginal | undefined;
  let helperOriginal: ManagedOriginal | undefined;
  try {
    const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
    const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
      root: frameworkRoot,
      executable: true,
    });
    if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
      return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
    }

    assertCanonicalContainment(mosaicHome, installedTools);
    assertCanonicalContainment(mosaicHome, installedHelper);
    assertNoSymlinkAncestors(mosaicHome);
    const homeExisted = existsSync(mosaicHome);
    const toolsDirectory = dirname(helperDirectory);
    const toolsDirectoryExisted = existsSync(toolsDirectory);
    const helperDirectoryExisted = existsSync(helperDirectory);
    if (homeExisted) {
      const homeStat = lstatSync(mosaicHome);
      if (homeStat.isSymbolicLink()) {
        throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
      }
      if (!homeStat.isDirectory()) {
        throw new Error(`managed root is not a real directory: ${mosaicHome}`);
      }
    }

    const installedToolsSnapshot = homeExisted
      ? optionalSecureFile(installedTools, mosaicHome)
      : undefined;
    let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
    let installedHelperExecutable = false;
    if (homeExisted) {
      try {
        installedHelperSnapshot = readRegularFileSecure(installedHelper, {
          root: mosaicHome,
          executable: true,
        });
        installedHelperExecutable = true;
      } catch (error) {
        if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
        if (isNodeErrorCode(error, 'EACCES')) {
          installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
        }
      }
    }

    const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
    const helperChanged =
      !installedHelperExecutable ||
      !installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
    if (!toolsChanged && !helperChanged) return { ok: true, changed: false };

    toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
    helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };

    ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
    createdHome = !homeExisted;
    ensureManagedDirectory(mosaicHome, helperDirectory);
    createdToolsDirectory = !toolsDirectoryExisted;
    createdHelperDirectory = !helperDirectoryExisted;

    if (toolsChanged) {
      stagedTools = stageManagedFile(
        mosaicHome,
        mosaicHome,
        installedTools,
        sourceToolsSnapshot.content,
        sourceToolsSnapshot.mode & 0o777,
      );
      if (installedToolsSnapshot) {
        const digest = createHash('sha256')
          .update(installedToolsSnapshot.content)
          .digest('hex')
          .slice(0, 16);
        backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
        const existingBackup = optionalSecureFile(backupPath, mosaicHome);
        if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
          throw new Error(`digest-qualified backup collision at ${backupPath}`);
        }
        if (!existingBackup) {
          stagedBackup = stageManagedFile(
            mosaicHome,
            mosaicHome,
            backupPath,
            installedToolsSnapshot.content,
            installedToolsSnapshot.mode & 0o777,
          );
        }
        rollbackTools = stageManagedFile(
          mosaicHome,
          mosaicHome,
          installedTools,
          installedToolsSnapshot.content,
          installedToolsSnapshot.mode & 0o777,
        );
      }
    }
    if (helperChanged) {
      stagedHelper = stageManagedFile(
        mosaicHome,
        helperDirectory,
        installedHelper,
        sourceHelperSnapshot.content,
        sourceHelperSnapshot.mode & 0o777,
      );
      if (installedHelperSnapshot) {
        rollbackHelper = stageManagedFile(
          mosaicHome,
          helperDirectory,
          installedHelper,
          installedHelperSnapshot.content,
          installedHelperSnapshot.mode & 0o777,
        );
      }
    }

    assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
    assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
    if (stagedBackup && backupPath) {
      hooks.beforeCommit?.('backup');
      assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
      assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
      installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
      stagedBackup = undefined;
      committedBackup = true;
    }
    if (stagedTools) {
      hooks.beforeCommit?.('tools');
      assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
      atomicInstall(stagedTools, installedTools, mosaicHome);
      stagedTools = undefined;
      committedTools = true;
    }
    if (stagedHelper) {
      hooks.beforeCommit?.('helper');
      assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
      atomicInstall(stagedHelper, installedHelper, mosaicHome);
      stagedHelper = undefined;
      committedHelper = true;
    }
    if (rollbackTools) unlinkSync(rollbackTools);
    if (rollbackHelper) unlinkSync(rollbackHelper);
    return { ok: true, changed: true, backupPath };
  } catch (error) {
    const failures: string[] = [];
    try {
      if (committedHelper) {
        if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
        else unlinkSync(installedHelper);
        rollbackHelper = undefined;
      }
    } catch (rollbackError) {
      failures.push(`helper rollback failed: ${String(rollbackError)}`);
    }
    try {
      if (committedTools) {
        if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
        else unlinkSync(installedTools);
        rollbackTools = undefined;
      }
    } catch (rollbackError) {
      failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
    }
    try {
      if (committedBackup && backupPath) {
        unlinkSync(backupPath);
        committedBackup = false;
      }
    } catch (rollbackError) {
      failures.push(`backup rollback failed: ${String(rollbackError)}`);
    }
    for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
      if (!staged) continue;
      try {
        unlinkSync(staged);
      } catch {
        failures.push(`staging cleanup failed: ${staged}`);
      }
    }
    for (const [created, directory] of [
      [createdHelperDirectory, helperDirectory],
      [createdToolsDirectory, dirname(helperDirectory)],
      [createdHome, mosaicHome],
    ] as const) {
      if (!created) continue;
      try {
        rmdirSync(directory);
      } catch (cleanupError) {
        if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
          failures.push(`directory cleanup failed: ${directory}`);
        }
      }
    }
    const reason = error instanceof Error ? error.message : String(error);
    return {
      ok: false,
      changed: failures.length > 0,
      backupPath: committedBackup ? backupPath : undefined,
      reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
    };
  }
}

/**
 * Re-seed the framework from the freshly-installed package. Returns a result
 * describing what happened (so callers can message + decide on relaunch).
 * Best-effort: a missing installer or a non-zero exit is reported, not thrown.
 */
export interface FrameworkReseedResult {
  ok: boolean;
  reason?: string;
  skillSync?: SkillSyncResult;
  skillSyncError?: string;
}

export function runFrameworkReseed(
  frameworkRoot = resolveBundledFrameworkRoot(),
  mosaicHome = join(homedir(), '.config', 'mosaic'),
  claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
): FrameworkReseedResult {
  const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
  if (!existsSync(installer)) {
    return { ok: false, reason: `installer not found: ${installer}` };
  }
  try {
    execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
  } catch (error: unknown) {
    return { ok: false, reason: error instanceof Error ? error.message : String(error) };
  }

  try {
    const skillSync = syncClaudeSkills({
      mosaicSkillsDir: join(mosaicHome, 'skills'),
      claudeSkillsDir,
    });
    return { ok: true, skillSync };
  } catch (error: unknown) {
    return {
      ok: true,
      skillSyncError: error instanceof Error ? error.message : String(error),
    };
  }
}

// ─── Post-reseed install-ordering guard (#882, Point-2 precondition) ────────
//
// Root cause (restated): `runFrameworkReseed` above runs the package's
// install.sh with MOSAIC_SYNC_ONLY=1, which — by design (see install.sh) —
// exits after the file-system phase, BEFORE the "Post-install tasks" step
// that runs `mosaic-link-runtime-assets`. That script is where the #869
// Point-1 C2 install-ordering guard (`runInstallOrderingGuard`,
// `packages/mosaic/src/commands/install-ordering-guard.ts`) decides whether
// the lease-enforcement hooks (PreToolUse mutator-gate.py / Stop
// receipt-observer-client.py) get wired into `~/.claude/settings.json`. A
// plain `mosaic update` reseed therefore never re-evaluated that wiring
// decision against current activation state — the bypass this closes.
//
// `runUpdatePathSettingsGuard` re-applies the EXACT SAME guard (no forked
// logic) against the MANAGED settings.json template the reseed just
// refreshed (`<mosaicHome>/runtime/claude/settings.json`) and the live
// `<claudeHome>/settings.json` — mirroring `copy_claude_settings_guarded`'s
// src/dest pair in `mosaic-link-runtime-assets`.

export interface UpdatePathSettingsGuardResult {
  /** False when there is no settings.json template on disk to re-link (e.g. a
   * framework layout that predates runtime/claude/settings.json) — nothing to
   * guard, so the guard did not run. */
  ran: boolean;
  result?: RunInstallOrderingGuardResult;
}

export function runUpdatePathSettingsGuard(
  mosaicHome = join(homedir(), '.config', 'mosaic'),
  claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'),
  options: InstallOrderingGuardOptions = {},
  deps: InstallOrderingGuardDeps = {},
): UpdatePathSettingsGuardResult {
  const src = join(mosaicHome, 'runtime', 'claude', 'settings.json');
  if (!existsSync(src)) {
    return { ran: false };
  }
  const dest = join(claudeHome, 'settings.json');
  return { ran: true, result: runInstallOrderingGuard(src, dest, options, deps) };
}

// ─── update-reseed flow (extracted for testability; called from cli.ts) ────
//
// Everything `mosaic update`'s `.action()` does once it has decided a reseed
// should happen (both call sites already gate on `opts.reseed !== false`
// before invoking this). Extracted out of cli.ts so the post-reseed guard
// wiring (#882 (b)) — and the `--no-reseed` short-circuit — are directly unit
// testable with injected fakes, matching the existing update-checker
// conventions (see update-checker.reseed.spec.ts).

export interface UpdateReseedFlowOptions {
  /** Mirrors the CLI's `--no-reseed` flag (commander sets `reseed: false`
   * when passed). `false` is a pure no-op: nothing is reseeded and the
   * post-reseed settings guard is not invoked either — there is nothing to
   * re-link. */
  reseed?: boolean;
  relaunch?: boolean;
  /** Threads `--allow-inactive-enforcement` to the post-reseed settings
   * guard, identically to the install path (see install-ordering-guard.ts).
   * Never sourced from an environment variable — explicit per-invocation
   * opt-out only. */
  allowInactiveEnforcement?: boolean;
}

export interface UpdateReseedFlowDeps {
  runFrameworkReseed?: typeof runFrameworkReseed;
  runUpdatePathSettingsGuard?: typeof runUpdatePathSettingsGuard;
  refreshActiveFleetUnits?: typeof refreshActiveFleetUnits;
  readRosterAgentNames?: typeof readRosterAgentNames;
  execSync?: typeof execSync;
  log?: (message: string) => void;
  warnLog?: (message: string) => void;
  errorLog?: (message: string) => void;
}

export interface UpdateReseedFlowResult {
  /** Whether a reseed was actually attempted (false only for `--no-reseed`). */
  attempted: boolean;
  reseed?: FrameworkReseedResult;
  settingsGuard?: UpdatePathSettingsGuardResult;
}

export function runUpdateReseedFlow(
  reason: string,
  options: UpdateReseedFlowOptions = {},
  deps: UpdateReseedFlowDeps = {},
): UpdateReseedFlowResult {
  if (options.reseed === false) {
    // Nothing to re-seed, and therefore nothing to re-link/guard either.
    return { attempted: false };
  }

  const log = deps.log ?? console.log;
  const warnLog = deps.warnLog ?? console.warn;
  const errorLog = deps.errorLog ?? console.error;
  const doReseed = deps.runFrameworkReseed ?? runFrameworkReseed;
  const doGuard = deps.runUpdatePathSettingsGuard ?? runUpdatePathSettingsGuard;
  const doRefresh = deps.refreshActiveFleetUnits ?? refreshActiveFleetUnits;
  const doReadRoster = deps.readRosterAgentNames ?? readRosterAgentNames;
  const exec = deps.execSync ?? execSync;

  log(reason);
  const reseed = doReseed();
  if (!reseed.ok) {
    errorLog(
      `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
        '  Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
        '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
    );
    return { attempted: true, reseed };
  }
  log('✔ Framework re-seeded.');
  if (reseed.skillSyncError) {
    errorLog(`  ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
  }
  const skillConflicts = reseed.skillSync?.conflicts ?? [];
  const skillChanges =
    (reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
  if (skillChanges > 0) {
    log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
  }
  for (const conflict of skillConflicts) {
    errorLog(`  ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
  }

  // #882 (b): re-apply the install-ordering guard (C2) to the MANAGED
  // settings.json the reseed just refreshed. install.sh's sync-only mode
  // never reaches the post-install step that would otherwise do this, so
  // `mosaic update` must do it itself — closing the bypass for every update
  // path. Never swallow the guard's fail-loud/opt-out output on this path.
  const settingsGuard = doGuard(undefined, undefined, {
    allowInactiveEnforcement: options.allowInactiveEnforcement === true,
  });
  if (settingsGuard.ran && settingsGuard.result) {
    for (const line of settingsGuard.result.logs) {
      (line.level === 'error' ? errorLog : warnLog)(line.message);
    }
  }

  // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
  // touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
  const units = doRefresh();
  if (units.refreshed.length > 0) {
    log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
  }
  const agents = doReadRoster();
  if (agents.length > 0) {
    if (options.relaunch) {
      log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
      for (const restart of buildRelaunchCommands(agents)) {
        try {
          exec(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
        } catch {
          errorLog(`  ⚠ failed to restart agent — run: ${restart.join(' ')}`);
        }
      }
      log('✔ Agents relaunched.');
    } else {
      log(
        `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
          'Restart them to activate the update:\n    mosaic update --relaunch   ' +
          '(or: mosaic fleet restart <agent>)',
      );
    }
  }

  return { attempted: true, reseed, settingsGuard };
}

// ─── Framework drift detection (#642) ────────────────────────────────────────
//
// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic
// package itself is upgraded *within that command*. When the CLI is upgraded
// some OTHER way — a direct `npm i -g @mosaicstack/mosaic`, or an upgrade run
// where only sibling packages were outdated — the framework files in
// ~/.config/mosaic stay stale and shipped launcher/runtime fixes never
// activate. Comparing the on-disk framework schema version against the version
// bundled in the installed package detects exactly that situation.

/** Read the framework schema version recorded on disk (~/.config/mosaic/.framework-version). */
export function readInstalledFrameworkVersion(
  mosaicHome = join(homedir(), '.config', 'mosaic'),
): number | undefined {
  const vf = join(mosaicHome, '.framework-version');
  if (!existsSync(vf)) return undefined;
  try {
    const n = parseInt(readFileSync(vf, 'utf-8').trim(), 10);
    return Number.isFinite(n) ? n : undefined;
  } catch {
    return undefined;
  }
}

/**
 * Read the framework schema version shipped in the installed package by parsing
 * `FRAMEWORK_VERSION=<n>` out of the bundled install.sh (the authoritative
 * source the installer writes to .framework-version).
 */
export function readBundledFrameworkVersion(
  frameworkRoot = resolveBundledFrameworkRoot(),
): number | undefined {
  const installer = join(frameworkRoot, 'install.sh');
  if (!existsSync(installer)) return undefined;
  try {
    const m = readFileSync(installer, 'utf-8').match(/^\s*FRAMEWORK_VERSION=(\d+)/m);
    const raw = m?.[1];
    if (!raw) return undefined;
    const n = parseInt(raw, 10);
    return Number.isFinite(n) ? n : undefined;
  } catch {
    return undefined;
  }
}

export interface FrameworkDrift {
  /** True only when both versions are known AND the on-disk one is older. */
  drifted: boolean;
  installed?: number;
  bundled?: number;
}

/**
 * Detect whether the on-disk framework is older than the framework bundled in
 * the installed CLI (#642). Conservative: if either version can't be read the
 * result is no-drift, so a missing/unreadable version file never triggers an
 * unexpected re-seed.
 */
export function checkFrameworkDrift(
  mosaicHome = join(homedir(), '.config', 'mosaic'),
  frameworkRoot = resolveBundledFrameworkRoot(),
): FrameworkDrift {
  const installed = readInstalledFrameworkVersion(mosaicHome);
  const bundled = readBundledFrameworkVersion(frameworkRoot);
  const drifted =
    typeof installed === 'number' && typeof bundled === 'number' && installed < bundled;
  return { drifted, installed, bundled };
}

/**
 * Canonically parse the installed fleet roster for relaunch targets. JSON is
 * considered only when roster.yaml is genuinely absent; all other failures
 * return no targets rather than guessing.
 */
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
  try {
    const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
    const source = readFileSync(rosterPath, 'utf8');
    return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
      (agent) => agent.name,
    );
  } catch {
    return [];
  }
}

/**
 * Refresh the ACTIVE systemd user units from the freshly re-seeded copies.
 *
 * The re-seed updates `~/.config/mosaic/systemd/user/*.service`, but the units
 * systemd actually runs live at `~/.config/systemd/user/`. Without this copy,
 * shipped unit fixes (e.g. the socket-env change) never take effect after
 * `mosaic update` until `mosaic fleet install` is re-run. Best-effort + scoped:
 * only refreshes when a fleet is already installed (the active dir already
 * carries `mosaic-*` units), so non-fleet hosts are untouched.
 */
export function refreshActiveFleetUnits(
  mosaicHome = join(homedir(), '.config', 'mosaic'),
  env: NodeJS.ProcessEnv = process.env,
): { refreshed: string[]; ok: boolean; reason?: string } {
  const src = join(mosaicHome, 'systemd', 'user');
  const configHome = env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
  const dest = join(configHome, 'systemd', 'user');
  if (!existsSync(src)) return { refreshed: [], ok: true };
  // Only refresh when a fleet is already installed (active dir has mosaic units).
  const fleetInstalled =
    existsSync(dest) &&
    readdirSync(dest).some((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
  if (!fleetInstalled) return { refreshed: [], ok: true };
  const units = readdirSync(src).filter((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
  const refreshed: string[] = [];
  for (const unit of units) {
    try {
      copyFileSync(join(src, unit), join(dest, unit));
      refreshed.push(unit);
    } catch {
      // best-effort per unit
    }
  }
  try {
    execSync('systemctl --user daemon-reload', { stdio: 'ignore', timeout: 15_000 });
  } catch {
    // non-systemd host or no session bus — non-fatal
  }
  return { refreshed, ok: true };
}

/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
export function buildRelaunchCommands(agentNames: string[]): string[][] {
  return agentNames.map((name) => [
    'systemctl',
    '--user',
    'restart',
    `mosaic-agent@${name}.service`,
  ]);
}

/**
 * Format a table showing all packages with their current/latest versions.
 */
export function formatAllPackagesTable(results: PackageUpdateResult[]): string {
  if (results.length === 0) return 'No @mosaicstack/* packages found.';

  const nameWidth = Math.max(...results.map((r) => r.package.length), 10);
  const verWidth = 10;

  const header =
    '  ' +
    'Package'.padEnd(nameWidth + 2) +
    'Current'.padEnd(verWidth + 2) +
    'Latest'.padEnd(verWidth + 2) +
    'Status';
  const sep = '  ' + '-'.repeat(header.length - 2);

  const rows = results.map((r) => {
    const status = !r.current
      ? 'not installed'
      : r.updateAvailable
        ? '⬆ update available'
        : '✔ up to date';
    return (
      '  ' +
      r.package.padEnd(nameWidth + 2) +
      (r.current || '-').padEnd(verWidth + 2) +
      (r.latest || '-').padEnd(verWidth + 2) +
      status
    );
  });

  return [header, sep, ...rows].join('\n');
}
