// engine/data-projects-gc/scanner.ts — SPEC-581
// Streams data/projects/ entries and classifies them as test-named, inactive,
// orphaned, or healthy.  Handles 10k+ dirs without loading everything into RAM.
import { opendir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { isTestProjectBasename } from './pattern-matcher.js';
const HEX_HASH_RE = /^[0-9a-f]{16}$/i;
/**
 * Return the most-recent mtime across the directory itself and every
 * immediate child (file or subdirectory).  Real project dirs under
 * data/projects/{hash}/ nest their state in subdirs (token-ledger/,
 * workers/, specs/, …) and rarely contain files at the top level, so
 * restricting to isFile() would leave newest at 0 and flag every live
 * project as inactive.  Returns 0 only when the dir itself is unreadable.
 */
async function newestMtime(dirPath) {
    let newest = 0;
    try {
        const top = await stat(dirPath);
        newest = top.mtimeMs;
        const dir = await opendir(dirPath);
        for await (const entry of dir) {
            try {
                const s = await stat(join(dirPath, entry.name));
                if (s.mtimeMs > newest) {
                    newest = s.mtimeMs;
                }
            }
            catch {
                /* v8 ignore next 2 -- unreadable entry: OS-level permission error */
            }
        }
    }
    catch {
        // ignore unreadable dirs
    }
    return newest;
}
/**
 * Scan `data/projects/` and classify every entry.
 *
 * @param dataProjectsDir - Absolute path to `data/projects/`
 * @param options          - Scan configuration
 */
export async function scanProjectDirs(dataProjectsDir, options) {
    const { inactiveDays = 90, registeredHashes, includeOrphans = false } = options;
    const cutoffMs = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
    const testNamed = [];
    const inactive = [];
    const orphaned = [];
    let total = 0;
    let dir;
    try {
        dir = await opendir(dataProjectsDir);
    }
    catch {
        // data/projects/ doesn't exist yet — nothing to scan
        return { testNamed, inactive, orphaned, total };
    }
    for await (const entry of dir) {
        if (!entry.isDirectory()) {
            continue;
        }
        total++;
        const name = entry.name;
        const fullPath = join(dataProjectsDir, name);
        // 1 — Test-named (catches non-hex names like proj-A, test-health-score-*)
        if (!HEX_HASH_RE.test(name) && isTestProjectBasename(name)) {
            testNamed.push({ name, fullPath, reason: 'test-named' });
            continue;
        }
        // 2 — Inactive (all files older than cutoff)
        const newest = await newestMtime(fullPath);
        if (newest < cutoffMs) {
            inactive.push({ name, fullPath, reason: 'inactive', lastModifiedMs: newest });
            continue;
        }
        // 3 — Orphaned (hex hash not in registry) — optional
        if (includeOrphans &&
            registeredHashes !== undefined &&
            HEX_HASH_RE.test(name) &&
            !registeredHashes.has(name)) {
            orphaned.push({ name, fullPath, reason: 'orphaned' });
        }
    }
    return { testNamed, inactive, orphaned, total };
}
//# sourceMappingURL=scanner.js.map