import { listTreeBlobs, readBlobHeaders, readBlobs, walkCommitWindow } from './git.js';
import { TextDecoder } from 'node:util';
import { compareCodeUnits } from '../../utils.js';
import { makeLineageId, matchCommitEntities } from './lineage.js';
import { entityStateKey, extractEntitiesFromBlob, looksBinary } from './snapshot.js';
import { exactSourceForRecord } from './syntax-evidence.js';
import { grammarManifestCandidatesForPath, verifyGrammarManifestEntry } from './grammar-manifest.js';
import { DEFAULT_ENTITY_BOUNDS, bumpCounter } from './types.js';
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true });
/**
 * Extraction is addressed by content *and* path, never by content alone.
 *
 * Git stores one blob per distinct content, so two byte-identical files — an
 * empty `__init__.py`, a duplicated barrel, a generated stub — share a single
 * blob id. Entity records carry the path they came from, so keying on the blob
 * alone would hand the second file the first file's records, and every entity
 * in it would be attributed to the wrong file. Reads are still deduplicated by
 * blob id; only extraction is per pair.
 */
export function blobEntityKey(blobSha, relativePath) {
    return `${blobSha}\u0000${relativePath}`;
}
export function createMemoryBlobCache(initial = []) {
    const held = new Map();
    for (const value of initial)
        held.set(blobEntityKey(value.blobSha, value.relativePath), value);
    return {
        get: (blobSha, relativePath) => held.get(blobEntityKey(blobSha, relativePath)),
        set: value => {
            held.set(blobEntityKey(value.blobSha, value.relativePath), value);
        },
        entries: () => held.values(),
    };
}
/** `.ts`, or `''` for a file with no extension — the grain a coverage gap is reported at. */
function extensionOf(relativePath) {
    const base = relativePath.slice(relativePath.lastIndexOf('/') + 1);
    const dot = base.lastIndexOf('.');
    return dot > 0 ? base.slice(dot).toLowerCase() : '';
}
function noteUnsupported(unsupported, relativePath) {
    const extension = extensionOf(relativePath);
    unsupported.set(extension, (unsupported.get(extension) ?? 0) + 1);
}
function noteParseFailure(value, relativePath, blobSha, diagnostics) {
    if (!value.provenance?.diagnostics.some(diagnostic => diagnostic.reason === 'parse-failed'))
        return;
    diagnostics.push({
        code: 'parse-failed',
        message: `The structure of ${relativePath} could not be fully parsed; extracted entities are partial.`,
        relativePath,
        blobSha,
    });
}
/**
 * One diagnostic for every extension the run could not read, or none.
 *
 * A silent skip is indistinguishable from "this file has no entities", and a
 * consumer reading an empty result has no way to tell an unsupported language
 * from an empty one.
 */
export function unsupportedDiagnostic(unsupported) {
    if (unsupported.size === 0)
        return undefined;
    let total = 0;
    for (const count of unsupported.values())
        total += count;
    const listed = [...unsupported.entries()]
        .sort((a, b) => b[1] - a[1] || compareCodeUnits(a[0], b[0]))
        .slice(0, 10)
        .map(([extension, count]) => `${extension || '(no extension)'} ×${count}`);
    return {
        code: 'blob-unsupported',
        message: `${total} file(s) had no entity extractor and contributed nothing: ${listed.join(', ')}${unsupported.size > listed.length ? ', …' : ''}.`,
        total,
    };
}
/**
 * Turn a set of (path, blob) pairs into entity records.
 *
 * Ordinary index construction reads contents even on an entity-cache hit, so
 * matching sees the same evidence whether that cache is warm or cold — a
 * matcher whose result depends on cache state produces history that cannot be
 * reproduced. Bounded all-parent observers may additionally supply a run-local
 * immutable body cache; that cache avoids duplicate reads of the same OID but
 * never changes the bytes supplied to matching. The persistent entity cache
 * still skips only the expensive half, parsing, and the counters report reads
 * and parses separately.
 */
