import { lstat, open, readFile, readdir, rm, stat, utimes } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';
import path from 'node:path';
import { abortIfNeeded, compareCodeUnits } from '../utils.js';
import { ENTITY_CACHE_SCHEMA_VERSION } from './entity/types.js';
import { CODE_INDEX_CACHE_SCHEMA_VERSION, LEGACY_CODE_INDEX_CACHE_SCHEMA_VERSION, resolveCodeIndexCacheDir, resolveEntityCacheDir, } from './cache-paths.js';
const retainedEntries = new Map();
const CACHE_ENTRY_LOCK_SUFFIX = '.occ-lock';
const CACHE_ENTRY_LOCK_TIMEOUT_MS = 30_000;
const UNOWNED_LOCK_STALE_MS = 3_600_000;
function normalized(value) {
    return path.resolve(value);
}
function containsPath(parent, child) {
    const relative = path.relative(parent, child);
    return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/** Keep an entry out of explicit pruning while a live store owns it. */
export function retainCacheEntry(entryPath) {
    const key = normalized(entryPath);
    retainedEntries.set(key, (retainedEntries.get(key) ?? 0) + 1);
    let released = false;
    return () => {
        if (released)
            return;
        released = true;
        const next = (retainedEntries.get(key) ?? 1) - 1;
        if (next <= 0)
            retainedEntries.delete(key);
        else
            retainedEntries.set(key, next);
    };
}
export function isCachePathRetained(candidatePath) {
    const candidate = normalized(candidatePath);
    for (const retained of retainedEntries.keys()) {
        if (containsPath(candidate, retained) || containsPath(retained, candidate))
            return true;
    }
    return false;
}
function cacheEntryLockPath(entryPath) {
    return `${normalized(entryPath)}${CACHE_ENTRY_LOCK_SUFFIX}`;
}
async function lockOwnerIsAlive(lockPath) {
    try {
        const parsed = JSON.parse(await readFile(lockPath, 'utf8'));
        if (typeof parsed.pid === 'number' && Number.isSafeInteger(parsed.pid) && parsed.pid > 0) {
            try {
                process.kill(parsed.pid, 0);
                return true;
            }
            catch (error) {
                return error.code !== 'ESRCH';
            }
        }
    }
    catch {
        // A competing writer may have created the file but not written its owner
        // yet. Only reap an ownerless lock after a conservative stale interval.
    }
    const lockStat = await stat(lockPath).catch(() => undefined);
    return lockStat !== undefined && Date.now() - lockStat.mtimeMs < UNOWNED_LOCK_STALE_MS;
}
async function clearStaleEntryLock(entryPath) {
    const lockPath = cacheEntryLockPath(entryPath);
    if (await lockOwnerIsAlive(lockPath))
        return false;
    await rm(lockPath, { force: true });
    return true;
}
/** Whether another process is atomically replacing this cache entry. */
export async function isCacheEntryWriteLocked(entryPath) {
    try {
        await stat(cacheEntryLockPath(entryPath));
    }
    catch {
        return false;
    }
    return !(await clearStaleEntryLock(entryPath));
}
/**
 * Serialize the destructive rename boundary across OCC processes. The lock is
 * a sibling file, so pruning can see it even while the destination entry does
 * not exist yet.
 */
export async function acquireCacheEntryWriteLock(entryPath, signal) {
    const lockPath = cacheEntryLockPath(entryPath);
    const started = Date.now();
    while (true) {
        abortIfNeeded(signal);
        try {
            const handle = await open(lockPath, 'wx');
            try {
                await handle.writeFile(JSON.stringify({ pid: process.pid }));
            }
            catch (error) {
                await handle.close().catch(() => undefined);
                await rm(lockPath, { force: true }).catch(() => undefined);
                throw error;
            }
            let released = false;
            return async () => {
                if (released)
                    return;
                released = true;
                await handle.close().catch(() => undefined);
                await rm(lockPath, { force: true }).catch(() => undefined);
            };
        }
        catch (error) {
            if (error.code !== 'EEXIST')
                throw error;
            if (await clearStaleEntryLock(entryPath))
                continue;
            if (Date.now() - started >= CACHE_ENTRY_LOCK_TIMEOUT_MS) {
                throw new Error(`Timed out waiting for the cache entry lock at ${lockPath}.`);
            }
            await delay(25, undefined, signal ? { signal } : undefined);
        }
    }
}
/** Last-open activity is recorded in metadata mtime without rewriting payload bytes. */
export async function touchCacheEntry(entryPath) {
    const now = new Date();
    await utimes(path.join(entryPath, 'meta.json'), now, now).catch(() => undefined);
}
async function scanTree(target) {
    let info;
    try {
        info = await lstat(target);
    }
    catch {
        return { bytes: 0, newestMtimeMs: 0 };
    }
    if (!info.isDirectory() || info.isSymbolicLink()) {
        return { bytes: info.size, newestMtimeMs: info.mtimeMs };
    }
    let bytes = 0;
    let newestMtimeMs = info.mtimeMs;
    const children = await readdir(target, { withFileTypes: true }).catch(() => []);
    for (const child of children) {
        const scanned = await scanTree(path.join(target, child.name));
        bytes += scanned.bytes;
        newestMtimeMs = Math.max(newestMtimeMs, scanned.newestMtimeMs);
    }
    return { bytes, newestMtimeMs };
}
async function builtAtMs(entryPath) {
    try {
        const raw = await readFile(path.join(entryPath, 'meta.json'), 'utf8');
        const parsed = JSON.parse(raw);
        return typeof parsed.builtAt === 'string' ? Date.parse(parsed.builtAt) || 0 : 0;
    }
    catch {
        try {
            const raw = await readFile(path.join(entryPath, 'index.json'), 'utf8');
            const parsed = JSON.parse(raw);
            return typeof parsed.cachedAt === 'string' ? Date.parse(parsed.cachedAt) || 0 : 0;
        }
        catch {
            return 0;
        }
    }
}
async function scannedPath(entryPath) {
    const tree = await scanTree(entryPath);
    const metaStat = await stat(path.join(entryPath, 'meta.json')).catch(() => undefined);
    const legacyStat = metaStat ? undefined : await stat(path.join(entryPath, 'index.json')).catch(() => undefined);
    return {
        path: normalized(entryPath),
        bytes: tree.bytes,
        lastUsedMs: Math.max(metaStat?.mtimeMs ?? legacyStat?.mtimeMs ?? 0, await builtAtMs(entryPath)),
    };
}
async function childDirectories(parent) {
    const children = await readdir(parent, { withFileTypes: true }).catch(() => []);
    return children
        .filter(child => child.isDirectory() && !child.isSymbolicLink())
        .map(child => path.join(parent, child.name))
        .sort(compareCodeUnits);
}
async function codeEntrySchema(entryPath) {
    try {
        const raw = await readFile(path.join(entryPath, 'meta.json'), 'utf8');
        const parsed = JSON.parse(raw);
        return typeof parsed.schemaVersion === 'number' && Number.isInteger(parsed.schemaVersion)
            ? parsed.schemaVersion
            : null;
    }
    catch {
        return null;
    }
}
async function scanOccCache(cacheDir) {
    const root = resolveEntityCacheDir(cacheDir);
    const entries = [];
    const superseded = [];
    const supersededRoots = [];
    for (const versionRoot of await childDirectories(root)) {
        const name = path.basename(versionRoot);
        const match = /^v(\d+)$/.exec(name);
        if (!match)
            continue;
        const schemaVersion = Number(match[1]);
        if (schemaVersion !== ENTITY_CACHE_SCHEMA_VERSION) {
            const scanned = await scannedPath(versionRoot);
            superseded.push(scanned);
            supersededRoots.push({ path: versionRoot, schemaVersion, bytes: scanned.bytes });
            continue;
        }
        for (const entry of await childDirectories(versionRoot))
            entries.push(await scannedPath(entry));
    }
    // Parsed entity blobs are shared by every lineage fingerprint that uses the
    // same repository and extraction contract. Each contract directory is an
    // independent pruning unit; its sibling lock protects concurrent reads and
    // appends exactly like a lineage entry lock does.
    for (const repoRoot of await childDirectories(path.join(root, 'blobs'))) {
        for (const contractDir of await childDirectories(repoRoot))
            entries.push(await scannedPath(contractDir));
    }
    const codeRoot = resolveCodeIndexCacheDir(cacheDir);
    for (const mode of ['none', 'excerpt', 'full']) {
        for (const entry of await childDirectories(path.join(codeRoot, mode))) {
            const schemaVersion = await codeEntrySchema(entry);
            const scanned = await scannedPath(entry);
            if (schemaVersion !== CODE_INDEX_CACHE_SCHEMA_VERSION) {
                superseded.push(scanned);
                supersededRoots.push({ path: entry, schemaVersion, bytes: scanned.bytes });
            }
            else {
                entries.push(scanned);
            }
        }
    }
    // Deprecated monolithic entries remain prunable. Only the active parser
    // contract counts as current; prior legacy roots are superseded as a unit.
    for (const legacyRoot of await childDirectories(codeRoot)) {
        const match = /^legacy-v(\d+)$/u.exec(path.basename(legacyRoot));
        if (!match)
            continue;
        const schemaVersion = Number(match[1]);
        if (schemaVersion !== LEGACY_CODE_INDEX_CACHE_SCHEMA_VERSION) {
            const scanned = await scannedPath(legacyRoot);
            superseded.push(scanned);
            supersededRoots.push({ path: legacyRoot, schemaVersion, bytes: scanned.bytes });
            continue;
        }
        for (const entry of await childDirectories(legacyRoot))
            entries.push(await scannedPath(entry));
    }
    const total = await scanTree(root);
    const orderedSuperseded = supersededRoots.sort((left, right) => compareCodeUnits(left.path, right.path));
    return {
        inventory: {
            root,
            bytes: total.bytes,
            entryCount: entries.length + superseded.length,
            currentEntryCount: entries.length,
            supersededRoots: orderedSuperseded,
        },
        entries,
        superseded,
    };
}
/** Read cache size/entry metadata without deserializing any index bodies. */
export async function inspectOccCache(cacheDir) {
    return (await scanOccCache(cacheDir)).inventory;
}
/** Explicitly prune superseded roots, aged entries, and oldest entries over size. */
export async function pruneOccCache(options = {}) {
    if (options.maxAgeDays != null && (!Number.isFinite(options.maxAgeDays) || options.maxAgeDays < 0)) {
        throw new Error('maxAgeDays must be a non-negative finite number.');
    }
    if (options.maxBytes != null && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
        throw new Error('maxBytes must be a non-negative safe integer.');
    }
    const scan = await scanOccCache(options.cacheDir);
    const removed = new Set();
    const removedPaths = [];
    const skippedOpenPaths = [];
    let removedBytes = 0;
    const removeCandidate = async (candidate) => {
        if (removed.has(candidate.path))
            return false;
        if (isCachePathRetained(candidate.path) || (await isCacheEntryWriteLocked(candidate.path))) {
            skippedOpenPaths.push(candidate.path);
            return false;
        }
        await rm(candidate.path, { recursive: true, force: true });
        removed.add(candidate.path);
        removedPaths.push(candidate.path);
        removedBytes += candidate.bytes;
        return true;
    };
    // Another installed OCC version may own these roots, so only this explicit
    // command removes them; ordinary store opens merely report them.
    for (const candidate of scan.superseded)
        await removeCandidate(candidate);
    const nowMs = (options.now ?? new Date()).getTime();
    if (options.maxAgeDays != null) {
        const cutoff = nowMs - options.maxAgeDays * 86_400_000;
        for (const candidate of scan.entries) {
            if (candidate.lastUsedMs < cutoff)
                await removeCandidate(candidate);
        }
    }
    if (options.maxBytes != null) {
        let remainingBytes = Math.max(0, scan.inventory.bytes - removedBytes);
        const oldestFirst = [...scan.entries].sort((left, right) => left.lastUsedMs - right.lastUsedMs || compareCodeUnits(left.path, right.path));
        for (const candidate of oldestFirst) {
            if (remainingBytes <= options.maxBytes)
                break;
            if (await removeCandidate(candidate))
                remainingBytes = Math.max(0, remainingBytes - candidate.bytes);
        }
    }
    const after = await inspectOccCache(options.cacheDir);
    return {
        root: scan.inventory.root,
        limits: {
            ...(options.maxAgeDays == null ? {} : { maxAgeDays: options.maxAgeDays }),
            ...(options.maxBytes == null ? {} : { maxBytes: options.maxBytes }),
        },
        before: scan.inventory,
        after,
        removedEntries: removed.size,
        removedBytes,
        removedPaths: removedPaths.sort(compareCodeUnits),
        skippedOpenPaths: [...new Set(skippedOpenPaths)].sort(compareCodeUnits),
    };
}
//# sourceMappingURL=cache-management.js.map