import { createHash } from 'node:crypto';
import { compareCodeUnits } from '../../utils.js';
import { bodyEvidenceAvailable, hashesComparable, lexDialectForPath, structuralSimilarity, normalizedSimilarity, } from './hash.js';
import { entityStateKey } from './snapshot.js';
import { ENTITY_CST_PROOF, ENTITY_SIGNATURE_PROOF, WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST, grammarManifestEntry, isGrammarVerified, } from './grammar-manifest.js';
import { DEFAULT_ENTITY_BOUNDS, DEFAULT_LINEAGE_THRESHOLDS, ENTITY_LINEAGE_ID_VERSION, MATCH_CONFIDENCE, bumpCounter, } from './types.js';
/**
 * A lineage id is derived from where a lineage *started*, never from where it
 * currently is — that is what lets it survive renames and moves while staying
 * reproducible from history alone.
 *
 * The schema version is both an input to the digest and the id's literal
 * prefix, so a format bump changes every id and a consumer holding a persisted
 * id can detect staleness from the string without a lookup.
 */
export function makeLineageId(birth) {
    const material = [
        ENTITY_LINEAGE_ID_VERSION,
        birth.repoIdentity,
        birth.birthCommit,
        birth.birthPath,
        birth.birthKind,
        birth.birthQualifiedName,
        birth.birthOrdinal,
    ].join('\u0000');
    return `ent${ENTITY_LINEAGE_ID_VERSION}:${createHash('sha256').update(material, 'utf8').digest('hex').slice(0, 32)}`;
}
export function createLineageState() {
    return { byLineageId: new Map(), byStateKey: new Map() };
}
/**
 * A function that gains or loses a container is still the same function, so
 * those two kinds are interchangeable. A class becoming an interface is a
 * genuine redeclaration and is not.
 */
export function kindsCompatible(a, b) {
    if (a === b)
        return true;
    const callable = new Set(['function', 'method']);
    return callable.has(a) && callable.has(b);
}
function exactBodyHash(record) {
    return record.hashes.exactBodyHash;
}
function bodyChangedBetween(previous, current) {
    const before = exactBodyHash(previous);
    const after = exactBodyHash(current);
    if (before !== undefined && after !== undefined)
        return before !== after;
    return previous.hashes.bodyHash !== current.hashes.bodyHash;
}
function syntaxEvidenceComparable(previous, current) {
    const before = previous.syntaxEvidence;
    const after = current.syntaxEvidence;
    const beforeManifest = before ? grammarManifestEntry(before.grammar) : undefined;
    const afterManifest = after ? grammarManifestEntry(after.grammar) : undefined;
    const staticallyComparable = Boolean(before &&
        after &&
        before.complete &&
        after.complete &&
        before.proof === ENTITY_CST_PROOF &&
        after.proof === ENTITY_CST_PROOF &&
        before.manifestDigest === WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST &&
        after.manifestDigest === WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST &&
        beforeManifest?.wasmSha256 === before.grammarDigest &&
        afterManifest?.wasmSha256 === after.grammarDigest &&
        before.proof === after.proof &&
        before.grammar === after.grammar &&
        before.grammarDigest === after.grammarDigest &&
        before.manifestDigest === after.manifestDigest);
    if (!staticallyComparable || !before || !after)
        return 'unavailable';
    return isGrammarVerified(before.grammar) && isGrammarVerified(after.grammar)
        ? 'comparable'
        : 'grammar-verification-refused';
}
function signatureEvidenceComparable(previous, current) {
    const before = previous.signatureEvidence;
    const after = current.signatureEvidence;
    const beforeManifest = before ? grammarManifestEntry(before.grammar) : undefined;
    const afterManifest = after ? grammarManifestEntry(after.grammar) : undefined;
    return Boolean(before &&
        after &&
        before.complete &&
        after.complete &&
        before.proof === ENTITY_SIGNATURE_PROOF &&
        after.proof === ENTITY_SIGNATURE_PROOF &&
        before.manifestDigest === WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST &&
        after.manifestDigest === WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST &&
        beforeManifest?.wasmSha256 === before.grammarDigest &&
        afterManifest?.wasmSha256 === after.grammarDigest &&
        before.proof === after.proof &&
        before.grammar === after.grammar &&
        before.grammarDigest === after.grammarDigest &&
        before.manifestDigest === after.manifestDigest &&
        isGrammarVerified(before.grammar) &&
        isGrammarVerified(after.grammar));
}
function emptyEvidence(pool, candidateCount) {
    return { pool, hashesComparable: false, candidateCount };
}
/**
 * Group by a key and accept a pairing only when exactly one entity on each
 * side carries it.
 *
 * Mutual uniqueness is what makes every rung a refusal rather than a guess: if
 * two candidates share the evidence, neither is matched and both stay separate
 * lineages. Picking "the first one" here is how a matcher silently fuses two
 * unrelated entities.
 */
