/**
 * olam clean — reap orphan world filesystem state.
 *
 * The "no new orphans" half of the contract:
 *   - destroy.ts → destroyWorld() already calls fs.rmSync on the
 *     workspace dir, but the result is best-effort (try/catch swallows).
 *   - This command audits ~/.olam/worlds/<id>/ against the live
 *     registry + git worktree state, and either lists what's drift
 *     (default, dry-run) or reaps it (`--apply`).
 *
 * Categorisation of each on-disk world:
 *
 *   active     — registry knows about it AND status != destroyed.
 *                Always preserved. Excluded from --apply.
 *
 *   dirty      — git worktree registered AND has uncommitted changes
 *                or unpushed commits. Preserved by default; deletable
 *                via `--include-dirty` (with a final summary list so
 *                the operator sees what's about to disappear).
 *
 *   stale-wt   — git worktree registered but clean (no uncommits, no
 *                unpushed). Reaped via `git worktree remove --force`,
 *                which drops both the registration AND the directory.
 *
 *   orphan     — neither in the registry nor a registered worktree.
 *                Just a directory on disk. Reaped via `rm -rf`.
 *
 * Default invocation prints the table + total reclaimable bytes and
 * exits 0 without touching disk. `--apply` performs the reap.
 */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { loadContext } from '../context.js';
