import { z } from 'zod';
import { isOccAbortError } from '../../errors.js';
import { abortIfNeeded } from '../../utils.js';
import { compareSymbolHashTuples, hashCodeSymbols } from '../hash.js';
import { CodeNodeTypeSchema, toNormalizedSymbolKind } from '../types.js';
import { projectEntitiesAtCommit } from './at-ref.js';
import { strongestDriftEvidence } from './drift.js';
import { countEntityAmbiguityEvidence, summarizeEntityEvidenceGaps } from './evidence.js';
import { resolveCommit } from './git.js';
import { mapEntityGitError } from './git-error.js';
import { ENTITY_HASH_VERSION } from './hash.js';
import { kindsCompatible } from './lineage.js';
import { normalizeRelativePath, resolveEntityHandle } from './resolve.js';
import { resolveEntitySuccessor } from './successor.js';
import { TRUNCATION_CODES } from './store.js';
import { EntityKindSchema } from './types.js';
export const ENTITY_ANCHOR_DEFAULT_MAX_ANCHORS = 1_000;
export const ENTITY_ANCHOR_HARD_MAX_ANCHORS = 10_000;
/**
 * Distinct base commits per batch. Each distinct commit costs one memoized
 * O(events) replay (~16ms on this repository's own 265-commit window), so the
 * bound rests on the memo's memory footprint — every cached projection holds a
 * full LiveEntityIndex — not on replay time. Capture stamps one baseCommit per
 * capture session, so distinct commits grow with ordinary memory age; a low
 * default would refuse the headline consolidation use case.
 */
export const ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS = 64;
/** CLI wire bounds, enforced by the NDJSON reader before parsing. */
export const ENTITY_ANCHOR_DEFAULT_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
export const ENTITY_ANCHOR_HARD_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
const DIAGNOSTIC_LIMIT = 50;
const HASH_64 = /^[0-9a-f]{64}$/;
export const EntityAnchorExpectedSchema = z.object({
    bodyHash: z.string().regex(HASH_64),
    normalizedHash: z.string().regex(HASH_64).optional(),
    structuralHash: z.string().regex(HASH_64).optional(),
    exactSpanHash: z.string().regex(HASH_64).optional(),
    hashVersion: z.number().int().positive().optional(),
    algo: z.enum(['lexer', 'declaration', 'none']).optional(),
});
export const EntityAnchorSymbolKeySchema = z.object({
    relativePath: z.string().min(1),
    name: z.string().min(1),
    kind: CodeNodeTypeSchema.optional(),
    containerName: z.string().optional(),
});
export const EntityAnchorInputSchema = z
    .object({
    id: z.string().min(1).max(1_024),
    lineageId: z
        .string()
        .regex(/^ent\d+:[0-9a-f]{32}$/)
        .optional(),
    symbolKey: EntityAnchorSymbolKeySchema.optional(),
    baseCommit: z.string().trim().min(1).max(1_024).optional(),
    expected: EntityAnchorExpectedSchema.optional(),
})
    .superRefine((anchor, ctx) => {
    // At least one handle, not exactly one: `occ code anchor` deliberately
    // emits BOTH (the lineage id is durable, the symbol key is the readable
    // fallback), and capture output must verify verbatim. When both are
    // present the lineage id is authoritative and the symbol key is consulted
    // only where the lineage yields nothing; a disagreement surfaces as a
    // `handle-mismatch` diagnostic, never a guess.
    if (anchor.lineageId === undefined && anchor.symbolKey === undefined) {
        ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: 'An anchor carries at least one of "lineageId" or "symbolKey".',
        });
    }
});
export const VerifyEntityAnchorsOptionsSchema = z.object({
    anchors: z.array(EntityAnchorInputSchema).min(1),
    maxAnchors: z.number().int().positive().max(ENTITY_ANCHOR_HARD_MAX_ANCHORS).optional(),
});
export const EntityAnchorVerdictSchema = z.enum([
    'fresh',
    'normalized-equal',
    'semantic-drift',
    'moved',
    'renamed',
    'deleted',
    'unknown',
]);
export const EntityAnchorUnknownReasonSchema = z.enum([
    'unknown-tier',
    'outside-window',
    'evidence-unavailable',
    'lineage-version-mismatch',
    /**
     * The live index behind the hash lane could not be proved to mirror the
     * working tree (an unmeasurable borrowed session); grading `fresh` from
     * possibly-stale bytes would fabricate the very claim this surface exists
     * to verify.
     */
    'index-stale',
]);
// ---------------------------------------------------------------------------
// capture
// ---------------------------------------------------------------------------
/**
 * How a caller names the entity to anchor. A plain object rather than a union
 * so the exactly-one-of guard sees every key the caller wrote — a union of
 * stripping object schemas would silently discard the second handle on mixed
 * input, which is the guess this surface exists to refuse.
 */
export const EntityAnchorCaptureHandleSchema = z
    .object({
    name: z.string().min(1).optional(),
    relativePath: z.string().optional(),
    kind: EntityKindSchema.optional(),
    containerName: z.string().optional(),
    lineageId: z
        .string()
        .regex(/^ent\d+:[0-9a-f]{32}$/)
        .optional(),
    nodeId: z.string().min(1).optional(),
})
    .superRefine((handle, ctx) => {
    const handles = [handle.name, handle.lineageId, handle.nodeId].filter(value => value !== undefined).length;
    if (handles !== 1) {
        ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: 'A capture handle carries exactly one of "name", "lineageId", or "nodeId".',
        });
    }
});
/**
 * Project one live entity into a verifiable anchor, from one session snapshot.
 *
 * The expected tuple is a projection of the live record's hashes — never
 * `signatureHash`/`tokenCount`, and never an exact-span tier: `exactBodyHash`
 * digests the whole-diff CST span while symbol-hash's `exactSpanHash` digests
 * the live-index node span, and equating the two without a per-language proof
 * would manufacture false drift.
 */