function matchUnique(previous, current, keyOf, reason, buildEvidence, accept = () => true) {
    const previousByKey = new Map();
    for (const candidate of previous) {
        const key = keyOf(candidate.record);
        if (key === undefined)
            continue;
        const bucket = previousByKey.get(key);
        if (bucket)
            bucket.push(candidate);
        else
            previousByKey.set(key, [candidate]);
    }
    const currentByKey = new Map();
    for (const record of current) {
        const key = keyOf(record);
        if (key === undefined)
            continue;
        const bucket = currentByKey.get(key);
        if (bucket)
            bucket.push(record);
        else
            currentByKey.set(key, [record]);
    }
    const matches = [];
    const refusedPrevious = new Set();
    const refusedCurrent = new Set();
    let refusals = 0;
    let acceptDeclines = 0;
    for (const [key, currentBucket] of currentByKey) {
        const previousBucket = previousByKey.get(key);
        if (!previousBucket)
            continue;
        if (previousBucket.length !== 1 || currentBucket.length !== 1) {
            refusals++;
            for (const candidate of previousBucket)
                refusedPrevious.add(candidate.record);
            for (const record of currentBucket)
                refusedCurrent.add(record);
            continue;
        }
        const before = previousBucket[0];
        const after = currentBucket[0];
        if (!accept(before.record, after)) {
            acceptDeclines++;
            continue;
        }
        matches.push({
            previous: before,
            current: after,
            reason,
            evidence: buildEvidence(before.record, after, previousBucket.length + currentBucket.length),
        });
    }
    return { matches, refusals, refusedPrevious, refusedCurrent, similaritySkipped: false, acceptDeclines, reason };
}
/**
 * The evidence ladder. Rungs run in order and each removes what it matched, so
 * a loose rung never competes for an entity a strong rung already claimed.
 */