export async function readBlobEntities(requests, options) {
    const entities = new Map();
    const contents = new Map();
    const buffers = new Map();
    const diagnostics = [];
    const evidenceUnavailablePaths = new Set();
    const unsupported = new Map();
    if (requests.length === 0) {
        return { entities, contents, buffers, diagnostics, evidenceUnavailablePaths, unsupported };
    }
    // Every path a blob appears at, not just the first: identical content is one
    // blob but many files, and each of them needs its own records.
    const pathsByBlob = new Map();
    for (const request of requests) {
        const paths = pathsByBlob.get(request.blobSha);
        if (!paths)
            pathsByBlob.set(request.blobSha, [request.relativePath]);
        else if (!paths.includes(request.relativePath))
            paths.push(request.relativePath);
    }
    const pathByBlob = new Map([...pathsByBlob].map(([oid, paths]) => [oid, paths[0]]));
    const noteUnavailable = (oid) => {
        for (const relativePath of pathsByBlob.get(oid) ?? [])
            evidenceUnavailablePaths.add(relativePath);
    };
    const oids = [...pathsByBlob.keys()];
    bumpCounter(options.counters, 'blobsRequested', oids.length);
    const unreadOids = oids.filter(oid => !options.bodyCache?.has(oid));
    const headers = await readBlobHeaders(unreadOids, options);
    const wanted = [];
    for (const oid of unreadOids) {
        const header = headers.get(oid);
        const relativePath = pathByBlob.get(oid) ?? '';
        if (!header || header.missing) {
            noteUnavailable(oid);
            // The all-zero oid is the routine placeholder for unhashed working-tree
            // content; a real id that the repository lacks (a partial clone whose
            // blob was never fetched, corruption) is an evidence gap to report.
            if (!/^0+$/.test(oid)) {
                diagnostics.push({
                    code: 'blob-missing',
                    message: `Skipped ${relativePath}: object ${oid} is not physically present in the repository.`,
                    relativePath,
                    blobSha: oid,
                });
            }
            continue;
        }
        if (header.size > options.bounds.maxBlobBytes) {
            noteUnavailable(oid);
            diagnostics.push({
                code: 'blob-too-large',
                message: `Skipped ${relativePath}: ${header.size} bytes exceeds the ${options.bounds.maxBlobBytes} byte limit.`,
                relativePath,
                blobSha: oid,
                total: header.size,
                limit: options.bounds.maxBlobBytes,
            });
            continue;
        }
        const alreadyCounted = options.budget?.seenOids?.has(oid) ?? false;
        if (options.budget &&
            !alreadyCounted &&
            options.budget.bytesRead + header.size > options.bounds.maxTotalBlobBytes) {
            noteUnavailable(oid);
            diagnostics.push({
                code: 'blob-budget-exhausted',
                message: `Stopped reading blobs at ${options.budget.bytesRead} bytes, the ${options.bounds.maxTotalBlobBytes} byte total limit.`,
                relativePath,
                blobSha: oid,
                limit: options.bounds.maxTotalBlobBytes,
            });
            break;
        }
        if (options.budget && !alreadyCounted) {
            options.budget.bytesRead += header.size;
            options.budget.seenOids?.add(oid);
        }
        wanted.push(oid);
    }
    const newlyRead = await readBlobs(wanted, headers, { ...options, maxBatchBytes: options.bounds.maxBatchBytes });
    bumpCounter(options.counters, 'blobsRead', newlyRead.size);
    for (const [oid, buffer] of newlyRead)
        options.bodyCache?.set(oid, buffer);
    const raw = new Map();
    for (const oid of oids) {
        const buffer = newlyRead.get(oid) ?? options.bodyCache?.get(oid);
        if (buffer)
            raw.set(oid, buffer);
    }
    for (const oid of oids) {
        const buffer = raw.get(oid);
        const relativePath = pathByBlob.get(oid) ?? '';
        if (!buffer) {
            noteUnavailable(oid);
            continue;
        }
        buffers.set(oid, buffer);
        if (buffer.includes(0) || looksBinary(buffer)) {
            noteUnavailable(oid);
            bumpCounter(options.counters, 'blobsSkipped');
            diagnostics.push({
                code: 'blob-binary',
                message: `Skipped ${relativePath}: content is not text.`,
                relativePath,
                blobSha: oid,
            });
            continue;
        }
        let text;
        try {
            text = FATAL_UTF8.decode(buffer);
        }
        catch {
            noteUnavailable(oid);
            bumpCounter(options.counters, 'blobsSkipped');
            diagnostics.push({
                code: 'evidence-unavailable',
                message: `Skipped ${relativePath}: content is not valid UTF-8.`,
                relativePath,
                blobSha: oid,
            });
            continue;
        }
        const lineCount = countLines(text);
        if (lineCount > options.bounds.maxBlobLines) {
            noteUnavailable(oid);
            bumpCounter(options.counters, 'blobsSkipped');
            diagnostics.push({
                code: 'blob-too-many-lines',
                message: `Skipped ${relativePath}: ${lineCount} lines exceeds the ${options.bounds.maxBlobLines} line limit.`,
                relativePath,
                blobSha: oid,
                total: lineCount,
                limit: options.bounds.maxBlobLines,
            });
            continue;
        }
        contents.set(oid, text);
        // One read, but one extraction per path the blob appears at. A `.c` and a
        // `.cpp` file with identical bytes are one blob and two files, and the
        // language spec — hence the entities — is chosen by the path.
        for (const path of pathsByBlob.get(oid) ?? []) {
            if (options.shouldExtract && !options.shouldExtract(path))
                continue;
            const cached = options.cache?.get(oid, path);
            if (cached) {
                bumpCounter(options.counters, 'blobsFromCache');
                bumpCounter(options.counters, 'cacheHits');
                entities.set(blobEntityKey(oid, path), cached);
                noteParseFailure(cached, path, oid, diagnostics);
                if (cached.skippedReason)
                    noteUnsupported(unsupported, path);
                continue;
            }
            bumpCounter(options.counters, 'cacheMisses');
            const extracted = await extractEntitiesFromBlob({
                repoRoot: options.repoRoot,
                relativePath: path,
                blobSha: oid,
                content: text,
                byteLength: buffer.length,
                guardrails: options.guardrails,
                bounds: options.bounds,
                counters: options.counters,
            });
            entities.set(blobEntityKey(oid, path), extracted);
            noteParseFailure(extracted, path, oid, diagnostics);
            options.cache?.set(extracted);
            if (extracted.skippedReason)
                noteUnsupported(unsupported, path);
            if (extracted.truncated) {
                diagnostics.push({
                    code: 'entities-truncated',
                    message: `${path} has more than ${options.bounds.maxEntitiesPerFile} entities; the rest were dropped.`,
                    relativePath: path,
                    blobSha: oid,
                    kept: extracted.entities.length,
                    limit: options.bounds.maxEntitiesPerFile,
                });
            }
        }
    }
    return { entities, contents, buffers, diagnostics, evidenceUnavailablePaths, unsupported };
}
function countLines(text) {
    let lines = 1;
    for (let index = 0; index < text.length; index++) {
        if (text.charCodeAt(index) === 10)
            lines++;
    }
    return lines;
}
/**
 * Every entity in one revision.
 *
 * Seeds a bounded walk at its window boundary, and later lets entity diff
 * describe a revision that is not a commit at all — the index, or a tree.
 */