export function captureEntityAnchor(input) {
    const { entity, entityIndex, freshness } = input;
    const hashes = entity.record.hashes;
    const expected = {
        bodyHash: hashes.bodyHash,
        ...(hashes.normalizedHash !== undefined ? { normalizedHash: hashes.normalizedHash } : {}),
        ...(hashes.structuralHash !== undefined ? { structuralHash: hashes.structuralHash } : {}),
        hashVersion: hashes.hashVersion,
        algo: hashes.algo,
    };
    const symbolKey = {
        relativePath: entity.record.relativePath,
        name: entity.record.name,
        ...(entity.record.containerName !== undefined ? { containerName: entity.record.containerName } : {}),
    };
    const warnings = [];
    const anchor = { lineageId: entity.ref.lineageId, symbolKey, expected };
    if (hashes.algo !== 'lexer') {
        warnings.push('This record has no full body tier (algo "' +
            hashes.algo +
            '"); hash verification against it will refuse rather than classify drift.');
    }
    if (entity.ref.provisional === true) {
        anchor.provisional = true;
        warnings.push('This entity is uncommitted: the anchor is stable within this working tree but it is not history.');
        return { anchor, warnings };
    }
    if (entity.ref.indeterminate === true) {
        anchor.indeterminate = true;
        warnings.push('The index hit a bound before reaching this entity; the anchor carries no proved lineage.');
        return { anchor, warnings };
    }
    if (input.workingTreeTrusted === false) {
        // Neither direction of the fabrication: no baseCommit (the tree may hold
        // different bytes than the commit) and no uncommittedContent (the tree may
        // hold exactly the committed bytes). The tuple itself still records what
        // the index holds, honestly labeled.
        warnings.push('The working tree could not be measured against this index, so commit pairing was not attempted; ' +
            "the anchor is expected-only and its tuple reflects the index's view of the entity.");
        return { anchor, warnings };
    }
    const head = entityIndex.state.byLineageId.get(entity.ref.lineageId);
    const headMoved = freshness.head !== undefined && freshness.indexedHead !== undefined && freshness.head !== freshness.indexedHead;
    if (!head || head.record.hashes.bodyHash !== hashes.bodyHash) {
        anchor.uncommittedContent = true;
        warnings.push('Working-tree bytes differ from the committed head record, so the anchor is emitted without a baseCommit; ' +
            'verify runs the honest hash-tier lane. Commit the change and re-capture to anchor at a commit.');
    }
    else if (headMoved || freshness.indexedHead === undefined) {
        warnings.push('The repository head moved past the indexed head, so the anchor is emitted without a baseCommit. ' +
            'Refresh the session and re-capture to anchor at a commit.');
    }
    else {
        anchor.baseCommit = freshness.indexedHead;
    }
    return { anchor, warnings };
}
/**
 * Which recorded index diagnostics can silently drop lineage events. Only
 * these forfeit absence-based claims — `commit-window-truncated` is true on
 * nearly every real repository and the baseline is already pinned inside the
 * window, so the global truncated flag must not degrade every verdict.
 */
const EVENT_DROPPING_CODES = new Set([
    'event-budget-exhausted',
    'commit-files-truncated',
    'blob-budget-exhausted',
    'similarity-skipped',
    'cross-file-skipped',
]);
function newestIndexedCommit(entityIndex) {
    if (entityIndex.status.head && entityIndex.commitBySha.has(entityIndex.status.head))
        return entityIndex.status.head;
    let newest;
    for (const commit of entityIndex.commits) {
        if (!newest || commit.order > newest.order)
            newest = commit;
    }
    return newest?.sha;
}
/**
 * The committed head projection: lineage-lane facts (deleted/moved/renamed,
 * `entityAtHead`, `location`) come solely from here, never from the working
 * tree — the hash lane alone reads live bytes, and mixing the two heads would
 * let a working-tree deletion masquerade as a proved historical one.
 */
function currentProjection(context) {
    const commit = newestIndexedCommit(context.entityIndex);
    if (!commit)
        return undefined;
    const projected = projectEntitiesAtCommit({
        ref: commit,
        commit,
        entityIndex: context.entityIndex,
        recognizer: context.recognizer,
    });
    return projected.status === 'resolved' ? projected.at.index : undefined;
}
function sharedHonesty(context) {
    const diagnostics = context.entityIndex.status.diagnostics;
    return {
        freshness: {
            fresh: context.freshness.fresh,
            reasons: context.freshness.reasons,
            workingTreeMeasured: context.freshness.workingTree !== undefined,
        },
        truncation: {
            truncated: context.entityIndex.status.truncated,
            reasons: diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
            omitted: 0,
        },
        evidenceGaps: summarizeEntityEvidenceGaps(diagnostics),
    };
}
/**
 * Do recorded event-dropping gaps intersect this anchor's files inside the
 * base→head range? A diagnostic with no path or no commit cannot be scoped
 * away and counts as covering — an unscopable gap is still a gap.
 */