export function matchFileEntities(input) {
    let previous = [...input.previous];
    let current = [...input.current];
    const matches = [];
    const refusedPrevious = new Set();
    const refusedCurrent = new Set();
    let similaritySkipped = false;
    let refusals = 0;
    const acceptDeclines = new Map();
    const consume = (result) => {
        refusals += result.refusals;
        similaritySkipped ||= result.similaritySkipped;
        if (result.acceptDeclines > 0) {
            acceptDeclines.set(result.reason, (acceptDeclines.get(result.reason) ?? 0) + result.acceptDeclines);
            bumpCounter(input.counters, 'lineageAcceptDeclines', result.acceptDeclines);
        }
        for (const record of result.refusedPrevious)
            refusedPrevious.add(record);
        for (const record of result.refusedCurrent)
            refusedCurrent.add(record);
        if (result.matches.length === 0)
            return;
        const takenPrevious = new Set(result.matches.map(match => match.previous.record));
        const takenCurrent = new Set(result.matches.map(match => match.current));
        matches.push(...result.matches);
        previous = previous.filter(candidate => !takenPrevious.has(candidate.record));
        current = current.filter(record => !takenCurrent.has(record));
    };
    bumpCounter(input.counters, 'lineageComparisons', previous.length * current.length);
    // 1. Same qualified identity in the same slot.
    consume(matchUnique(previous, current, record => entityStateKey(record), input.pool === 'git-rename' ? 'git-rename-identity' : 'exact-identity', (before, after, count) => ({
        ...emptyEvidence(input.pool, count),
        hashesComparable: hashesComparable(before.hashes, after.hashes),
        bodyHashEqual: before.hashes.bodyHash === after.hashes.bodyHash,
        exactBodyHashEqual: before.hashes.exactBodyHash !== undefined &&
            after.hashes.exactBodyHash !== undefined &&
            before.hashes.exactBodyHash === after.hashes.exactBodyHash,
    })));
    // 2. Byte-identical body.
    consume(matchUnique(previous, current, record => record.hashes.exactBodyHash ??
        (record.hashes.algo === 'lexer' ? `legacy:${record.hashes.hashVersion}:${record.hashes.bodyHash}` : undefined), 'content-hash', (before, after, count) => ({
        ...emptyEvidence(input.pool, count),
        hashesComparable: hashesComparable(before.hashes, after.hashes),
        bodyHashEqual: before.hashes.bodyHash === after.hashes.bodyHash,
        exactBodyHashEqual: before.hashes.exactBodyHash !== undefined &&
            after.hashes.exactBodyHash !== undefined &&
            before.hashes.exactBodyHash === after.hashes.exactBodyHash,
    })));
    // 3. Same code modulo comments and formatting.
    consume(matchUnique(previous, current, record => record.hashes.normalizedHash
        ? `${record.hashes.hashVersion}:${record.hashes.algo}:${record.hashes.normalizedHash}`
        : undefined, 'normalized-hash', (before, after, count) => ({
        ...emptyEvidence(input.pool, count),
        hashesComparable: true,
        normalizedHashEqual: true,
    }), (before, after) => bodyEvidenceAvailable(before.hashes, after.hashes) && kindsCompatible(before.kind, after.kind)));
    // 4. Same structure with different names — the rung that catches a rename.
    consume(matchUnique(previous, current, record => record.hashes.structuralHash
        ? `${record.hashes.hashVersion}:${record.hashes.algo}:${record.hashes.structuralHash}`
        : undefined, 'structural-hash', (before, after, count) => ({
        ...emptyEvidence(input.pool, count),
        hashesComparable: true,
        structuralHashEqual: true,
    }), (before, after) => bodyEvidenceAvailable(before.hashes, after.hashes) && kindsCompatible(before.kind, after.kind)));
    // 5. Same name and same declared contract.
    consume(matchUnique(previous, current, 
    // Gated off for the cross-file pool: `signatureHash` is name-elided by
    // construction, so `helper(a, b)` and `format(items, seen)` hash
    // identically. Across arbitrary files this rung would pair on a shared
    // name plus an argument count, which is not evidence that two entities
    // are the same entity. Gating the key rather than the accept predicate
    // keeps those buckets from forming, so they cannot count as refusals.
    record => {
        if (input.pool === 'cross-file')
            return undefined;
        if (record.signatureEvidence) {
            return `${record.name}\u0000v${record.signatureEvidence.version}:${record.signatureEvidence.proof}:${record.signatureEvidence.grammar}:${record.signatureEvidence.grammarDigest}:${record.signatureEvidence.manifestDigest}:${record.signatureEvidence.hash}`;
        }
        return record.hashes.signatureHash
            ? `${record.name}\u0000legacy:${record.hashes.hashVersion}:${record.hashes.algo}:${record.hashes.signatureHash}`
            : undefined;
    }, 'signature-name', (before, after, count) => ({
        ...emptyEvidence(input.pool, count),
        hashesComparable: hashesComparable(before.hashes, after.hashes),
        signatureHashEqual: true,
        signatureEvidenceEqual: before.signatureEvidence !== undefined &&
            after.signatureEvidence !== undefined &&
            signatureEvidenceComparable(before, after) &&
            before.signatureEvidence.hash === after.signatureEvidence.hash,
    }), (before, after) => kindsCompatible(before.kind, after.kind)));
    // 6. Same qualified name. Only within a file or across a git-detected
    //    rename — across arbitrary files a shared name is not evidence.
    if (input.pool !== 'cross-file') {
        consume(matchUnique(previous, current, record => record.qualifiedName, 'qualified-name', (before, after, count) => ({
            ...emptyEvidence(input.pool, count),
            hashesComparable: hashesComparable(before.hashes, after.hashes),
        }), (before, after) => kindsCompatible(before.kind, after.kind)));
    }
    // 7. Similarity, last and most heavily gated.
    if (input.sourceFor && previous.length > 0 && current.length > 0) {
        const similarityResult = matchBySimilarity({ ...input, previous, current });
        consume(similarityResult);
    }
    bumpCounter(input.counters, 'ambiguitiesRefused', refusals);
    bumpCounter(input.counters, 'lineageMatches', matches.length);
    matches.sort((left, right) => {
        const currentOrder = compareCodeUnits(entityStateKey(left.current), entityStateKey(right.current));
        return (currentOrder || compareCodeUnits(entityStateKey(left.previous.record), entityStateKey(right.previous.record)));
    });
    const unmatchedPreviousRecords = new Set(previous.map(candidate => candidate.record));
    const unmatchedCurrentRecords = new Set(current);
    return {
        matches,
        unmatchedPrevious: previous,
        unmatchedCurrent: current,
        refusals,
        refusedPrevious: new Set([...refusedPrevious].filter(record => unmatchedPreviousRecords.has(record))),
        refusedCurrent: new Set([...refusedCurrent].filter(record => unmatchedCurrentRecords.has(record))),
        similaritySkipped,
        acceptDeclines: [...acceptDeclines]
            .sort(([left], [right]) => compareCodeUnits(left, right))
            .map(([rung, count]) => ({ rung, count })),
    };
}
function stablePairKey(pair) {
    return `${entityStateKey(pair.candidate.record)}\u0000${entityStateKey(pair.current)}`;
}
function ranked(pairs) {
    return [...pairs].sort((left, right) => right.structural - left.structural ||
        right.normalized - left.normalized ||
        compareCodeUnits(stablePairKey(left), stablePairKey(right)));
}
function matchBySimilarity(input) {
    const thresholds = input.thresholds;
    const matches = [];
    const refusedPrevious = new Set();
    const refusedCurrent = new Set();
    let refusals = 0;
    const limit = input.maxSimilarityCandidates ?? DEFAULT_ENTITY_BOUNDS.maxSimilarityCandidates;
    if (input.previous.length > limit || input.current.length > limit) {
        for (const candidate of input.previous)
            refusedPrevious.add(candidate.record);
        for (const record of input.current)
            refusedCurrent.add(record);
        return {
            matches,
            refusals: 0,
            refusedPrevious,
            refusedCurrent,
            similaritySkipped: true,
            acceptDeclines: 0,
            reason: 'similarity',
        };
    }
    const pairs = [];
    for (const current of input.current) {
        const currentSource = input.sourceFor?.(current, 'current');
        const currentTokens = current.hashes.tokenCount ?? 0;
        if (!currentSource || currentTokens < thresholds.similarityMinTokens)
            continue;
        for (const candidate of input.previous) {
            if (!kindsCompatible(candidate.record.kind, current.kind))
                continue;
            if (lexDialectForPath(candidate.record.relativePath) !== lexDialectForPath(current.relativePath))
                continue;
            if (!(candidate.record.sourceSpan?.exact ?? candidate.record.spanExact))
                continue;
            if (!(current.sourceSpan?.exact ?? current.spanExact))
                continue;
            const previousTokens = candidate.record.hashes.tokenCount ?? 0;
            if (previousTokens < thresholds.similarityMinTokens)
                continue;
            const ratio = Math.min(previousTokens, currentTokens) / Math.max(previousTokens, currentTokens);
            if (ratio < thresholds.similarityLengthRatioMin)
                continue;
            const previousSource = input.sourceFor?.(candidate.record, 'previous');
            if (!previousSource)
                continue;
            bumpCounter(input.counters, 'similarityComparisons');
            const structural = structuralSimilarity(previousSource, currentSource, current.relativePath, thresholds.shingleWidth);
            const normalized = normalizedSimilarity(previousSource, currentSource, current.relativePath, thresholds.shingleWidth);
            if (structural < thresholds.similarityMin || normalized < thresholds.similarityMin)
                continue;
            pairs.push({ candidate, current, structural, normalized });
        }
    }
    const byCurrent = new Map();
    const byPrevious = new Map();
    for (const pair of pairs) {
        const currentBucket = byCurrent.get(pair.current);
        if (currentBucket)
            currentBucket.push(pair);
        else
            byCurrent.set(pair.current, [pair]);
        const previousBucket = byPrevious.get(pair.candidate.record);
        if (previousBucket)
            previousBucket.push(pair);
        else
            byPrevious.set(pair.candidate.record, [pair]);
    }
    for (const current of input.current) {
        const currentRanked = ranked(byCurrent.get(current) ?? []);
        const best = currentRanked[0];
        if (!best)
            continue;
        const currentRunnerUp = currentRanked[1]?.structural ?? 0;
        const previousRanked = ranked(byPrevious.get(best.candidate.record) ?? []);
        const reverseBest = previousRanked[0];
        const previousRunnerUp = previousRanked[1]?.structural ?? 0;
        const reciprocal = reverseBest?.current === current;
        const separated = best.structural - currentRunnerUp >= thresholds.similarityMargin &&
            best.structural - previousRunnerUp >= thresholds.similarityMargin;
        if (!reciprocal || !separated) {
            refusals++;
            refusedCurrent.add(current);
            refusedPrevious.add(best.candidate.record);
            continue;
        }
        matches.push({
            previous: best.candidate,
            current,
            reason: 'similarity',
            evidence: {
                pool: input.pool,
                hashesComparable: hashesComparable(best.candidate.record.hashes, current.hashes),
                similarity: best.structural,
                runnerUpSimilarity: currentRunnerUp,
                reverseRunnerUpSimilarity: previousRunnerUp,
                tiebreakSimilarity: best.normalized,
                candidateCount: Math.max(currentRanked.length, previousRanked.length),
            },
        });
    }
    return {
        matches,
        refusals,
        refusedPrevious,
        refusedCurrent,
        similaritySkipped: false,
        acceptDeclines: 0,
        reason: 'similarity',
    };
}
/**
 * Classification precedence. Evaluated top to bottom, first match wins, and
 * every fact a lower rule would have reported survives as a flag — a rename
 * that also changed logic is `renamed` with `logicChanged: true`, not one at
 * the expense of the other.
 */