export async function snapshotEntities(ref, options) {
    const entries = options.entries ??
        (await listTreeBlobs(ref, options)).map(entry => ({ blobSha: entry.blobSha, relativePath: entry.path }));
    const result = await readBlobEntities(entries, options);
    const byPath = new Map();
    const contents = new Map();
    for (const entry of entries) {
        const blob = result.entities.get(blobEntityKey(entry.blobSha, entry.relativePath));
        if (blob && blob.entities.length > 0)
            byPath.set(entry.relativePath, blob.entities);
        const text = result.contents.get(entry.blobSha);
        if (text !== undefined)
            contents.set(entry.relativePath, text);
    }
    return { byPath, contents, diagnostics: result.diagnostics, unsupported: result.unsupported };
}
/**
 * Resolve and physically verify the bounded grammar set before any commit is
 * matched. The verifier parameter is a test seam only; `indexHistory` always
 * uses the digest-pinned physical verifier.
 */
export async function preverifyLineageGrammars(relativePaths, verify = verifyGrammarManifestEntry) {
    const entries = new Map();
    for (const relativePath of relativePaths) {
        for (const entry of grammarManifestCandidatesForPath(relativePath))
            entries.set(entry.grammar, entry);
    }
    const ordered = [...entries.values()].sort((left, right) => compareCodeUnits(left.grammar, right.grammar));
    const results = await Promise.all(ordered.map(async (entry) => ({ entry, verified: await verify(entry) })));
    return results
        .filter(result => !result.verified)
        .map(({ entry }) => ({
        code: 'grammar-verification-refused',
        message: `Refused lineage CST evidence for grammar "${entry.grammar}": its physical WASM bytes ` +
            'did not match the checked-in manifest.',
        grammar: entry.grammar,
    }));
}
/**
 * Walk a commit window, matching each commit's entities against the running
 * lineage state.
 *
 * The walk is first-parent and linear, which is what lets the running state
 * serve as the parent snapshot: no full tree is ever materialised per commit.
 */
