/**
 * fs-purge — filesystem removal for uninstall + cleanup.
 *
 * Two modes:
 *   - purge:   nuke a path completely, report bytes reclaimed
 *   - sweep:   remove entries under a root that exceed TTL / count / bytes budgets
 *
 * Every removal is bounded to known Nexus paths (tmp prefixes + state dir
 * children). Callers must never pass an arbitrary path — state-locator.ts is
 * the source of truth.
 */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getNexusStateDir, getRuntimeTmpRoots, getWorktreeRoots, NEXUS_TMP_PREFIXES, NEXUS_TMP_GLOB_PREFIXES, } from './state-locator.js';
export function dirBytes(target) {
    try {
        const stat = fs.lstatSync(target);
        if (stat.isSymbolicLink())
            return 0;
        if (stat.isFile())
            return stat.size;
        if (!stat.isDirectory())
            return 0;
        let total = 0;
        const stack = [target];
        while (stack.length) {
            const cur = stack.pop();
            let entries = [];
            try {
                entries = fs.readdirSync(cur, { withFileTypes: true });
            }
            catch {
                continue;
            }
            for (const entry of entries) {
                const abs = path.join(cur, entry.name);
                try {
                    const st = fs.lstatSync(abs);
                    if (st.isDirectory())
                        stack.push(abs);
                    else if (st.isFile())
                        total += st.size;
                }
                catch {
                    // unreadable — skip
                }
            }
        }
        return total;
    }
    catch {
        return 0;
    }
}
function classify(target) {
    try {
        const st = fs.lstatSync(target);
        if (st.isDirectory())
            return 'dir';
        if (st.isFile() || st.isSymbolicLink())
            return 'file';
        return 'file';
    }
    catch {
        return 'missing';
    }
}
export function purgePath(target, scope, options = {}) {
    const dryRun = options.dryRun ?? false;
    const kind = classify(target);
    if (kind === 'missing') {
        return { path: target, scope, kind, bytes: 0, removed: false };
    }
    const bytes = dirBytes(target);
    if (dryRun) {
        return { path: target, scope, kind, bytes, removed: false };
    }
    try {
        fs.rmSync(target, { recursive: true, force: true });
        return { path: target, scope, kind, bytes, removed: true };
    }
    catch (err) {
        return {
            path: target,
            scope,
            kind,
            bytes,
            removed: false,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
/** Purge every entry returned by enumerateStatePaths, plus manifest-recorded ones. */
export function purgeStatePaths(extras = [], options = {}) {
    const stateDir = getNexusStateDir();
    const entries = [
        { path: stateDir, scope: 'state', optional: true },
        ...extras.map((e) => ({ path: e.path, scope: e.scope, optional: true })),
    ];
    const seen = new Set();
    const ops = [];
    let totalBytesRemoved = 0;
    let totalBytesSkipped = 0;
    for (const entry of entries) {
        if (seen.has(entry.path))
            continue;
        seen.add(entry.path);
        const op = purgePath(entry.path, entry.scope, options);
        ops.push(op);
        if (op.removed || options.dryRun)
            totalBytesRemoved += op.bytes;
        else
            totalBytesSkipped += op.bytes;
    }
    return { ops, totalBytesRemoved, totalBytesSkipped, leftovers: collectLeftovers(stateDir), dryRun: options.dryRun ?? false };
}
/** Purge every known Nexus tmp root. */
export function purgeTmpRoots(options = {}) {
    const tmp = os.tmpdir();
    const roots = new Set();
    for (const prefix of NEXUS_TMP_PREFIXES)
        roots.add(path.join(tmp, prefix));
    for (const root of getWorktreeRoots())
        roots.add(root);
    for (const root of getRuntimeTmpRoots())
        roots.add(root);
    const ops = [];
    let totalBytesRemoved = 0;
    let totalBytesSkipped = 0;
    for (const root of roots) {
        const op = purgePath(root, 'runtime', options);
        ops.push(op);
        if (op.removed || options.dryRun)
            totalBytesRemoved += op.bytes;
        else
            totalBytesSkipped += op.bytes;
    }
    // Glob-prefixed siblings (e.g. nexus-prime-feature-registry-<id>)
    try {
        for (const entry of fs.readdirSync(tmp)) {
            if (!NEXUS_TMP_GLOB_PREFIXES.some((p) => entry.startsWith(p)))
                continue;
            const target = path.join(tmp, entry);
            const op = purgePath(target, 'runtime', options);
            ops.push(op);
            if (op.removed || options.dryRun)
                totalBytesRemoved += op.bytes;
            else
                totalBytesSkipped += op.bytes;
        }
    }
    catch {
        // tmp dir unreadable — nothing to do
    }
    return { ops, totalBytesRemoved, totalBytesSkipped, leftovers: [], dryRun: options.dryRun ?? false };
}
/** Report paths that remain under the state dir after a purge attempt. */
export function collectLeftovers(stateDir = getNexusStateDir()) {
    try {
        if (!fs.existsSync(stateDir))
            return [];
        return fs.readdirSync(stateDir).map((n) => path.join(stateDir, n));
    }
    catch {
        return [];
    }
}
export function sweepDirectory(root, opts) {
    const dryRun = opts.dryRun ?? false;
    const maxAgeMs = opts.maxAgeMs ?? Infinity;
    const maxCount = opts.maxCount ?? Infinity;
    const maxBytes = opts.maxBytes ?? Infinity;
    const report = {
        root,
        removed: [],
        totalBytesBefore: 0,
        totalBytesAfter: 0,
        entriesBefore: 0,
        entriesAfter: 0,
        dryRun,
    };
    if (!fs.existsSync(root))
        return report;
    let entries = [];
    try {
        entries = fs.readdirSync(root);
    }
    catch {
        return report;
    }
    const decorated = entries
        .map((name) => {
        const p = path.join(root, name);
        try {
            const st = fs.statSync(p);
            return { path: p, name, mtime: st.mtimeMs, bytes: dirBytes(p) };
        }
        catch {
            return null;
        }
    })
        .filter((v) => v !== null)
        .sort((a, b) => a.mtime - b.mtime); // oldest first
    report.entriesBefore = decorated.length;
    report.totalBytesBefore = decorated.reduce((s, e) => s + e.bytes, 0);
    const now = Date.now();
    const remaining = [];
    const removePath = (item, reason) => {
        if (dryRun) {
            report.removed.push({ path: item.path, bytes: item.bytes, reason });
            return;
        }
        try {
            fs.rmSync(item.path, { recursive: true, force: true });
            report.removed.push({ path: item.path, bytes: item.bytes, reason });
        }
        catch {
            // best-effort: keep the entry counted against budgets
            remaining.push(item);
        }
    };
    for (const entry of decorated) {
        if (Number.isFinite(maxAgeMs) && now - entry.mtime > maxAgeMs) {
            removePath(entry, 'age');
        }
        else {
            remaining.push(entry);
        }
    }
    // Count budget
    while (remaining.length > maxCount) {
        const victim = remaining.shift();
        if (!victim)
            break;
        removePath(victim, 'count');
    }
    // Bytes budget — remove oldest first until under budget
    let totalBytes = remaining.reduce((s, e) => s + e.bytes, 0);
    while (totalBytes > maxBytes && remaining.length) {
        const victim = remaining.shift();
        if (!victim)
            break;
        removePath(victim, 'bytes');
        totalBytes -= victim.bytes;
    }
    report.entriesAfter = remaining.length;
    report.totalBytesAfter = totalBytes;
    return report;
}
/** Remove any git-worktree directory that no longer appears in `git worktree list`. */
export async function sweepOrphanWorktrees(repoRoot, options = {}) {
    const dryRun = options.dryRun ?? false;
    const ops = [];
    const { execFile } = await import('child_process');
    const { promisify } = await import('util');
    const execFileP = promisify(execFile);
    // Ask git which worktrees it knows about. Any dir under known roots not in this set is orphan.
    const known = new Set();
    try {
        const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot, maxBuffer: 4 * 1024 * 1024 });
        for (const line of stdout.split('\n')) {
            if (line.startsWith('worktree '))
                known.add(path.normalize(line.slice('worktree '.length).trim()));
        }
    }
    catch {
        // Not a git repo or git unavailable — skip orphan sweep.
        return { ops, totalBytesRemoved: 0, totalBytesSkipped: 0, leftovers: [], dryRun };
    }
    let totalBytesRemoved = 0;
    for (const root of getWorktreeRoots()) {
        if (!fs.existsSync(root))
            continue;
        let entries = [];
        try {
            entries = fs.readdirSync(root);
        }
        catch {
            continue;
        }
        for (const name of entries) {
            const abs = path.normalize(path.join(root, name));
            if (known.has(abs))
                continue;
            const op = purgePath(abs, 'worktree', { dryRun });
            ops.push(op);
            if (op.removed || dryRun)
                totalBytesRemoved += op.bytes;
        }
    }
    return { ops, totalBytesRemoved, totalBytesSkipped: 0, leftovers: [], dryRun };
}
export function formatBytes(n) {
    if (!Number.isFinite(n) || n < 0)
        return '0 B';
    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`;
}
