import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { isOccAbortError } from '../../errors.js';
import { abortIfNeeded, compareCodeUnits } from '../../utils.js';
import { createTokenizer, resolveTokenizerName } from '../../tokens.js';
import { getGitUntrackedFiles } from '../../git/api.js';
import { createTestRecognizer } from '../tests.js';
import { discoverCodeFiles } from '../discover.js';
import { CODE_EXTENSIONS } from '../languages.js';
import { FORMAT_EXTENSIONS } from '../formats/index.js';
import { fuzzyMatch } from '../search.js';
import { createCodeQuerySession } from '../session.js';
import { openCodeIndexStore } from '../store.js';
import { readCodeDocs } from '../doc.js';
import { queryCodePattern } from '../rules.js';
import { candidateFromNode, readCodeSource } from '../source.js';
import { computeEntityDiff } from './diff.js';
import { resolveBlobLineage as linkBlobLineage } from './blob-lineage.js';
import { entityBlame } from './blame.js';
import { entityCochange } from './cochange.js';
import { EntityContextBudgetError, entityContext, entityContextMany } from './context.js';
import { entityDrift } from './drift.js';
import { captureEntityAnchor, EntityAnchorCaptureHandleSchema, verifyEntityAnchors } from './anchor-verify.js';
import { entityDriftForDocuments } from './document-drift.js';
import { entityHistory } from './history.js';
import { addEntityImpactInlineContext, entityImpact } from './impact.js';
import { computeEntityObligations } from './obligations.js';
import { classifyEntityDiffSemantics } from './semantic.js';
import { classifyWholeDiffSemantics } from './whole-diff.js';
import { mapEntityGitError } from './git-error.js';
import { rankEntityHotspots } from './hotspots.js';
import { summarizeParserEvidence } from './parser-honesty.js';
import { countEntityAmbiguityEvidence, summarizeEntityEvidenceGaps } from './evidence.js';
import { resolveEntitySuccessor } from './successor.js';
import { readEntityRepoIdentity, repoIdentityKey } from './git.js';
import { resolveEntitiesAtRef } from './at-ref.js';
import { walkBackAttribution, walkBackKeyFor } from './walkback.js';
import { buildLiveEntities, matchesPathScope, normalizePathScope, normalizeRelativePath, resolveEntity, resolveEntityHandle, } from './resolve.js';
import { TRUNCATION_CODES, openEntityStore, resolveEntityCacheDir } from './store.js';
import { ENTITY_PARSER_CONTRACT } from './snapshot.js';
import { OccEntityQuerySchema } from './api-types.js';
async function openCodeSource(options) {
    if (options.codeSession) {
        const borrowed = options.codeSession;
        return {
            session: borrowed,
            refresh: ({ signal, onProgress }) => borrowed.refresh(onProgress, signal),
            update: (_changedFiles, { signal, onProgress }) => borrowed.refresh(onProgress, signal),
            isFresh: async () => undefined,
            freshByConstruction: false,
            dispose: () => { },
        };
    }
    // Context bundles hand back real source, so the index must carry it rather
    // than the excerpt the default mode keeps.
    const build = {
        repoRoot: options.repoRoot,
        excludeDir: options.excludeDir,
        contentMode: 'full',
        typescriptChecker: options.typescriptChecker ?? 'preferred',
    };
    if (options.cacheDir === false) {
        const session = await createCodeQuerySession(build, options.onProgress, options.signal);
        return {
            session,
            refresh: ({ signal, onProgress }) => session.refresh(onProgress, signal),
            update: (_changedFiles, { signal, onProgress }) => session.refresh(onProgress, signal),
            isFresh: async () => undefined,
            freshByConstruction: true,
            dispose: () => { },
        };
    }
    // Sits beside the lineage cache, which is outside the repository by default;
    // a code index is derived data too and does not belong in the working tree.
    const base = options.cacheDir ? path.resolve(options.cacheDir) : resolveEntityCacheDir();
    const store = openCodeIndexStore({ ...build, cacheDir: path.join(base, 'code-index') });
    let session = await store.getSession({ signal: options.signal, onProgress: options.onProgress });
    return {
        get session() {
            return session;
        },
        // `update` over `refresh`: a full rebuild of a large repository costs
        // minutes, and the whole point of re-checking freshness on a long-lived
        // session is that acting on the answer stays affordable.
        async refresh({ signal, onProgress }) {
            session = await store.update(undefined, { signal, onProgress });
            return session.getIndex();
        },
        async update(changedFiles, { signal, onProgress }) {
            session = await store.update(changedFiles, { signal, onProgress });
            return session.getIndex();
        },
        isFresh: async ({ signal }) => (await store.isFresh({ signal })) ?? undefined,
        freshByConstruction: false,
        dispose: () => store.dispose(),
    };
}
export async function createEntitySession(options) {
    const repoRoot = path.resolve(options.repoRoot);
    const recognizer = createTestRecognizer(options.tests);
    const tokenizerName = resolveTokenizerName(options.tokenizer);
    const [code, tokenizer, identity] = await Promise.all([
        openCodeSource({ ...options, repoRoot }),
        createTokenizer(tokenizerName),
        readEntityRepoIdentity({ repoRoot, signal: options.signal }),
    ]);
    const repoIdentity = repoIdentityKey(identity);
    const entityStore = openEntityStore({
        repoRoot,
        cacheDir: options.cacheDir,
        head: options.head,
        bounds: options.bounds,
        thresholds: options.thresholds,
        guardrails: options.guardrails,
        onProgress: options.onProgress,
    });
    const initialIndex = code.session.getIndex();
    const initialEntityIndex = await entityStore.index({
        signal: options.signal,
        onProgress: options.onProgress,
    });
    const initialFormatFiles = await readFormatFiles(repoRoot, options);
    // Untracked paths are read before the projection, not after: they are the
    // evidence that separates "git has never seen this" from "the index never
    // reached it", and the projection cannot be honest without them.
    const initialUntracked = new Set(await safeUntracked(repoRoot, options.signal));
    function buildState(input) {
        return {
            index: input.index,
            entityIndex: input.entityIndex,
            formatFiles: input.formatFiles,
            untracked: input.untracked,
            live: buildLiveEntities({
                index: input.index,
                entityIndex: input.entityIndex,
                repoIdentity,
                recognizer,
                formatFiles: input.formatFiles,
                untracked: input.untracked,
                truncated: input.entityIndex.status.truncated,
            }),
            refCache: input.refCache ?? new Map(),
        };
    }
    let state = buildState({
        index: initialIndex,
        entityIndex: initialEntityIndex,
        formatFiles: initialFormatFiles,
        untracked: initialUntracked,
    });
    let writerTail = Promise.resolve();
    function serializedWriter(operation) {
        const result = writerTail.then(operation, operation);
        writerTail = result.then(() => undefined, () => undefined);
        return result;
    }
    function resolve(query, at = state.live) {
        const parsed = OccEntityQuerySchema.safeParse(query);
        if (!parsed.success)
            return { status: 'error', message: parsed.error.issues[0]?.message ?? 'Invalid entity query.' };
        return resolveEntity(parsed.data, at);
    }
    /**
     * Reconstructed revisions, kept for the life of the session.
     *
     * A caller asking about one ref usually asks several questions about it, and
     * the replay is pure — the same ref against the same index gives the same
     * answer. The cap is small on purpose: this is a convenience for a working
     * pattern, not a general-purpose cache, and a long-lived server must not
     * accumulate a revision per request.
     */
    const MAX_CACHED_REFS = 4;
    async function entitiesAt(ref, snapshot, signal) {
        const cached = snapshot.refCache.get(ref);
        if (cached)
            return { status: 'resolved', at: cached };
        const outcome = await resolveEntitiesAtRef({
            ref,
            repoRoot,
            entityIndex: snapshot.entityIndex,
            recognizer,
            signal,
        });
        if (outcome.status === 'resolved') {
            if (snapshot.refCache.size >= MAX_CACHED_REFS) {
                const oldest = snapshot.refCache.keys().next();
                if (!oldest.done)
                    snapshot.refCache.delete(oldest.value);
            }
            snapshot.refCache.set(ref, outcome.at);
        }
        return outcome;
    }
    /**
     * Why `impact` and `context` refuse a ref rather than ignoring it.
     *
     * Both traverse the code graph, and a graph exists for exactly one working
     * tree: its node ids name symbols in the index that was built, not symbols at
     * some earlier commit. Silently answering about the working tree would be the
     * accepted-and-ignored option this release set out to remove.
     */
    /**
     * The bounded look further back, or nothing.
     *
     * One walk per query covers every entity in the file, because they all share
     * its path history. A failure here is never fatal: the caller asked about
     * entities, and losing the extra reach is a smaller loss than losing the
     * answer.
     */
    // No `limit` parameter by design: the walk's budget is its own
    // (`ENTITY_WALK_BACK_COMMITS`), never a caller's display limit.
    async function lookBack(snapshot, relativePath, startRef, signal) {
        try {
            return await walkBackAttribution({
                repoRoot,
                relativePath,
                startRef,
                bounds: options.bounds,
                guardrails: options.guardrails,
                counters: snapshot.entityIndex.counters,
                signal,
            });
        }
        catch (error) {
            if (isOccAbortError(error))
                throw error;
            return undefined;
        }
    }
    function refuseRef(capability) {
        return {
            code: 'invalid-ref',
            message: `${capability} is answered from the working-tree code graph, which exists for one revision only, ` +
                `so it cannot be asked about a past ref. Use blame or log for historical questions.`,
        };
    }
    /**
     * Bring the live view up to date before minting working-tree claims.
     *
     * The anchor surfaces assert facts about the working tree (uncommitted
     * content, commit pairing, live hash verdicts); a warm store cache served
     * without reconciliation would make those assertions from stale bytes — the
     * fabrication the pre-tag review reproduced. Store-backed sources reconcile
     * incrementally (a manifest stat-diff reparse, never a full rebuild); an
     * in-process build is fresh by construction; a borrowed session can neither
     * measure nor cheaply reconcile, and the caller of this helper must treat
     * its live view as untrusted.
     *
     * Returns the post-reconciliation freshness plus whether the live view can
     * be trusted to mirror the working tree.
     */
    async function reconcileForWorkingTreeClaims(signal) {
        let freshness = await measureFreshness({ signal });
        if (freshness.workingTree !== undefined && !freshness.workingTree.fresh) {
            await serializedWriter(async () => {
                abortIfNeeded(signal);
                const nextIndex = await code.refresh({ signal, onProgress: options.onProgress });
                const nextFormatFiles = await readFormatFiles(repoRoot, { ...options, signal });
                const nextUntracked = new Set(await safeUntracked(repoRoot, signal));
                abortIfNeeded(signal);
                state = buildState({
                    index: nextIndex,
                    entityIndex: state.entityIndex,
                    formatFiles: nextFormatFiles,
                    untracked: nextUntracked,
                });
            });
            freshness = await measureFreshness({ signal });
        }
        const liveTrusted = code.freshByConstruction || freshness.workingTree?.fresh === true;
        return { freshness, liveTrusted };
    }
    async function measureFreshness(freshnessOptions = {}) {
        const signal = freshnessOptions.signal;
        const reasons = [];
        const head = await entityStore.isFresh({ signal });
        if (!head.fresh)
            reasons.push('head-moved');
        const workingTree = freshnessOptions.workingTree === false ? undefined : await code.isFresh({ signal });
        if (workingTree && !workingTree.fresh)
            reasons.push('working-tree-changed');
        return {
            fresh: reasons.length === 0,
            head: head.head,
            indexedHead: head.indexedHead,
            workingTree,
            reasons,
        };
    }
    function searchEntities(snapshot, query, searchOptions) {
        const wanted = query.toLowerCase();
        const wantedPath = searchOptions.relativePath ? normalizePathScope(searchOptions.relativePath) : undefined;
        const limit = searchOptions.limit ?? 50;
        const useFuzzy = searchOptions.fuzzy === true && wanted.length > 0;
        const found = [];
        const fuzzyScores = useFuzzy ? new Map() : undefined;
        for (const entity of snapshot.live.all) {
            if (searchOptions.kind && entity.record.kind !== searchOptions.kind)
                continue;
            if (wantedPath && !matchesPathScope(entity.record.relativePath, wantedPath))
                continue;
            if (searchOptions.includeTests === false && entity.ref.isTest)
                continue;
            if (useFuzzy) {
                const qualified = entity.record.qualifiedName;
                const match = fuzzyMatch(qualified, qualified.toLowerCase(), wanted);
                if (!match)
                    continue;
                fuzzyScores.set(entity.ref, { score: match.score, span: match.span });
            }
            else if (wanted && !entity.record.qualifiedName.toLowerCase().includes(wanted)) {
                continue;
            }
            found.push(entity.ref);
        }
        // Exact names first in both modes — a search for `parse` should not bury
        // `parse` under `parseHeaderWithFallback`. The default substring mode then
        // prefers the shortest name; the fuzzy mode replaces that tier with match
        // quality (the scorer's span penalty subsumes name length).
        found.sort((a, b) => {
            const aExact = a.name.toLowerCase() === wanted ? 0 : 1;
            const bExact = b.name.toLowerCase() === wanted ? 0 : 1;
            if (fuzzyScores) {
                const aMatch = fuzzyScores.get(a) ?? { score: 0, span: Number.MAX_SAFE_INTEGER };
                const bMatch = fuzzyScores.get(b) ?? { score: 0, span: Number.MAX_SAFE_INTEGER };
                return (aExact - bExact ||
                    bMatch.score - aMatch.score ||
                    aMatch.span - bMatch.span ||
                    compareCodeUnits(a.relativePath, b.relativePath) ||
                    a.startLine - b.startLine);
            }
            return (aExact - bExact ||
                a.name.length - b.name.length ||
                compareCodeUnits(a.relativePath, b.relativePath) ||
                a.startLine - b.startLine);
        });
        return { found, limit };
    }
    return {
        search(query, searchOptions = {}) {
            const snapshot = state;
            const { found, limit } = searchEntities(snapshot, query, searchOptions);
            return found.slice(0, limit);
        },
        async searchDetailed(query, searchOptions = {}) {
            const freshness = await measureFreshness();
            const snapshot = state;
            const { found, limit } = searchEntities(snapshot, query, searchOptions);
            const entities = found.slice(0, limit);
            const omitted = Math.max(0, found.length - entities.length);
            const diagnostics = snapshot.entityIndex.status.diagnostics;
            const evidenceGaps = summarizeEntityEvidenceGaps(diagnostics);
            const limitTruncated = omitted > 0;
            return {
                entities,
                totalMatches: found.length,
                limit,
                truncated: limitTruncated,
                honesty: {
                    freshness: {
                        fresh: freshness.fresh,
                        reasons: freshness.reasons,
                        workingTreeMeasured: freshness.workingTree !== undefined,
                    },
                    truncation: {
                        truncated: snapshot.entityIndex.status.truncated || limitTruncated,
                        reasons: diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
                        omitted,
                    },
                    coverage: {
                        entitiesConsidered: snapshot.live.all.length,
                        entitiesMatched: found.length,
                        entitiesReturned: entities.length,
                    },
                    ambiguityCount: countEntityAmbiguityEvidence(snapshot.entityIndex.counters.ambiguitiesRefused, diagnostics),
                    evidenceGaps,
                },
            };
        },
        async doc(query, docOptions = {}) {
            const [result] = await readCodeDocs(state.index, [query], docOptions);
            return result;
        },
        async source(query, sourceOptions = {}) {
            abortIfNeeded(sourceOptions.signal);
            const freshness = await measureFreshness({ signal: sourceOptions.signal });
            const snapshot = state;
            const result = await readCodeSource(snapshot.index, query);
            abortIfNeeded(sourceOptions.signal);
            const diagnostics = snapshot.entityIndex.status.diagnostics;
            const evidenceGaps = summarizeEntityEvidenceGaps(diagnostics);
            const candidatesMatched = result.status === 'resolved' ? 1 : 'candidates' in result ? result.candidates.length : 0;
            const honesty = {
                freshness: {
                    fresh: freshness.fresh,
                    reasons: freshness.reasons,
                    workingTreeMeasured: freshness.workingTree !== undefined,
                },
                truncation: {
                    truncated: snapshot.entityIndex.status.truncated || snapshot.index.truncated !== undefined,
                    reasons: diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
                    omitted: snapshot.index.truncated?.droppedFiles ?? 0,
                },
                coverage: {
                    indexNodesConsidered: snapshot.index.nodes.length,
                    candidatesMatched,
                    sourceRead: result.status === 'resolved',
                },
                ambiguityCount: countEntityAmbiguityEvidence(snapshot.entityIndex.counters.ambiguitiesRefused, diagnostics) +
                    (result.status === 'ambiguous' ? 1 : 0),
                evidenceGaps,
            };
            if (result.status !== 'resolved')
                return { ...result, honesty };
            const active = sourceOptions.tokenizer
                ? await createTokenizer(resolveTokenizerName(sourceOptions.tokenizer))
                : tokenizer;
            return {
                ...result,
                tokens: active.count(result.source),
                tokenizer: active.name,
                honesty,
            };
        },
        async pattern(patternOptions) {
            return queryCodePattern(state.index, patternOptions);
        },
        async hotspots(hotspotOptions = {}) {
            const freshness = await measureFreshness();
            const snapshot = state;
            return rankEntityHotspots(hotspotOptions, {
                index: snapshot.index,
                entityIndex: snapshot.entityIndex,
                live: snapshot.live,
                freshness,
            });
        },
        async cochange(query, cochangeOptions = {}) {
            const freshness = await measureFreshness({ signal: cochangeOptions.signal });
            const snapshot = state;
            return entityCochange(query, cochangeOptions, {
                entityIndex: snapshot.entityIndex,
                live: snapshot.live,
                freshness,
            });
        },
        async drift(driftOptions = {}) {
            const freshness = await measureFreshness({ signal: driftOptions.signal });
            const snapshot = state;
            return entityDrift(driftOptions, {
                formatFiles: snapshot.formatFiles,
                entityIndex: snapshot.entityIndex,
                live: snapshot.live,
                freshness,
            });
        },
        async driftForDocuments(driftOptions) {
            const freshness = await measureFreshness({ signal: driftOptions.signal, workingTree: false });
            const snapshot = state;
            return entityDriftForDocuments(driftOptions, {
                repoRoot,
                entityIndex: snapshot.entityIndex,
                freshness,
                recognizer,
            });
        },
        async anchor(handle, anchorOptions = {}) {
            const parsed = EntityAnchorCaptureHandleSchema.safeParse(handle);
            if (!parsed.success) {
                return {
                    status: 'error',
                    code: 'invalid-input',
                    message: parsed.error.issues[0]?.message ?? 'Invalid capture handle.',
                };
            }
            // Capture asserts working-tree facts, so the live view is reconciled
            // first; a warm cache must never mint a tuple or a commit pairing from
            // bytes the tree no longer holds.
            const { freshness, liveTrusted } = await reconcileForWorkingTreeClaims(anchorOptions.signal);
            const snapshot = state;
            let entity;
            if (parsed.data.nodeId !== undefined) {
                const byNode = snapshot.live.byNodeId.get(parsed.data.nodeId);
                if (!byNode) {
                    return {
                        status: 'not_found',
                        candidates: [],
                        message: `No live entity carries node id "${parsed.data.nodeId}".`,
                    };
                }
                entity = byNode;
            }
            else if (parsed.data.lineageId !== undefined) {
                const outcome = resolveEntityHandle(parsed.data.lineageId, snapshot.live, snapshot.entityIndex);
                if (outcome.status !== 'resolved') {
                    return { status: 'not_found', candidates: [], message: outcome.message };
                }
                entity = outcome.entity;
            }
            else {
                const outcome = resolve({
                    name: parsed.data.name,
                    ...(parsed.data.relativePath !== undefined ? { relativePath: parsed.data.relativePath } : {}),
                    ...(parsed.data.kind !== undefined ? { kind: parsed.data.kind } : {}),
                });
                if (outcome.status === 'error') {
                    return { status: 'error', code: 'invalid-input', message: outcome.message };
                }
                if (outcome.status !== 'resolved') {
                    return { status: outcome.status, candidates: outcome.candidates, message: outcome.message };
                }
                if (parsed.data.containerName !== undefined &&
                    outcome.entity.record.containerName !== parsed.data.containerName) {
                    return {
                        status: 'not_found',
                        candidates: [outcome.entity.ref],
                        message: `"${parsed.data.name}" resolved, but not inside container "${parsed.data.containerName}".`,
                    };
                }
                entity = outcome.entity;
            }
            const captured = captureEntityAnchor({
                entity,
                entityIndex: snapshot.entityIndex,
                freshness,
                workingTreeTrusted: liveTrusted,
            });
            return { status: 'resolved', anchor: captured.anchor, freshness, warnings: captured.warnings };
        },
        async verifyAnchors(verifyOptions) {
            // The hash lane grades live bytes, so the live view is reconciled first;
            // an untrusted view (a borrowed session) degrades hash-lane verdicts to
            // unknown/index-stale instead of grading fresh from stale bytes.
            const { freshness, liveTrusted } = await reconcileForWorkingTreeClaims(verifyOptions.signal);
            const snapshot = state;
            return verifyEntityAnchors(verifyOptions, {
                repoRoot,
                entityIndex: snapshot.entityIndex,
                freshness,
                recognizer,
                // The session already holds its live snapshot, so the lazy lane is
                // free here; the thunk contract still spares the store-only CLI path.
                getLive: () => Promise.resolve({ live: snapshot.live, index: snapshot.index, trusted: liveTrusted }),
            });
        },
        resolveSuccessor(query, expectedBodyHash) {
            const snapshot = state;
            const outcome = resolveEntitySuccessor(query, expectedBodyHash, {
                entityIndex: snapshot.entityIndex,
                live: snapshot.live,
            });
            if (outcome.status === 'unavailable')
                return outcome;
            const nodes = new Map(snapshot.index.nodes.map(node => [node.id, node]));
            const candidate = (ref) => {
                const node = ref.nodeId ? nodes.get(ref.nodeId) : undefined;
                return node ? candidateFromNode(node) : undefined;
            };
            if (outcome.status === 'resolved') {
                const successor = candidate(outcome.successor);
                return successor ? { status: 'resolved', successor } : { status: 'unavailable' };
            }
            const candidates = outcome.candidates.flatMap(ref => {
                const found = candidate(ref);
                return found ? [found] : [];
            });
            if (candidates.length === 0)
                return { status: 'unavailable' };
            if (candidates.length === 1)
                return { status: 'resolved', successor: candidates[0] };
            return { status: 'ambiguous', candidates };
        },
        async diff(diffOptions) {
            const snapshot = state;
            try {
                abortIfNeeded(diffOptions.signal);
                const result = await computeEntityDiff(diffOptions, {
                    repoRoot,
                    repoIdentity,
                    state: snapshot.entityIndex.state,
                    recognizer,
                    formats: options.formats,
                    bounds: options.bounds,
                    thresholds: options.thresholds,
                    guardrails: options.guardrails,
                    signal: diffOptions.signal,
                    counters: snapshot.entityIndex.counters,
                });
                if (diffOptions.obligations && result.status === 'resolved') {
                    const computed = computeEntityObligations(result, {
                        index: snapshot.index,
                        live: snapshot.live,
                        formatFiles: snapshot.formatFiles,
                        bounds: options.bounds,
                        indexTruncated: snapshot.entityIndex.status.truncated,
                    });
                    result.obligations = computed.obligations;
                    result.diagnostics.push(...computed.diagnostics);
                    result.semantic = classifyEntityDiffSemantics(result);
                }
                return result;
            }
            catch (error) {
                const mapped = mapEntityGitError(error);
                const result = {
                    status: 'error',
                    ...mapped,
                    changedFiles: [],
                    changes: [],
                    truncated: false,
                    omitted: 0,
                    diagnostics: [],
                };
                return {
                    ...result,
                    semantic: classifyEntityDiffSemantics(result),
                    wholeDiffSemantic: await classifyWholeDiffSemantics({ status: 'error', files: [] }),
                };
            }
        },
        async resolveBlobLineage(input, lineageOptions = {}) {
            const snapshot = state;
            return linkBlobLineage(input, {
                repoRoot,
                entityIndex: snapshot.entityIndex,
                recognizer,
                signal: lineageOptions.signal,
            });
        },
        async impact(query, impactOptions = {}) {
            const snapshot = state;
            if (query.ref)
                return { status: 'error', query, ...refuseRef('impact') };
            const outcome = resolve(query, snapshot.live);
            if (outcome.status === 'error')
                return { status: 'error', query, code: 'invalid-path', message: outcome.message };
            if (outcome.status !== 'resolved')
                return { ...outcome, query };
            const impactContext = {
                index: snapshot.index,
                live: snapshot.live,
                formatFiles: snapshot.formatFiles,
                bounds: options.bounds,
                indexTruncated: snapshot.entityIndex.status.truncated,
            };
            const payload = entityImpact(outcome.entity, impactOptions, impactContext);
            return {
                status: 'resolved',
                query,
                ...(impactOptions.inlineContext
                    ? await addEntityImpactInlineContext(payload, impactOptions.inlineContext, impactContext)
                    : payload),
            };
        },
        async blame(blameOptions) {
            const snapshot = state;
            const signal = blameOptions.signal;
            try {
                abortIfNeeded(signal);
                const relativePath = normalizeRelativePath(blameOptions.relativePath);
                if (blameOptions.ref) {
                    const at = await entitiesAt(blameOptions.ref, snapshot, signal);
                    if (at.status === 'error')
                        return { status: 'error', code: at.code, message: at.message };
                    if (!at.at.index.byPath.has(relativePath)) {
                        return {
                            status: 'not_found',
                            relativePath,
                            message: `No indexed entities in "${relativePath}" at ${blameOptions.ref}.`,
                        };
                    }
                    return {
                        status: 'resolved',
                        ...entityBlame(relativePath, {
                            entityIndex: snapshot.entityIndex,
                            live: at.at.index,
                            untracked: new Set(),
                            shallow: identity.shallow,
                            atCommitOrder: at.at.commitOrder,
                            walkBack: blameOptions.walkBack === false
                                ? undefined
                                : await lookBack(snapshot, relativePath, at.at.commit, signal),
                        }),
                    };
                }
                if (!snapshot.live.byPath.has(relativePath)) {
                    return {
                        status: 'not_found',
                        relativePath,
                        message: `No indexed entities in "${relativePath}".`,
                    };
                }
                // An untracked file has no path history to walk, so the extra git call
                // would be certain to find nothing.
                const walkBack = blameOptions.walkBack === false || snapshot.untracked.has(relativePath)
                    ? undefined
                    : await lookBack(snapshot, relativePath, undefined, signal);
                return {
                    status: 'resolved',
                    ...entityBlame(relativePath, {
                        entityIndex: snapshot.entityIndex,
                        live: snapshot.live,
                        untracked: snapshot.untracked,
                        shallow: identity.shallow,
                        walkBack,
                    }),
                };
            }
            catch (error) {
                return {
                    status: 'error',
                    code: isOccAbortError(error) ? 'aborted' : 'git-failed',
                    message: error instanceof Error ? error.message : String(error),
                };
            }
        },
        async history(query, historyOptions = {}) {
            const snapshot = state;
            const signal = historyOptions.signal;
            try {
                abortIfNeeded(signal);
                const ref = query.ref ?? historyOptions.ref;
                let at;
                if (ref) {
                    const resolved = await entitiesAt(ref, snapshot, signal);
                    if (resolved.status === 'error') {
                        return { status: 'error', query, code: resolved.code, message: resolved.message };
                    }
                    at = resolved.at;
                }
                const outcome = resolve(query, at?.index ?? snapshot.live);
                if (outcome.status === 'error')
                    return { status: 'error', query, code: 'invalid-path', message: outcome.message };
                if (outcome.status !== 'resolved')
                    return { ...outcome, query };
                // Only pay for the walk when the window has nothing to say about this
                // entity; when it does, its events are the better evidence anyway.
                //
                // "Nothing to say" has to be judged against the same cutoff the payload
                // applies. An entity seeded at the window boundary whose only indexed
                // events post-date the requested ref looks covered by a bare count, so
                // the walk is skipped — and then `entityHistory` filters those very
                // events away and returns nothing, for a change `blame --ref` finds.
                const record = outcome.entity.record;
                const events = snapshot.entityIndex.eventsByLineage.get(outcome.entity.ref.lineageId) ?? [];
                const covered = at === undefined ? events.length > 0 : events.some(event => event.commitOrder <= at.commitOrder);
                const walkBack = historyOptions.walkBack === false || covered || outcome.entity.ref.provisional
                    ? undefined
                    : // Deliberately not `historyOptions.limit`: that caps the entries
                        // returned, and spending it as the walk's commit budget would make
                        // asking for fewer results search less far and find none.
                        await lookBack(snapshot, record.relativePath, at?.commit, signal);
                return {
                    status: 'resolved',
                    query,
                    ...entityHistory(outcome.entity, historyOptions, {
                        entityIndex: snapshot.entityIndex,
                        atCommitOrder: at?.commitOrder,
                        walkBack,
                        walkBackKey: walkBackKeyFor(record),
                    }),
                };
            }
            catch (error) {
                return {
                    status: 'error',
                    query,
                    code: isOccAbortError(error) ? 'aborted' : 'git-failed',
                    message: error instanceof Error ? error.message : String(error),
                };
            }
        },
        async context(query, contextOptions = {}) {
            const snapshot = state;
            try {
                abortIfNeeded(contextOptions.signal);
                if (query.ref)
                    return { status: 'error', query, ...refuseRef('context') };
                const outcome = resolve(query, snapshot.live);
                if (outcome.status === 'error')
                    return { status: 'error', query, code: 'invalid-path', message: outcome.message };
                if (outcome.status !== 'resolved')
                    return { ...outcome, query };
                const active = contextOptions.tokenizer
                    ? await createTokenizer(resolveTokenizerName(contextOptions.tokenizer))
                    : tokenizer;
                return {
                    status: 'resolved',
                    query,
                    ...(await entityContext(outcome.entity, { ...contextOptions, budgetQuery: query }, {
                        repoRoot,
                        index: snapshot.index,
                        live: snapshot.live,
                        tokenizer: active,
                        formatFiles: snapshot.formatFiles,
                    })),
                };
            }
            catch (error) {
                if (error instanceof EntityContextBudgetError) {
                    return {
                        status: 'error',
                        query,
                        code: 'budget-exceeded',
                        message: error.message,
                        ...error.refusal,
                    };
                }
                return {
                    status: 'error',
                    query,
                    code: isOccAbortError(error) ? 'aborted' : 'git-failed',
                    message: error instanceof Error ? error.message : String(error),
                };
            }
        },
        async contextMany(queries, contextOptions = {}) {
            const snapshot = state;
            try {
                abortIfNeeded(contextOptions.signal);
                const targets = [];
                const resolvedTargets = [];
                for (const [targetIndex, query] of queries.entries()) {
                    if (query.ref) {
                        targets.push({ status: 'error', query, ...refuseRef('context') });
                        continue;
                    }
                    const outcome = resolve(query, snapshot.live);
                    if (outcome.status === 'error') {
                        targets.push({ status: 'error', query, code: 'invalid-path', message: outcome.message });
                    }
                    else if (outcome.status !== 'resolved') {
                        targets.push({ ...outcome, query });
                    }
                    else {
                        targets.push({ status: 'resolved', query, entity: outcome.entity.ref, included: false });
                        resolvedTargets.push({ targetIndex, entity: outcome.entity });
                    }
                }
                const active = contextOptions.tokenizer
                    ? await createTokenizer(resolveTokenizerName(contextOptions.tokenizer))
                    : tokenizer;
                const bundle = await entityContextMany(resolvedTargets, contextOptions, {
                    repoRoot,
                    index: snapshot.index,
                    live: snapshot.live,
                    tokenizer: active,
                    formatFiles: snapshot.formatFiles,
                });
                const included = new Set(bundle.includedTargetIndexes);
                for (const [targetIndex, target] of targets.entries()) {
                    if (target.status === 'resolved')
                        target.included = included.has(targetIndex);
                }
                return {
                    status: 'resolved',
                    targets,
                    entries: bundle.entries,
                    tokenBudget: bundle.tokenBudget,
                    tokensUsed: bundle.tokensUsed,
                    tokenizer: bundle.tokenizer,
                    truncated: bundle.truncated,
                    omissions: bundle.omissions,
                    diagnostics: bundle.diagnostics,
                };
            }
            catch (error) {
                return {
                    status: 'error',
                    code: isOccAbortError(error) ? 'aborted' : 'git-failed',
                    message: error instanceof Error ? error.message : String(error),
                };
            }
        },
        status() {
            const { status } = state.entityIndex;
            const evidenceGaps = summarizeEntityEvidenceGaps(status.diagnostics);
            return {
                mode: status.mode,
                codeIndexSchemaVersion: state.index.schemaVersion,
                parserContract: state.entityIndex.parserContract ?? ENTITY_PARSER_CONTRACT,
                parserEvidence: summarizeParserEvidence(state.index),
                head: status.head,
                commitsIndexed: status.commitsIndexed,
                eventsRecorded: status.eventsRecorded,
                lineageCount: status.lineageCount,
                truncated: status.truncated,
                evidenceComplete: evidenceGaps.count === 0,
                reasons: status.diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
                diagnostics: status.diagnostics,
            };
        },
        async freshness(freshnessOptions = {}) {
            // Only measure the working tree when asked (the default): head comparison
            // is one git call, while manifest comparison stats every discoverable file.
            return measureFreshness(freshnessOptions);
        },
        stats() {
            const snapshot = state;
            const counters = snapshot.entityIndex.counters;
            return {
                blobsParsed: counters.blobsParsed,
                blobsFromCache: counters.blobsFromCache,
                blobsRead: counters.blobsRead,
                gitInvocations: counters.gitInvocations,
                commitsIndexed: snapshot.entityIndex.status.commitsIndexed,
                entitiesExtracted: counters.entitiesExtracted,
                lineageCount: snapshot.entityIndex.status.lineageCount,
                eventCount: snapshot.entityIndex.status.eventsRecorded,
                cacheHits: counters.cacheHits,
                cacheMisses: counters.cacheMisses,
                cacheWrites: counters.cacheWrites,
                cacheBytesRead: counters.cacheBytesRead,
                ambiguitiesRefused: counters.ambiguitiesRefused,
                lineageMatches: counters.lineageMatches,
                lineageAcceptDeclines: counters.lineageAcceptDeclines,
            };
        },
        async refresh(refreshOptions = {}) {
            return serializedWriter(async () => {
                const signal = refreshOptions.signal;
                const onProgress = refreshOptions.onProgress ?? options.onProgress;
                abortIfNeeded(signal);
                const nextIndex = await code.refresh({ signal, onProgress });
                const nextEntityIndex = await entityStore.index({ signal, refresh: true, onProgress });
                const nextFormatFiles = await readFormatFiles(repoRoot, { ...options, signal });
                const nextUntracked = new Set(await safeUntracked(repoRoot, signal));
                abortIfNeeded(signal);
                state = buildState({
                    index: nextIndex,
                    entityIndex: nextEntityIndex,
                    formatFiles: nextFormatFiles,
                    untracked: nextUntracked,
                });
            });
        },
        async update(changedFiles, updateOptions = {}) {
            const normalized = normalizeChangedFiles(repoRoot, changedFiles);
            if (normalized.length === 0)
                return;
            return serializedWriter(async () => {
                const snapshot = state;
                const signal = updateOptions.signal;
                const onProgress = updateOptions.onProgress ?? options.onProgress;
                abortIfNeeded(signal);
                const codeExtensions = new Set(CODE_EXTENSIONS);
                const codePaths = normalized.filter(relativePath => codeExtensions.has(path.extname(relativePath).toLowerCase().slice(1)) ||
                    snapshot.index.files.some(file => file.relativePath === relativePath));
                const nextIndex = codePaths.length === 0 ? snapshot.index : await code.update(codePaths, { signal, onProgress });
                const nextFormatFiles = await updateFormatFiles(repoRoot, snapshot.formatFiles, normalized, options, signal);
                const nextUntracked = new Set(snapshot.untracked);
                for (const relativePath of normalized)
                    nextUntracked.delete(relativePath);
                for (const relativePath of await safeUntracked(repoRoot, signal, normalized)) {
                    nextUntracked.add(normalizeRelativePath(relativePath));
                }
                abortIfNeeded(signal);
                state = buildState({
                    index: nextIndex,
                    entityIndex: snapshot.entityIndex,
                    formatFiles: nextFormatFiles,
                    untracked: nextUntracked,
                    refCache: snapshot.refCache,
                });
            });
        },
        async dispose() {
            await serializedWriter(async () => {
                entityStore.dispose();
                code.dispose();
            });
        },
    };
}
/**
 * Read the data-format documents in the working tree.
 *
 * A separate walk, because these extensions are deliberately not in
 * `CODE_EXTENSIONS` — adding them there would put JSON keys into `occ code map`
 * and the code graph, which is not what a repo map is for. The same ignore
 * rules apply, so `--exclude-dir` and .gitignore behave identically.
 */
