import { z } from 'zod';
import { isOccAbortError } from '../../errors.js';
import { abortIfNeeded, compareCodeUnits } from '../../utils.js';
import { extractMarkdownEntities } from '../formats/markdown.js';
import { trimTrailingBlank } from '../formats/types.js';
import { projectEntitiesAtCommit } from './at-ref.js';
import { compareDriftCandidateRefs, filteredDriftLiveIndex, isDriftDeclarationTarget, scanDriftDocumentLinks, strongestDriftEvidence, } from './drift.js';
import { countEntityAmbiguityEvidence, summarizeEntityEvidenceGaps } from './evidence.js';
import { resolveCommit } from './git.js';
import { mapEntityGitError } from './git-error.js';
import { resolveEntity } from './resolve.js';
import { TRUNCATION_CODES } from './store.js';
export const ENTITY_DOCUMENT_DRIFT_DEFAULT_MAX_DOCUMENTS = 1_000;
export const ENTITY_DOCUMENT_DRIFT_HARD_MAX_DOCUMENTS = 10_000;
export const ENTITY_DOCUMENT_DRIFT_DEFAULT_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
export const ENTITY_DOCUMENT_DRIFT_HARD_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
const DIAGNOSTIC_LIMIT = 50;
const CANDIDATE_LIMIT = 5;
export const EntityDriftDocumentInputSchema = z.object({
    id: z.string().min(1).max(1_024),
    content: z.string(),
    baseCommit: z.string().trim().min(1).max(1_024),
});
export const EntityDriftForDocumentsOptionsSchema = z.object({
    documents: z.array(EntityDriftDocumentInputSchema).min(1),
    maxDocuments: z.number().int().positive().max(ENTITY_DOCUMENT_DRIFT_HARD_MAX_DOCUMENTS).optional(),
    maxTotalDocumentBytes: z.number().int().positive().max(ENTITY_DOCUMENT_DRIFT_HARD_MAX_TOTAL_BYTES).optional(),
});
function inputLimits(options) {
    return {
        maxDocuments: options?.maxDocuments ?? ENTITY_DOCUMENT_DRIFT_DEFAULT_MAX_DOCUMENTS,
        maxTotalDocumentBytes: options?.maxTotalDocumentBytes ?? ENTITY_DOCUMENT_DRIFT_DEFAULT_MAX_TOTAL_BYTES,
        hardMaxDocuments: ENTITY_DOCUMENT_DRIFT_HARD_MAX_DOCUMENTS,
        hardMaxTotalDocumentBytes: ENTITY_DOCUMENT_DRIFT_HARD_MAX_TOTAL_BYTES,
    };
}
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),
    };
}
function documentHonesty(input) {
    const shared = sharedHonesty(input.context);
    const allRefused = input.candidates > 0 && input.resolved === 0;
    return {
        ...shared,
        coverage: {
            documentsConsidered: 1,
            documentsWithCandidates: input.candidates > 0 ? 1 : 0,
            documentsAllLinksRefused: allRefused ? 1 : 0,
            allRefusedDocuments: allRefused ? [input.id] : [],
            allRefusedDocumentsOmitted: 0,
            linksExtracted: input.candidates,
            linksResolved: input.resolved,
            linksRefused: input.ambiguous + input.notFound,
            linksAmbiguous: input.ambiguous,
            linksNotFound: input.notFound,
            linksWithoutDocHistory: 0,
            rowsEmitted: input.rows,
            eligibleCodeTargets: input.eligibleTargets,
            diagnosticsOmitted: input.diagnosticsOmitted,
        },
        ambiguityCount: countEntityAmbiguityEvidence(input.context.entityIndex.counters.ambiguitiesRefused, input.context.entityIndex.status.diagnostics) + input.ambiguous,
    };
}
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;
}
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 documentRowOrder(left, right) {
    return (left.docSpan.startLine - right.docSpan.startLine ||
        left.docSpan.endLine - right.docSpan.endLine ||
        compareCodeUnits(left.entityAtBase.relativePath, right.entityAtBase.relativePath) ||
        left.entityAtBase.startLine - right.entityAtBase.startLine ||
        compareCodeUnits(left.entityAtBase.qualifiedName ?? left.entityAtBase.name, right.entityAtBase.qualifiedName ?? right.entityAtBase.name) ||
        compareCodeUnits(left.entityAtBase.lineageId, right.entityAtBase.lineageId));
}
/**
 * Evaluate Markdown held entirely by the caller against entity state at each
 * supplied baseline. Only Git commits and the already-built lineage index are
 * read; document paths and working-tree bytes are never consulted.
 */