import { printError, printInfo } from '../output.js';
export function registerClean(program) {
    program
        .command('clean')
        .description('Reap orphan world filesystem state under ~/.olam/worlds/')
        .option('--apply', 'Actually delete the orphans (default is dry-run)', false)
        .option('--include-dirty', "Also delete worktrees with uncommitted changes (default preserves them)", false)
        .option('-y, --yes', 'Skip the confirmation prompt under --apply', false)
        .option('--json', 'Emit a machine-readable JSON inventory instead of the text table', false)
        .action(async (raw) => {
        const opts = {
            apply: raw.apply === true,
            includeDirty: raw.includeDirty === true,
            yes: raw.yes === true,
            json: raw.json === true,
        };
        const exitCode = await runClean(opts);
        process.exitCode = exitCode;
    });
}
export async function runClean(opts) {
    const { ctx, error } = await loadContext();
    if (!ctx) {
        printError(error?.message ?? 'Olam is not configured. Run `olam init` first.');
        return 1;
    }
    const worldsDir = path.join(os.homedir(), '.olam', 'worlds');
    if (!fs.existsSync(worldsDir)) {
        if (opts.json) {
            process.stdout.write(`${JSON.stringify({ worldsDir, entries: [] })}\n`);
        }
        else {
            printInfo('Clean', `No ${worldsDir} directory present.`);
        }
        return 0;
    }
    // Set of world ids the registry knows are still live (not destroyed).
    const liveIds = new Set();
    for (const w of ctx.worldManager.listWorlds()) {
        if (w.status !== 'destroyed')
            liveIds.add(w.id);
    }
    // Map of canonical-repo-managed worktrees, keyed by their path. We
    // can't tell from the operator's repo path alone what the canonical
    // repo is, so we resolve it lazily once: walk every world dir's
    // `olam/` subdir, and if it's a worktree, ask its parent.
    const worktreeMap = collectWorktrees(worldsDir);
    const entries = [];
    for (const id of fs.readdirSync(worldsDir).sort()) {
        const fullPath = path.join(worldsDir, id);
        const stat = safeStat(fullPath);
        if (!stat || !stat.isDirectory())
            continue;
        entries.push(classifyWorld({ id, fullPath, liveIds, worktreeMap }));
    }
    if (opts.json) {
        process.stdout.write(`${JSON.stringify({ worldsDir, entries }, null, 2)}\n`);
        return 0;
    }
    printTable(entries);
    // Deletable subset (for both dry-run summary and --apply path).
    const deletable = entries.filter((e) => isDeletable(e, opts));
    const reclaimable = deletable.reduce((acc, e) => acc + e.bytes, 0);
    process.stdout.write(`\n  Reclaimable: ${formatBytes(reclaimable)} across ${deletable.length} entries`);
    if (entries.length > deletable.length) {
        process.stdout.write(` (preserved ${entries.length - deletable.length}: live registry + ${opts.includeDirty ? 'none' : 'dirty worktrees'})`);
    }
    process.stdout.write('\n');
    if (!opts.apply) {
        process.stdout.write('\n  Dry-run only. Re-run with `--apply` to delete.\n');
        return 0;
    }
    if (deletable.length === 0) {
        process.stdout.write('  Nothing to delete. Exit clean.\n');
        return 0;
    }
    if (!opts.yes) {
        process.stdout.write(`\n  About to delete ${deletable.length} entries (${formatBytes(reclaimable)}). Pass --yes to skip this prompt.\n`);
        const confirmed = await confirmInteractive();
        if (!confirmed) {
            process.stdout.write('  Aborted.\n');
            return 0;
        }
    }
    let removedBytes = 0;
    let removedCount = 0;
    for (const entry of deletable) {
        const ok = reapEntry(entry);
        if (ok) {
            removedBytes += entry.bytes;
            removedCount += 1;
        }
        else {
            process.stderr.write(`  ! failed to reap ${entry.id}\n`);
        }
    }
    process.stdout.write(`\n  Reaped ${removedCount}/${deletable.length} entries · ${formatBytes(removedBytes)} reclaimed.\n`);
    return removedCount === deletable.length ? 0 : 1;
}
function classifyWorld(args) {
    const { id, fullPath, liveIds, worktreeMap } = args;
    const bytes = computeBytes(fullPath);
    if (liveIds.has(id)) {
        return { id, path: fullPath, bytes, category: 'active', note: 'in registry' };
    }
    const worktreeChild = path.join(fullPath, 'olam');
    const worktreeInfo = worktreeMap.get(worktreeChild);
    if (worktreeInfo) {
        if (worktreeInfo.dirty > 0 || worktreeInfo.unpushed > 0) {
            const flags = [];
            if (worktreeInfo.dirty > 0)
                flags.push(`dirty=${worktreeInfo.dirty}`);
            if (worktreeInfo.unpushed > 0)
                flags.push(`unpushed=${worktreeInfo.unpushed}`);
            return {
                id,
                path: fullPath,
                bytes,
                category: 'dirty',
                worktreePath: worktreeChild,
                note: `${worktreeInfo.branch} · ${flags.join(' ')}`,
            };
        }
        return {
            id,
            path: fullPath,
            bytes,
            category: 'stale-wt',
            worktreePath: worktreeChild,
            note: worktreeInfo.branch,
        };
    }
    return { id, path: fullPath, bytes, category: 'orphan' };
}
function isDeletable(entry, opts) {
    if (entry.category === 'active')
        return false;
    if (entry.category === 'dirty' && !opts.includeDirty)
        return false;
    return true;
}
function reapEntry(entry) {
    if (entry.worktreePath) {
        // Try a clean `git worktree remove --force` first — that drops the
        // git registration AND the directory in one call. Fall back to
        // raw rm if git can't find the canonical repo (e.g. deleted).
        try {
            const gitDir = resolveGitDirForWorktree(entry.worktreePath);
            if (gitDir) {
                execFileSync('git', ['worktree', 'remove', '--force', entry.worktreePath], {
                    cwd: gitDir,
                    stdio: 'pipe',
                });
            }
        }
        catch {
            // Fallthrough to rm.
        }
    }
    try {
        fs.rmSync(entry.path, { recursive: true, force: true });
    }
    catch (err) {
        process.stderr.write(`  ! rm ${entry.path}: ${err instanceof Error ? err.message : String(err)}\n`);
        return false;
    }
    // Final sanity check.
    return !fs.existsSync(entry.path);
}
// ---------------------------------------------------------------------------
// Worktree discovery
// ---------------------------------------------------------------------------
/**
 * Walk every world's `olam/` subdir, ask its `.git` file what canonical
 * repo it refers to, and bucket worktrees by absolute path. Cheap enough
 * to do every invocation — there are typically <500 dirs.
 */
