import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { once } from 'node:events';
import path from 'node:path';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
import { isOccAbortError } from '../../errors.js';
import { abortIfNeeded, compareCodeUnits } from '../../utils.js';
import { isAncestorCommit, readEntityRepoIdentity, repoIdentityKey, resolveCommit } from './git.js';
import { blobEntityKey, createMemoryBlobCache, indexHistory } from './indexer.js';
import { createLineageState } from './lineage.js';
import { ENTITY_PARSER_CONTRACT, entityStateKey } from './snapshot.js';
import { DEFAULT_ENTITY_BOUNDS, DEFAULT_LINEAGE_THRESHOLDS, ENTITY_CACHE_SCHEMA_VERSION, bumpCounter, createEntityCounters, } from './types.js';
import { ENTITY_HASH_VERSION } from './hash.js';
import { TRUNCATION_CODES } from './evidence.js';
import { resolveEntityCacheDir } from '../cache-paths.js';
import { acquireCacheEntryWriteLock, retainCacheEntry, touchCacheEntry } from '../cache-management.js';
/**
 * Cache directory, resolved outside the analyzed repository by default.
 *
 * A cache is derived data about a repository, not part of it; writing it into
 * the working tree would show up in `git status` and in every subsequent diff.
 * A caller who wants it there can always say so explicitly.
 */
export { resolveEntityCacheDir } from '../cache-paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let cachedOccVersion;
/** Resolved from package.json, which sits two or three levels up in src/ vs dist/src/. */
async function readOccVersion() {
    if (cachedOccVersion)
        return cachedOccVersion;
    for (const relative of ['../../..', '../../../..']) {
        try {
            const raw = await readFile(path.resolve(__dirname, relative, 'package.json'), 'utf8');
            const parsed = JSON.parse(raw);
            if (parsed.version) {
                cachedOccVersion = parsed.version;
                return cachedOccVersion;
            }
        }
        catch {
            /* try the next candidate */
        }
    }
    cachedOccVersion = '0.0.0';
    return cachedOccVersion;
}
const EntityCacheMetaSchema = z.object({
    schemaVersion: z.number().int(),
    hashVersion: z.number().int(),
    occVersion: z.string(),
    repoRoot: z.string(),
    repoIdentity: z.string(),
    optionsFingerprint: z.string(),
    indexedHead: z.string().optional(),
    commitCount: z.number().int().nonnegative(),
    eventCount: z.number().int().nonnegative(),
    lineageCount: z.number().int().nonnegative(),
    truncated: z.boolean(),
    builtAt: z.string(),
});
const SharedBlobCacheMetaSchema = z.object({
    hashVersion: z.number().int(),
    parserContract: z.object({
        parserName: z.string(),
        parserVersion: z.number().int(),
    }),
    maxEntitiesPerFile: z.number().int().positive(),
    formats: z.boolean(),
    repoIdentity: z.string(),
    contractDigest: z.string().regex(/^[0-9a-f]{64}$/),
    blobCount: z.number().int().nonnegative(),
    builtAt: z.string(),
});
/** Historical entity indexing always includes the supported data formats. */
const ENTITY_BLOB_FORMATS = true;
export { TRUNCATION_CODES } from './evidence.js';
function fingerprint(input) {
    const material = JSON.stringify({
        repoRoot: path.resolve(input.repoRoot),
        repoIdentity: input.repoIdentity,
        bounds: input.bounds,
        thresholds: input.thresholds,
        guardrails: input.guardrails ?? null,
    });
    return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, 16);
}
function entryDir(cacheDir, key) {
    return path.join(cacheDir, `v${ENTITY_CACHE_SCHEMA_VERSION}`, key);
}
function blobContractDigest(maxEntitiesPerFile) {
    const material = JSON.stringify({
        hashVersion: ENTITY_HASH_VERSION,
        parserContract: ENTITY_PARSER_CONTRACT,
        maxEntitiesPerFile,
        formats: ENTITY_BLOB_FORMATS,
    });
    return createHash('sha256').update(material, 'utf8').digest('hex');
}
function sharedBlobDir(cacheDir, repoIdentity, maxEntitiesPerFile) {
    return path.join(cacheDir, 'blobs', repoIdentity, blobContractDigest(maxEntitiesPerFile));
}
/**
 * Read a cache entry's metadata, or nothing.
 *
 * Deliberately separate from the body: the metadata is a few hundred bytes and
 * decides *which* of the bodies the run will actually consume, so reading it
 * first is what makes a warm reopen cheap. Any failure — missing, truncated,
 * corrupt, or written by an incompatible version — resolves to `undefined` so
 * the caller rebuilds. A cache is an optimisation, and an optimisation that can
 * fail a command is a liability.
 */