export async function entityDriftForDocuments(options, context) {
    const parsed = EntityDriftForDocumentsOptionsSchema.safeParse(options);
    const preliminaryLimits = inputLimits();
    if (!parsed.success) {
        return {
            status: 'error',
            code: 'invalid-options',
            message: parsed.error.issues[0]?.message ?? 'Invalid externally anchored drift options.',
            limits: preliminaryLimits,
        };
    }
    const limits = inputLimits(parsed.data);
    const totalDocumentBytes = parsed.data.documents.reduce((total, document) => total + Buffer.byteLength(document.content, 'utf8'), 0);
    const actual = { documents: parsed.data.documents.length, totalDocumentBytes };
    if (actual.documents > limits.maxDocuments || actual.totalDocumentBytes > limits.maxTotalDocumentBytes) {
        return {
            status: 'error',
            code: 'budget-exceeded',
            message: `Externally anchored drift input exceeds its batch bounds: ${actual.documents}/${limits.maxDocuments} ` +
                `documents and ${actual.totalDocumentBytes}/${limits.maxTotalDocumentBytes} UTF-8 bytes.`,
            limits,
            actual,
        };
    }
    const signal = options.signal;
    const projectionByRef = new Map();
    const projectionByCommit = new Map();
    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;
            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,
                };
            }
            const declarationTargets = filteredDriftLiveIndex(projected.at.index, isDriftDeclarationTarget);
            const result = {
                status: 'resolved',
                at: projected.at,
                declarationTargets,
                bareTargets: filteredDriftLiveIndex(declarationTargets, entity => entity.record.isExported && !entity.ref.isTest),
            };
            projectionByCommit.set(commit, result);
            return result;
        })();
        projectionByRef.set(requested, pending);
        return pending;
    };
    const atHead = currentProjection(context);
    const outcomes = [];
    let batchDiagnosticsOmitted = 0;
    for (const document of parsed.data.documents) {
        abortIfNeeded(signal);
        const baseline = await resolveBaseline(document.baseCommit);
        if (baseline.status === 'refused') {
            const diagnostic = {
                code: baseline.code,
                message: baseline.message,
                documentId: document.id,
                baseCommit: document.baseCommit,
                ...(baseline.underlyingCode ? { underlyingCode: baseline.underlyingCode } : {}),
            };
            outcomes.push({
                status: 'refused',
                id: document.id,
                baseCommit: document.baseCommit,
                rows: [],
                diagnostics: [diagnostic],
                honesty: documentHonesty({
                    context,
                    id: document.id,
                    candidates: 0,
                    resolved: 0,
                    ambiguous: 0,
                    notFound: 0,
                    rows: 0,
                    eligibleTargets: 0,
                    diagnosticsOmitted: 0,
                }),
            });
            continue;
        }
        const extractedSections = extractMarkdownEntities(document.content);
        const documentLines = document.content.split(/\r?\n/u);
        const sections = extractedSections.map((section, index) => {
            // The format extractor's headingless preamble is a one-line entity
            // because repository lineage keys only need its stable start. A
            // caller-held document still needs every later prose line scanned, so
            // extend that sole section without changing the shared scanner or the
            // repository entity contract.
            const endLine = extractedSections.length === 1 && section.qualifiedName === '(preamble)'
                ? trimTrailingBlank(documentLines, Math.max(1, documentLines.length), section.startLine)
                : section.endLine;
            return {
                key: `${section.qualifiedName}\0${section.startLine}\0${endLine}\0${index}`,
                startLine: section.startLine,
                endLine,
                value: { ...section, endLine },
            };
        });
        const links = scanDriftDocumentLinks(document.content, sections);
        const diagnostics = [];
        let diagnosticsOmitted = 0;
        let linksResolved = 0;
        let linksAmbiguous = 0;
        let linksNotFound = 0;
        const rowsByPair = new Map();
        const addDiagnostic = (diagnostic) => {
            if (diagnostics.length < DIAGNOSTIC_LIMIT)
                diagnostics.push(diagnostic);
            else {
                diagnosticsOmitted++;
                batchDiagnosticsOmitted++;
            }
        };
        for (const link of links) {
            abortIfNeeded(signal);
            const targetIndex = link.relativePath ? baseline.declarationTargets : baseline.bareTargets;
            const resolution = resolveEntity({ name: link.name, ...(link.relativePath ? { relativePath: link.relativePath } : {}) }, targetIndex);
            const docSpan = { startLine: link.section.startLine, endLine: link.section.endLine };
            if (resolution.status !== 'resolved') {
                if (resolution.status === 'ambiguous')
                    linksAmbiguous++;
                else
                    linksNotFound++;
                const candidates = [...resolution.candidates].sort(compareDriftCandidateRefs).slice(0, CANDIDATE_LIMIT);
                addDiagnostic({
                    code: resolution.status === 'ambiguous' ? 'link-ambiguous' : 'link-not-found',
                    message: resolution.status === 'ambiguous'
                        ? `Refused "${link.raw}": it matches ${resolution.candidates.length} eligible declarations at the baseline.`
                        : `Refused "${link.raw}": no eligible declaration resolved uniquely at the baseline.`,
                    documentId: document.id,
                    baseCommit: document.baseCommit,
                    docSpan,
                    line: link.line,
                    reference: link.raw,
                    ...(candidates.length > 0 ? { candidates } : {}),
                });
                continue;
            }
            linksResolved++;
            const events = (context.entityIndex.eventsByLineage.get(resolution.entity.ref.lineageId) ?? []).filter(event => event.commitOrder > baseline.at.commitOrder && event.flags.logicChanged === true);
            const pairKey = `${link.section.key}\0${resolution.entity.ref.lineageId}`;
            if (rowsByPair.has(pairKey))
                continue;
            const entityAtHead = atHead?.byLineageId.get(resolution.entity.ref.lineageId)?.ref;
            rowsByPair.set(pairKey, {
                documentId: document.id,
                docSpan,
                entityAtBase: resolution.entity.ref,
                ...(entityAtHead ? { entityAtHead } : {}),
                changesSince: {
                    count: events.length,
                    strongestEvidence: strongestDriftEvidence(events),
                },
            });
        }
        const rows = [...rowsByPair.values()].sort(documentRowOrder);
        const commitRecord = context.entityIndex.commitBySha.get(baseline.at.commit);
        if (!commitRecord) {
            throw new Error(`Resolved baseline ${baseline.at.commit} disappeared from the immutable entity index.`);
        }
        outcomes.push({
            status: 'resolved',
            id: document.id,
            baseline: {
                requested: document.baseCommit,
                commit: baseline.at.commit,
                commitOrder: baseline.at.commitOrder,
                committedAt: commitRecord.committedAt,
            },
            rows,
            diagnostics,
            honesty: documentHonesty({
                context,
                id: document.id,
                candidates: links.length,
                resolved: linksResolved,
                ambiguous: linksAmbiguous,
                notFound: linksNotFound,
                rows: rows.length,
                eligibleTargets: baseline.declarationTargets.all.length,
                diagnosticsOmitted,
            }),
        });
    }
    const shared = sharedHonesty(context);
    const ambiguityCount = outcomes.reduce((total, outcome) => {
        const indexAmbiguities = countEntityAmbiguityEvidence(context.entityIndex.counters.ambiguitiesRefused, context.entityIndex.status.diagnostics);
        return total + Math.max(0, outcome.honesty.ambiguityCount - indexAmbiguities);
    }, countEntityAmbiguityEvidence(context.entityIndex.counters.ambiguitiesRefused, context.entityIndex.status.diagnostics));
    return {
        status: 'resolved',
        documents: outcomes,
        honesty: {
            ...shared,
            coverage: {
                documentsSubmitted: outcomes.length,
                documentsResolved: outcomes.filter(outcome => outcome.status === 'resolved').length,
                documentsRefused: outcomes.filter(outcome => outcome.status === 'refused').length,
                totalDocumentBytes,
                rowsEmitted: outcomes.reduce((total, outcome) => total + outcome.rows.length, 0),
                diagnosticsOmitted: batchDiagnosticsOmitted,
            },
            inputBounds: limits,
            ambiguityCount,
        },
    };
}
//# sourceMappingURL=document-drift.js.map