/**
 * Stale-file sweep for generated output trees.
 *
 * `src/` is hermetic — every file there is emitted by the compiler and
 * carries the "Generated by quickback.dev" header. When a feature is
 * removed/renamed or a generator stops emitting a file, the orphaned file
 * lingers under merge-only writes and can mask bugs (dead routes, ghost
 * imports, mismatched types, stale schemas the user assumes are current).
 *
 * This helper walks a target dir, deletes any file whose path isn't in the
 * compiler's manifest *and* carries the generated-header marker, then
 * collapses the empty directories left behind. Files without the marker are
 * preserved (defence against accidentally nuking hand-authored content that
 * snuck into a generated tree).
 *
 * Before any deletes, it copies the target dir into a timestamped archive
 * under `archiveDir` (when provided). The archive is uncompressed (just a
 * recursive copy via Node's built-in fs.cp) so it's trivially inspectable
 * and recoverable with `cp -r`. Old archives beyond `archiveKeep` are
 * pruned. The archive is skipped entirely on no-op compiles where no
 * orphans were found.
 */
import { promises as fs } from 'fs';
import { join, relative, sep } from 'path';
const GENERATED_MARKER = 'Generated by quickback.dev';
const HEADER_PROBE_BYTES = 512;
const DEFAULT_ARCHIVE_KEEP = 3;
/**
 * OS / editor noise that every contributor accidentally drops into checked-out
 * trees. These files are *never* compiler output and *never* user-authored
 * content the user cares about — silently skip them rather than warning the
 * user every compile that ".DS_Store has no Quickback header".
 */
const SILENT_IGNORE_BASENAMES = new Set([
    '.DS_Store',
    'Thumbs.db',
    '.localized',
]);
/**
 * Sweep stale generated files under `targetDir`. Paths in `manifest` are
 * relative to `outputDir` (matching the wire shape the compiler returns).
 */
export async function sweepStaleGeneratedFiles(outputDir, targetDir, manifest, options = {}) {
    const result = { deleted: [], preserved: [] };
    const fullTarget = join(outputDir, targetDir);
    if (!(await pathExists(fullTarget)))
        return result;
    const manifestSet = new Set();
    for (const p of manifest)
        manifestSet.add(normalizePath(p));
    const skipNormalized = [...(options.skipPrefixes ?? [])].map(normalizePath);
    const isSkipped = (relPath) => skipNormalized.some((sp) => relPath === sp || relPath.startsWith(sp + '/'));
    const onDisk = [];
    await collectFiles(fullTarget, outputDir, onDisk);
    // First pass: identify deletions without acting, so the archive captures
    // the pre-delete state and runs only when there's something to lose.
    const toDelete = [];
    for (const relPath of onDisk) {
        if (manifestSet.has(relPath))
            continue;
        if (isSkipped(relPath))
            continue;
        const base = relPath.slice(relPath.lastIndexOf('/') + 1);
        if (SILENT_IGNORE_BASENAMES.has(base))
            continue;
        const fullPath = join(outputDir, relPath);
        if (await fileHasMarker(fullPath, GENERATED_MARKER)) {
            toDelete.push(relPath);
        }
        else {
            result.preserved.push(relPath);
        }
    }
    if (toDelete.length === 0)
        return result;
    if (options.archiveDir) {
        result.archivePath = await archiveDirectory(fullTarget, options.archiveDir, basename(targetDir));
        await pruneArchives(options.archiveDir, basename(targetDir), options.archiveKeep ?? DEFAULT_ARCHIVE_KEEP);
    }
    for (const relPath of toDelete) {
        await fs.unlink(join(outputDir, relPath));
        result.deleted.push(relPath);
    }
    await removeEmptyDirs(fullTarget);
    return result;
}
async function archiveDirectory(srcDir, archiveBase, prefix) {
    const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
    const dest = join(archiveBase, `${prefix}-${stamp}`);
    await fs.mkdir(archiveBase, { recursive: true });
    // fs.cp with recursive copies the tree, preserves dir structure, no deps.
    await fs.cp(srcDir, dest, { recursive: true, force: true });
    return dest;
}
async function pruneArchives(archiveBase, prefix, keep) {
    if (!(await pathExists(archiveBase)))
        return;
    const entries = await fs.readdir(archiveBase);
    const stamped = entries.filter((e) => e.startsWith(`${prefix}-`)).sort().reverse();
    const stale = stamped.slice(keep);
    for (const old of stale) {
        await fs.rm(join(archiveBase, old), { recursive: true, force: true }).catch(() => { });
    }
}
async function pathExists(p) {
    try {
        await fs.access(p);
        return true;
    }
    catch {
        return false;
    }
}
async function collectFiles(dir, outputDir, into) {
    let entries;
    try {
        entries = await fs.readdir(dir, { withFileTypes: true });
    }
    catch {
        return;
    }
    for (const entry of entries) {
        const full = join(dir, entry.name);
        if (entry.isDirectory()) {
            await collectFiles(full, outputDir, into);
        }
        else if (entry.isFile()) {
            into.push(normalizePath(relative(outputDir, full)));
        }
    }
}
async function fileHasMarker(path, marker) {
    let handle = null;
    try {
        handle = await fs.open(path, 'r');
        const buf = Buffer.alloc(HEADER_PROBE_BYTES);
        const { bytesRead } = await handle.read(buf, 0, HEADER_PROBE_BYTES, 0);
        return buf.subarray(0, bytesRead).toString('utf8').includes(marker);
    }
    catch {
        return false;
    }
    finally {
        if (handle)
            await handle.close();
    }
}
async function removeEmptyDirs(dir) {
    let entries;
    try {
        entries = await fs.readdir(dir, { withFileTypes: true });
    }
    catch {
        return;
    }
    for (const entry of entries) {
        if (entry.isDirectory()) {
            await removeEmptyDirs(join(dir, entry.name));
        }
    }
    try {
        const after = await fs.readdir(dir);
        if (after.length === 0) {
            await fs.rmdir(dir).catch(() => { });
        }
    }
    catch {
        /* ignore */
    }
}
function normalizePath(p) {
    return sep === '/' ? p : p.split(sep).join('/');
}
function basename(p) {
    const norm = normalizePath(p).replace(/\/+$/, '');
    const idx = norm.lastIndexOf('/');
    return idx >= 0 ? norm.slice(idx + 1) : norm;
}
//# sourceMappingURL=sweep-stale.js.map