function gapsCoverAnchor(entityIndex, baselineOrder, files) {
    for (const diagnostic of entityIndex.status.diagnostics) {
        if (!EVENT_DROPPING_CODES.has(diagnostic.code))
            continue;
        if (diagnostic.relativePath && files.size > 0 && !files.has(normalizeRelativePath(diagnostic.relativePath))) {
            continue;
        }
        if (diagnostic.commit && baselineOrder !== undefined) {
            const record = entityIndex.commitBySha.get(diagnostic.commit);
            if (record && record.order <= baselineOrder)
                continue;
        }
        return true;
    }
    return false;
}
function anchorFileSet(base, head, events) {
    const files = new Set();
    if (base)
        files.add(normalizeRelativePath(base.relativePath));
    if (head)
        files.add(normalizeRelativePath(head.relativePath));
    for (const event of events) {
        if (event.previousPath)
            files.add(normalizeRelativePath(event.previousPath));
        if (event.currentPath)
            files.add(normalizeRelativePath(event.currentPath));
    }
    return files;
}
/** Resolve a symbol key against one entity view with exact-path precision. */
function resolveSymbolKeyAtView(view, key) {
    const wantedPath = normalizeRelativePath(key.relativePath);
    const expectedKind = key.kind
        ? toNormalizedSymbolKind(key.kind, key.containerName)
        : undefined;
    const qualified = key.containerName ? view.byQualifiedName.get(`${key.containerName}.${key.name}`) : undefined;
    const pool = qualified && qualified.length > 0 ? qualified : (view.byName.get(key.name) ?? []);
    const matches = pool.filter(entity => {
        if (normalizeRelativePath(entity.record.relativePath) !== wantedPath)
            return false;
        if (expectedKind && !kindsCompatible(entity.record.kind, expectedKind))
            return false;
        if (key.containerName !== undefined && entity.record.containerName !== key.containerName)
            return false;
        return true;
    });
    if (matches.length === 1)
        return { status: 'resolved', entity: matches[0] };
    if (matches.length > 1)
        return { status: 'ambiguous', candidates: matches.map(entity => entity.ref) };
    return { status: 'not_found' };
}
function hashLaneFromComparison(current, expected) {
    const verdict = compareSymbolHashTuples(current, expected, { normalizedEqualVerdict: true });
    const exactCompared = current.exactSpanHash !== undefined && expected.exactSpanHash !== undefined;
    switch (verdict) {
        case 'fresh':
            return { verdict: 'fresh', tier: exactCompared ? 'exact-span' : 'body' };
        case 'normalized-equal':
        case 'cosmetic-drift':
            return { verdict: 'normalized-equal', tier: 'normalized' };
        case 'semantic-drift':
            return { verdict: 'semantic-drift', tier: 'normalized' };
        default:
            return { verdict: 'unknown', tier: 'none', unknownReason: 'unknown-tier' };
    }
}
/**
 * Did this event change the entity's content, as opposed to only its identity?
 *
 * Two flag subtleties make the raw `logicChanged` bit unusable here. First, a
 * pure rename records `logicChanged: true` — the declared name is non-trivia
 * and OCC never claims a rename is a semantic no-op for callers — so for
 * identity motion (renamed/moved/reordered) the event's own structural hashes
 * decide: they are identifier-blind, and equality across the motion is real
 * evidence the shape survived (literal-only edits are invisible to that tier,
 * one more reason expected-tuple anchors are strictly stronger). Second, a
 * CST-proved comment-only change is recorded as `modified` with
 * `semanticBasis: 'comment-only-change'` and `logicChanged` unset — proved
 * cosmetic, not unverified — while any other unproven body change (whitespace
 * significance, missing grammar evidence) grades `undecidable`, never guessed.
 */
export function gradeEventContent(event) {
    if (event.flags.logicChanged === true) {
        if (event.changeType === 'renamed' || event.changeType === 'moved' || event.changeType === 'reordered') {
            const previous = event.previousStructuralHash;
            const current = event.currentStructuralHash;
            if (previous !== undefined && current !== undefined) {
                return previous === current ? 'unchanged' : 'changed';
            }
            return 'undecidable';
        }
        return 'changed';
    }
    if (event.flags.bodyChanged !== true)
        return 'unchanged';
    if (event.flags.semanticBasis === 'comment-only-change')
        return 'cosmetic';
    return 'undecidable';
}
/**
 * Derive the single verdict a memory consolidator switches on.
 *
 * Precedence `deleted > semantic-drift > renamed > moved > normalized-equal >
 * fresh`, with one scoped override: proved tuple equality beats event-lane
 * content inference (an edit-and-revert is `fresh` with its events still
 * surfaced), while `renamed`/`moved` survive a byte-equal tuple because equal
 * content does not repair a broken symbol-key handle. `moved`/`renamed` are
 * net base-vs-head state — per-event flags are never a verdict condition,
 * because a rename-and-revert nets to identity.
 */
