import { createHash } from 'node:crypto';
import { parseTreeSitterContent } from '../tree-sitter.js';
import { WHOLE_DIFF_GRAMMAR_MANIFEST, WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST, grammarManifestEntry, grammarManifestEntryForPath, verifyGrammarManifestEntry, } from './grammar-manifest.js';
import { WHOLE_DIFF_TRIVIA_POLICY_DIGEST, isWholeDiffDirectiveComment, isWholeDiffWhitespaceSafeGrammar, } from './trivia-policy.js';
export const WHOLE_DIFF_SEMANTIC_PROOF = 'whole-file-cst-v4';
/**
 * Comments are grammar nodes, not text matched by a language-agnostic lexer.
 * Keep this list explicit: silently guessing a comment node would turn a
 * grammar update into a stronger proof than this version specifies.
 */
export const WHOLE_DIFF_COMMENT_NODE_TYPES = Object.freeze(Object.fromEntries(WHOLE_DIFF_GRAMMAR_MANIFEST.map(entry => [entry.grammar, Object.freeze([...entry.commentNodeTypes])])));
const TOPOLOGY_STATUSES = new Set(['added', 'deleted', 'renamed', 'copied', 'type-changed']);
const PARTIAL_UNKNOWN_BASES = new Set([
    'whitespace-significance-unverified',
    'directive-comment-present',
    'byte-order-mark-change',
    'index-unmerged',
    'index-visibility-hidden',
    'concurrent-mutation',
    'untracked-not-in-scope',
    'unknown-status',
]);
const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1\n';
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
function unknownCoverage(basis) {
    return PARTIAL_UNKNOWN_BASES.has(basis) ? 'partial' : 'unavailable';
}
function baseEvidence(input) {
    return {
        relativePath: input.relativePath,
        ...(input.previousPath === undefined ? {} : { previousPath: input.previousPath }),
        status: input.status,
        ...(input.oldMode === undefined ? {} : { oldMode: input.oldMode }),
        ...(input.newMode === undefined ? {} : { newMode: input.newMode }),
        triviaPolicyDigest: WHOLE_DIFF_TRIVIA_POLICY_DIGEST,
    };
}
function unknownEvidence(input, basis, grammar) {
    const manifest = grammar === undefined ? undefined : grammarManifestEntry(grammar);
    return {
        ...baseEvidence(input),
        verdict: 'unknown',
        coverage: unknownCoverage(basis),
        basis,
        ...(grammar === undefined ? {} : { grammar }),
        ...(manifest === undefined
            ? {}
            : {
                grammarDigest: manifest.wasmSha256,
                grammarManifestDigest: WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
            }),
    };
}
function containsUnpairedSurrogate(value) {
    return UNPAIRED_SURROGATE.test(value);
}
function materializeContent(content) {
    if (typeof content === 'string') {
        if (containsUnpairedSurrogate(content))
            return 'invalid-encoding';
        return { bytes: Buffer.from(content, 'utf8'), text: content };
    }
    const bytes = new Uint8Array(content);
    try {
        const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
        if (containsUnpairedSurrogate(text))
            return 'invalid-encoding';
        return { bytes, text };
    }
    catch {
        return 'invalid-encoding';
    }
}
function lineCount(value) {
    let lines = 1;
    for (let index = 0; index < value.length; index++) {
        const code = value.charCodeAt(index);
        if (code === 0x0a) {
            lines++;
        }
        else if (code === 0x0d) {
            lines++;
            if (value.charCodeAt(index + 1) === 0x0a)
                index++;
        }
        else if (code === 0x2028 || code === 0x2029) {
            lines++;
        }
    }
    return lines;
}
function exceedsBounds(content, input) {
    if (input.maxBytes !== undefined && content.bytes.byteLength > input.maxBytes)
        return true;
    return input.maxLines !== undefined && lineCount(content.text) > input.maxLines;
}
function containsNul(content) {
    return content.bytes.includes(0);
}
function hasUtf8ByteOrderMark(content) {
    return (content.bytes.byteLength >= 3 && content.bytes[0] === 0xef && content.bytes[1] === 0xbb && content.bytes[2] === 0xbf);
}
function isRegularFileMode(mode) {
    return mode === undefined || mode.startsWith('100');
}
function rawContentContainsNul(content) {
    if (typeof content === 'string')
        return content.includes('\0');
    return content.includes(0);
}
function isLfsPointer(content) {
    return content.text.startsWith(LFS_POINTER_PREFIX);
}
/** Return the vendored whole-file grammar selected solely from a valid path. */
export function resolveWholeDiffGrammar(relativePath) {
    return grammarManifestEntryForPath(relativePath)?.grammar ?? null;
}
function emitDigestField(hash, value) {
    const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
    hash.update(Buffer.from(`${bytes.byteLength}:`, 'ascii'));
    hash.update(bytes);
}
function emitDigestRecord(hash, marker, fields) {
    emitDigestField(hash, marker);
    for (const field of fields)
        emitDigestField(hash, field);
}
function flagsFor(node) {
    return [node.isNamed, node.isExtra, node.isError, node.isMissing, node.hasError]
        .map(value => (value ? '1' : '0'))
        .join('');
}
function isWhitespace(value) {
    return /^\s*$/u.test(value);
}
function lineAndIndentProfile(source) {
    const lineEndings = source.match(/\r\n|\r|\n|\u2028|\u2029/gu) ?? [];
    const lines = source.split(/\r\n|\r|\n|\u2028|\u2029/u);
    const indents = lines.map(line => {
        const prefix = /^[\t ]*/u.exec(line)?.[0] ?? '';
        return /^[\t ]*$/u.test(line) ? '<blank>' : prefix;
    });
    return JSON.stringify({ lineEndings, indents });
}
function whitespaceAdmissionProved(grammar, oldSource, newSource) {
    if (isWholeDiffWhitespaceSafeGrammar(grammar))
        return true;
    return grammar === 'python' && lineAndIndentProfile(oldSource) === lineAndIndentProfile(newSource);
}
function commentLineShape(comment) {
    return JSON.stringify(comment.match(/\r\n|\r|\n|\u2028|\u2029/gu) ?? []);
}
function digestTree(manifest, source, root) {
    const primary = createHash('sha256');
    const commentsElided = createHash('sha256');
    const leaves = [];
    const commentSpans = [];
    const commentTypes = new Set(manifest.commentNodeTypes);
    let valid = true;
    const proofFields = [
        WHOLE_DIFF_SEMANTIC_PROOF,
        manifest.grammar,
        manifest.wasmSha256,
        WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
        WHOLE_DIFF_TRIVIA_POLICY_DIGEST,
    ];
    emitDigestRecord(primary, 'proof', proofFields);
    emitDigestRecord(commentsElided, 'proof', [...proofFields, 'comments-elided']);
    const visit = (node, fieldName, secondaryVisible, rootNode) => {
        if (!valid)
            return;
        if (node.isMissing ||
            node.isError ||
            node.hasError ||
            node.startIndex < 0 ||
            node.endIndex < node.startIndex ||
            node.endIndex > source.length) {
            valid = false;
            return;
        }
        const fields = [fieldName ?? '<no-field>', node.type, node.grammarType, flagsFor(node)];
        emitDigestRecord(primary, 'open', fields);
        const commentNode = commentTypes.has(node.type);
        const visibleHere = secondaryVisible && !commentNode;
        if (visibleHere)
            emitDigestRecord(commentsElided, 'open', fields);
        if (commentNode || (node.childCount === 0 && !rootNode)) {
            const leaf = source.slice(node.startIndex, node.endIndex);
            if (leaf !== node.text) {
                valid = false;
                return;
            }
            leaves.push({ start: node.startIndex, end: node.endIndex });
            if (commentNode) {
                commentSpans.push({ start: node.startIndex, end: node.endIndex, type: node.type, text: leaf });
            }
            emitDigestRecord(primary, 'leaf', [leaf]);
            if (visibleHere)
                emitDigestRecord(commentsElided, 'leaf', [leaf]);
        }
        else {
            for (let index = 0; index < node.childCount; index++) {
                const child = node.child(index);
                if (!child) {
                    valid = false;
                    return;
                }
                visit(child, node.fieldNameForChild(index), visibleHere, false);
            }
        }
        emitDigestRecord(primary, 'close', [node.type]);
        if (visibleHere)
            emitDigestRecord(commentsElided, 'close', [node.type]);
    };
    visit(root, null, true, true);
    if (!valid)
        return null;
    let cursor = 0;
    for (const leaf of leaves) {
        if (leaf.start < cursor || !isWhitespace(source.slice(cursor, leaf.start)))
            return null;
        cursor = leaf.end;
    }
    if (!isWhitespace(source.slice(cursor)))
        return null;
    const commentMasked = createHash('sha256');
    emitDigestRecord(commentMasked, 'proof', [
        WHOLE_DIFF_SEMANTIC_PROOF,
        manifest.grammar,
        manifest.wasmSha256,
        WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
        WHOLE_DIFF_TRIVIA_POLICY_DIGEST,
        'comment-masked-source',
    ]);
    cursor = 0;
    for (const comment of commentSpans) {
        if (comment.start < cursor)
            return null;
        emitDigestRecord(commentMasked, 'source', [source.slice(cursor, comment.start)]);
        emitDigestRecord(commentMasked, 'comment', [comment.type, commentLineShape(comment.text)]);
        cursor = comment.end;
    }
    emitDigestRecord(commentMasked, 'source', [source.slice(cursor)]);
    return {
        primary: primary.digest('hex'),
        commentsElided: commentsElided.digest('hex'),
        commentMasked: commentMasked.digest('hex'),
        comments: commentSpans.map(comment => comment.text),
    };
}
async function computeCstDigests(grammar, source) {
    const manifest = grammarManifestEntry(grammar);
    if (!manifest || !(await verifyGrammarManifestEntry(manifest)))
        return { basis: 'grammar-unavailable' };
    let tree;
    try {
        tree = await parseTreeSitterContent(grammar, source);
    }
    catch {
        return { basis: 'grammar-unavailable' };
    }
    if (!tree)
        return { basis: 'grammar-unavailable' };
    try {
        const digests = digestTree(manifest, source, tree.rootNode);
        return digests ?? { basis: 'parse-error' };
    }
    catch {
        return { basis: 'parse-error' };
    }
    finally {
        try {
            tree.delete();
        }
        catch {
            // Cleanup cannot strengthen or invalidate the already-copied evidence.
        }
    }
}
function isDigestFailure(value) {
    return 'basis' in value;
}
/**
 * Classify one raw changed-file ledger row.
 *
 * `logic-change` means proved non-trivia structural source drift. It does not
 * claim observational program inequivalence. `cosmetic-change` means the V4
 * trivia policy admitted a token-identical whitespace or ordinary-comment
 * change; directives and unsafe language-specific whitespace fail closed.
 */
