import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import fg from 'fast-glob';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { assembleCodebaseIndexCooperative, assembleCodebaseIndex, buildCodebaseIndexWithStats, CodeIndexBudgetExceededError, parseCodeFiles, } from './build.js';
import { classifyChangedFiles, computeManifestDiff, findResolutionImpactedFiles, spliceIndexInputs, } from './incremental.js';
import { readSectionedCodeIndex, writeNdjsonFile, writeSectionedCodeIndex, } from './index-io.js';
import { chunkFromStoreIsolated } from './isolated.js';
import { invalidateTsConfigResolutionCache, normalizePath } from './languages.js';
import { enrichTypeScriptSemantics } from './typescript-semantics.js';
import { createCodeQuerySessionFromIndex } from './session.js';
import { abortIfNeeded, compareCodeUnits, createYielder, isCooperative } from '../utils.js';
import { CODE_INDEX_CACHE_SCHEMA_VERSION } from './cache-paths.js';
import { acquireCacheEntryWriteLock, retainCacheEntry, touchCacheEntry } from './cache-management.js';
import { attachFileTokenCountCache, createFileTokenCountCache, getFileTokenCountCache, isFileTokenCountRow, } from './token-count-cache.js';
import { attachSourceInlineManifest, getSourceInlineManifestEntry } from './source-inline.js';
export { createFileTokenCountCache, } from './token-count-cache.js';
export { resolveCodeIndexCacheDir } from './cache-paths.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// v4 rebuilds the parser-v3 call-extraction contract and its refusal counters.
// Old cache entries are rebuilt, not upgraded.
const SCHEMA_VERSION = CODE_INDEX_CACHE_SCHEMA_VERSION;
// Resolve version from package.json — works from both src/ (dev) and dist/src/ (built)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let _occVersion;
async function getOccVersion() {
    if (_occVersion)
        return _occVersion;
    for (const rel of ['../..', '../../..']) {
        try {
            const raw = await readFile(path.resolve(__dirname, rel, 'package.json'), 'utf8');
            const pkg = JSON.parse(raw);
            if (pkg.version) {
                _occVersion = pkg.version;
                return _occVersion;
            }
        }
        catch {
            /* try next */
        }
    }
    _occVersion = '0.0.0';
    return _occVersion;
}
const resolverDigestMemo = new Map();
const PACKAGE_RESOLUTION_FIELDS = ['main', 'exports', 'imports', 'type', 'workspaces'];
function packageResolutionMaterial(raw) {
    try {
        const parsed = JSON.parse(raw.toString('utf8'));
        if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
            return raw;
        const record = parsed;
        const selected = {};
        for (const field of PACKAGE_RESOLUTION_FIELDS) {
            if (Object.prototype.hasOwnProperty.call(record, field))
                selected[field] = record[field];
        }
        return JSON.stringify(selected);
    }
    catch {
        // Invalid package metadata can affect resolution by making a project fail
        // closed, so retain its exact bytes in the key rather than ignoring it.
        return raw;
    }
}
async function resolverFileDigest(repoRoot, relativePath) {
    const absolutePath = path.join(repoRoot, relativePath);
    try {
        const fileStat = await stat(absolutePath);
        const cached = resolverDigestMemo.get(absolutePath);
        if (cached && cached.size === fileStat.size && cached.mtimeMs === fileStat.mtimeMs)
            return cached.digest;
        const raw = await readFile(absolutePath);
        const material = path.basename(relativePath) === 'package.json' ? packageResolutionMaterial(raw) : raw;
        const digest = createHash('sha256').update(material).digest('hex');
        resolverDigestMemo.set(absolutePath, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, digest });
        return digest;
    }
    catch {
        resolverDigestMemo.delete(absolutePath);
        return '<unreadable>';
    }
}
export async function buildOptionFingerprint(opts) {
    // tsconfig/jsconfig also affect the syntax resolver used when the checker is
    // disabled. Package metadata is narrowed to fields that can affect module
    // resolution, so a version/script-only edit does not orphan a large index.
    // Digests are memoized by stat identity, making an unchanged config sweep
    // stat-only after the first store operation in this process.
    const repoRoot = path.resolve(opts.repoRoot);
    const resolverFiles = await fg(['**/tsconfig*.json', '**/jsconfig*.json', '**/package.json'], {
        cwd: repoRoot,
        absolute: false,
        onlyFiles: true,
        unique: true,
        ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/.occ-cache/**'],
    });
    const resolverDigest = createHash('sha256');
    for (const relativePath of resolverFiles.sort(compareCodeUnits)) {
        resolverDigest.update(relativePath).update('\0');
        resolverDigest.update(await resolverFileDigest(repoRoot, relativePath));
        resolverDigest.update('\0');
    }
    const resolverConfigFingerprint = resolverDigest.digest('hex');
    const material = JSON.stringify({
        repoRoot: path.resolve(opts.repoRoot),
        excludeDir: (opts.excludeDir ?? []).slice().sort(),
        noGitignore: opts.noGitignore ?? false,
        ignorePatterns: opts.ignorePatterns ?? [],
        overlayIgnorePatterns: opts.overlayIgnorePatterns ?? [],
        contentMode: opts.contentMode ?? 'excerpt',
        maxFileSizeBytes: opts.maxFileSizeBytes ?? null,
        maxLines: opts.maxLines ?? null,
        skipMinified: opts.skipMinified ?? true,
        maxFiles: opts.maxFiles ?? null,
        maxBytes: opts.maxBytes ?? null,
        onBudgetExceeded: opts.onBudgetExceeded ?? 'throw',
        typescriptChecker: opts.typescriptChecker ?? 'preferred',
        resolverConfigFingerprint,
    });
    return createHash('sha256').update(material).digest('hex').slice(0, 16);
}
function metaPath(cacheDir) {
    return path.join(cacheDir, 'meta.json');
}
function manifestPath(cacheDir) {
    return path.join(cacheDir, 'manifest.json');
}
function tokenCountsPath(cacheDir) {
    return path.join(cacheDir, 'token-counts.ndjson');
}
function cacheEntryDir(cacheRoot, contentMode, fingerprint) {
    return path.join(cacheRoot, contentMode, fingerprint);
}
// ---------------------------------------------------------------------------
// Cache I/O
// ---------------------------------------------------------------------------
async function loadCachedIndex(cacheDir, signal) {
    try {
        const { index } = await readSectionedCodeIndex(cacheDir, signal);
        return index;
    }
    catch {
        return undefined;
    }
}
async function loadCacheMeta(cacheDir) {
    try {
        const raw = await readFile(metaPath(cacheDir), 'utf8');
        if (!raw)
            return undefined;
        const parsed = JSON.parse(raw);
        if (parsed.schemaVersion !== SCHEMA_VERSION)
            return undefined;
        if (!parsed.occVersion || !parsed.builtAt || !parsed.buildOptionFingerprint)
            return undefined;
        return {
            schemaVersion: parsed.schemaVersion,
            occVersion: parsed.occVersion,
            repoRoot: parsed.repoRoot,
            builtAt: parsed.builtAt,
            fileCount: parsed.fileCount,
            nodeCount: parsed.nodeCount,
            edgeCount: parsed.edgeCount,
            buildOptionFingerprint: parsed.buildOptionFingerprint,
            contentMode: parsed.contentMode ?? 'excerpt',
        };
    }
    catch {
        return undefined;
    }
}
async function loadManifest(cacheDir, signal) {
    try {
        abortIfNeeded(signal);
        const raw = await readFile(manifestPath(cacheDir), 'utf8');
        if (!raw)
            return undefined;
        return JSON.parse(raw);
    }
    catch {
        return undefined;
    }
}
function normalizeTokenCountRows(rows) {
    const cache = createFileTokenCountCache();
    cache.load(rows);
    return cache.entries();
}
async function loadTokenCountRows(cacheDir, signal) {
    try {
        abortIfNeeded(signal);
        const raw = await readFile(tokenCountsPath(cacheDir), 'utf8');
        const rows = [];
        for (const line of raw.split(/\r?\n/u)) {
            abortIfNeeded(signal);
            if (!line)
                continue;
            try {
                const row = JSON.parse(line);
                if (isFileTokenCountRow(row))
                    rows.push(row);
            }
            catch {
                // One torn or malformed derived row does not hide later valid rows.
            }
        }
        return normalizeTokenCountRows(rows);
    }
    catch {
        abortIfNeeded(signal);
        // This is a derived cache: absence or corruption means recompute, never a
        // failed index load and never an unvalidated count entering an answer.
        return [];
    }
}
async function appendTokenCountRow(cacheDir, row) {
    const releaseWriteLock = await acquireCacheEntryWriteLock(cacheDir);
    try {
        // A concurrent prune may have removed the entry after its index was
        // returned to a caller. Do not recreate a detached derived-cache file.
        if (!(await loadCacheMeta(cacheDir)))
            return;
        await appendFile(tokenCountsPath(cacheDir), `${JSON.stringify(row)}\n`, 'utf8');
        await touchCacheEntry(cacheDir);
    }
    finally {
        await releaseWriteLock();
    }
}
function attachPersistedTokenCountCache(index, cacheDir, initialRows) {
    attachFileTokenCountCache(index, createFileTokenCountCache({
        initialRows,
        persist: row => appendTokenCountRow(cacheDir, row),
    }));
}
async function persistCache(cacheDir, index, buildOpts, expectedFingerprint, signal, onProgress, precomputedManifest, inMemoryTokenCounts = []) {
    const cacheRoot = path.dirname(path.dirname(cacheDir));
    const tempDir = path.join(cacheRoot, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
    await mkdir(path.dirname(cacheDir), { recursive: true });
    await mkdir(tempDir, { recursive: true });
    abortIfNeeded(signal);
    onProgress?.({ phase: 'serialize', total: 3, completed: 0, detail: 'writing cache' });
    // Build and write manifest (stat calls in parallel). The manifest must
    // cover guardrail-skipped files too, or freshness checks would treat them
    // as perpetually new and rebuild forever.
    const repoRoot = path.resolve(buildOpts.repoRoot);
    let manifest;
    if (precomputedManifest) {
        manifest = precomputedManifest;
    }
    else {
        const manifestPaths = [
            ...index.files.map(file => file.relativePath),
            ...index.skippedFiles.map(skipped => skipped.relativePath),
        ];
        const statResults = await Promise.all(manifestPaths.map(async (relativePath) => {
            try {
                const fileStat = await stat(path.join(repoRoot, relativePath));
                const identity = getSourceInlineManifestEntry(index, relativePath);
                return {
                    relativePath,
                    size: fileStat.size,
                    mtimeMs: fileStat.mtimeMs,
                    ...(identity?.size === fileStat.size && identity.sha256 ? { sha256: identity.sha256 } : {}),
                };
            }
            catch {
                return null; // File may have been deleted between build and persist
            }
        }));
        manifest = statResults.filter((e) => e !== null);
    }
    // Write metadata
    const occVersion = await getOccVersion();
    const meta = {
        schemaVersion: SCHEMA_VERSION,
        occVersion,
        repoRoot: repoRoot,
        builtAt: new Date().toISOString(),
        fileCount: index.files.length,
        nodeCount: index.nodes.length,
        edgeCount: index.edges.length,
        // The destination directory was selected from this exact snapshot. Do not
        // rescan configuration here: a mid-build edit would otherwise write new
        // metadata under the old cache key.
        buildOptionFingerprint: expectedFingerprint,
        contentMode: index.contentMode,
    };
    let tokenCounts = [];
    try {
        await writeSectionedCodeIndex(tempDir, index, meta, signal);
        onProgress?.({ phase: 'serialize', total: 3, completed: 1, detail: 'index written' });
        abortIfNeeded(signal);
        await writeFile(manifestPath(tempDir), JSON.stringify(manifest), 'utf8');
        onProgress?.({ phase: 'serialize', total: 3, completed: 2, detail: 'manifest written' });
        abortIfNeeded(signal);
        const releaseWriteLock = await acquireCacheEntryWriteLock(cacheDir, signal);
        try {
            tokenCounts = normalizeTokenCountRows([...(await loadTokenCountRows(cacheDir, signal)), ...inMemoryTokenCounts]);
            await writeNdjsonFile(tokenCountsPath(tempDir), tokenCounts, signal);
            await rm(cacheDir, { recursive: true, force: true });
            await rename(tempDir, cacheDir);
        }
        finally {
            await releaseWriteLock();
        }
    }
    catch (error) {
        await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
        throw error;
    }
    onProgress?.({ phase: 'serialize', total: 3, completed: 3, detail: 'metadata written' });
    return { meta, manifest, tokenCounts };
}
// ---------------------------------------------------------------------------
// Store implementation
// ---------------------------------------------------------------------------
export function openCodeIndexStore(options) {
    const repoRoot = path.resolve(options.repoRoot);
    // A prior direct build/store in this process may have memoized path mappings
    // before the current metadata content existed.
    invalidateTsConfigResolutionCache(repoRoot);
    const cacheRoot = path.resolve(options.cacheDir);
    const contentMode = options.contentMode ?? 'excerpt';
    let fingerprint = '';
    let cacheDir = '';
    let fullFallbackFingerprint;
    let fullFallbackCacheDir;
    const buildOpts = {
        repoRoot,
        excludeDir: options.excludeDir,
        noGitignore: options.noGitignore,
        ignorePatterns: options.ignorePatterns,
        overlayIgnorePatterns: options.overlayIgnorePatterns,
        contentMode: options.contentMode,
        maxFileSizeBytes: options.maxFileSizeBytes,
        maxLines: options.maxLines,
        skipMinified: options.skipMinified,
        maxFiles: options.maxFiles,
        maxBytes: options.maxBytes,
        onBudgetExceeded: options.onBudgetExceeded,
        parseConcurrency: options.parseConcurrency,
        typescriptChecker: options.typescriptChecker ?? 'preferred',
    };
    const discoverOpts = {
        excludeDir: options.excludeDir,
        noGitignore: options.noGitignore,
        ignorePatterns: options.ignorePatterns,
        overlayIgnorePatterns: options.overlayIgnorePatterns,
    };
    let currentSession = null;
    let currentMeta = null;
    let currentManifest = null;
    let currentEntryDir = null;
    let releaseCurrentEntry;
    function releaseEntry() {
        releaseCurrentEntry?.();
        releaseCurrentEntry = undefined;
        currentEntryDir = null;
    }
    function retainEntry(entryDir) {
        const resolved = path.resolve(entryDir);
        if (currentEntryDir === resolved)
            return;
        releaseEntry();
        currentEntryDir = resolved;
        releaseCurrentEntry = retainCacheEntry(resolved);
    }
    function clearCurrent() {
        currentSession = null;
        currentMeta = null;
        currentManifest = null;
        releaseEntry();
    }
    /**
     * Move this long-lived store to the cache entry for the resolver metadata
     * that exists now. Config-only edits are not code-discovery manifest rows,
     * so this check is their freshness boundary.
     */
    async function synchronizeCacheEntry() {
        const nextFingerprint = await buildOptionFingerprint(options);
        if (nextFingerprint === fingerprint)
            return false;
        invalidateTsConfigResolutionCache(repoRoot);
        fingerprint = nextFingerprint;
        cacheDir = cacheEntryDir(cacheRoot, contentMode, fingerprint);
        if (contentMode === 'excerpt') {
            fullFallbackFingerprint = await buildOptionFingerprint({ ...options, contentMode: 'full' });
            fullFallbackCacheDir = cacheEntryDir(cacheRoot, 'full', fullFallbackFingerprint);
        }
        else {
            fullFallbackFingerprint = undefined;
            fullFallbackCacheDir = undefined;
        }
        clearCurrent();
        return true;
    }
    // Operation mutex: rapid watcher-driven update() calls (and concurrent
    // getSession/refresh) must not race the persistCache temp-dir rename.
    let pending = Promise.resolve();
    function serialized(fn) {
        const run = pending.then(fn, fn);
        pending = run.catch(() => undefined);
        return run;
    }
    async function buildAndPersist(signal, onProgress, cooperative) {
        const inheritedTokenCounts = currentSession
            ? (getFileTokenCountCache(currentSession.getIndex())?.entries() ?? [])
            : [];
        for (let attempt = 0; attempt < 2; attempt++) {
            abortIfNeeded(signal);
            const targetFingerprint = fingerprint;
            const targetCacheDir = cacheDir;
            const { index, fileStats } = await buildCodebaseIndexWithStats({ ...buildOpts, cooperative }, onProgress, signal);
            abortIfNeeded(signal);
            if (await synchronizeCacheEntry()) {
                // The checker may have observed either side of a concurrent config
                // write. Retry once against the newly selected immutable cache key.
                continue;
            }
            // Reuse the build's pre-parse stats as the manifest: no second stat pass,
            // and an edit made during the build window diverges from the recorded
            // stat, so the next freshness check catches it instead of masking it.
            // Restrict to files the index retained (parsed or guardrail-skipped) —
            // files that vanished mid-build must not linger in the manifest.
            const retained = new Set([
                ...index.files.map(file => file.relativePath),
                ...index.skippedFiles.map(skipped => skipped.relativePath),
            ]);
            const manifestFromBuild = fileStats.filter(entry => retained.has(entry.relativePath));
            const { meta: cacheMeta, manifest, tokenCounts, } = await persistCache(targetCacheDir, index, options, targetFingerprint, signal, onProgress, manifestFromBuild, inheritedTokenCounts);
            attachPersistedTokenCountCache(index, targetCacheDir, tokenCounts);
            attachSourceInlineManifest(index, manifest);
            const session = createCodeQuerySessionFromIndex(buildOpts, index);
            const meta = {
                ...cacheMeta,
                loadSource: 'build',
                updateKind: 'full',
            };
            return { session, meta, manifest };
        }
        throw new Error('TypeScript resolver configuration changed repeatedly while the code index was building.');
    }
    /**
     * Re-parse only the changed files and splice them into the cached parse
     * set. Returns undefined when an incremental update cannot be applied
     * safely — the caller falls back to a full buildAndPersist.
     */
    async function applyIncrementalUpdate(diff, base, signal, onProgress, cooperative) {
        // A truncated index dropped files non-deterministically relative to the
        // diff; a contentMode mismatch means the cache cannot seed this store.
        // Known limitation (intended, conservative v1): a truncated index
        // therefore falls back to a full rebuild on every ensure-fresh/update,
        // even when nothing changed — the manifest only covers retained files,
        // so fixing this requires it to also cover the truncated-out ones.
        if (base.index.truncated)
            return undefined;
        if (base.index.contentMode !== contentMode)
            return undefined;
        const deletedPaths = new Set(diff.deleted.map(entry => entry.path));
        const deletedRel = new Set(diff.deleted.map(entry => entry.relativePath));
        const survivors = base.index.files.filter(file => !deletedPaths.has(file.path));
        const impacted = findResolutionImpactedFiles(survivors, diff, repoRoot);
        const reparseSet = new Set([
            ...diff.added.map(entry => entry.path),
            ...diff.modified.map(entry => entry.path),
            ...impacted,
        ]);
        const reparseList = [...reparseSet].sort();
        const freshStats = [...diff.added, ...diff.modified];
        const freshRel = new Set(freshStats.map(entry => entry.relativePath));
        const yielder = isCooperative(cooperative) ? createYielder(cooperative, signal) : undefined;
        // Budget re-check using projected totals (manifest survivors + fresh stats).
        if (options.maxFiles != null || options.maxBytes != null) {
            const survivingEntries = base.manifest.filter(entry => !deletedRel.has(entry.relativePath) && !freshRel.has(entry.relativePath));
            const totalFiles = survivingEntries.length + freshStats.length;
            const totalBytes = survivingEntries.reduce((sum, entry) => sum + entry.size, 0) +
                freshStats.reduce((sum, entry) => sum + entry.size, 0);
            const overFiles = options.maxFiles != null && totalFiles > options.maxFiles;
            const overBytes = options.maxBytes != null && totalBytes > options.maxBytes;
            if (overFiles || overBytes) {
                if ((options.onBudgetExceeded ?? 'throw') === 'throw') {
                    throw new CodeIndexBudgetExceededError({
                        maxFiles: options.maxFiles,
                        maxBytes: options.maxBytes,
                        totalFiles,
                        totalBytes,
                    });
                }
                return undefined; // 'truncate' — let the full build apply truncation.
            }
        }
        const incrementalParseOptions = {
            repoRoot,
            contentMode,
            maxFileSizeBytes: options.maxFileSizeBytes,
            maxLines: options.maxLines,
            skipMinified: options.skipMinified,
            concurrency: options.parseConcurrency,
            typescriptChecker: options.typescriptChecker ?? 'preferred',
        };
        const fresh = await parseCodeFiles(reparseList, incrementalParseOptions, onProgress, signal, yielder);
        abortIfNeeded(signal);
        const removals = new Set([...deletedPaths, ...reparseList]);
        const { parsedFiles, skippedFiles } = spliceIndexInputs(base.index.files, base.index.skippedFiles, removals, fresh);
        await enrichTypeScriptSemantics(parsedFiles, {
            mode: options.typescriptChecker ?? 'preferred',
            signal,
        });
        const assemblyInput = {
            repoRoot,
            contentMode,
            parsedFiles,
            skippedFiles,
            truncated: undefined,
        };
        const index = yielder
            ? await assembleCodebaseIndexCooperative(assemblyInput, yielder, onProgress, signal)
            : assembleCodebaseIndex(assemblyInput, onProgress, signal);
        // New manifest: untouched survivors + fresh stats + the (on-disk
        // unchanged) entries for resolution-impacted re-parses. Skipped and
        // parsed files are both covered.
        const reparseRel = new Set(reparseList.map(filePath => normalizePath(path.relative(repoRoot, filePath))));
        const untouched = base.manifest.filter(entry => !deletedRel.has(entry.relativePath) && !reparseRel.has(entry.relativePath));
        const impactedEntries = base.manifest.filter(entry => reparseRel.has(entry.relativePath) && !freshRel.has(entry.relativePath) && !deletedRel.has(entry.relativePath));
        const manifest = [
            ...untouched,
            ...freshStats.map(entry => {
                const identity = fresh.sourceIdentities.get(entry.relativePath);
                return {
                    relativePath: entry.relativePath,
                    size: entry.size,
                    mtimeMs: entry.mtimeMs,
                    ...(identity?.size === entry.size ? { sha256: identity.sha256 } : {}),
                };
            }),
            ...impactedEntries,
        ];
        if (await synchronizeCacheEntry())
            return undefined;
        const { meta: cacheMeta, tokenCounts } = await persistCache(cacheDir, index, options, fingerprint, signal, onProgress, manifest, getFileTokenCountCache(base.index)?.entries());
        attachPersistedTokenCountCache(index, cacheDir, tokenCounts);
        attachSourceInlineManifest(index, manifest);
        const session = createCodeQuerySessionFromIndex(buildOpts, index);
        const meta = {
            ...cacheMeta,
            loadSource: 'build',
            updateKind: 'incremental',
        };
        return { session, meta, manifest };
    }
    /**
     * The on-disk cache entry's metadata, but only when it belongs to this
     * store: same build-option fingerprint, same occ version, same repo.
     */
    async function loadValidCacheMeta(entryDir = cacheDir, expectedFingerprint = fingerprint) {
        const cacheMeta = await loadCacheMeta(entryDir);
        if (!cacheMeta)
            return undefined;
        if (cacheMeta.buildOptionFingerprint !== expectedFingerprint)
            return undefined;
        if (cacheMeta.occVersion !== (await getOccVersion()))
            return undefined;
        // A shared cacheDir can hold entries for multiple repos; never serve one
        // repo's index for another even if their build options collide.
        if (path.resolve(cacheMeta.repoRoot) !== repoRoot)
            return undefined;
        return cacheMeta;
    }
    async function loadFromCache(signal, onProgress, entryDir = cacheDir, expectedFingerprint = fingerprint) {
        onProgress?.({ phase: 'cache-check', total: 1, completed: 0, detail: 'checking cache' });
        const cacheMeta = await loadValidCacheMeta(entryDir, expectedFingerprint);
        if (!cacheMeta)
            return undefined;
        onProgress?.({ phase: 'deserialize', total: 1, completed: 0, detail: 'loading index' });
        const cachedIndex = await loadCachedIndex(entryDir, signal);
        if (!cachedIndex)
            return undefined;
        cachedIndex.contentMode ??= options.contentMode ?? 'excerpt';
        cachedIndex.skippedFiles ??= [];
        const cachedManifest = (await loadManifest(entryDir, signal)) ?? null;
        const tokenCounts = await loadTokenCountRows(entryDir, signal);
        // Do not adopt an entry if resolver metadata moved while it was being
        // deserialized. The caller will miss this entry and build/load the newly
        // selected one instead.
        if (await synchronizeCacheEntry())
            return undefined;
        onProgress?.({ phase: 'deserialize', total: 1, completed: 1, detail: 'index loaded' });
        await touchCacheEntry(entryDir);
        attachPersistedTokenCountCache(cachedIndex, entryDir, tokenCounts);
        if (cachedManifest)
            attachSourceInlineManifest(cachedIndex, cachedManifest);
        const session = createCodeQuerySessionFromIndex(buildOpts, cachedIndex);
        const meta = {
            ...cacheMeta,
            loadSource: 'cache',
        };
        return {
            session,
            meta,
            manifest: cachedManifest,
            entryDir,
        };
    }
    return {
        async getSession(opts) {
            abortIfNeeded(opts?.signal);
            return serialized(async () => {
                const strategy = opts?.strategy ?? 'prefer-cache';
                const { signal, onProgress, cooperative } = opts ?? {};
                abortIfNeeded(signal);
                await synchronizeCacheEntry();
                if (strategy === 'rebuild') {
                    const result = await buildAndPersist(signal, onProgress, cooperative);
                    currentSession = result.session;
                    currentMeta = result.meta;
                    currentManifest = result.manifest;
                    retainEntry(cacheDir);
                    return result.session;
                }
                if (strategy === 'prefer-cache') {
                    if (currentSession) {
                        return currentSession;
                    }
                    const cached = await loadFromCache(signal, onProgress);
                    if (cached) {
                        currentSession = cached.session;
                        currentMeta = cached.meta;
                        currentManifest = cached.manifest;
                        retainEntry(cached.entryDir);
                        return cached.session;
                    }
                    // Cache miss — build
                    const result = await buildAndPersist(signal, onProgress, cooperative);
                    currentSession = result.session;
                    currentMeta = result.meta;
                    currentManifest = result.manifest;
                    retainEntry(cacheDir);
                    return result.session;
                }
                // ensure-fresh. An excerpt request may read through a fresh full-mode
                // entry, but a stale full entry is never rewritten or downgraded by
                // the excerpt store — it falls through to an excerpt build instead.
                let manifest = currentManifest;
                let sourceEntryDir = currentEntryDir ?? cacheDir;
                let sourceFingerprint = currentEntryDir &&
                    fullFallbackCacheDir &&
                    path.resolve(currentEntryDir) === path.resolve(fullFallbackCacheDir)
                    ? fullFallbackFingerprint
                    : fingerprint;
                if (!manifest && (await loadValidCacheMeta(cacheDir, fingerprint))) {
                    manifest = (await loadManifest(cacheDir, signal)) ?? null;
                    sourceEntryDir = cacheDir;
                    sourceFingerprint = fingerprint;
                }
                if (!manifest &&
                    fullFallbackCacheDir &&
                    fullFallbackFingerprint &&
                    (await loadValidCacheMeta(fullFallbackCacheDir, fullFallbackFingerprint))) {
                    manifest = (await loadManifest(fullFallbackCacheDir, signal)) ?? null;
                    sourceEntryDir = fullFallbackCacheDir;
                    sourceFingerprint = fullFallbackFingerprint;
                }
                const readingFullFallback = fullFallbackCacheDir !== undefined && path.resolve(sourceEntryDir) === path.resolve(fullFallbackCacheDir);
                if (manifest) {
                    onProgress?.({ phase: 'cache-check', total: 1, completed: 0, detail: 'checking freshness' });
                    const diff = await computeManifestDiff(manifest, repoRoot, discoverOpts, signal);
                    onProgress?.({
                        phase: 'cache-check',
                        total: 1,
                        completed: 1,
                        detail: diff.hasChanges ? 'changes detected' : 'cache fresh',
                    });
                    if (!diff.hasChanges) {
                        if (currentSession) {
                            return currentSession;
                        }
                        const cached = await loadFromCache(signal, onProgress, sourceEntryDir, sourceFingerprint);
                        if (cached) {
                            currentSession = cached.session;
                            currentMeta = cached.meta;
                            currentManifest = cached.manifest;
                            retainEntry(cached.entryDir);
                            return cached.session;
                        }
                    }
                    else if (!readingFullFallback) {
                        const baseIndex = currentSession?.getIndex() ??
                            (await loadFromCache(signal, onProgress, sourceEntryDir, sourceFingerprint))?.session.getIndex();
                        if (baseIndex) {
                            const incremental = await applyIncrementalUpdate(diff, { index: baseIndex, manifest }, signal, onProgress, cooperative);
                            if (incremental) {
                                currentSession = incremental.session;
                                currentMeta = incremental.meta;
                                currentManifest = incremental.manifest;
                                retainEntry(cacheDir);
                                return incremental.session;
                            }
                        }
                    }
                }
                // Stale or no cache — rebuild
                const result = await buildAndPersist(signal, onProgress, cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                retainEntry(cacheDir);
                return result.session;
            });
        },
        async refresh(opts) {
            return serialized(async () => {
                await synchronizeCacheEntry();
                const result = await buildAndPersist(opts?.signal, opts?.onProgress, opts?.cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                retainEntry(cacheDir);
                return result.session;
            });
        },
        async update(changedFiles, opts) {
            abortIfNeeded(opts?.signal);
            return serialized(async () => {
                const { signal, onProgress, cooperative } = opts ?? {};
                abortIfNeeded(signal);
                await synchronizeCacheEntry();
                // Acquire a base index + manifest; missing either forces a full build.
                let baseIndex;
                let baseManifest = currentManifest;
                let cachedLoad = undefined;
                if (currentSession) {
                    baseIndex = currentSession.getIndex();
                }
                else {
                    cachedLoad = await loadFromCache(signal, onProgress);
                    if (cachedLoad) {
                        baseIndex = cachedLoad.session.getIndex();
                        baseManifest = cachedLoad.manifest;
                    }
                }
                if (!baseManifest)
                    baseManifest = (await loadManifest(cacheDir, signal)) ?? null;
                if (!baseIndex || !baseManifest) {
                    const result = await buildAndPersist(signal, onProgress, cooperative);
                    currentSession = result.session;
                    currentMeta = result.meta;
                    currentManifest = result.manifest;
                    retainEntry(cacheDir);
                    return result.session;
                }
                onProgress?.({ phase: 'cache-check', total: 1, completed: 0, detail: 'diffing changed files' });
                const diff = changedFiles && changedFiles.length > 0
                    ? await classifyChangedFiles(changedFiles, baseManifest, repoRoot, discoverOpts, signal)
                    : await computeManifestDiff(baseManifest, repoRoot, discoverOpts, signal);
                onProgress?.({
                    phase: 'cache-check',
                    total: 1,
                    completed: 1,
                    detail: diff.hasChanges ? 'changes detected' : 'no changes',
                });
                if (!diff.hasChanges) {
                    if (currentSession)
                        return currentSession;
                    if (cachedLoad) {
                        currentSession = cachedLoad.session;
                        currentMeta = cachedLoad.meta;
                        currentManifest = cachedLoad.manifest;
                        retainEntry(cachedLoad.entryDir);
                        return cachedLoad.session;
                    }
                    // baseIndex always comes from one of the two branches above.
                }
                const incremental = await applyIncrementalUpdate(diff, { index: baseIndex, manifest: baseManifest }, signal, onProgress, cooperative);
                if (incremental) {
                    currentSession = incremental.session;
                    currentMeta = incremental.meta;
                    currentManifest = incremental.manifest;
                    retainEntry(cacheDir);
                    return incremental.session;
                }
                const result = await buildAndPersist(signal, onProgress, cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                retainEntry(cacheDir);
                return result.session;
            });
        },
        async exportIndex(opts) {
            // getSession also checks resolver metadata before it considers the live
            // session, so even prefer-cache exports cannot leak a config-stale graph.
            const session = await this.getSession(opts);
            return session.getIndex();
        },
        async chunkIsolated(opts) {
            const { strategy, signal, onProgress, cooperative, ...chunkOptions } = opts ?? {};
            return chunkFromStoreIsolated({
                ...options,
                strategy,
                signal,
                cooperative,
                chunk: Object.keys(chunkOptions).length > 0 ? chunkOptions : undefined,
            }, onProgress);
        },
        dispose() {
            clearCurrent();
        },
        async clearCache() {
            // Serialized so an in-flight update's persistCache cannot recreate the
            // cache directory right after the rm, or race the temp-dir rename.
            await serialized(async () => {
                clearCurrent();
                try {
                    await rm(cacheRoot, { recursive: true, force: true });
                }
                catch {
                    // Ignore if already missing
                }
            });
        },
        async clear() {
            await this.clearCache();
        },
        getMetadata() {
            return currentMeta;
        },
        async peekMetadata() {
            return serialized(async () => {
                await synchronizeCacheEntry();
                if (currentMeta)
                    return currentMeta;
                const cacheMeta = (await loadValidCacheMeta()) ??
                    (fullFallbackCacheDir && fullFallbackFingerprint
                        ? await loadValidCacheMeta(fullFallbackCacheDir, fullFallbackFingerprint)
                        : undefined);
                return cacheMeta ? { ...cacheMeta, loadSource: 'cache' } : null;
            });
        },
        async isFresh(opts) {
            const signal = opts?.signal;
            abortIfNeeded(signal);
            // Keep this manifest-only probe outside the operation mutex. A config
            // edit is still a definite stale answer even though it has no code-file
            // add/modify/delete count to report.
            const previousFingerprint = fingerprint;
            if ((await synchronizeCacheEntry()) && previousFingerprint) {
                return { fresh: false, added: 0, modified: 0, deleted: 0 };
            }
            let manifest = currentManifest;
            let manifestEntryDir = currentEntryDir ?? cacheDir;
            if (!manifest) {
                // Only trust the on-disk manifest when the cache entry is this store's.
                if (await loadValidCacheMeta(cacheDir, fingerprint)) {
                    manifest = (await loadManifest(cacheDir, signal)) ?? null;
                    manifestEntryDir = cacheDir;
                }
                else if (fullFallbackCacheDir &&
                    fullFallbackFingerprint &&
                    (await loadValidCacheMeta(fullFallbackCacheDir, fullFallbackFingerprint))) {
                    manifest = (await loadManifest(fullFallbackCacheDir, signal)) ?? null;
                    manifestEntryDir = fullFallbackCacheDir;
                }
                else {
                    return null;
                }
            }
            if (!manifest)
                return null;
            // Keep the selected entry visible to a concurrent in-process prune for
            // the duration of this manifest read, even before a session is loaded.
            const release = retainCacheEntry(manifestEntryDir);
            try {
                const diff = await computeManifestDiff(manifest, repoRoot, discoverOpts, signal);
                return {
                    fresh: !diff.hasChanges,
                    added: diff.added.length,
                    modified: diff.modified.length,
                    deleted: diff.deleted.length,
                };
            }
            finally {
                release();
            }
        },
    };
}
export function openChunkCodeIndexStore(options) {
    return openCodeIndexStore({
        ...options,
        contentMode: 'full',
    });
}
export const openChunkStore = openChunkCodeIndexStore;
//# sourceMappingURL=store.js.map