export function classifyEntityChange(input) {
    const { previous, current } = input;
    const flags = {};
    const pathChanged = previous.relativePath !== current.relativePath;
    const nameChanged = previous.qualifiedName !== current.qualifiedName;
    const containerChanged = previous.containerName !== current.containerName;
    const kindChanged = previous.kind !== current.kind;
    const bodyChanged = bodyChangedBetween(previous, current);
    const semanticComparison = syntaxEvidenceComparable(previous, current);
    const semanticComparable = semanticComparison === 'comparable';
    const signatureComparable = signatureEvidenceComparable(previous, current);
    const signatureChanged = signatureComparable && previous.signatureEvidence?.hash !== current.signatureEvidence?.hash;
    const exportChanged = previous.isExported !== current.isExported;
    // Supplied by the caller, which is the only level that can answer it: whether
    // an entity moved is a question about its siblings, not about itself. The
    // fallback is for direct callers with a single pair and no file context.
    const orderChanged = input.orderChanged ?? previous.declarationIndex !== current.declarationIndex;
    if (pathChanged)
        flags.pathChanged = true;
    if (nameChanged)
        flags.nameChanged = true;
    if (containerChanged)
        flags.containerChanged = true;
    if (kindChanged)
        flags.kindChanged = true;
    if (bodyChanged)
        flags.bodyChanged = true;
    if (signatureChanged)
        flags.signatureChanged = true;
    if (!signatureComparable && previous.signature !== current.signature)
        flags.signatureEvidenceUnavailable = true;
    if (exportChanged)
        flags.exportChanged = true;
    if (orderChanged)
        flags.orderChanged = true;
    if (!(previous.sourceSpan?.exact ?? previous.spanExact) || !(current.sourceSpan?.exact ?? current.spanExact)) {
        flags.spanInexact = true;
    }
    if (input.boundHit)
        flags.boundHit = true;
    if (bodyChanged && !semanticComparable) {
        flags.evidenceUnavailable = true;
        flags.semanticBasis =
            semanticComparison === 'grammar-verification-refused'
                ? 'grammar-verification-refused'
                : 'syntax-evidence-unavailable';
    }
    if (bodyChanged && semanticComparable) {
        const before = previous.syntaxEvidence;
        const after = current.syntaxEvidence;
        if (before.commentsElidedHash !== after.commentsElidedHash) {
            flags.logicChanged = true;
            flags.semanticBasis = 'nontrivia-syntax-change';
        }
        else {
            flags.evidenceUnavailable = true;
            flags.semanticBasis =
                before.primaryHash === after.primaryHash ? 'whitespace-significance-unverified' : 'comment-only-change';
        }
    }
    // Identity changes outrank content changes: they are what break external
    // references to the entity.
    if (pathChanged)
        return { changeType: 'moved', flags };
    if (nameChanged)
        return { changeType: 'renamed', flags };
    if (bodyChanged) {
        return { changeType: flags.logicChanged ? 'modified_logic' : 'modified', flags };
    }
    if (orderChanged)
        return { changeType: 'reordered', flags };
    return { changeType: 'modified', flags };
}
/**
 * True when nothing about the entity changed, so no event should be emitted.
 *
 * Position is deliberately excluded, both line numbers and declaration index.
 * Both shift whenever anything above the entity in the same file is inserted or
 * removed, which is a property of the file rather than of the entity; the head
 * still advances to the new position, but silently. Comparing them here put a
 * spurious event on every entity below every edit — on this repository that
 * alone accounted for two thirds of all events generated. Whether an entity
 * genuinely moved relative to its siblings is answered by the caller and passed
 * to `classifyEntityChange` as `orderChanged`.
 */