export async function classifyWholeDiffFile(input) {
    // These bases invalidate the binding between a ledger row and the Git scope
    // itself. They must take precedence even over otherwise self-contained
    // topology evidence: a row observed across two generations is not proof
    // about either complete generation.
    if (input.unavailableBasis === 'concurrent-mutation' ||
        input.unavailableBasis === 'index-unmerged' ||
        input.unavailableBasis === 'unknown-status') {
        return unknownEvidence(input, input.unavailableBasis, input.grammar);
    }
    if (TOPOLOGY_STATUSES.has(input.status) ||
        (input.status === 'modified' && input.previousPath !== undefined && input.previousPath !== input.relativePath)) {
        return {
            ...baseEvidence(input),
            verdict: 'logic-change',
            coverage: 'complete',
            basis: 'path-topology-change',
        };
    }
    if (input.status === 'unmerged')
        return unknownEvidence(input, 'index-unmerged');
    if (input.status !== 'modified')
        return unknownEvidence(input, 'unknown-status');
    if (input.oldMode === undefined || input.newMode === undefined) {
        return unknownEvidence(input, 'unknown-status');
    }
    if (input.oldMode !== input.newMode || !isRegularFileMode(input.oldMode) || !isRegularFileMode(input.newMode)) {
        return {
            ...baseEvidence(input),
            verdict: 'logic-change',
            coverage: 'complete',
            basis: 'path-topology-change',
        };
    }
    if (input.unavailableBasis !== undefined)
        return unknownEvidence(input, input.unavailableBasis, input.grammar);
    if (input.binary === true)
        return unknownEvidence(input, 'binary', input.grammar);
    if (input.oldContent === undefined || input.newContent === undefined) {
        return unknownEvidence(input, 'blob-unavailable', input.grammar);
    }
    if (rawContentContainsNul(input.oldContent) || rawContentContainsNul(input.newContent)) {
        return unknownEvidence(input, 'binary', input.grammar);
    }
    const oldContent = materializeContent(input.oldContent);
    const newContent = materializeContent(input.newContent);
    if (oldContent === 'invalid-encoding' || newContent === 'invalid-encoding') {
        return unknownEvidence(input, 'invalid-encoding', input.grammar);
    }
    if (containsNul(oldContent) || containsNul(newContent))
        return unknownEvidence(input, 'binary', input.grammar);
    if (hasUtf8ByteOrderMark(oldContent) !== hasUtf8ByteOrderMark(newContent)) {
        return unknownEvidence(input, 'byte-order-mark-change', input.grammar);
    }
    if (exceedsBounds(oldContent, input) || exceedsBounds(newContent, input)) {
        return unknownEvidence(input, 'resource-bound', input.grammar);
    }
    if (isLfsPointer(oldContent) || isLfsPointer(newContent)) {
        return unknownEvidence(input, 'blob-unavailable', input.grammar);
    }
    const grammar = input.grammar ?? resolveWholeDiffGrammar(input.relativePath);
    if (!grammar)
        return unknownEvidence(input, 'unsupported-language');
    const manifest = grammarManifestEntry(grammar);
    if (!manifest)
        return unknownEvidence(input, 'grammar-unavailable', grammar);
    const [oldDigests, newDigests] = await Promise.all([
        computeCstDigests(grammar, oldContent.text),
        computeCstDigests(grammar, newContent.text),
    ]);
    if (isDigestFailure(oldDigests))
        return unknownEvidence(input, oldDigests.basis, grammar);
    if (isDigestFailure(newDigests))
        return unknownEvidence(input, newDigests.basis, grammar);
    // Directives gate only cosmetic admission: comments-elided digest
    // inequality proves logic-change independently of comment content, so the
    // directive check must never pre-empt that proof.
    const directiveCommentPresent = () => [...oldDigests.comments, ...newDigests.comments].some(isWholeDiffDirectiveComment);
    if (oldDigests.primary === newDigests.primary) {
        if (directiveCommentPresent()) {
            return unknownEvidence(input, 'directive-comment-present', grammar);
        }
        if (!whitespaceAdmissionProved(grammar, oldContent.text, newContent.text)) {
            return unknownEvidence(input, 'whitespace-significance-unverified', grammar);
        }
        return {
            ...baseEvidence(input),
            verdict: 'cosmetic-change',
            coverage: 'complete',
            basis: 'whitespace-only',
            grammar,
            grammarDigest: manifest.wasmSha256,
            grammarManifestDigest: WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
        };
    }
    if (oldDigests.commentsElided === newDigests.commentsElided) {
        if (directiveCommentPresent()) {
            return unknownEvidence(input, 'directive-comment-present', grammar);
        }
        const admitted = whitespaceAdmissionProved(grammar, oldContent.text, newContent.text) ||
            oldDigests.commentMasked === newDigests.commentMasked;
        if (!admitted)
            return unknownEvidence(input, 'whitespace-significance-unverified', grammar);
        return {
            ...baseEvidence(input),
            verdict: 'cosmetic-change',
            coverage: 'complete',
            basis: 'comment-only-change',
            grammar,
            grammarDigest: manifest.wasmSha256,
            grammarManifestDigest: WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
        };
    }
    return {
        ...baseEvidence(input),
        verdict: 'logic-change',
        coverage: 'complete',
        basis: 'nontrivia-syntax-change',
        grammar,
        grammarDigest: manifest.wasmSha256,
        grammarManifestDigest: WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
    };
}
/** Aggregate every raw ledger row asymmetrically into the authoritative proof. */
export const classifyWholeDiffSemantics = async (input) => {
    const files = await Promise.all(input.files.map(file => classifyWholeDiffFile(file)));
    const counts = { logic: 0, noOp: 0, unknown: 0, cosmetic: 0, whitespaceOnly: 0, commentOnly: 0 };
    for (const file of files) {
        if (file.verdict === 'logic-change')
            counts.logic++;
        else if (file.verdict === 'semantic-no-op')
            counts.noOp++;
        else if (file.verdict === 'cosmetic-change') {
            counts.cosmetic++;
            if (file.basis === 'whitespace-only')
                counts.whitespaceOnly++;
            else if (file.basis === 'comment-only-change')
                counts.commentOnly++;
        }
        else
            counts.unknown++;
    }
    const diagnostics = [...(input.diagnostics ?? [])];
    const unclassifiedFiles = [
        ...files
            .filter(file => file.verdict === 'unknown')
            .map(file => ({ relativePath: file.relativePath, basis: file.basis })),
        ...(input.unclassifiedFiles ?? []),
    ];
    const callFailed = input.status === 'error' || input.gitScope === undefined;
    const scopeGap = input.gitScope?.untracked === 'excluded-present';
    const incomplete = unclassifiedFiles.length > 0 || diagnostics.length > 0 || scopeGap;
    const verdict = callFailed
        ? 'unknown'
        : counts.logic > 0
            ? 'logic-change'
            : incomplete
                ? 'unknown'
                : 'semantic-no-op';
    const coverage = callFailed ? 'unavailable' : incomplete ? 'partial' : 'complete';
    return {
        scope: 'whole-diff',
        proof: WHOLE_DIFF_SEMANTIC_PROOF,
        grammarManifestDigest: WHOLE_DIFF_GRAMMAR_MANIFEST_DIGEST,
        triviaPolicyDigest: WHOLE_DIFF_TRIVIA_POLICY_DIGEST,
        verdict,
        coverage,
        counts,
        files,
        evidenceGaps: { unclassifiedFiles, diagnostics },
        ...(input.gitScope === undefined ? {} : { gitScope: input.gitScope }),
    };
};
//# sourceMappingURL=whole-diff.js.map