/**
 * `olam world snapshot` — Phase A snapshot capture/list commands.
 *
 * Subcommands:
 *   create <worldId> --kind gems|node|pg|all
 *     Capture installed state (gems, node_modules, postgres data) from a
 *     running world into tarballs under ~/.olam/snapshots/<worldId>/.
 *     Writes a manifest JSON alongside each tarball.
 *
 *   list [--world <id>]
 *     List captured snapshots with fingerprints, sizes, and ages.
 *
 * Phase B (auto-restore on create) and Phase C (eviction / auto-capture)
 * are NOT wired here — they live in follow-up PRs.
 */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import pc from 'picocolors';
import { computeGemsFingerprint, computeNodeFingerprint, computePgFingerprint, evictOldSnapshotsWithFlock, inspectSnapshotsForRepos, listSnapshots, packTarball, snapshotsDir, snapshotTarPath, writeManifest, } from '@olam/core/src/world/snapshot.js';
import { printError, printSuccess, printWarning, printHeader } from '../output.js';
import { getOrCreateWorldCommand } from './world.js';
// ---------------------------------------------------------------------------
// Phase D D1 — deprecation warning + local counter
//
// The world-snapshot CLI is being phased out (Phase 1 warm-create now
// auto-captures + auto-restores). This helper emits a structured stderr
// warning + increments a local counter at ~/.olam/snapshots/.deprecation-
// counter (mode 0600). Counter is NEVER transmitted; it's local-only
// telemetry to inform the post-1.5 deletion follow-on plan.
//
// Per umbrella plan Phase 1.5 + D22 (≥1-operator-surfaces-any-usage gate
// before deletion): if an operator runs the CLI even once during the
// 30-day Slack monitoring window, the deletion follow-on plan is
// HALTED and we re-investigate.
//
// Internal callers (e.g. `spawnAutoCapture` in
// packages/core/src/world/snapshot.ts) set `OLAM_INTERNAL_SNAPSHOT=1`
// on the spawn env. The warning still emits to stderr (suppressed by
// stdio: 'ignore' on the parent side), but the counter does NOT
// increment — see CP3 review HIGH finding (counter inflation breaks
// D22 signal integrity). Only operator-driven invocations bump the
// counter.
// ---------------------------------------------------------------------------
const DEPRECATION_WARNING_PREFIX = '[deprecated]';
const INTERNAL_SENTINEL_ENV = 'OLAM_INTERNAL_SNAPSHOT';
export function emitDeprecationWarning(subcommand) {
    process.stderr.write(`${DEPRECATION_WARNING_PREFIX} \`olam world snapshot ${subcommand}\` is being phased out — Phase 1 warm-create now restores snapshots automatically on world-create. This CLI will be removed in a future release. See ~/.claude/plans/olam-3-org-fatbox-runtime.md § Phase 1.5 for context.\n`);
    bumpDeprecationCounter();
}
function bumpDeprecationCounter() {
    // Internal-call sentinel — set by spawnAutoCapture in
    // packages/core/src/world/snapshot.ts. Skipping the bump preserves
    // D22's signal integrity (the counter measures operator-driven
    // invocations, not auto-capture-triggered ones).
    if (process.env[INTERNAL_SENTINEL_ENV] === '1')
        return;
    const counterPath = path.join(snapshotsDir(), '.deprecation-counter');
    try {
        fs.mkdirSync(path.dirname(counterPath), { recursive: true });
        let current = 0;
        if (fs.existsSync(counterPath)) {
            const raw = fs.readFileSync(counterPath, 'utf-8').trim();
            const parsed = parseInt(raw, 10);
            if (Number.isInteger(parsed) && parsed >= 0)
                current = parsed;
        }
        fs.writeFileSync(counterPath, String(current + 1), { mode: 0o600 });
    }
    catch {
        // Best-effort: a telemetry counter must NEVER block or fail the CLI.
    }
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
export function registerWorldSnapshot(program) {
    const world = getOrCreateWorldCommand(program);
    const snapshot = world
        .command('snapshot')
        .description('Manage world snapshots for fast boot');
    // ── create ────────────────────────────────────────────────────────────────
    snapshot
        .command('create <worldId>')
        .description('Capture installed state from a running world into tarballs')
        .option('--kind <kind>', 'Layer(s) to capture: gems, node, pg, or all', 'all')
        .action(async (worldId, opts) => {
        await handleCreate(worldId, opts.kind);
    });
    // ── list ──────────────────────────────────────────────────────────────────
    snapshot
        .command('list')
        .description('List captured snapshots')
        .option('--world <id>', 'Filter to a specific world id')
        .action((opts) => {
        handleList(opts.world);
    });
    // ── inspect ─────────────────────────────────────────────────────────────────
    // Read-only diagnostic: would the next `olam create` hit or miss the warm
    // snapshot cache, and (on miss) why? Mutates nothing — no extract, no evict.
    snapshot
        .command('inspect')
        .description('Diagnose warm-create cache hits/misses for a workspace (read-only; mutates nothing)')
        .option('--workspace <name>', 'Workspace key in the snapshot layout', 'default')
        .option('--workspace-path <path>', 'Filesystem path holding the repo worktrees (default: cwd)')
        .requiredOption('--repo <name>', 'Repo to inspect (repeatable). Folder name under --workspace-path.', collectRepo, [])
        .option('--image-digest <sha256:...>', 'Pulled devbox image digest to fold into the fingerprint (D24). Omit to match digest-less captures.')
        .action((opts) => {
        handleInspect(opts);
    });
    // ── evict ─────────────────────────────────────────────────────────────────
    // Phase C C9: per-host flocked LRU eviction with --dry-run preview.
    snapshot
        .command('evict')
        .description('Evict oldest snapshots until total size ≤ cap (default 5GB; override via OLAM_SNAPSHOT_MAX_BYTES)')
        .option('--max-bytes <N>', 'Cap in bytes (default: $OLAM_SNAPSHOT_MAX_BYTES or 5GB)')
        .option('--dry-run', 'List what would be evicted; do not delete', false)
        .action(async (opts) => {
        await handleEvict({ maxBytes: opts.maxBytes, dryRun: !!opts.dryRun });
    });
}
// ---------------------------------------------------------------------------
// create handler
// ---------------------------------------------------------------------------
async function handleCreate(worldId, kindArg) {
    emitDeprecationWarning('create');
    const kinds = resolveKinds(kindArg);
    if (kinds.length === 0) {
        printError(`Unknown --kind "${kindArg}". Use: gems, node, pg, or all.`);
        process.exitCode = 1;
        return;
    }
    // Load world metadata to get workspacePath + repo list.
    let workspacePath;
    let repoNames;
    try {
        ({ workspacePath, repoNames } = await loadWorldMeta(worldId));
    }
    catch (err) {
        printError(err instanceof Error ? err.message : String(err));
        process.exitCode = 1;
        return;
    }
    const results = [];
    for (const kind of kinds) {
        if (kind === 'gems') {
            for (const repo of repoNames) {
                const result = await captureGems(worldId, workspacePath, repo);
                results.push({ kind, repo, ...result });
            }
        }
        else if (kind === 'node') {
            for (const repo of repoNames) {
                const result = await captureNode(worldId, workspacePath, repo);
                results.push({ kind, repo, ...result });
            }
        }
        else if (kind === 'pg') {
            const result = await capturePg(worldId, workspacePath, repoNames);
            results.push({ kind, repo: null, ...result });
        }
    }
    // Print results.
    console.log();
    printHeader(`Snapshot results for ${worldId}`);
    console.log();
    for (const r of results) {
        const label = r.repo ? `${r.kind}/${r.repo}` : r.kind;
        if (r.ok) {
            printSuccess(`${label}`);
            console.log(`         ${pc.dim(r.tarPath)}`);
        }
        else {
            printWarning(`${label}: ${r.msg ?? 'skipped'}`);
        }
    }
    console.log();
}
function resolveKinds(arg) {
    if (arg === 'all')
        return ['gems', 'node', 'pg'];
    const valid = ['gems', 'node', 'pg'];
    if (valid.includes(arg))
        return [arg];
    return [];
}
// ---------------------------------------------------------------------------
// Per-kind capture helpers
// ---------------------------------------------------------------------------
async function captureGems(worldId, workspacePath, repo) {
    const repoDir = path.join(workspacePath, repo);
    const fingerprint = computeGemsFingerprint(repoDir);
    if (!fingerprint) {
        return { ok: false, tarPath: '', msg: 'no Gemfile.lock — layer does not apply' };
    }
    const tarPath = snapshotTarPath(worldId, 'gems', repo, fingerprint);
    // Prefer vendor/bundle (on bind mount — directly accessible on host).
    // Fall back to bundler config inside the devbox container.
    const vendorBundle = path.join(repoDir, 'vendor', 'bundle');
    if (fs.existsSync(vendorBundle)) {
        try {
            packTarball(vendorBundle, tarPath);
            const stat = fs.statSync(tarPath);
            const manifest = {
                kind: 'gems',
                worldId,
                repo,
                fingerprint,
                sizeBytes: stat.size,
                tarPath,
                createdAt: new Date().toISOString(),
            };
            writeManifest(manifest, tarPath);
            return { ok: true, tarPath };
        }
        catch (err) {
            return { ok: false, tarPath, msg: `pack failed: ${String(err)}` };
        }
    }
    // vendor/bundle not found — exec into container to find $BUNDLE_PATH.
    const containerName = `olam-${worldId}-devbox`;
    let bundlePath;
    try {
        bundlePath = execSync(`docker exec ${containerName} sh -c 'bundle config get path 2>/dev/null || echo ~/.bundle'`, { encoding: 'utf-8', timeout: 10_000 }).trim();
        if (!bundlePath)
            bundlePath = `/home/olam/.bundle`;
    }
    catch {
        return { ok: false, tarPath, msg: 'container not reachable; start the world first' };
    }
    // Pack via docker exec to avoid host-path issues for container-internal dirs.
    try {
        const tmpTar = `/tmp/olam-snap-gems-${repo}-${fingerprint}.tar.gz`;
        execSync(`docker exec ${containerName} sh -c 'mkdir -p "$(dirname ${tmpTar})" && tar -czf ${tmpTar}.tmp -C ${bundlePath} . && mv ${tmpTar}.tmp ${tmpTar}'`, { stdio: 'pipe', timeout: 120_000 });
        fs.mkdirSync(path.dirname(tarPath), { recursive: true });
        execSync(`docker cp ${containerName}:${tmpTar} "${tarPath}"`, { stdio: 'pipe', timeout: 120_000 });
        execSync(`docker exec ${containerName} rm -f ${tmpTar}`, { stdio: 'pipe' });
        const stat = fs.statSync(tarPath);
        const manifest = {
            kind: 'gems',
            worldId,
            repo,
            fingerprint,
            sizeBytes: stat.size,
            tarPath,
            createdAt: new Date().toISOString(),
        };
        writeManifest(manifest, tarPath);
        return { ok: true, tarPath };
    }
    catch (err) {
        return { ok: false, tarPath, msg: `docker capture failed: ${String(err)}` };
    }
}
async function captureNode(worldId, workspacePath, repo) {
    const repoDir = path.join(workspacePath, repo);
    const fingerprint = computeNodeFingerprint(repoDir);
    if (!fingerprint) {
        return { ok: false, tarPath: '', msg: 'no lockfile — layer does not apply' };
    }
    const nodeModules = path.join(repoDir, 'node_modules');
    if (!fs.existsSync(nodeModules)) {
        return { ok: false, tarPath: '', msg: 'node_modules not installed yet' };
    }
    const tarPath = snapshotTarPath(worldId, 'node', repo, fingerprint);
    try {
        packTarball(nodeModules, tarPath);
        const stat = fs.statSync(tarPath);
        const manifest = {
            kind: 'node',
            worldId,
            repo,
            fingerprint,
            sizeBytes: stat.size,
            tarPath,
            createdAt: new Date().toISOString(),
        };
        writeManifest(manifest, tarPath);
        return { ok: true, tarPath };
    }
    catch (err) {
        return { ok: false, tarPath, msg: `pack failed: ${String(err)}` };
    }
}
async function capturePg(worldId, workspacePath, repoNames) {
    const repoDirs = repoNames.map((r) => path.join(workspacePath, r));
    const fingerprint = computePgFingerprint(repoDirs);
    if (!fingerprint) {
        return { ok: false, tarPath: '', msg: 'no Gemfile.lock / schema.rb — layer does not apply' };
    }
    const tarPath = snapshotTarPath(worldId, 'pg', null, fingerprint);
    const containerName = `olam-${worldId}-postgres`;
    const volumeName = `olam-${worldId}-postgres-data`;
    // Verify postgres container/volume exists.
    try {
        execSync(`docker inspect ${containerName}`, { stdio: 'pipe', timeout: 5_000 });
    }
    catch {
        return { ok: false, tarPath, msg: 'postgres container not found; world may not use postgres' };
    }
    // Stop postgres, snapshot PGDATA via an alpine sidecar, restart.
    // Using raw PGDATA rather than pg_dumpall for byte-exact restore in Phase B.
    try {
        execSync(`docker stop ${containerName}`, { stdio: 'pipe', timeout: 30_000 });
        fs.mkdirSync(path.dirname(tarPath), { recursive: true });
        execSync(`docker run --rm \
        -v "${volumeName}:/pgdata:ro" \
        -v "${path.dirname(tarPath)}:/dest" \
        alpine \
        sh -c 'tar -czf /dest/${path.basename(tarPath)}.tmp -C /pgdata . && mv /dest/${path.basename(tarPath)}.tmp /dest/${path.basename(tarPath)}'`, { stdio: 'pipe', timeout: 180_000 });
        execSync(`docker start ${containerName}`, { stdio: 'pipe', timeout: 30_000 });
        const stat = fs.statSync(tarPath);
        const manifest = {
            kind: 'pg',
            worldId,
            fingerprint,
            sizeBytes: stat.size,
            tarPath,
            createdAt: new Date().toISOString(),
        };
        writeManifest(manifest, tarPath);
        return { ok: true, tarPath };
    }
    catch (err) {
        // Always try to restart postgres on failure.
        try {
            execSync(`docker start ${containerName}`, { stdio: 'pipe', timeout: 10_000 });
        }
        catch { /* best-effort */ }
        return { ok: false, tarPath, msg: `pg capture failed: ${String(err)}` };
    }
}
// ---------------------------------------------------------------------------
// list handler
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// inspect handler (read-only warm-create diagnostic)
// ---------------------------------------------------------------------------
/** Commander accumulator for repeatable `--repo` flags. */
function collectRepo(value, previous) {
    return [...previous, value];
}
/**
 * Map an inspect miss-reason to a one-line, operator-actionable explanation.
 * Mirrors the FAQ's "Why did my world-create take the cold path?" list in
 * docs/architecture/snapshot-restore.md.
 */
export function explainInspectReason(outcome) {
    switch (outcome.reason) {
        case 'no-lockfile':
            return 'no lockfile in repo — this layer does not apply';
        case 'no-tarball':
            return 'no capture for this fingerprint (image-retag, lockfile change, or capture not yet complete)';
        case 'arch-mismatch':
            return 'capture exists but was built on a different CPU arch — refused to avoid native-extension corruption';
        case 'no-manifest':
            return 'capture tarball present but its manifest is missing/corrupt — restore would refuse';
        default:
            return 'cold install will run';
    }
}
export function handleInspect(opts) {
    const workspacePath = opts.workspacePath
        ? path.resolve(opts.workspacePath)
        : process.cwd();
    if (opts.repo.length === 0) {
        printError('At least one --repo is required.');
        process.exitCode = 1;
        return;
    }
    const result = inspectSnapshotsForRepos({
        workspace: opts.workspace,
        arch: process.arch,
        imageDigest: opts.imageDigest,
        repos: opts.repo.map((name) => ({
            name,
            worktreeDir: path.join(workspacePath, name),
        })),
    });
    const hits = result.outcomes.filter((o) => o.outcome === 'hit').length;
    const total = result.outcomes.length;
    console.log();
    printHeader(`Warm-create inspect — workspace=${opts.workspace} arch=${process.arch} (${hits}/${total} would hit)`);
    console.log();
    for (const out of result.outcomes) {
        const label = `${out.kind}/${out.repo}`;
        if (out.outcome === 'hit') {
            const size = out.sizeBytes !== undefined ? ` (${formatBytes(out.sizeBytes)})` : '';
            printSuccess(`${label.padEnd(24)} HIT  ${pc.dim(out.fingerprint ?? '')}${size}`);
        }
        else {
            printWarning(`${label.padEnd(24)} MISS ${explainInspectReason(out)}`);
            if (out.expectedTarPath) {
                console.log(`         ${pc.dim('expected: ' + out.expectedTarPath)}`);
            }
        }
    }
    console.log();
    if (hits === 0) {
        console.log(pc.dim('No warm snapshot would be restored — the next `olam create` takes the cold path. See docs/architecture/snapshot-restore.md § FAQ.'));
    }
}
// ---------------------------------------------------------------------------
// evict handler (Phase C C9)
// ---------------------------------------------------------------------------
async function handleEvict(opts) {
    const cap = opts.maxBytes
        ? parseInt(opts.maxBytes, 10)
        : parseInt(process.env['OLAM_SNAPSHOT_MAX_BYTES'] ?? '', 10);
    const maxBytes = Number.isInteger(cap) && cap > 0 ? cap : 5 * 1024 * 1024 * 1024;
    if (opts.dryRun) {
        // Walk the snapshot dir, sort oldest-first, simulate eviction.
        const root = snapshotsDir();
        if (!fs.existsSync(root)) {
            console.log(pc.dim('No snapshot dir; nothing to evict.'));
            return;
        }
        const allTars = [];
        const walk = (d) => {
            for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
                const full = path.join(d, entry.name);
                if (entry.isDirectory()) {
                    walk(full);
                }
                else if (entry.name.endsWith('.tar.gz')) {
                    const stat = fs.statSync(full);
                    allTars.push({ path: full, size: stat.size, mtime: stat.mtimeMs });
                }
            }
        };
        walk(root);
        const total = allTars.reduce((a, t) => a + t.size, 0);
        if (total <= maxBytes) {
            console.log(`${formatBytes(total)} total ≤ ${formatBytes(maxBytes)} cap; nothing to evict.`);
            return;
        }
        allTars.sort((a, b) => a.mtime - b.mtime);
        const toEvict = [];
        let remaining = total;
        for (const t of allTars) {
            if (remaining <= maxBytes)
                break;
            toEvict.push(t);
            remaining -= t.size;
        }
        printHeader(`Would evict ${toEvict.length} snapshot(s) (${formatBytes(total - remaining)})`);
        console.log();
        for (const t of toEvict) {
            const age = formatAge(Date.now() - t.mtime);
            console.log(`  ${pc.dim(formatBytes(t.size).padStart(8))}  ${age.padStart(10)}  ${t.path}`);
        }
        console.log();
        console.log(pc.dim(`Total after eviction: ${formatBytes(remaining)} (cap: ${formatBytes(maxBytes)})`));
        return;
    }
    // Live eviction with flock.
    const freed = evictOldSnapshotsWithFlock(maxBytes);
    if (freed > 0) {
        printSuccess(`Evicted ${formatBytes(freed)} (cap: ${formatBytes(maxBytes)})`);
    }
    else {
        console.log(pc.dim('No eviction needed (or another process held the lock).'));
    }
}
function handleList(worldIdFilter) {
    emitDeprecationWarning('list');
    const entries = listSnapshots(worldIdFilter);
    if (entries.length === 0) {
        const where = worldIdFilter ? ` for world "${worldIdFilter}"` : '';
        console.log(pc.dim(`No snapshots found${where}. Run \`olam world snapshot create <worldId>\` first.`));
        return;
    }
    printHeader(`${entries.length} snapshot(s)`);
    console.log();
    let lastWorldId = '';
    for (const entry of entries) {
        const { manifest } = entry;
        if (manifest.worldId !== lastWorldId) {
            console.log(pc.bold(manifest.worldId));
            lastWorldId = manifest.worldId;
        }
        const repo = manifest.repo ?? '(all repos)';
        const size = formatBytes(manifest.sizeBytes);
        const age = formatAge(entry.ageMs);
        const kindColor = manifest.kind === 'gems'
            ? pc.magenta(manifest.kind)
            : manifest.kind === 'node'
                ? pc.cyan(manifest.kind)
                : pc.yellow(manifest.kind);
        console.log(`  ${kindColor.padEnd(6)}  ${pc.dim(repo.padEnd(24))}  ${manifest.fingerprint}  ${size.padStart(8)}  ${age}`);
    }
    console.log();
}
// ---------------------------------------------------------------------------
// World metadata loading
// ---------------------------------------------------------------------------
async function loadWorldMeta(worldId) {
    const { loadContext } = await import('../context.js');
    const { ctx, error } = await loadContext();
    if (!ctx) {
        throw new Error(error?.message ?? 'Olam is not configured. Run `olam init` first.');
    }
    const world = ctx.worldManager.getWorld(worldId);
    if (!world) {
        throw new Error(`World "${worldId}" not found. Run \`olam list\` to see active worlds.`);
    }
    return { workspacePath: world.workspacePath, repoNames: [...world.repos] };
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes) {
    if (bytes < 1024)
        return `${bytes}B`;
    if (bytes < 1024 * 1024)
        return `${(bytes / 1024).toFixed(1)}KB`;
    if (bytes < 1024 * 1024 * 1024)
        return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
}
function formatAge(ms) {
    const minutes = Math.floor(ms / 60_000);
    if (minutes < 60)
        return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    if (hours < 24)
        return `${hours}h ago`;
    const days = Math.floor(hours / 24);
    return `${days}d ago`;
}
//# sourceMappingURL=world-snapshot.js.map