function collectWorktrees(worldsDir) {
    const out = new Map();
    for (const id of fs.readdirSync(worldsDir)) {
        // Per-world layout: ~/.olam/worlds/<id>/<repo>/. We're only auditing
        // the canonical "olam" worktree to detect dirty / unpushed; non-olam
        // repos under the same world dir are reaped together with the world.
        const child = path.join(worldsDir, id, 'olam');
        const gitMarker = path.join(child, '.git');
        if (!fs.existsSync(gitMarker))
            continue;
        const gitDir = resolveGitDirForWorktree(child);
        if (!gitDir)
            continue;
        const branch = readBranch(child);
        const dirty = countDirty(child);
        const unpushed = countUnpushed(child);
        out.set(child, { branch, dirty, unpushed, gitDir });
    }
    return out;
}
function resolveGitDirForWorktree(worktreePath) {
    const gitMarker = path.join(worktreePath, '.git');
    // .git inside a worktree is a file with `gitdir: <abs-path>`. We don't
    // need to read it — `git rev-parse --show-toplevel` from inside the
    // worktree resolves the canonical repo for us.
    try {
        const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
            cwd: worktreePath,
            encoding: 'utf-8',
            stdio: 'pipe',
        }).trim();
        if (!top)
            return null;
        // For worktrees, --show-toplevel returns the worktree root; the
        // canonical repo is one level up via --git-common-dir.
        const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
            cwd: worktreePath,
            encoding: 'utf-8',
            stdio: 'pipe',
        }).trim();
        if (!common)
            return top;
        return path.dirname(path.resolve(worktreePath, common));
    }
    catch {
        return null;
    }
    // (gitMarker referenced for readability above; the lookup uses git itself)
    void gitMarker;
}
function readBranch(worktreePath) {
    try {
        return execFileSync('git', ['branch', '--show-current'], {
            cwd: worktreePath,
            encoding: 'utf-8',
            stdio: 'pipe',
        }).trim() || '(detached)';
    }
    catch {
        return '(unknown)';
    }
}
function countDirty(worktreePath) {
    try {
        const out = execFileSync('git', ['status', '--porcelain'], {
            cwd: worktreePath,
            encoding: 'utf-8',
            stdio: 'pipe',
        });
        return out.split('\n').filter((l) => l.trim()).length;
    }
    catch {
        return 0;
    }
}
function countUnpushed(worktreePath) {
    try {
        const out = execFileSync('git', ['log', '@{u}..HEAD', '--oneline'], {
            cwd: worktreePath,
            encoding: 'utf-8',
            stdio: 'pipe',
        });
        return out.split('\n').filter((l) => l.trim()).length;
    }
    catch {
        // No upstream → can't measure; treat as 0 to avoid false positives.
        return 0;
    }
}
// ---------------------------------------------------------------------------
// Disk + formatting helpers
// ---------------------------------------------------------------------------
function safeStat(p) {
    try {
        return fs.statSync(p);
    }
    catch {
        return null;
    }
}
function computeBytes(p) {
    let total = 0;
    const stack = [p];
    while (stack.length > 0) {
        const cur = stack.pop();
        let st;
        try {
            st = fs.lstatSync(cur);
        }
        catch {
            continue;
        }
        if (st.isSymbolicLink())
            continue;
        if (st.isDirectory()) {
            let entries;
            try {
                entries = fs.readdirSync(cur);
            }
            catch {
                continue;
            }
            for (const name of entries)
                stack.push(path.join(cur, name));
        }
        else {
            total += st.size;
        }
    }
    return total;
}
function formatBytes(n) {
    if (n < 1024)
        return `${n}B`;
    if (n < 1024 * 1024)
        return `${(n / 1024).toFixed(1)}KB`;
    if (n < 1024 * 1024 * 1024)
        return `${(n / 1024 / 1024).toFixed(1)}MB`;
    return `${(n / 1024 / 1024 / 1024).toFixed(2)}GB`;
}
function printTable(entries) {
    if (entries.length === 0) {
        process.stdout.write('  (no worlds on disk)\n');
        return;
    }
    // Header.
    process.stdout.write(`  ${'category'.padEnd(10)}  ${'size'.padStart(10)}  ${'world id'.padEnd(28)}  note\n`);
    process.stdout.write(`  ${'-'.repeat(10)}  ${'-'.repeat(10)}  ${'-'.repeat(28)}  ${'-'.repeat(40)}\n`);
    for (const e of entries) {
        process.stdout.write(`  ${e.category.padEnd(10)}  ${formatBytes(e.bytes).padStart(10)}  ${e.id.padEnd(28)}  ${e.note ?? ''}\n`);
    }
}
async function confirmInteractive() {
    process.stdout.write('  Type `yes` to proceed: ');
    const buf = [];
    return new Promise((resolve) => {
        const onData = (chunk) => {
            buf.push(chunk);
            if (Buffer.concat(buf).toString('utf-8').includes('\n')) {
                process.stdin.removeListener('data', onData);
                process.stdin.pause();
                const answer = Buffer.concat(buf).toString('utf-8').trim();
                resolve(answer.toLowerCase() === 'yes');
            }
        };
        process.stdin.resume();
        process.stdin.on('data', onData);
    });
}
//# sourceMappingURL=clean.js.map