export function deriveAnchorVerdict(input) {
    const lineage = input.lineage;
    if (lineage?.status === 'version-mismatch') {
        return { verdict: 'unknown', unknownReason: 'lineage-version-mismatch', tier: input.hashLane?.tier ?? 'none' };
    }
    if (lineage?.status === 'deleted') {
        // A live hash match cannot co-occur: the lane resolves by the same lineage
        // id the projection just proved ended.
        return { verdict: 'deleted', tier: 'lineage-events' };
    }
    if (lineage?.status === 'outside-window') {
        if (input.hashLane && input.hashLane.verdict !== 'unknown') {
            return { verdict: input.hashLane.verdict, tier: input.hashLane.tier };
        }
        return { verdict: 'unknown', unknownReason: 'outside-window', tier: 'none' };
    }
    if (lineage?.status === 'absent') {
        // In-window, no proved end event, absent from the head projection: a
        // working-tree deletion or a visibility gap. Never `deleted`.
        return { verdict: 'unknown', unknownReason: 'evidence-unavailable', tier: 'none' };
    }
    // Hash-lane-only anchors (no baseline, no committed lineage view).
    if (!lineage) {
        if (input.hashLane) {
            if (input.hashLane.verdict === 'unknown') {
                return { verdict: 'unknown', unknownReason: input.hashLane.unknownReason ?? 'unknown-tier', tier: 'none' };
            }
            return { verdict: input.hashLane.verdict, tier: input.hashLane.tier };
        }
        return { verdict: 'unknown', unknownReason: 'evidence-unavailable', tier: 'none' };
    }
    // lineage.status === 'live' from here on.
    const atHead = lineage.atHead;
    const netPathChanged = input.atBase !== undefined &&
        normalizeRelativePath(input.atBase.relativePath) !== normalizeRelativePath(atHead.relativePath);
    const netNameChanged = input.atBase !== undefined && input.atBase.name !== atHead.name;
    if (input.indeterminate) {
        // The event scan cannot run against a synthesized id; only the hash lane
        // (live bytes, independent of index state) may still answer.
        if (input.hashLane && input.hashLane.verdict !== 'unknown') {
            return { verdict: input.hashLane.verdict, tier: input.hashLane.tier };
        }
        return {
            verdict: 'unknown',
            unknownReason: input.hashLane?.unknownReason ?? 'evidence-unavailable',
            tier: 'none',
        };
    }
    // The grade already encodes provability, so no separate evidenceUnavailable
    // veto: a comment-only event carries that flag for its hash tiers while the
    // CST basis is the proof, and an unproven body change grades undecidable.
    const events = input.eventsSince ?? [];
    const grades = events.map(gradeEventContent);
    const anyLogic = grades.some(grade => grade === 'changed');
    const anyUndecidable = grades.some(grade => grade === 'undecidable');
    const anyCosmetic = grades.some(grade => grade === 'cosmetic');
    // Content classification. Committed endpoint byte equality is checked
    // before event inference AND before the gap degradation on purpose:
    // sha256 equality of the two committed endpoint records is strictly
    // stronger evidence than event-content inference (an edit-and-revert nets
    // to identical bytes and must grade fresh with its events surfaced in
    // changesSince), and hash-backed verdicts keep full strength under
    // in-range evidence gaps — only absence-based claims forfeit.
    let content;
    if (input.atBase !== undefined) {
        const comparable = input.baseBodyHash !== undefined &&
            input.baseBodyHash !== '' &&
            input.headBodyHash !== undefined &&
            input.headBodyHash !== '';
        if (comparable && input.baseBodyHash === input.headBodyHash) {
            content = { verdict: 'fresh', tier: 'body' };
        }
        else if (anyLogic) {
            content = { verdict: 'semantic-drift', tier: 'lineage-events' };
        }
        else if (anyUndecidable || input.gapsCovered) {
            content = { verdict: 'unknown', unknownReason: 'evidence-unavailable', tier: 'lineage-events' };
        }
        else if (anyCosmetic) {
            // Every byte change on the lineage since base is a CST-proved
            // comment-only change: the identity heuristic holds without a stored
            // tuple. Still never a semantic no-op claim.
            content = { verdict: 'normalized-equal', tier: 'lineage-events' };
        }
        else {
            // Bytes unequal or unrecoverable with no content-changing event proved:
            // the change was never attributed. Never a comparison against the empty
            // string.
            content = { verdict: 'unknown', unknownReason: 'evidence-unavailable', tier: 'none' };
        }
    }
    // Proved tuple equality overrides event-lane content inference; a refused
    // hash lane (version/algo mismatch — the changelog's "refuses as
    // unknown-tier" promise) names the refusal when nothing else decides.
    if (input.hashLane) {
        if (input.hashLane.verdict === 'fresh') {
            content = { verdict: 'fresh', tier: input.hashLane.tier };
        }
        else if (input.hashLane.verdict === 'normalized-equal') {
            if (content === undefined || content.verdict === 'semantic-drift' || content.verdict === 'unknown') {
                content = { verdict: 'normalized-equal', tier: input.hashLane.tier };
            }
        }
        else if (content === undefined || content.verdict === 'unknown') {
            if (input.hashLane.verdict === 'semantic-drift') {
                content = { verdict: 'semantic-drift', tier: input.hashLane.tier };
            }
            else if (input.hashLane.verdict === 'unknown') {
                content = { verdict: 'unknown', unknownReason: input.hashLane.unknownReason ?? 'unknown-tier', tier: 'none' };
            }
        }
    }
    // Identity facts outrank content equality but not proved logic drift. Their
    // tier floor is `lineage-events` — the verdict itself rests on proved
    // lineage evidence even when the content lane is unknown.
    if (content?.verdict === 'semantic-drift')
        return content;
    if (netNameChanged) {
        return { verdict: 'renamed', tier: content && content.tier !== 'none' ? content.tier : 'lineage-events' };
    }
    if (netPathChanged) {
        return { verdict: 'moved', tier: content && content.tier !== 'none' ? content.tier : 'lineage-events' };
    }
    if (content)
        return content;
    return { verdict: 'unknown', unknownReason: 'evidence-unavailable', tier: 'none' };
}
/**
 * Verify caller-held anchors against lineage evidence and, when asked, live
 * hashes. OCC validates the memory and stores nothing: input is consumed,
 * never persisted, and no anchor registry exists on either side of this call.
 */