export function isUnchanged(previous, current) {
    return (previous.relativePath === current.relativePath &&
        previous.qualifiedName === current.qualifiedName &&
        previous.kind === current.kind &&
        !bodyChangedBetween(previous, current) &&
        previous.isExported === current.isExported);
}
export function buildLineageEvent(input) {
    const event = {
        lineageId: input.lineageId,
        commit: input.commit.sha,
        commitOrder: input.commit.order,
        authoredAt: input.commit.authoredAt,
        committedAt: input.commit.committedAt,
        authorName: input.commit.authorName,
        authorEmail: input.commit.authorEmail,
        subject: input.commit.subject,
        changeType: input.changeType,
        matchReason: input.matchReason,
        confidence: input.confidence ?? MATCH_CONFIDENCE[input.matchReason],
        evidence: input.evidence,
        flags: input.flags,
    };
    if (input.previous) {
        event.previousName = input.previous.name;
        event.previousQualifiedName = input.previous.qualifiedName;
        event.previousPath = input.previous.relativePath;
        event.previousKind = input.previous.kind;
        event.previousStartLine = input.previous.startLine;
        event.previousEndLine = input.previous.endLine;
        event.previousBodyHash = input.previous.hashes.bodyHash;
        if (input.previous.hashes.exactBodyHash)
            event.previousExactBodyHash = input.previous.hashes.exactBodyHash;
        event.previousOrdinal = input.previous.ordinal;
        event.previousIsExported = input.previous.isExported;
        if (input.previous.containerName)
            event.previousContainerName = input.previous.containerName;
        if (input.previous.signature)
            event.previousSignature = input.previous.signature;
        if (input.previous.hashes.structuralHash)
            event.previousStructuralHash = input.previous.hashes.structuralHash;
        if (input.previous.sourceSpan)
            event.previousSourceSpan = input.previous.sourceSpan;
        if (input.previous.syntaxEvidence)
            event.previousSyntaxEvidence = input.previous.syntaxEvidence;
        if (input.previous.signatureEvidence)
            event.previousSignatureEvidence = input.previous.signatureEvidence;
    }
    if (input.current) {
        event.currentName = input.current.name;
        event.currentQualifiedName = input.current.qualifiedName;
        event.currentPath = input.current.relativePath;
        event.currentKind = input.current.kind;
        event.currentStartLine = input.current.startLine;
        event.currentEndLine = input.current.endLine;
        event.currentBodyHash = input.current.hashes.bodyHash;
        if (input.current.hashes.exactBodyHash)
            event.currentExactBodyHash = input.current.hashes.exactBodyHash;
        event.currentOrdinal = input.current.ordinal;
        event.currentIsExported = input.current.isExported;
        if (input.current.containerName)
            event.currentContainerName = input.current.containerName;
        if (input.current.signature)
            event.currentSignature = input.current.signature;
        if (input.current.hashes.structuralHash)
            event.currentStructuralHash = input.current.hashes.structuralHash;
        if (input.current.sourceSpan)
            event.currentSourceSpan = input.current.sourceSpan;
        if (input.current.syntaxEvidence)
            event.currentSyntaxEvidence = input.current.syntaxEvidence;
        if (input.current.signatureEvidence)
            event.currentSignatureEvidence = input.current.signatureEvidence;
    }
    return event;
}
/**
 * Match one commit's entities against the running lineage state and emit the
 * resulting events. `state` is mutated in place: under a first-parent walk the
 * running state *is* the parent snapshot, so no full parent tree is retained.
 */