async function loadCacheMeta(dir, expected, counters) {
    try {
        const meta = EntityCacheMetaSchema.parse(JSON.parse(await readCacheFile(path.join(dir, 'meta.json'), counters)));
        if (meta.schemaVersion !== expected.schemaVersion)
            return undefined;
        if (meta.hashVersion !== expected.hashVersion)
            return undefined;
        if (meta.occVersion !== expected.occVersion)
            return undefined;
        if (meta.repoIdentity !== expected.repoIdentity)
            return undefined;
        if (meta.optionsFingerprint !== expected.optionsFingerprint)
            return undefined;
        if (path.resolve(meta.repoRoot) !== path.resolve(expected.repoRoot))
            return undefined;
        return meta;
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        return undefined;
    }
}
/**
 * Read a cache entry's contents, or nothing.
 *
 * Each NDJSON body is streamed so a large index is never duplicated as one
 * giant UTF-16 JavaScript string before its records can be decoded. Metadata
 * counts and the commit tail form the commit point: an interrupted append
 * leaves them disagreeing and this reader rejects the entire lineage entry.
 */
async function loadCacheBody(dir, meta, counters, signal) {
    try {
        const [commits, events, heads] = await Promise.all([
            readNdjsonFile(path.join(dir, 'commits.ndjson'), counters, signal),
            readNdjsonFile(path.join(dir, 'events.ndjson'), counters, signal),
            readNdjsonFile(path.join(dir, 'state.ndjson'), counters, signal),
        ]);
        if (commits.length !== meta.commitCount)
            return undefined;
        if (events.length !== meta.eventCount)
            return undefined;
        if (heads.length !== meta.lineageCount)
            return undefined;
        if (commits.at(-1)?.sha !== meta.indexedHead)
            return undefined;
        return { commits, events, heads };
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        return undefined;
    }
}
/** Stream one NDJSON file while charging the exact bytes consumed. */
async function readNdjsonFile(file, counters, signal) {
    const values = [];
    const stream = createReadStream(file);
    stream.on('data', chunk => {
        bumpCounter(counters, 'cacheBytesRead', Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk));
    });
    const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
    try {
        for await (const line of reader) {
            abortIfNeeded(signal);
            if (line)
                values.push(JSON.parse(line));
        }
    }
    finally {
        reader.close();
        stream.destroy();
    }
    return values;
}
/** Read one cache file, charging its size to the run's deserialization budget. */
async function readCacheFile(file, counters) {
    const raw = await readFile(file, 'utf8');
    bumpCounter(counters, 'cacheBytesRead', Buffer.byteLength(raw));
    return raw;
}
function temporarySibling(file) {
    return `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
async function writeNdjsonStream(file, values, options = {}) {
    const stream = createWriteStream(file, { encoding: 'utf8', flags: options.append ? 'a' : 'w' });
    let needsSeparator = options.hasExistingRows ?? false;
    try {
        for (const value of values) {
            abortIfNeeded(options.signal);
            const serialized = `${needsSeparator ? '\n' : ''}${JSON.stringify(value)}`;
            needsSeparator = true;
            if (!stream.write(serialized))
                await once(stream, 'drain');
        }
        stream.end();
        await once(stream, 'finish');
    }
    finally {
        stream.destroy();
    }
}
async function replaceNdjsonFile(file, values, signal) {
    const temp = temporarySibling(file);
    try {
        await writeNdjsonStream(temp, values, { signal });
        abortIfNeeded(signal);
        await rename(temp, file);
    }
    catch (error) {
        await rm(temp, { force: true }).catch(() => undefined);
        throw error;
    }
}
async function replaceJsonFile(file, value, signal) {
    const temp = temporarySibling(file);
    try {
        abortIfNeeded(signal);
        await writeFile(temp, JSON.stringify(value, null, 2));
        abortIfNeeded(signal);
        await rename(temp, file);
    }
    catch (error) {
        await rm(temp, { force: true }).catch(() => undefined);
        throw error;
    }
}
function sameCacheBase(left, right) {
    return (left.indexedHead === right.indexedHead &&
        left.commitCount === right.commitCount &&
        left.eventCount === right.eventCount &&
        left.lineageCount === right.lineageCount);
}
/**
 * Persist lineage state with metadata as the commit record.
 *
 * A normal extension appends only its new commit/event tails, atomically swaps
 * the state snapshot, then atomically swaps metadata last. A full rebuild uses
 * the same temp-sibling swaps, so every old file remains present until its
 * replacement rename succeeds and a partial replacement is rejected on read.
 */
async function persistCache(dir, payload, extension, signal) {
    await mkdir(path.dirname(dir), { recursive: true });
    const releaseWriteLock = await acquireCacheEntryWriteLock(dir, signal);
    try {
        await mkdir(dir, { recursive: true });
        const currentMeta = extension ? await loadCacheMeta(dir, payload.meta) : undefined;
        const canAppend = currentMeta !== undefined && extension !== undefined && sameCacheBase(currentMeta, extension.previousMeta);
        if (canAppend) {
            await writeNdjsonStream(path.join(dir, 'commits.ndjson'), extension.commits, {
                append: true,
                hasExistingRows: currentMeta.commitCount > 0,
                signal,
            });
            await writeNdjsonStream(path.join(dir, 'events.ndjson'), extension.events, {
                append: true,
                hasExistingRows: currentMeta.eventCount > 0,
                signal,
            });
        }
        else {
            await replaceNdjsonFile(path.join(dir, 'commits.ndjson'), payload.commits, signal);
            await replaceNdjsonFile(path.join(dir, 'events.ndjson'), payload.events, signal);
        }
        await replaceNdjsonFile(path.join(dir, 'state.ndjson'), payload.heads, signal);
        await replaceJsonFile(path.join(dir, 'meta.json'), payload.meta, signal);
        // V3 entries written before the split kept this large file beside lineage
        // state. It is no longer read; remove it only after the new commit record is
        // durable so an interrupted migration still has its old optimisation.
        await rm(path.join(dir, 'blobs.ndjson'), { force: true }).catch(() => undefined);
    }
    finally {
        await releaseWriteLock();
    }
}
function sharedBlobIdentity(repoIdentity, maxEntitiesPerFile) {
    return {
        hashVersion: ENTITY_HASH_VERSION,
        parserContract: ENTITY_PARSER_CONTRACT,
        maxEntitiesPerFile,
        formats: ENTITY_BLOB_FORMATS,
        repoIdentity,
        contractDigest: blobContractDigest(maxEntitiesPerFile),
    };
}
function sharedBlobMetaMatches(meta, expected) {
    return (meta.hashVersion === expected.hashVersion &&
        meta.parserContract.parserName === expected.parserContract.parserName &&
        meta.parserContract.parserVersion === expected.parserContract.parserVersion &&
        meta.maxEntitiesPerFile === expected.maxEntitiesPerFile &&
        meta.formats === expected.formats &&
        meta.repoIdentity === expected.repoIdentity &&
        meta.contractDigest === expected.contractDigest);
}
async function loadSharedBlobCacheUnlocked(dir, expected, counters, signal) {
    try {
        const meta = await loadSharedBlobMetaUnlocked(dir, expected, counters);
        if (!meta)
            return undefined;
        const blobs = await readNdjsonFile(path.join(dir, 'blobs.ndjson'), counters, signal);
        if (blobs.length !== meta.blobCount)
            return undefined;
        const keys = new Set();
        for (const blob of blobs) {
            if (blob.hashVersion !== expected.hashVersion)
                return undefined;
            const key = blobEntityKey(blob.blobSha, blob.relativePath);
            if (keys.has(key))
                return undefined;
            keys.add(key);
        }
        return { meta, blobs };
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        return undefined;
    }
}
async function loadSharedBlobMetaUnlocked(dir, expected, counters) {
    try {
        const meta = SharedBlobCacheMetaSchema.parse(JSON.parse(await readCacheFile(path.join(dir, 'meta.json'), counters)));
        return sharedBlobMetaMatches(meta, expected) ? meta : undefined;
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        return undefined;
    }
}
async function loadSharedBlobCache(dir, expected, counters, signal) {
    await mkdir(path.dirname(dir), { recursive: true });
    const releaseWriteLock = await acquireCacheEntryWriteLock(dir, signal);
    try {
        return (await loadSharedBlobCacheUnlocked(dir, expected, counters, signal)) ?? { blobs: [] };
    }
    finally {
        await releaseWriteLock();
    }
}
function sameSharedBlobBase(left, right) {
    return left.blobCount === right.blobCount && left.builtAt === right.builtAt;
}
function mergeBlobCandidates(existing, candidates) {
    const byKey = new Map();
    for (const blob of existing)
        byKey.set(blobEntityKey(blob.blobSha, blob.relativePath), blob);
    const additions = [];
    for (const blob of candidates) {
        const key = blobEntityKey(blob.blobSha, blob.relativePath);
        if (byKey.has(key))
            continue;
        byKey.set(key, blob);
        additions.push(blob);
    }
    return { blobs: [...byKey.values()], additions };
}
async function persistSharedBlobCache(dir, expected, base, candidates, signal) {
    await mkdir(path.dirname(dir), { recursive: true });
    const releaseWriteLock = await acquireCacheEntryWriteLock(dir, signal);
    try {
        const currentMeta = await loadSharedBlobMetaUnlocked(dir, expected);
        const baseStillCurrent = (base.meta === undefined && currentMeta === undefined) ||
            (base.meta !== undefined && currentMeta !== undefined && sameSharedBlobBase(base.meta, currentMeta));
        // The normal path compares only the small metadata receipt under the lock:
        // the body just streamed into `base` cannot have changed without metadata
        // changing last. Only a competing writer forces the rare second body load.
        const current = baseStillCurrent ? base : await loadSharedBlobCacheUnlocked(dir, expected, undefined, signal);
        const merged = mergeBlobCandidates(current?.blobs ?? [], candidates);
        if (!current?.meta && merged.blobs.length === 0)
            return;
        if (current?.meta && merged.additions.length === 0)
            return;
        await mkdir(dir, { recursive: true });
        if (current?.meta) {
            await writeNdjsonStream(path.join(dir, 'blobs.ndjson'), merged.additions, {
                append: true,
                hasExistingRows: current.meta.blobCount > 0,
                signal,
            });
        }
        else {
            await replaceNdjsonFile(path.join(dir, 'blobs.ndjson'), merged.blobs, signal);
        }
        const meta = {
            ...expected,
            parserContract: { ...expected.parserContract },
            blobCount: merged.blobs.length,
            builtAt: new Date().toISOString(),
        };
        await replaceJsonFile(path.join(dir, 'meta.json'), meta, signal);
    }
    finally {
        await releaseWriteLock();
    }
}
function byPathFromState(state) {
    const byPath = new Map();
    for (const head of state.byLineageId.values()) {
        const bucket = byPath.get(head.record.relativePath);
        if (bucket)
            bucket.push(head.record);
        else
            byPath.set(head.record.relativePath, [head.record]);
    }
    for (const records of byPath.values()) {
        records.sort((a, b) => a.declarationIndex - b.declarationIndex || compareCodeUnits(a.qualifiedName, b.qualifiedName));
    }
    return byPath;
}
function stateFromHeads(heads) {
    const state = createLineageState();
    for (const head of heads) {
        state.byLineageId.set(head.lineageId, head);
        state.byStateKey.set(entityStateKey(head.record), head.lineageId);
    }
    return state;
}
function groupEvents(events) {
    const byLineage = new Map();
    for (const event of events) {
        const bucket = byLineage.get(event.lineageId);
        if (bucket)
            bucket.push(event);
        else
            byLineage.set(event.lineageId, [event]);
    }
    return byLineage;
}
/**
 * The newest indexed commit that is still reachable from the current head.
 *
 * Along a first-parent chain, reachability is monotonic — if a commit is an
 * ancestor of the new head then so is every commit before it — so the boundary
 * can be found in log2(n) probes rather than by walking.
 */
async function findRewindPoint(commits, head, options) {
    let low = 0;
    let high = commits.length - 1;
    let found = -1;
    while (low <= high) {
        const mid = (low + high) >> 1;
        if (await isAncestorCommit(commits[mid].sha, head, options)) {
            found = mid;
            low = mid + 1;
        }
        else {
            high = mid - 1;
        }
    }
    return found;
}
export function openEntityStore(options) {
    const repoRoot = path.resolve(options.repoRoot);
    const bounds = options.bounds ?? DEFAULT_ENTITY_BOUNDS;
    const thresholds = options.thresholds ?? DEFAULT_LINEAGE_THRESHOLDS;
    const cacheDir = options.cacheDir === false ? undefined : resolveEntityCacheDir(options.cacheDir);
    let current;
    let pending;
    let resolvedDir;
    let resolvedBlobDir;
    let releaseRetainedEntries = [];
    function releaseEntries() {
        for (const release of releaseRetainedEntries)
            release();
        releaseRetainedEntries = [];
    }
    function retainEntries(dirs) {
        releaseEntries();
        const unique = new Set(dirs.filter((dir) => dir !== undefined).map(dir => path.resolve(dir)));
        releaseRetainedEntries = [...unique].map(dir => retainCacheEntry(dir));
    }
    async function build(signal, onProgress) {
        const counters = createEntityCounters();
        const gitOptions = { repoRoot, signal, counters };
        const diagnostics = [];
        const identity = await readEntityRepoIdentity(gitOptions);
        const repoIdentity = repoIdentityKey(identity);
        if (identity.shallow) {
            diagnostics.push({
                code: 'shallow-history',
                message: 'Repository history is shallow; entities older than the graft point cannot be attributed.',
            });
        }
        const head = await resolveCommit(options.head ?? 'HEAD', gitOptions);
        if (!head) {
            diagnostics.push({
                code: 'unborn-repo',
                message: 'Repository has no commits, so there is no history to index.',
            });
            return emptyIndex('fresh', diagnostics, counters);
        }
        const occVersion = await readOccVersion();
        const optionsFingerprint = fingerprint({
            repoRoot,
            repoIdentity,
            bounds,
            thresholds,
            guardrails: options.guardrails,
        });
        const dir = cacheDir ? entryDir(cacheDir, optionsFingerprint) : undefined;
        const blobDir = cacheDir ? sharedBlobDir(cacheDir, repoIdentity, bounds.maxEntitiesPerFile) : undefined;
        resolvedDir = dir;
        resolvedBlobDir = blobDir;
        const expected = {
            schemaVersion: ENTITY_CACHE_SCHEMA_VERSION,
            hashVersion: ENTITY_HASH_VERSION,
            occVersion,
            repoRoot,
            repoIdentity,
            optionsFingerprint,
        };
        const expectedBlobs = sharedBlobIdentity(repoIdentity, bounds.maxEntitiesPerFile);
        onProgress?.({ phase: 'cache-check', scope: 'entity', total: 1, completed: 0, detail: 'reading cache metadata' });
        const meta = dir ? await loadCacheMeta(dir, expected, counters) : undefined;
        // The mode is settled from the metadata alone, before a single index body is
        // read, because it decides which bodies are worth reading at all.
        let mode = 'fresh';
        if (meta?.indexedHead === head)
            mode = 'unchanged';
        else if (meta?.indexedHead) {
            mode = (await isAncestorCommit(meta.indexedHead, head, gitOptions)) ? 'extended' : 'rewound';
        }
        onProgress?.({ phase: 'cache-check', scope: 'entity', total: 1, completed: 1, detail: mode });
        // An unchanged index answers from its lineage heads and event log; no walk
        // runs, so the shared parsed-blob cache is never consulted and must not be
        // paid for. Every other mode walks, and reparsing what the cache already
        // holds would cost far more than streaming it.
        let cached;
        if (dir && meta) {
            onProgress?.({
                phase: 'cache-load',
                scope: 'entity',
                total: 1,
                completed: 0,
                detail: mode === 'unchanged' ? 'lineage state' : 'lineage state and shared parsed blobs',
            });
            cached = await loadCacheBody(dir, meta, counters, signal);
            // An admissible entry whose bodies will not read is no entry at all.
            if (!cached) {
                diagnostics.push({
                    code: 'cache-rebuilt',
                    message: 'The lineage cache metadata disagreed with its streamed bodies; rebuilding instead of serving it.',
                });
                mode = 'fresh';
            }
        }
        const sharedBlobs = blobDir && mode !== 'unchanged'
            ? await loadSharedBlobCache(blobDir, expectedBlobs, counters, signal)
            : { blobs: [] };
        if (dir && meta) {
            onProgress?.({ phase: 'cache-load', scope: 'entity', total: 1, completed: 1, detail: mode });
        }
        const blobCache = createMemoryBlobCache(sharedBlobs.blobs);
        let state = createLineageState();
        let commits = [];
        let events = [];
        let sinceCommit;
        let extensionCommitCount = 0;
        let extensionEventCount = 0;
        // Reusing a bounded index inherits its bounds. Recomputing `truncated` from
        // this run's diagnostics alone would let a warm read claim a completeness
        // the build it reused never had.
        let inheritedTruncation = false;
        if (cached && meta?.indexedHead) {
            if (mode === 'unchanged') {
                state = stateFromHeads(cached.heads);
                commits = cached.commits;
                events = cached.events;
                inheritedTruncation = meta.truncated;
                bumpCounter(counters, 'cacheHits');
            }
            else if (mode === 'extended') {
                state = stateFromHeads(cached.heads);
                commits = [...cached.commits];
                events = [...cached.events];
                extensionCommitCount = commits.length;
                extensionEventCount = events.length;
                sinceCommit = meta.indexedHead;
                inheritedTruncation = meta.truncated;
            }
            else if (mode === 'rewound') {
                // History was rewritten. The event log and the head snapshot describe
                // commits that no longer exist, so both are discarded — but blobs are
                // content-addressed, so the expensive half of the work survives and the
                // rebuild reparses nothing.
                const rewindPoint = await findRewindPoint(cached.commits, head, gitOptions);
                diagnostics.push({
                    code: 'history-rewritten',
                    message: `Indexed head ${meta.indexedHead.slice(0, 12)} is no longer reachable; reindexing from ${rewindPoint >= 0 ? `${cached.commits[rewindPoint].sha.slice(0, 12)} onward` : 'the beginning'}.`,
                    commit: meta.indexedHead,
                });
                diagnostics.push({
                    code: 'cache-rewound',
                    message: `Dropped ${cached.commits.length - rewindPoint - 1} commit(s) and ${cached.events.length} event(s); parsed blobs were kept.`,
                    kept: rewindPoint + 1,
                    total: cached.commits.length,
                });
            }
        }
        if (inheritedTruncation) {
            diagnostics.push({
                code: 'commit-window-truncated',
                message: `Reusing an index built under a ${bounds.maxCommits} commit window; older commits were never indexed.`,
                kept: commits.length,
                limit: bounds.maxCommits,
            });
        }
        let byPath = byPathFromState(state);
        if (mode !== 'unchanged') {
            const walk = (from, offset) => indexHistory({
                repoRoot,
                repoIdentity,
                state,
                byPath,
                head,
                sinceCommit: from,
                // An extension continues the existing numbering rather than restarting
                // it, so "which of these two events came first" stays answerable across
                // every walk that built the index.
                orderOffset: offset,
                bounds,
                thresholds,
                guardrails: options.guardrails,
                cache: blobCache,
                signal,
                counters,
                onProgress,
            });
            let walked = await walk(sinceCommit, mode === 'extended' ? commits.length : 0);
            // More commits landed than the window can hold, so the extension cannot
            // reach back to what it was extending. Splicing it on would leave a hole
            // in the middle of the commit list that nothing downstream is entitled to
            // assume is there. Start over instead — the cached state is discarded,
            // but the blob cache is content-addressed, so nothing is reparsed.
            if (walked.gapped) {
                diagnostics.push({
                    code: 'cache-rewound',
                    message: `More than ${bounds.maxCommits} commit(s) landed since ${(meta?.indexedHead ?? head).slice(0, 12)}; ` +
                        'the extension could not reach the indexed head, so the window was rebuilt from the current one.',
                    kept: 0,
                    total: commits.length,
                });
                mode = 'rewound';
                state = createLineageState();
                // Derived from `state`, so it has to be re-derived with it; a stale
                // path map would hand the rebuild the very snapshot it is discarding.
                byPath = byPathFromState(state);
                commits = [];
                events = [];
                inheritedTruncation = false;
                walked = await walk(undefined, 0);
            }
            commits = mode === 'extended' ? [...commits, ...walked.commits] : walked.commits;
            events = mode === 'extended' ? [...events, ...walked.events] : walked.events;
            diagnostics.push(...walked.diagnostics);
        }
        const status = {
            mode,
            head,
            commitsIndexed: commits.length,
            eventsRecorded: events.length,
            lineageCount: state.byLineageId.size,
            truncated: diagnostics.some(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
            diagnostics,
        };
        if (dir && mode !== 'unchanged') {
            const blobs = [];
            let entityTotal = 0;
            for (const blob of blobCache.entries()) {
                if (entityTotal > bounds.maxEntitiesTotal)
                    break;
                entityTotal += blob.entities.length;
                blobs.push(blob);
            }
            onProgress?.({ phase: 'cache-save', scope: 'entity', total: 1, completed: 0, detail: `${blobs.length} blob(s)` });
            if (blobDir)
                await persistSharedBlobCache(blobDir, expectedBlobs, sharedBlobs, blobs, signal);
            const persistedMeta = {
                ...expected,
                indexedHead: head,
                commitCount: commits.length,
                eventCount: events.length,
                lineageCount: state.byLineageId.size,
                truncated: status.truncated,
                builtAt: new Date().toISOString(),
            };
            await persistCache(dir, {
                meta: persistedMeta,
                commits,
                events,
                heads: [...state.byLineageId.values()],
            }, mode === 'extended' && meta
                ? {
                    previousMeta: meta,
                    commits: commits.slice(extensionCommitCount),
                    events: events.slice(extensionEventCount),
                }
                : undefined, signal);
            bumpCounter(counters, 'cacheWrites');
            onProgress?.({ phase: 'cache-save', scope: 'entity', total: 1, completed: 1 });
        }
        onProgress?.({
            phase: 'complete',
            scope: 'entity',
            total: commits.length,
            completed: commits.length,
            detail: `${state.byLineageId.size} lineage(s), ${events.length} event(s)`,
        });
        if (dir)
            await touchCacheEntry(dir);
        if (blobDir && mode !== 'unchanged')
            await touchCacheEntry(blobDir);
        return {
            parserContract: ENTITY_PARSER_CONTRACT,
            status,
            state,
            byPath,
            commits,
            events,
            eventsByLineage: groupEvents(events),
            commitBySha: new Map(commits.map(commit => [commit.sha, commit])),
            counters,
        };
    }
    function emptyIndex(mode, diagnostics, counters) {
        return {
            parserContract: ENTITY_PARSER_CONTRACT,
            status: { mode, commitsIndexed: 0, eventsRecorded: 0, lineageCount: 0, truncated: false, diagnostics },
            state: createLineageState(),
            byPath: new Map(),
            commits: [],
            events: [],
            eventsByLineage: new Map(),
            commitBySha: new Map(),
            counters,
        };
    }
    /**
     * The cache entry this repository and these bounds would use.
     *
     * Recomputed rather than remembered from the last build, because a caller may
     * probe before ever building — which is exactly when knowing whether a warm
     * entry exists is worth the two git calls it costs.
     */
    async function entryFor(signal) {
        if (!cacheDir)
            return undefined;
        const identity = await readEntityRepoIdentity({ repoRoot, signal });
        const repoIdentity = repoIdentityKey(identity);
        const optionsFingerprint = fingerprint({
            repoRoot,
            repoIdentity,
            bounds,
            thresholds,
            guardrails: options.guardrails,
        });
        return {
            dir: entryDir(cacheDir, optionsFingerprint),
            expected: {
                schemaVersion: ENTITY_CACHE_SCHEMA_VERSION,
                hashVersion: ENTITY_HASH_VERSION,
                occVersion: await readOccVersion(),
                repoRoot,
                repoIdentity,
                optionsFingerprint,
            },
        };
    }
    async function peekMetadata(signal) {
        const entry = await entryFor(signal);
        if (!entry)
            return null;
        return (await loadCacheMeta(entry.dir, entry.expected)) ?? null;
    }
    return {
        get cacheDir() {
            return resolvedDir ?? cacheDir;
        },
        async index(callOptions = {}) {
            if (current && !callOptions.refresh)
                return current;
            if (pending)
                return pending;
            if (current && callOptions.refresh) {
                const signal = callOptions.signal ?? options.signal;
                const head = await resolveCommit(options.head ?? 'HEAD', { repoRoot, signal });
                if (head !== undefined && head === current.status.head)
                    return current;
            }
            if (!pending) {
                pending = build(callOptions.signal ?? options.signal, callOptions.onProgress ?? options.onProgress)
                    .then(result => {
                    current = result;
                    retainEntries([resolvedDir, resolvedBlobDir]);
                    return result;
                })
                    .finally(() => {
                    pending = undefined;
                });
            }
            return pending;
        },
        async peekMetadata(callOptions = {}) {
            return peekMetadata(callOptions.signal ?? options.signal);
        },
        async isFresh(callOptions = {}) {
            const signal = callOptions.signal ?? options.signal;
            const head = await resolveCommit(options.head ?? 'HEAD', { repoRoot, signal });
            // An index already in memory knows its own head, so the common case — a
            // long-lived caller polling a warm session — costs exactly one rev-parse.
            const indexedHead = current?.status.head ?? (await peekMetadata(signal))?.indexedHead;
            return {
                fresh: head !== undefined && indexedHead !== undefined && head === indexedHead,
                head,
                indexedHead,
            };
        },
        async clear() {
            current = undefined;
            releaseEntries();
            if (cacheDir)
                await rm(cacheDir, { recursive: true, force: true });
        },
        dispose() {
            current = undefined;
            releaseEntries();
        },
    };
}
//# sourceMappingURL=store.js.map