export async function verifyEntityAnchors(options, context) {
    const limits = {
        maxAnchors: options.maxAnchors ?? ENTITY_ANCHOR_DEFAULT_MAX_ANCHORS,
        hardMaxAnchors: ENTITY_ANCHOR_HARD_MAX_ANCHORS,
        maxDistinctBaseCommits: ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS,
    };
    const parsed = VerifyEntityAnchorsOptionsSchema.safeParse(options);
    if (!parsed.success) {
        return {
            status: 'error',
            code: 'invalid-options',
            message: parsed.error.issues[0]?.message ?? 'Invalid anchor verification options.',
            limits: {
                maxAnchors: options.maxAnchors ?? ENTITY_ANCHOR_DEFAULT_MAX_ANCHORS,
                hardMaxAnchors: ENTITY_ANCHOR_HARD_MAX_ANCHORS,
                maxDistinctBaseCommits: ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS,
            },
        };
    }
    if (parsed.data.anchors.length > limits.maxAnchors) {
        return {
            status: 'error',
            code: 'budget-exceeded',
            message: `Anchor batch exceeds its bounds: ${parsed.data.anchors.length}/${limits.maxAnchors} anchors.`,
            limits,
            actual: { anchors: parsed.data.anchors.length },
        };
    }
    const signal = options.signal;
    const shared = sharedHonesty(context);
    const headProjection = currentProjection(context);
    const projectionByRef = new Map();
    const projectionByCommit = new Map();
    let distinctBaseCommits = 0;
    const resolveBaseline = (requested) => {
        const cached = projectionByRef.get(requested);
        if (cached)
            return cached;
        const pending = (async () => {
            abortIfNeeded(signal);
            let commit;
            try {
                commit = await resolveCommit(requested, {
                    repoRoot: context.repoRoot,
                    signal,
                    counters: context.entityIndex.counters,
                });
            }
            catch (error) {
                if (isOccAbortError(error))
                    throw error;
                const mapped = mapEntityGitError(error);
                return {
                    status: 'refused',
                    code: 'base-commit-resolution-failed',
                    message: `Could not verify base commit "${requested}": ${mapped.message}`,
                    underlyingCode: mapped.code,
                };
            }
            if (!commit) {
                return {
                    status: 'refused',
                    code: 'base-commit-not-found',
                    message: `Could not resolve base commit "${requested}" to an existing commit.`,
                    underlyingCode: 'invalid-ref',
                };
            }
            const memoized = projectionByCommit.get(commit);
            if (memoized)
                return memoized;
            if (distinctBaseCommits >= ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS) {
                return {
                    status: 'refused',
                    // Callers see this as the budget code; the shared refused shape keeps
                    // one arm for all baseline refusals.
                    code: 'base-commit-not-indexed',
                    message: '',
                };
            }
            const projected = projectEntitiesAtCommit({
                ref: requested,
                commit,
                entityIndex: context.entityIndex,
                recognizer: context.recognizer,
            });
            if (projected.status !== 'resolved') {
                return {
                    status: 'refused',
                    code: 'base-commit-not-indexed',
                    message: `Base commit ${commit.slice(0, 12)} exists but is not reachable in OCC's pinned indexed history walk. ` +
                        'Raise maxCommits or choose a baseline inside the indexed window.',
                    underlyingCode: projected.code,
                };
            }
            distinctBaseCommits += 1;
            const result = { status: 'resolved', at: projected.at };
            projectionByCommit.set(commit, result);
            return result;
        })();
        projectionByRef.set(requested, pending);
        return pending;
    };
    let liveContextPromise;
    const getLive = context.getLive
        ? () => {
            liveContextPromise ??= context.getLive();
            return liveContextPromise;
        }
        : undefined;
    const outcomes = [];
    let diagnosticsOmitted = 0;
    const verdictCounts = {
        fresh: 0,
        'normalized-equal': 0,
        'semantic-drift': 0,
        moved: 0,
        renamed: 0,
        deleted: 0,
        unknown: 0,
    };
    for (const anchor of parsed.data.anchors) {
        abortIfNeeded(signal);
        const outcome = await verifyOneAnchor(anchor, {
            context,
            shared,
            headProjection,
            resolveBaseline,
            getLive,
            onDiagnosticsOmitted: (count) => {
                diagnosticsOmitted += count;
            },
        });
        if (outcome.status === 'resolved')
            verdictCounts[outcome.verdict] += 1;
        outcomes.push(outcome);
    }
    return {
        status: 'resolved',
        anchors: outcomes,
        honesty: {
            ...shared,
            coverage: {
                anchorsSubmitted: outcomes.length,
                anchorsResolved: outcomes.filter(outcome => outcome.status === 'resolved').length,
                anchorsRefused: outcomes.filter(outcome => outcome.status === 'refused').length,
                verdictCounts,
                distinctBaseCommits,
                baseCommitBudget: ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS,
                diagnosticsOmitted,
            },
            inputBounds: limits,
            ambiguityCount: countEntityAmbiguityEvidence(context.entityIndex.counters.ambiguitiesRefused, context.entityIndex.status.diagnostics),
        },
    };
}
async function verifyOneAnchor(anchor, env) {
    const { context, shared, headProjection } = env;
    const diagnostics = [];
    let omitted = 0;
    const addDiagnostic = (diagnostic) => {
        if (diagnostics.length < DIAGNOSTIC_LIMIT)
            diagnostics.push(diagnostic);
        else
            omitted += 1;
    };
    const finishRefused = (code, message) => {
        env.onDiagnosticsOmitted(omitted);
        return { status: 'refused', id: anchor.id, code, message, diagnostics, honesty: shared };
    };
    // Handle resolution runs before any refusal gate: baseline-free lineage
    // facts (version-mismatch, proved deletion, outside-window) always win.
    let lineageId = anchor.lineageId;
    let lineageState;
    let atHeadEntity;
    let provisional = false;
    let indeterminate = false;
    if (anchor.lineageId !== undefined && headProjection) {
        const handle = resolveEntityHandle(anchor.lineageId, headProjection, context.entityIndex);
        if (handle.status === 'resolved') {
            atHeadEntity = handle.entity;
            lineageState = { status: 'live', atHead: handle.entity.ref };
            provisional = handle.entity.ref.provisional === true;
            indeterminate = handle.entity.ref.indeterminate === true;
            if (handle.collision) {
                addDiagnostic({
                    code: 'lineage-collision',
                    message: `More than one live entity claims lineage ${anchor.lineageId}; the first declaration answered.`,
                    anchorId: anchor.id,
                });
            }
        }
        else {
            lineageState =
                handle.reason === 'version-mismatch'
                    ? { status: 'version-mismatch' }
                    : handle.reason === 'deleted'
                        ? { status: 'deleted' }
                        : handle.reason === 'absent'
                            ? { status: 'absent' }
                            : { status: 'outside-window' };
        }
    }
    else if (anchor.lineageId !== undefined) {
        lineageState = { status: 'outside-window' };
    }
    // Baseline-independent verdicts short-circuit here; adding or removing a
    // baseCommit must not change a proved deletion.
    if (lineageState && (lineageState.status === 'version-mismatch' || lineageState.status === 'deleted')) {
        const derived = deriveAnchorVerdict({ lineage: lineageState, gapsCovered: false, indeterminate: false });
        env.onDiagnosticsOmitted(omitted);
        return {
            status: 'resolved',
            id: anchor.id,
            verdict: derived.verdict,
            ...(derived.unknownReason ? { unknownReason: derived.unknownReason } : {}),
            verification: { tier: derived.tier, hashVersion: ENTITY_HASH_VERSION, expectedCompared: false },
            diagnostics,
            honesty: shared,
        };
    }
    // The unverifiable gate applies only to anchors with nothing to verify
    // against; existence is not evidence a stored description is fresh.
    if (anchor.baseCommit === undefined && anchor.expected === undefined) {
        if (lineageState && lineageState.status !== 'live') {
            const derived = deriveAnchorVerdict({ lineage: lineageState, gapsCovered: false, indeterminate: false });
            env.onDiagnosticsOmitted(omitted);
            return {
                status: 'resolved',
                id: anchor.id,
                verdict: derived.verdict,
                ...(derived.unknownReason ? { unknownReason: derived.unknownReason } : {}),
                verification: { tier: derived.tier, hashVersion: ENTITY_HASH_VERSION, expectedCompared: false },
                diagnostics,
                honesty: shared,
            };
        }
        return finishRefused('unverifiable-anchor', 'An anchor needs a baseCommit or an expected hash tuple; entity existence alone is not freshness evidence.');
    }
    // Baseline lane.
    let baseline;
    let atBase;
    let eventsSince = [];
    let baseBodyHash;
    if (anchor.baseCommit !== undefined) {
        const projection = await env.resolveBaseline(anchor.baseCommit);
        if (projection.status === 'refused') {
            if (projection.message === '' && projection.code === 'base-commit-not-indexed') {
                // Distinct-commit budget: an expected-carrying anchor degrades to the
                // projection-free hash lane instead of being refused wholesale.
                addDiagnostic({
                    code: 'base-commit-budget-exceeded',
                    message: `Anchor base commit "${anchor.baseCommit}" would exceed the ` +
                        `${ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS} distinct base commits per batch; ` +
                        'group anchors by baseCommit across batches.',
                    anchorId: anchor.id,
                    baseCommit: anchor.baseCommit,
                });
                if (anchor.expected === undefined) {
                    return finishRefused('base-commit-budget-exceeded', `Batch exceeded ${ENTITY_ANCHOR_MAX_DISTINCT_BASE_COMMITS} distinct base commits and this anchor carries no expected tuple.`);
                }
            }
            else {
                addDiagnostic({
                    code: projection.code,
                    message: projection.message,
                    anchorId: anchor.id,
                    baseCommit: anchor.baseCommit,
                    ...(projection.underlyingCode ? { underlyingCode: projection.underlyingCode } : {}),
                });
                return finishRefused(projection.code, projection.message);
            }
        }
        else {
            const commitRecord = context.entityIndex.commitBySha.get(projection.at.commit);
            baseline = {
                requested: anchor.baseCommit,
                commit: projection.at.commit,
                commitOrder: projection.at.commitOrder,
                committedAt: commitRecord?.committedAt ?? '',
            };
            if (anchor.lineageId !== undefined) {
                const baseEntity = projection.at.index.byLineageId.get(anchor.lineageId);
                if (baseEntity) {
                    atBase = baseEntity.ref;
                    baseBodyHash = baseEntity.record.hashes.bodyHash || undefined;
                    // Both handles present and disagreeing is a caller inconsistency
                    // worth naming, never silently resolving to either side.
                    if (anchor.symbolKey !== undefined) {
                        const bySymbol = resolveSymbolKeyAtView(projection.at.index, anchor.symbolKey);
                        if (bySymbol.status === 'resolved' && bySymbol.entity.ref.lineageId !== anchor.lineageId) {
                            addDiagnostic({
                                code: 'handle-mismatch',
                                message: `The anchor's symbolKey resolves to a different lineage (${bySymbol.entity.ref.lineageId}) than ` +
                                    `its lineageId at base commit ${projection.at.commit.slice(0, 12)}; the lineageId answered.`,
                                anchorId: anchor.id,
                                baseCommit: anchor.baseCommit,
                            });
                        }
                    }
                }
                else if (anchor.symbolKey !== undefined) {
                    // The lineage id yields nothing at this baseline; the symbol key is
                    // the documented fallback lane.
                    const resolved = resolveSymbolKeyAtView(projection.at.index, anchor.symbolKey);
                    if (resolved.status === 'resolved') {
                        atBase = resolved.entity.ref;
                        baseBodyHash = resolved.entity.record.hashes.bodyHash || undefined;
                        if (resolved.entity.ref.lineageId !== anchor.lineageId) {
                            addDiagnostic({
                                code: 'handle-mismatch',
                                message: `Lineage ${anchor.lineageId} has no entity at base commit ${projection.at.commit.slice(0, 12)}; ` +
                                    `the symbolKey resolved lineage ${resolved.entity.ref.lineageId} there instead.`,
                                anchorId: anchor.id,
                                baseCommit: anchor.baseCommit,
                            });
                        }
                    }
                    else {
                        addDiagnostic({
                            code: 'base-entity-unresolved',
                            message: `Lineage ${anchor.lineageId} has no entity at base commit ${projection.at.commit.slice(0, 12)}.`,
                            anchorId: anchor.id,
                            baseCommit: anchor.baseCommit,
                        });
                    }
                }
                else {
                    addDiagnostic({
                        code: 'base-entity-unresolved',
                        message: `Lineage ${anchor.lineageId} has no entity at base commit ${projection.at.commit.slice(0, 12)}.`,
                        anchorId: anchor.id,
                        baseCommit: anchor.baseCommit,
                    });
                }
            }
            else if (anchor.symbolKey !== undefined) {
                const resolved = resolveSymbolKeyAtView(projection.at.index, anchor.symbolKey);
                if (resolved.status === 'ambiguous') {
                    addDiagnostic({
                        code: 'symbol-key-ambiguous',
                        message: `Symbol key "${anchor.symbolKey.name}" matches ${resolved.candidates.length} entities at the baseline.`,
                        anchorId: anchor.id,
                        baseCommit: anchor.baseCommit,
                        candidates: resolved.candidates.slice(0, 5),
                    });
                    return finishRefused('symbol-key-ambiguous', `Symbol key "${anchor.symbolKey.name}" matches more than one entity at base commit ${projection.at.commit.slice(0, 12)}.`);
                }
                if (resolved.status === 'not_found') {
                    return finishRefused('symbol-key-not-found-at-base', `Symbol key "${anchor.symbolKey.name}" (${anchor.symbolKey.relativePath}) resolves to no entity at base commit ${projection.at.commit.slice(0, 12)}.`);
                }
                atBase = resolved.entity.ref;
                baseBodyHash = resolved.entity.record.hashes.bodyHash || undefined;
                lineageId = resolved.entity.ref.lineageId;
                if (headProjection && lineageState === undefined) {
                    const handle = resolveEntityHandle(lineageId, headProjection, context.entityIndex);
                    if (handle.status === 'resolved') {
                        atHeadEntity = handle.entity;
                        lineageState = { status: 'live', atHead: handle.entity.ref };
                        provisional = handle.entity.ref.provisional === true;
                        indeterminate = handle.entity.ref.indeterminate === true;
                    }
                    else {
                        lineageState =
                            handle.reason === 'deleted'
                                ? { status: 'deleted' }
                                : handle.reason === 'absent'
                                    ? { status: 'absent' }
                                    : handle.reason === 'version-mismatch'
                                        ? { status: 'version-mismatch' }
                                        : { status: 'outside-window' };
                    }
                }
            }
            if (lineageId !== undefined && baseline) {
                eventsSince = (context.entityIndex.eventsByLineage.get(lineageId) ?? []).filter(event => event.commitOrder > baseline.commitOrder);
            }
        }
    }
    // Hash lane.
    let hashLane;
    let successor;
    let expectedCompared = false;
    if (anchor.expected !== undefined) {
        if (env.getLive) {
            const liveContext = await env.getLive();
            const liveTrusted = liveContext.trusted !== false;
            // The lineage handle is authoritative wherever one is in hand — from the
            // anchor itself or resolved at the baseline — so a moved or renamed
            // entity still gets its tuple compared against the live record on the
            // same lineage instead of a stale stored key.
            const liveByLineage = lineageId !== undefined ? liveContext.live.byLineageId.get(lineageId) : undefined;
            if (!liveTrusted) {
                // Grading `fresh` from bytes the index cannot prove current would
                // fabricate the claim this surface exists to verify.
                hashLane = { verdict: 'unknown', tier: 'none', unknownReason: 'index-stale' };
            }
            else if (liveByLineage) {
                const hashes = liveByLineage.record.hashes;
                hashLane = hashLaneFromComparison({
                    hashVersion: hashes.hashVersion,
                    algo: hashes.algo,
                    bodyHash: hashes.bodyHash,
                    ...(hashes.normalizedHash !== undefined ? { normalizedHash: hashes.normalizedHash } : {}),
                    ...(hashes.structuralHash !== undefined ? { structuralHash: hashes.structuralHash } : {}),
                }, anchor.expected);
                expectedCompared = hashLane.verdict !== 'unknown';
                if (liveByLineage.ref.provisional === true)
                    provisional = true;
                if (liveByLineage.ref.indeterminate === true)
                    indeterminate = true;
            }
            else if (anchor.symbolKey !== undefined) {
                const key = anchor.symbolKey;
                const results = await hashCodeSymbols(liveContext.index, [
                    {
                        relativePath: key.relativePath,
                        name: key.name,
                        ...(key.kind !== undefined ? { kind: key.kind } : {}),
                        ...(key.containerName !== undefined ? { containerName: key.containerName } : {}),
                    },
                ], { normalizedEqualVerdict: true });
                const result = results[0];
                if (result?.status === 'resolved') {
                    hashLane = hashLaneFromComparison(result.symbol.hashes, anchor.expected);
                    expectedCompared = hashLane.verdict !== 'unknown';
                }
                else if (result?.status === 'not_found') {
                    // The key misses now: lineage facts replace `gone`. The successor scan
                    // requires the remembered bodyHash as evidence.
                    successor = resolveEntitySuccessor({
                        relativePath: key.relativePath,
                        name: key.name,
                        ...(key.kind !== undefined ? { kind: key.kind } : {}),
                        ...(key.containerName !== undefined ? { containerName: key.containerName } : {}),
                    }, anchor.expected.bodyHash, { entityIndex: context.entityIndex, live: liveContext.live });
                    if (lineageState === undefined && successor.status === 'resolved') {
                        const successorRef = successor.successor;
                        const pathChanged = normalizeRelativePath(successorRef.relativePath) !== normalizeRelativePath(key.relativePath);
                        const nameChanged = successorRef.name !== key.name;
                        env.onDiagnosticsOmitted(omitted);
                        return {
                            status: 'resolved',
                            id: anchor.id,
                            verdict: nameChanged ? 'renamed' : pathChanged ? 'moved' : 'unknown',
                            ...(!nameChanged && !pathChanged ? { unknownReason: 'evidence-unavailable' } : {}),
                            entityAtHead: successorRef,
                            location: {
                                pathChanged,
                                nameChanged,
                                pathAtBase: normalizeRelativePath(key.relativePath),
                                pathAtHead: successorRef.relativePath,
                            },
                            verification: { tier: 'lineage-events', hashVersion: ENTITY_HASH_VERSION, expectedCompared: false },
                            successor,
                            diagnostics,
                            honesty: shared,
                        };
                    }
                }
            }
        }
    }
    const atHead = atHeadEntity?.ref ?? (lineageState?.status === 'live' ? lineageState.atHead : undefined);
    const headBodyHash = lineageId !== undefined
        ? context.entityIndex.state.byLineageId.get(lineageId)?.record.hashes.bodyHash || undefined
        : undefined;
    const files = anchorFileSet(atBase, atHead, eventsSince);
    const gapsCovered = gapsCoverAnchor(context.entityIndex, baseline?.commitOrder, files);
    const derived = deriveAnchorVerdict({
        ...(lineageState ? { lineage: lineageState } : {}),
        ...(atBase ? { atBase } : {}),
        eventsSince,
        ...(baseBodyHash !== undefined ? { baseBodyHash } : {}),
        ...(headBodyHash !== undefined ? { headBodyHash } : {}),
        ...(hashLane ? { hashLane } : {}),
        gapsCovered,
        indeterminate,
    });
    const logicEvents = eventsSince.filter(event => gradeEventContent(event) === 'changed');
    const anyUndecidableEvent = eventsSince.some(event => gradeEventContent(event) === 'undecidable');
    const location = atBase !== undefined || atHead !== undefined
        ? {
            pathChanged: atBase !== undefined &&
                atHead !== undefined &&
                normalizeRelativePath(atBase.relativePath) !== normalizeRelativePath(atHead.relativePath),
            nameChanged: atBase !== undefined && atHead !== undefined && atBase.name !== atHead.name,
            ...(atBase !== undefined ? { pathAtBase: atBase.relativePath } : {}),
            ...(atHead !== undefined ? { pathAtHead: atHead.relativePath } : {}),
        }
        : undefined;
    env.onDiagnosticsOmitted(omitted);
    return {
        status: 'resolved',
        id: anchor.id,
        verdict: derived.verdict,
        ...(derived.unknownReason ? { unknownReason: derived.unknownReason } : {}),
        ...(baseline ? { baseline } : {}),
        ...(atBase ? { entityAtBase: atBase } : {}),
        ...(atHead ? { entityAtHead: atHead } : {}),
        ...(location ? { location } : {}),
        ...(baseline
            ? {
                changesSince: {
                    count: logicEvents.length,
                    strongestEvidence: strongestDriftEvidence(logicEvents),
                    evidenceComplete: !gapsCovered && !indeterminate && !anyUndecidableEvent,
                },
            }
            : {}),
        verification: { tier: derived.tier, hashVersion: ENTITY_HASH_VERSION, expectedCompared },
        ...(successor ? { successor } : {}),
        ...(provisional ? { provisional } : {}),
        ...(indeterminate ? { indeterminate } : {}),
        diagnostics,
        honesty: shared,
    };
}
//# sourceMappingURL=anchor-verify.js.map