export function matchCommitEntities(input) {
    const thresholds = input.thresholds ?? DEFAULT_LINEAGE_THRESHOLDS;
    const events = [];
    const diagnostics = [];
    const births = [];
    const deaths = [];
    const noteAcceptDeclines = (declines, relativePath) => {
        for (const decline of declines) {
            diagnostics.push({
                code: 'lineage-accept-declined',
                message: `The ${decline.rung} lineage rung declined ${decline.count} otherwise unique pairing(s).`,
                commit: input.commit.sha,
                ...(relativePath ? { relativePath } : {}),
                rung: decline.rung,
                total: decline.count,
            });
        }
    };
    /** Commit a settled pairing: emit its event unless nothing changed, then advance the head. */
    const settle = (match, pool, orderChanged = false) => {
        const lineageId = match.previous.lineageId;
        if (!lineageId)
            return;
        if (!orderChanged && isUnchanged(match.previous.record, match.current)) {
            updateHead(input.state, lineageId, match.current, input.commit);
            return;
        }
        const { changeType, flags } = classifyEntityChange({
            previous: match.previous.record,
            current: match.current,
            boundHit: input.boundHit,
            orderChanged,
        });
        if (pool === 'git-rename')
            flags.gitRenameApplied = true;
        events.push(buildLineageEvent({
            lineageId,
            commit: input.commit,
            changeType,
            matchReason: match.reason,
            evidence: match.evidence,
            flags,
            previous: match.previous.record,
            current: match.current,
        }));
        updateHead(input.state, lineageId, match.current, input.commit);
    };
    for (const unavailablePath of input.evidenceUnavailablePaths ?? []) {
        const previousPath = input.renameMap.get(unavailablePath) ?? unavailablePath;
        for (const record of input.previousByPath.get(previousPath) ?? []) {
            const lineageId = input.state.byStateKey.get(entityStateKey(record));
            if (!lineageId)
                continue;
            const renamed = previousPath !== unavailablePath;
            const carried = renamed ? { ...record, relativePath: unavailablePath } : record;
            events.push(buildLineageEvent({
                lineageId,
                commit: input.commit,
                changeType: 'orphan',
                matchReason: 'none',
                evidence: emptyEvidence(input.renameMap.has(unavailablePath) ? 'git-rename' : 'same-path', 0),
                flags: {
                    boundHit: true,
                    evidenceUnavailable: true,
                    ...(renamed ? { pathChanged: true, gitRenameApplied: true } : {}),
                },
                previous: record,
                ...(renamed ? { current: carried } : {}),
                confidence: 0,
            }));
            // Preserve the last observed record and identity while recording that
            // this commit was seen. The next in-bounds post-image can match it.
            updateHead(input.state, lineageId, carried, input.commit);
        }
    }
    for (const [currentPath, currentEntities] of input.currentByPath) {
        if (input.evidenceUnavailablePaths?.has(currentPath))
            continue;
        const previousPath = input.renameMap.get(currentPath) ?? currentPath;
        const pool = input.renameMap.has(currentPath) ? 'git-rename' : 'same-path';
        const previousEntities = input.previousByPath.get(previousPath) ?? [];
        const candidates = previousEntities.map(record => ({
            record,
            lineageId: input.state.byStateKey.get(entityStateKey(record)),
            pool,
        }));
        const result = matchFileEntities({
            previous: candidates,
            current: currentEntities,
            pool,
            thresholds,
            sourceFor: input.sourceFor,
            maxSimilarityCandidates: input.maxSimilarityCandidates,
            counters: input.counters,
        });
        if (result.similaritySkipped) {
            const limit = input.maxSimilarityCandidates ?? DEFAULT_ENTITY_BOUNDS.maxSimilarityCandidates;
            diagnostics.push({
                code: 'similarity-skipped',
                message: `Similarity matching skipped for "${currentPath}": ${result.unmatchedCurrent.length} new and ${result.unmatchedPrevious.length} removed entities exceed the ${limit} candidate bound.`,
                commit: input.commit.sha,
                relativePath: currentPath,
                total: Math.max(result.unmatchedCurrent.length, result.unmatchedPrevious.length),
                limit,
            });
        }
        noteAcceptDeclines(result.acceptDeclines, currentPath);
        const moved = reorderedEntities(result.matches);
        for (const match of result.matches)
            settle(match, pool, moved.has(match.current));
        // Leftovers are deliberately not decided here. An entity that vanished from
        // one file and appeared in another is a single move, and no per-path view
        // can see that — only the commit-wide pass below can.
        for (const record of result.unmatchedCurrent) {
            births.push({
                record,
                pool,
                refused: result.refusedCurrent.has(record),
                candidateCount: result.unmatchedPrevious.length,
            });
        }
        for (const candidate of result.unmatchedPrevious) {
            deaths.push({
                candidate,
                pool,
                refused: result.refusedPrevious.has(candidate.record),
                candidateCount: result.unmatchedCurrent.length,
            });
        }
    }
    for (const deletedPath of input.deletedPaths) {
        if (input.evidenceUnavailablePaths?.has(deletedPath))
            continue;
        for (const record of input.previousByPath.get(deletedPath) ?? []) {
            const lineageId = input.state.byStateKey.get(entityStateKey(record));
            if (!lineageId)
                continue;
            deaths.push({
                candidate: { record, lineageId, pool: 'same-path' },
                pool: 'same-path',
                refused: false,
                candidateCount: 0,
            });
        }
    }
    const claimed = matchAcrossFiles(input, births, deaths, thresholds, settle, diagnostics, noteAcceptDeclines);
    for (const birth of births) {
        if (claimed.currents.has(birth.record))
            continue;
        // A bound or a refusal means we declined to decide, which is a statement
        // about our evidence, not about the developer's intent. Calling that
        // `added` would be the most damaging error this subsystem can make.
        const orphaned = birth.refused || input.boundHit === true;
        const origin = {
            repoIdentity: input.repoIdentity,
            birthCommit: input.commit.sha,
            birthPath: birth.record.relativePath,
            birthKind: birth.record.kind,
            birthQualifiedName: birth.record.qualifiedName,
            birthOrdinal: birth.record.ordinal,
        };
        const lineageId = makeLineageId(origin);
        const flags = {};
        if (birth.refused)
            flags.ambiguityRefused = true;
        if (input.boundHit)
            flags.boundHit = true;
        if (input.atWindowStart)
            flags.birthAtWindowBoundary = true;
        events.push(buildLineageEvent({
            lineageId,
            commit: input.commit,
            changeType: orphaned ? 'orphan' : 'added',
            matchReason: 'none',
            evidence: emptyEvidence(birth.pool, birth.candidateCount),
            flags,
            current: birth.record,
            confidence: orphaned ? 0 : 1,
        }));
        input.state.byLineageId.set(lineageId, {
            lineageId,
            record: birth.record,
            birthCommit: origin.birthCommit,
            birthPath: origin.birthPath,
            birthQualifiedName: origin.birthQualifiedName,
            lastCommit: input.commit.sha,
            lastCommitOrder: input.commit.order,
            eventCount: 1,
            birthAtWindowBoundary: input.atWindowStart === true,
        });
        input.state.byStateKey.set(entityStateKey(birth.record), lineageId);
        if (birth.refused) {
            diagnostics.push({
                code: 'ambiguity-refused',
                message: `Refused to attach "${birth.record.qualifiedName}" to a predecessor: candidates were indistinguishable.`,
                commit: input.commit.sha,
                relativePath: birth.record.relativePath,
            });
        }
    }
    for (const death of deaths) {
        const lineageId = death.candidate.lineageId;
        if (!lineageId || claimed.lineageIds.has(lineageId))
            continue;
        const orphaned = death.refused || input.boundHit === true;
        const flags = {};
        if (death.refused)
            flags.ambiguityRefused = true;
        if (input.boundHit)
            flags.boundHit = true;
        events.push(buildLineageEvent({
            lineageId,
            commit: input.commit,
            changeType: orphaned ? 'orphan' : 'deleted',
            matchReason: 'none',
            evidence: emptyEvidence(death.pool, death.candidateCount),
            flags,
            previous: death.candidate.record,
            confidence: orphaned ? 0 : 1,
        }));
        input.state.byLineageId.delete(lineageId);
        input.state.byStateKey.delete(entityStateKey(death.candidate.record));
    }
    return { events, diagnostics };
}
/**
 * Which matched entities genuinely moved relative to their siblings.
 *
 * A declaration index shifts whenever anything above it is inserted or removed,
 * so comparing indices directly reports every entity below an insertion as
 * reordered. The entities that kept their relative order are exactly the
 * longest increasing subsequence of the new indices taken in old-index order;
 * whatever falls outside it is what actually moved. Inserting one declaration
 * at the top of a file therefore reorders nothing, and swapping two functions
 * reorders precisely those two.
 */
