import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
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, writeSectionedCodeIndex } from './index-io.js';
import { chunkFromStoreIsolated } from './isolated.js';
import { normalizePath } from './languages.js';
import { createCodeQuerySessionFromIndex } from './session.js';
import { abortIfNeeded, createYielder, isCooperative } from '../utils.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SCHEMA_VERSION = 1;
// 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;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildOptionFingerprint(opts) {
    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',
    });
    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 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;
    }
}
async function persistCache(cacheDir, index, buildOpts, signal, onProgress, precomputedManifest) {
    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));
                return { relativePath, size: fileStat.size, mtimeMs: fileStat.mtimeMs };
            }
            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,
        buildOptionFingerprint: buildOptionFingerprint(buildOpts),
        contentMode: index.contentMode,
    };
    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);
        await rm(cacheDir, { recursive: true, force: true });
        await rename(tempDir, cacheDir);
    }
    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 };
}
// ---------------------------------------------------------------------------
// Store implementation
// ---------------------------------------------------------------------------
export function openCodeIndexStore(options) {
    const repoRoot = path.resolve(options.repoRoot);
    const cacheRoot = path.resolve(options.cacheDir);
    const fingerprint = buildOptionFingerprint(options);
    const contentMode = options.contentMode ?? 'excerpt';
    const cacheDir = cacheEntryDir(cacheRoot, contentMode, fingerprint);
    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,
    };
    const discoverOpts = {
        excludeDir: options.excludeDir,
        noGitignore: options.noGitignore,
        ignorePatterns: options.ignorePatterns,
        overlayIgnorePatterns: options.overlayIgnorePatterns,
    };
    let currentSession = null;
    let currentMeta = null;
    let currentManifest = null;
    // 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) {
        abortIfNeeded(signal);
        const { index, fileStats } = await buildCodebaseIndexWithStats({ ...buildOpts, cooperative }, onProgress, signal);
        abortIfNeeded(signal);
        // 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 } = await persistCache(cacheDir, index, options, signal, onProgress, manifestFromBuild);
        const session = createCodeQuerySessionFromIndex(buildOpts, index);
        const meta = {
            ...cacheMeta,
            loadSource: 'build',
            updateKind: 'full',
        };
        return { session, meta, manifest };
    }
    /**
     * 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 fresh = await parseCodeFiles(reparseList, {
            repoRoot,
            contentMode,
            maxFileSizeBytes: options.maxFileSizeBytes,
            maxLines: options.maxLines,
            skipMinified: options.skipMinified,
            concurrency: options.parseConcurrency,
        }, onProgress, signal, yielder);
        abortIfNeeded(signal);
        const removals = new Set([...deletedPaths, ...reparseList]);
        const { parsedFiles, skippedFiles } = spliceIndexInputs(base.index.files, base.index.skippedFiles, removals, fresh);
        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 => ({ relativePath: entry.relativePath, size: entry.size, mtimeMs: entry.mtimeMs })),
            ...impactedEntries,
        ];
        const { meta: cacheMeta } = await persistCache(cacheDir, index, options, signal, onProgress, 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() {
        const cacheMeta = await loadCacheMeta(cacheDir);
        if (!cacheMeta)
            return undefined;
        if (cacheMeta.buildOptionFingerprint !== fingerprint)
            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) {
        onProgress?.({ phase: 'cache-check', total: 1, completed: 0, detail: 'checking cache' });
        const cacheMeta = await loadValidCacheMeta();
        if (!cacheMeta)
            return undefined;
        onProgress?.({ phase: 'deserialize', total: 1, completed: 0, detail: 'loading index' });
        const cachedIndex = await loadCachedIndex(cacheDir, signal);
        if (!cachedIndex)
            return undefined;
        cachedIndex.contentMode ??= options.contentMode ?? 'excerpt';
        cachedIndex.skippedFiles ??= [];
        onProgress?.({ phase: 'deserialize', total: 1, completed: 1, detail: 'index loaded' });
        const session = createCodeQuerySessionFromIndex(buildOpts, cachedIndex);
        const meta = {
            ...cacheMeta,
            loadSource: 'cache',
        };
        return {
            session,
            meta,
            manifest: (await loadManifest(cacheDir, signal)) ?? null,
        };
    }
    return {
        async getSession(opts) {
            abortIfNeeded(opts?.signal);
            return serialized(async () => {
                const strategy = opts?.strategy ?? 'prefer-cache';
                const { signal, onProgress, cooperative } = opts ?? {};
                abortIfNeeded(signal);
                if (strategy === 'rebuild') {
                    const result = await buildAndPersist(signal, onProgress, cooperative);
                    currentSession = result.session;
                    currentMeta = result.meta;
                    currentManifest = result.manifest;
                    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;
                        return cached.session;
                    }
                    // Cache miss — build
                    const result = await buildAndPersist(signal, onProgress, cooperative);
                    currentSession = result.session;
                    currentMeta = result.meta;
                    currentManifest = result.manifest;
                    return result.session;
                }
                // ensure-fresh
                const manifest = currentManifest ?? (await loadManifest(cacheDir, signal));
                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);
                        if (cached) {
                            currentSession = cached.session;
                            currentMeta = cached.meta;
                            currentManifest = cached.manifest;
                            return cached.session;
                        }
                    }
                    else {
                        const baseIndex = currentSession?.getIndex() ?? (await loadFromCache(signal, onProgress))?.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;
                                return incremental.session;
                            }
                        }
                    }
                }
                // Stale or no cache — rebuild
                const result = await buildAndPersist(signal, onProgress, cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                return result.session;
            });
        },
        async refresh(opts) {
            return serialized(async () => {
                const result = await buildAndPersist(opts?.signal, opts?.onProgress, opts?.cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                return result.session;
            });
        },
        async update(changedFiles, opts) {
            abortIfNeeded(opts?.signal);
            return serialized(async () => {
                const { signal, onProgress, cooperative } = opts ?? {};
                abortIfNeeded(signal);
                // 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;
                    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;
                        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;
                    return incremental.session;
                }
                const result = await buildAndPersist(signal, onProgress, cooperative);
                currentSession = result.session;
                currentMeta = result.meta;
                currentManifest = result.manifest;
                return result.session;
            });
        },
        async exportIndex(opts) {
            // 'ensure-fresh'/'rebuild' must reconcile with the working tree; only the
            // default 'prefer-cache' may short-circuit on the in-memory session.
            const strategy = opts?.strategy ?? 'prefer-cache';
            if (strategy === 'prefer-cache' && currentSession) {
                return currentSession.getIndex();
            }
            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() {
            currentSession = null;
            currentMeta = null;
            currentManifest = null;
        },
        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 () => {
                currentSession = null;
                currentMeta = null;
                currentManifest = null;
                try {
                    await rm(cacheRoot, { recursive: true, force: true });
                }
                catch {
                    // Ignore if already missing
                }
            });
        },
        async clear() {
            await this.clearCache();
        },
        getMetadata() {
            return currentMeta;
        },
        async peekMetadata() {
            if (currentMeta)
                return currentMeta;
            const cacheMeta = await loadValidCacheMeta();
            return cacheMeta ? { ...cacheMeta, loadSource: 'cache' } : null;
        },
        async isFresh(opts) {
            const signal = opts?.signal;
            abortIfNeeded(signal);
            let manifest = currentManifest;
            if (!manifest) {
                // Only trust the on-disk manifest when the cache entry is this store's.
                if (!(await loadValidCacheMeta()))
                    return null;
                manifest = (await loadManifest(cacheDir, signal)) ?? null;
            }
            if (!manifest)
                return null;
            const diff = await computeManifestDiff(manifest, repoRoot, discoverOpts, signal);
            return {
                fresh: !diff.hasChanges,
                added: diff.added.length,
                modified: diff.modified.length,
                deleted: diff.deleted.length,
            };
        },
    };
}
export function openChunkCodeIndexStore(options) {
    return openCodeIndexStore({
        ...options,
        contentMode: 'full',
    });
}
export const openChunkStore = openChunkCodeIndexStore;
//# sourceMappingURL=store.js.map