export async function indexHistory(options) {
    const bounds = options.bounds ?? DEFAULT_ENTITY_BOUNDS;
    const events = [];
    const diagnostics = [];
    // A blob can be the post-image of one commit and the pre-image of the next.
    // Charge its immutable Git object once across the whole walk, matching the
    // physical-evidence budget semantics used by atoms.
    const budget = { bytesRead: 0, seenOids: new Set() };
    // Accumulated across the whole walk, then reported once: per-commit
    // reporting would emit the same gap up to `maxCommits` times.
    const unsupported = new Map();
    options.onProgress?.({ phase: 'discover', scope: 'entity', total: 0, completed: 0, detail: 'walking commit window' });
    const window = await walkCommitWindow({
        repoRoot: options.repoRoot,
        head: options.head,
        sinceCommit: options.sinceCommit,
        orderOffset: options.orderOffset,
        bounds,
        signal: options.signal,
        counters: options.counters,
    });
    options.onProgress?.({
        phase: 'discover',
        scope: 'entity',
        total: window.commits.length,
        completed: window.commits.length,
        detail: `${window.commits.length} commit(s) to index`,
    });
    if (window.truncated) {
        diagnostics.push({
            code: 'commit-window-truncated',
            message: `History window truncated at ${bounds.maxCommits} commits; older commits were not indexed.`,
            kept: window.commits.length,
            limit: bounds.maxCommits,
        });
    }
    // An extension that truncated has dropped the commits adjacent to the head it
    // was extending, so the running state is no longer the parent snapshot of the
    // first commit in view. Every entity born in that hole would be recorded as a
    // birth at the wrong commit, with full confidence and no diagnostic — the
    // failure mode this whole module exists to avoid. Seeding cannot repair it,
    // because the state to diff against is the one already held. So the walk
    // declines before reading a single blob and the caller rebuilds; the
    // content-addressed blob cache makes that far cheaper than it sounds.
    if (window.truncated && options.sinceCommit) {
        return { commits: [], events: [], diagnostics: [], truncated: true, gapped: true };
    }
    const changedPaths = [];
    for (const changes of window.changesByCommit.values()) {
        for (const change of changes) {
            changedPaths.push(change.path);
            if (change.oldPath)
                changedPaths.push(change.oldPath);
        }
    }
    diagnostics.push(...(await preverifyLineageGrammars(changedPaths)));
    const readOptions = {
        repoRoot: options.repoRoot,
        bounds,
        guardrails: options.guardrails,
        cache: options.cache,
        signal: options.signal,
        counters: options.counters,
        budget,
    };
    // A truncated cold window starts mid-history, so the entities already present
    // at that point were not born there. Seeding from the boundary's parent keeps
    // the first indexed commit a real diff instead of a mass birth event.
    const first = window.commits[0];
    const seedRef = window.truncated && !options.sinceCommit ? first?.parents[0] : undefined;
    if (seedRef && first) {
        const seeded = await snapshotEntities(seedRef, readOptions);
        for (const [relativePath, records] of seeded.byPath)
            options.byPath.set(relativePath, records);
        diagnostics.push(...seeded.diagnostics);
        for (const [extension, count] of seeded.unsupported) {
            unsupported.set(extension, (unsupported.get(extension) ?? 0) + count);
        }
        seedLineageState(options, seeded.byPath, seedRef);
    }
    // Only when the window starts mid-history *and* seeding was impossible do
    // pre-existing entities look like new ones; that is the case `orphan` exists
    // for, and it must never be reported as `added`.
    const blindStart = window.truncated && !options.sinceCommit && !seedRef;
    let walked = 0;
    for (const commit of window.commits) {
        bumpCounter(options.counters, 'commitsWalked');
        // Emitted before the commit's blobs are read, so a stalled event names the
        // commit that is stalling rather than the last one that finished.
        options.onProgress?.({
            phase: 'index',
            scope: 'entity',
            total: window.commits.length,
            completed: walked,
            detail: commit.sha.slice(0, 12),
        });
        walked++;
        const changes = window.changesByCommit.get(commit.sha) ?? [];
        const requests = [];
        for (const change of changes) {
            if (change.newBlobSha && !isNullOid(change.newBlobSha)) {
                requests.push({ blobSha: change.newBlobSha, relativePath: change.path });
            }
            if (change.oldBlobSha && !isNullOid(change.oldBlobSha)) {
                requests.push({ blobSha: change.oldBlobSha, relativePath: change.oldPath ?? change.path });
            }
        }
        const read = await readBlobEntities(requests, readOptions);
        diagnostics.push(...read.diagnostics);
        for (const [extension, count] of read.unsupported) {
            unsupported.set(extension, (unsupported.get(extension) ?? 0) + count);
        }
        const currentByPath = new Map();
        const previousByPath = new Map();
        const renameMap = new Map();
        const deletedPaths = new Set();
        const currentSource = new Map();
        const previousSource = new Map();
        const evidenceUnavailablePaths = new Set();
        for (const change of changes) {
            const previousPath = change.oldPath ?? change.path;
            if (change.status === 'deleted') {
                deletedPaths.add(previousPath);
            }
            else {
                const blob = change.newBlobSha ? read.entities.get(blobEntityKey(change.newBlobSha, change.path)) : undefined;
                if (!blob && read.evidenceUnavailablePaths.has(change.path)) {
                    evidenceUnavailablePaths.add(change.path);
                }
                else {
                    currentByPath.set(change.path, blob?.entities ?? []);
                }
                const text = change.newBlobSha ? read.contents.get(change.newBlobSha) : undefined;
                if (text !== undefined)
                    currentSource.set(change.path, text);
                // A copy leaves its source in place, so it is a birth, not a move —
                // only a real rename retires the previous path.
                if (change.status === 'renamed' && change.oldPath && change.oldPath !== change.path) {
                    renameMap.set(change.path, change.oldPath);
                }
            }
            const previous = options.byPath.get(previousPath);
            if (previous)
                previousByPath.set(previousPath, previous);
            const previousText = change.oldBlobSha ? read.contents.get(change.oldBlobSha) : undefined;
            if (previousText !== undefined)
                previousSource.set(previousPath, previousText);
        }
        const atWindowStart = blindStart && commit.order === 0;
        const result = matchCommitEntities({
            commit,
            repoIdentity: options.repoIdentity,
            state: options.state,
            currentByPath,
            previousByPath,
            renameMap,
            deletedPaths,
            evidenceUnavailablePaths,
            thresholds: options.thresholds,
            boundHit: atWindowStart,
            atWindowStart,
            maxCrossFileCandidates: bounds.maxCrossFileCandidates,
            maxSimilarityCandidates: bounds.maxSimilarityCandidates,
            sourceFor: (record, side) => sliceSource(side === 'previous' ? previousSource : currentSource, record),
            counters: options.counters,
        });
        events.push(...result.events);
        diagnostics.push(...result.diagnostics);
        for (const [relativePath, records] of currentByPath)
            options.byPath.set(relativePath, records);
        for (const currentPath of evidenceUnavailablePaths) {
            const previousPath = renameMap.get(currentPath) ?? currentPath;
            const previous = options.byPath.get(previousPath);
            if (previous) {
                options.byPath.set(currentPath, previous.map(record => (previousPath === currentPath ? record : { ...record, relativePath: currentPath })));
            }
        }
        for (const relativePath of deletedPaths)
            options.byPath.delete(relativePath);
        for (const relativePath of renameMap.values())
            options.byPath.delete(relativePath);
        if (events.length > bounds.maxLineageEvents) {
            diagnostics.push({
                code: 'event-budget-exhausted',
                message: `Stopped indexing at ${bounds.maxLineageEvents} lineage events.`,
                commit: commit.sha,
                kept: events.length,
                limit: bounds.maxLineageEvents,
            });
            break;
        }
        if (options.state.byLineageId.size > bounds.maxLiveEntities) {
            diagnostics.push({
                code: 'entity-budget-exhausted',
                message: `Stopped indexing at ${bounds.maxLiveEntities} live entities.`,
                commit: commit.sha,
                kept: options.state.byLineageId.size,
                limit: bounds.maxLiveEntities,
            });
            break;
        }
    }
    options.onProgress?.({
        phase: 'index',
        scope: 'entity',
        total: window.commits.length,
        completed: walked,
        detail: `${events.length} event(s)`,
    });
    const unsupportedReport = unsupportedDiagnostic(unsupported);
    if (unsupportedReport)
        diagnostics.push(unsupportedReport);
    return { commits: window.commits, events, diagnostics, truncated: window.truncated };
}
/** An all-zero object id marks "no such side" in a raw diff record. */
function isNullOid(oid) {
    return /^0+$/.test(oid);
}
function sliceSource(sources, record) {
    const text = sources.get(record.relativePath);
    if (text === undefined)
        return undefined;
    const exact = exactSourceForRecord(text, record);
    if (exact !== undefined)
        return exact;
    const lines = text.split(/\r?\n/);
    if (record.startLine > lines.length)
        return undefined;
    return lines.slice(record.startLine - 1, record.endLine).join('\n');
}
/**
 * Give seeded entities lineages of their own.
 *
 * They are attributed to the commit the seed was taken from, which is the
 * oldest point this index can see. `birthAtWindowBoundary` marks every one, so
 * a consumer can tell "created here" from "already existed when we first
 * looked" — the distinction a bounded index would otherwise erase.
 */
function seedLineageState(options, byPath, seedCommit) {
    for (const records of byPath.values()) {
        for (const record of records) {
            const lineageId = makeLineageId({
                repoIdentity: options.repoIdentity,
                birthCommit: seedCommit,
                birthPath: record.relativePath,
                birthKind: record.kind,
                birthQualifiedName: record.qualifiedName,
                birthOrdinal: record.ordinal,
            });
            options.state.byLineageId.set(lineageId, {
                lineageId,
                record,
                birthCommit: seedCommit,
                birthPath: record.relativePath,
                birthQualifiedName: record.qualifiedName,
                lastCommit: seedCommit,
                lastCommitOrder: 0,
                eventCount: 0,
                birthAtWindowBoundary: true,
            });
            options.state.byStateKey.set(entityStateKey(record), lineageId);
        }
    }
}
//# sourceMappingURL=indexer.js.map