function reorderedEntities(matches) {
    const moved = new Set();
    if (matches.length < 2)
        return moved;
    const pairs = [...matches].sort((a, b) => a.previous.record.declarationIndex - b.previous.record.declarationIndex);
    const kept = longestIncreasingSubsequence(pairs.map(pair => pair.current.declarationIndex));
    for (const [index, pair] of pairs.entries()) {
        if (!kept.has(index))
            moved.add(pair.current);
    }
    return moved;
}
/** Positions of one longest strictly-increasing subsequence, via patience sorting. */
function longestIncreasingSubsequence(values) {
    const tails = [];
    const cameFrom = new Array(values.length).fill(-1);
    for (const [index, value] of values.entries()) {
        let low = 0;
        let high = tails.length;
        while (low < high) {
            const mid = (low + high) >> 1;
            if (values[tails[mid]] < value)
                low = mid + 1;
            else
                high = mid;
        }
        if (low > 0)
            cameFrom[index] = tails[low - 1];
        tails[low] = index;
    }
    const kept = new Set();
    let cursor = tails.length > 0 ? tails[tails.length - 1] : -1;
    while (cursor !== -1) {
        kept.add(cursor);
        cursor = cameFrom[cursor];
    }
    return kept;
}
/**
 * The cross-file pass — the only place an entity may change files.
 *
 * It sees leftovers only, so it can never take an entity a per-path rung
 * already claimed, and it rejects same-path pairings because the per-path pass
 * has already ruled on those: re-deciding them here would be a second attempt
 * at a question already answered "no". The qualified-name rung is skipped for
 * this pool inside `matchFileEntities`, so a move is accepted on hash or
 * similarity evidence alone — never on a shared name.
 */