async function readFormatFiles(repoRoot, options) {
    const files = new Map();
    if (options.formats === false)
        return files;
    const discovered = await discoverCodeFiles(repoRoot, {
        excludeDir: options.excludeDir,
        extensions: FORMAT_EXTENSIONS.map(extension => extension.slice(1)),
        signal: options.signal,
    }).catch(() => []);
    for (const absolute of discovered) {
        try {
            const relativePath = normalizeRelativePath(path.relative(repoRoot, absolute));
            files.set(relativePath, await readFile(absolute, 'utf8'));
        }
        catch {
            // A file that vanished between discovery and read is not an error worth
            // failing a whole session for.
        }
    }
    return files;
}
function normalizeChangedFiles(repoRoot, changedFiles) {
    const normalized = new Set();
    for (const changedFile of changedFiles) {
        const absolute = path.resolve(repoRoot, changedFile);
        const relative = path.relative(repoRoot, absolute);
        if (relative.length === 0 ||
            relative === '..' ||
            relative.startsWith(`..${path.sep}`) ||
            path.isAbsolute(relative)) {
            throw new Error(`Changed file must stay inside the repository: ${changedFile}`);
        }
        const relativePath = normalizeRelativePath(relative);
        normalized.add(relativePath);
    }
    return [...normalized].sort();
}
async function updateFormatFiles(repoRoot, current, changedFiles, options, signal) {
    if (options.formats === false)
        return new Map();
    const next = new Map(current);
    const supported = new Set(FORMAT_EXTENSIONS);
    for (const relativePath of changedFiles) {
        abortIfNeeded(signal);
        if (!supported.has(path.extname(relativePath).toLowerCase()))
            continue;
        try {
            next.set(relativePath, await readFile(path.join(repoRoot, relativePath), 'utf8'));
        }
        catch (error) {
            if (isOccAbortError(error))
                throw error;
            next.delete(relativePath);
        }
    }
    return next;
}
/**
 * Untracked paths, or none.
 *
 * A repository with no commits, or none at all, is a normal thing to point this
 * at; failing the whole session because one advisory listing failed would be a
 * poor trade.
 */
async function safeUntracked(repoRoot, signal, paths) {
    try {
        return await getGitUntrackedFiles({ repoRoot, signal, paths: paths ? [...paths] : undefined });
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        return [];
    }
}
//# sourceMappingURL=session.js.map