function matchAcrossFiles(input, births, deaths, thresholds, settle, diagnostics, noteAcceptDeclines) {
    const currents = new Set();
    const lineageIds = new Set();
    const previous = deaths
        .filter(death => death.candidate.lineageId !== undefined)
        .map((death) => ({ ...death.candidate, pool: 'cross-file' }));
    if (births.length === 0 || previous.length === 0)
        return { currents, lineageIds };
    const limit = input.maxCrossFileCandidates ?? DEFAULT_ENTITY_BOUNDS.maxCrossFileCandidates;
    if (births.length > limit || previous.length > limit) {
        // Sampling the pool would make the outcome depend on iteration order, so a
        // commit this large declines the pass outright and says so.
        diagnostics.push({
            code: 'cross-file-skipped',
            message: `Cross-file matching skipped: ${births.length} new and ${previous.length} removed entities exceed the ${limit} candidate bound.`,
            commit: input.commit.sha,
            total: Math.max(births.length, previous.length),
            limit,
        });
        return { currents, lineageIds };
    }
    const result = matchFileEntities({
        previous,
        current: births.map(birth => birth.record),
        pool: 'cross-file',
        thresholds,
        sourceFor: input.sourceFor,
        maxSimilarityCandidates: input.maxSimilarityCandidates,
        counters: input.counters,
    });
    noteAcceptDeclines(result.acceptDeclines);
    if (result.similaritySkipped) {
        const limit = input.maxSimilarityCandidates ?? DEFAULT_ENTITY_BOUNDS.maxSimilarityCandidates;
        for (const birth of births) {
            if (result.refusedCurrent.has(birth.record))
                birth.refused = true;
        }
        for (const death of deaths) {
            if (result.refusedPrevious.has(death.candidate.record))
                death.refused = true;
        }
        diagnostics.push({
            code: 'similarity-skipped',
            message: `Cross-file similarity matching skipped: ${births.length} new and ${previous.length} removed entities exceed the ${limit} candidate bound.`,
            commit: input.commit.sha,
            total: Math.max(births.length, previous.length),
            limit,
        });
    }
    for (const match of result.matches) {
        const lineageId = match.previous.lineageId;
        if (!lineageId)
            continue;
        if (match.previous.record.relativePath === match.current.relativePath)
            continue;
        settle(match, 'cross-file');
        currents.add(match.current);
        lineageIds.add(lineageId);
    }
    return { currents, lineageIds };
}
function updateHead(state, lineageId, record, commit) {
    const head = state.byLineageId.get(lineageId);
    const previousKey = head ? entityStateKey(head.record) : undefined;
    if (previousKey && previousKey !== entityStateKey(record))
        state.byStateKey.delete(previousKey);
    state.byLineageId.set(lineageId, {
        lineageId,
        record,
        birthCommit: head?.birthCommit ?? commit.sha,
        birthPath: head?.birthPath ?? record.relativePath,
        birthQualifiedName: head?.birthQualifiedName ?? record.qualifiedName,
        lastCommit: commit.sha,
        lastCommitOrder: commit.order,
        eventCount: (head?.eventCount ?? 0) + 1,
        birthAtWindowBoundary: head?.birthAtWindowBoundary ?? false,
    });
    state.byStateKey.set(entityStateKey(record), lineageId);
}
//# sourceMappingURL=lineage.js.map