import { lstat, readFile } from 'node:fs/promises';
import path from 'node:path';
import { TextDecoder } from 'node:util';
import { isOccAbortError } from '../../errors.js';
import { abortIfNeeded } from '../../utils.js';
import { runGit, runGitRaw } from '../../git/client.js';
import { assertValidGitPath, decodeGitPath, GitParseError, GitPathError, splitNulStrict } from '../../git/parse.js';
import { formatForPath } from '../formats/index.js';
import { getLanguageSpec } from '../languages.js';
import { buildEntityDiffRangeArgs, readRawDiff, resolveEntityDiffRange } from './git.js';
import { mapEntityGitError } from './git-error.js';
import { blobEntityKey, readBlobEntities, unsupportedDiagnostic } from './indexer.js';
import { classifyEntityChange, matchFileEntities } from './lineage.js';
import { classifyEntityDiffSemantics } from './semantic.js';
import { entityStateKey, extractEntitiesFromBlob } from './snapshot.js';
import { exactSourceForRecord } from './syntax-evidence.js';
import { DEFAULT_ENTITY_BOUNDS, DEFAULT_LINEAGE_THRESHOLDS, MATCH_CONFIDENCE, bumpCounter } from './types.js';
import { normalizeRelativePath, provisionalLineageId } from './resolve.js';
import { classifyWholeDiffSemantics } from './whole-diff.js';
/** All-zero object ids mean "this side is not a blob" — for a worktree diff, read from disk. */
const NULL_OID = /^0+$/;
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true });
const WORKSPACE_GIT_MAX_BUFFER = 128 * 1024 * 1024;
const WORKSPACE_VISIBILITY_TAGS = new Set(['H', 'S', 'M', 'R', 'C', 'K', '?']);
function asciiToken(value, label) {
    if (value.some(byte => byte > 0x7f))
        throw new GitParseError(`${label} is not ASCII.`);
    return value.toString('ascii');
}
function splitStatusPrefix(field, tokenCount) {
    const tokens = [];
    let start = 0;
    for (let index = 0; index < tokenCount; index++) {
        const space = field.indexOf(0x20, start);
        if (space === -1)
            throw new GitParseError('Malformed porcelain-v2 status record.');
        tokens.push(asciiToken(field.subarray(start, space), 'Porcelain-v2 status field'));
        start = space + 1;
    }
    const pathBytes = field.subarray(start);
    if (pathBytes.length === 0)
        throw new GitParseError('Porcelain-v2 status record is missing its path.');
    return { tokens, path: pathBytes };
}
/** Parse only the facts the whole-diff scope receipt needs, retaining raw path bytes until the path field. */
function parseWorkspaceStatus(input) {
    const fields = splitNulStrict(input, 'Porcelain-v2 status output');
    const entries = [];
    for (let index = 0; index < fields.length; index++) {
        const field = fields[index];
        const recordType = field[0];
        if (recordType === 0x31 || recordType === 0x32) {
            const parsed = splitStatusPrefix(field, recordType === 0x31 ? 8 : 9);
            const xy = parsed.tokens[1] ?? '..';
            const relativePath = decodeGitPath(parsed.path);
            if (recordType === 0x32) {
                const previousPath = fields[++index];
                if (!previousPath)
                    throw new GitParseError('Porcelain-v2 rename record is missing its previous path.');
                decodeGitPath(previousPath);
            }
            entries.push({
                relativePath,
                indexChanged: xy[0] !== '.',
                worktreeChanged: xy[1] !== '.',
                unmerged: false,
                untracked: false,
            });
            continue;
        }
        if (recordType === 0x75) {
            const parsed = splitStatusPrefix(field, 10);
            entries.push({
                relativePath: decodeGitPath(parsed.path),
                indexChanged: true,
                worktreeChanged: true,
                unmerged: true,
                untracked: false,
            });
            continue;
        }
        if (recordType === 0x3f || recordType === 0x21) {
            if (field[1] !== 0x20 || field.length <= 2)
                throw new GitParseError('Malformed porcelain-v2 path record.');
            const relativePath = decodeGitPath(field.subarray(2));
            if (recordType === 0x3f) {
                entries.push({
                    relativePath,
                    indexChanged: false,
                    worktreeChanged: false,
                    unmerged: false,
                    untracked: true,
                });
            }
            continue;
        }
        throw new GitParseError('Porcelain-v2 status output contains an unknown record type.');
    }
    return entries;
}
/** Parse `git ls-files -v -z` without decoding a path until its framing is proved. */
export function parseWorkspaceVisibility(input) {
    return splitNulStrict(input, 'Index visibility output').map(field => {
        if (field.length <= 2 || field[1] !== 0x20) {
            throw new GitParseError('Malformed index visibility record.');
        }
        const tag = asciiToken(field.subarray(0, 1), 'Index visibility tag');
        const normalizedTag = tag.toUpperCase();
        if (!WORKSPACE_VISIBILITY_TAGS.has(normalizedTag)) {
            throw new GitParseError('Index visibility output contains an unknown status tag.');
        }
        return {
            relativePath: decodeGitPath(field.subarray(2)),
            tag,
            assumeUnchanged: tag !== normalizedTag,
            skipWorktree: normalizedTag === 'S',
        };
    });
}
function validateIndexPaths(input) {
    let unmerged = false;
    for (const field of splitNulStrict(input, 'Index generation output')) {
        const tab = field.indexOf(0x09);
        if (tab <= 0 || tab === field.length - 1)
            throw new GitParseError('Malformed index generation record.');
        const header = asciiToken(field.subarray(0, tab), 'Index generation header');
        const match = header.match(/^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/);
        if (!match)
            throw new GitParseError('Malformed index generation header.');
        if (match[3] !== '0')
            unmerged = true;
        decodeGitPath(field.subarray(tab + 1));
    }
    return unmerged;
}
async function captureWorkspaceVisibility(relativePath, context) {
    const pathArgs = relativePath ? ['--', relativePath] : [];
    const result = await runGitRaw(['ls-files', '-v', '-z', ...pathArgs], {
        repoRoot: context.repoRoot,
        signal: context.signal,
        physicalObjects: true,
        maxBuffer: context.wholeDiffTestHooks?.visibilityMaxBufferBytes ?? WORKSPACE_GIT_MAX_BUFFER,
    });
    bumpCounter(context.counters, 'gitInvocations');
    bumpCounter(context.counters, 'gitBytesRead', result.stdout.length);
    return { raw: result.stdout, entries: parseWorkspaceVisibility(result.stdout) };
}
async function captureWorkspaceSnapshot(relativePath, context, options) {
    const pathArgs = relativePath ? ['--', relativePath] : [];
    const visibilityPromise = options.includeVisibility
        ? options.initialVisibility
            ? Promise.resolve(options.initialVisibility)
            : captureWorkspaceVisibility(relativePath, context)
        : Promise.resolve(undefined);
    const [status, index, visibility] = await Promise.all([
        runGitRaw(['status', '--porcelain=v2', '-z', '--untracked-files=all', ...pathArgs], {
            repoRoot: context.repoRoot,
            signal: context.signal,
            physicalObjects: true,
            maxBuffer: WORKSPACE_GIT_MAX_BUFFER,
        }),
        runGitRaw(['ls-files', '-s', '-z', ...pathArgs], {
            repoRoot: context.repoRoot,
            signal: context.signal,
            physicalObjects: true,
            maxBuffer: WORKSPACE_GIT_MAX_BUFFER,
        }),
        visibilityPromise,
    ]);
    bumpCounter(context.counters, 'gitInvocations', 2);
    bumpCounter(context.counters, 'gitBytesRead', status.stdout.length + index.stdout.length);
    const unmerged = validateIndexPaths(index.stdout);
    let indexTreeOid;
    if (!unmerged) {
        const tree = await runGit(['write-tree'], {
            repoRoot: context.repoRoot,
            signal: context.signal,
            physicalObjects: true,
        });
        bumpCounter(context.counters, 'gitInvocations');
        bumpCounter(context.counters, 'gitBytesRead', Buffer.byteLength(tree.stdout, 'utf8'));
        const oid = tree.stdout.trim();
        if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) {
            throw new GitParseError('Git write-tree returned an invalid index tree object id.');
        }
        indexTreeOid = oid;
    }
    return {
        statusRaw: status.stdout,
        indexRaw: index.stdout,
        entries: parseWorkspaceStatus(status.stdout),
        ...(visibility ? { visibility } : {}),
        ...(indexTreeOid ? { indexTreeOid } : {}),
    };
}
function sameFileState(before, after) {
    return (before.dev === after.dev &&
        before.ino === after.ino &&
        before.mode === after.mode &&
        before.size === after.size &&
        before.mtimeMs === after.mtimeMs &&
        before.ctimeMs === after.ctimeMs);
}
function countSourceLines(text) {
    let lines = 1;
    for (let index = 0; index < text.length; index++) {
        if (text.charCodeAt(index) === 0x0a)
            lines++;
    }
    return lines;
}
async function materializeWorkingTreeFile(relativePath, context, bounds, budget, diagnostics, wholeDiagnostics) {
    try {
        abortIfNeeded(context.signal);
        await context.wholeDiffTestHooks?.beforeWorkingTreeRead?.(relativePath);
        abortIfNeeded(context.signal);
        const absolute = path.join(context.repoRoot, relativePath);
        const before = await lstat(absolute);
        if (!before.isFile() || before.isSymbolicLink()) {
            return { unavailableBasis: 'working-tree-read-failed', state: before };
        }
        if (before.size > bounds.maxBlobBytes)
            return { unavailableBasis: 'resource-bound', state: before };
        if (budget.bytesRead + before.size > bounds.maxTotalBlobBytes) {
            return { unavailableBasis: 'resource-bound', state: before };
        }
        const buffer = await readFile(absolute);
        const after = await lstat(absolute);
        if (!sameFileState(before, after) || buffer.length !== before.size) {
            return { buffer, unavailableBasis: 'concurrent-mutation', state: after };
        }
        budget.bytesRead += buffer.length;
        if (buffer.includes(0))
            return { buffer, state: after };
        let text;
        try {
            text = FATAL_UTF8.decode(buffer);
        }
        catch {
            return { buffer, unavailableBasis: 'invalid-encoding', state: after };
        }
        if (countSourceLines(text) > bounds.maxBlobLines) {
            return { buffer, unavailableBasis: 'resource-bound', state: after };
        }
        abortIfNeeded(context.signal);
        return { buffer, text, state: after };
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        const diagnostic = {
            code: 'evidence-unavailable',
            message: `Could not read working-tree file ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
            relativePath,
        };
        diagnostics.push(diagnostic);
        wholeDiagnostics.push(diagnostic);
        return { unavailableBasis: 'working-tree-read-failed' };
    }
}
function scopeComparison(range) {
    if (range.kind === 'three-dot')
        return 'merge-base-to-head';
    if (range.kind === 'staged')
        return 'head-to-index';
    if (range.kind === 'working-tree')
        return 'head-to-worktree';
    return 'base-to-head';
}
function scopeLayers(range) {
    if (range.kind === 'staged')
        return ['committed', 'index'];
    if (range.kind === 'working-tree')
        return ['committed', 'index', 'worktree'];
    return ['committed'];
}
function uniqueScopeGaps(gaps) {
    const seen = new Set();
    return gaps.filter(gap => {
        const key = `${gap.relativePath}\0${gap.basis}`;
        if (seen.has(key))
            return false;
        seen.add(key);
        return true;
    });
}
function compareGitPaths(left, right) {
    return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
}
function hiddenVisibilityPaths(snapshots) {
    const hidden = new Set();
    for (const snapshot of snapshots) {
        for (const entry of snapshot?.entries ?? []) {
            if (entry.assumeUnchanged || entry.skipWorktree)
                hidden.add(entry.relativePath);
        }
    }
    return [...hidden].sort(compareGitPaths);
}
function visibilityTagsByPath(entries) {
    const tags = new Map();
    for (const entry of entries) {
        const pathTags = tags.get(entry.relativePath);
        if (pathTags)
            pathTags.push(entry.tag);
        else
            tags.set(entry.relativePath, [entry.tag]);
    }
    return new Map([...tags].map(([relativePath, pathTags]) => [relativePath, pathTags.sort().join('')]));
}
function changedVisibilityPaths(before, after) {
    const beforeTags = visibilityTagsByPath(before.entries);
    const afterTags = visibilityTagsByPath(after.entries);
    const paths = new Set([...beforeTags.keys(), ...afterTags.keys()]);
    return [...paths]
        .filter(relativePath => beforeTags.get(relativePath) !== afterTags.get(relativePath))
        .sort(compareGitPaths);
}
function workspaceStatusByPath(entries) {
    const statuses = new Map();
    for (const entry of entries) {
        const status = [entry.indexChanged, entry.worktreeChanged, entry.unmerged, entry.untracked]
            .map(value => (value ? '1' : '0'))
            .join('');
        const pathStatuses = statuses.get(entry.relativePath);
        if (pathStatuses)
            pathStatuses.push(status);
        else
            statuses.set(entry.relativePath, [status]);
    }
    return new Map([...statuses].map(([relativePath, pathStatuses]) => [relativePath, pathStatuses.sort().join(',')]));
}
function changedWorkspaceStatusPaths(before, after) {
    const beforeStatuses = workspaceStatusByPath(before);
    const afterStatuses = workspaceStatusByPath(after);
    const paths = new Set([...beforeStatuses.keys(), ...afterStatuses.keys()]);
    return [...paths]
        .filter(relativePath => beforeStatuses.get(relativePath) !== afterStatuses.get(relativePath))
        .sort(compareGitPaths);
}
function promoteToWorkingTreeRange(range) {
    if (!range.resolvedHead) {
        throw new GitParseError('Implicit worktree scope is missing its pinned HEAD commit.');
    }
    return {
        kind: 'working-tree',
        head: 'HEAD',
        resolvedHead: range.resolvedHead,
        describe: 'working tree',
    };
}
function normalizeDiffPath(value) {
    assertValidGitPath(value);
    const normalized = normalizeRelativePath(value).replace(/\/$/, '');
    if (normalized.split('/').includes('..'))
        throw new GitPathError('Entity diff path escapes the repository.');
    return normalized;
}
/**
 * A path whose only entities can be data-format ones.
 *
 * Asked of the current side of the change, which is the side the caller is
 * looking at. A file with a language spec is code even when its extension also
 * names a format, so the spec is checked first — the same precedence
 * `extractEntitiesFromBlob` uses, and it must not diverge from it.
 */
function isFormatOnlyPath(relativePath) {
    return !getLanguageSpec(relativePath) && formatForPath(relativePath) !== undefined;
}
const ENTITY_REF_ORDINALS = new WeakMap();
/** @internal Read the extractor slot without adding it to the public entity-ref wire shape. */
export function entityRefOrdinalFor(ref) {
    return ENTITY_REF_ORDINALS.get(ref);
}
function refFor(record, context, isTest) {
    const lineageId = context.state?.byStateKey.get(entityStateKey(record));
    const ref = {
        lineageId: lineageId ?? provisionalLineageId(context.repoIdentity, record),
        name: record.name,
        qualifiedName: record.qualifiedName,
        kind: record.kind,
        relativePath: record.relativePath,
        startLine: record.startLine,
        endLine: record.endLine,
        isExported: record.isExported,
        isTest,
    };
    // The all-parent atoms overlay needs this only for its immediate pinned-head
    // join. A WeakMap keeps the extractor slot out of the public object entirely.
    ENTITY_REF_ORDINALS.set(ref, record.ordinal);
    if (record.containerName)
        ref.containerName = record.containerName;
    if (record.signature)
        ref.signature = record.signature;
    if (!lineageId)
        ref.provisional = true;
    return ref;
}
/**
 * Structural truth for a change.
 *
 * `null` is a real answer and not a missing one: without complete, compatible
 * CST evidence OCC does not know whether the change was structural, and saying
 * `false` would be a semantic no-op claim it cannot support.
 */
function structuralTruth(flags) {
    if (flags.evidenceUnavailable)
        return null;
    if (flags.logicChanged)
        return true;
    if (flags.cosmeticOnly)
        return false;
    return null;
}
/**
 * Entity-level semantic diff.
 *
 * Deliberately independent of the lineage cache: it re-derives both sides from
 * blobs, so it answers for any range including the index and the working tree,
 * whether or not history has ever been indexed. When a cache is present its
 * lineage ids are attached, which is enrichment rather than a dependency.
 */
export async function computeEntityDiff(options, context) {
    const bounds = context.bounds ?? DEFAULT_ENTITY_BOUNDS;
    const thresholds = context.thresholds ?? DEFAULT_LINEAGE_THRESHOLDS;
    const diagnostics = [];
    const wholeDiagnostics = [];
    const gitOptions = { repoRoot: context.repoRoot, signal: context.signal, counters: context.counters };
    let range;
    let changes;
    let relativePath;
    let workspaceBefore;
    try {
        relativePath = options.relativePath ? normalizeDiffPath(options.relativePath) : undefined;
        if (context.immutableTransition) {
            if (relativePath)
                throw new GitParseError('A pre-resolved transition cannot be combined with a path filter.');
            range = context.immutableTransition.range;
            changes = context.immutableTransition.changes;
        }
        else {
            const implicit = options.base == null && options.head == null && options.staged !== true;
            range = await resolveEntityDiffRange({
                ...gitOptions,
                base: options.base,
                head: options.head,
                staged: options.staged,
                rangeMode: options.rangeMode,
            });
            let initialVisibility;
            if (implicit && range.kind !== 'working-tree') {
                // Ordinary status can call a hidden-only workspace clean and resolve the
                // default branch range. Visibility must therefore participate in scope
                // selection, while preserving the HEAD object id the resolver pinned.
                initialVisibility = await captureWorkspaceVisibility(relativePath, context);
                if (hiddenVisibilityPaths([initialVisibility]).length > 0) {
                    range = promoteToWorkingTreeRange(range);
                }
            }
            if (range.kind === 'working-tree' || range.kind === 'staged') {
                workspaceBefore = await captureWorkspaceSnapshot(relativePath, context, {
                    includeVisibility: range.kind === 'working-tree',
                    ...(initialVisibility ? { initialVisibility } : {}),
                });
                await context.wholeDiffTestHooks?.afterInitialWorkspaceSnapshot?.();
                abortIfNeeded(context.signal);
            }
            const paths = relativePath ? [relativePath] : undefined;
            changes = await readRawDiff(buildEntityDiffRangeArgs(range), { ...gitOptions, paths });
        }
    }
    catch (error) {
        const mapped = mapEntityGitError(error);
        const result = {
            status: 'error',
            ...mapped,
            changedFiles: [],
            changes: [],
            truncated: false,
            omitted: 0,
            diagnostics,
        };
        return {
            ...result,
            semantic: classifyEntityDiffSemantics(result),
            wholeDiffSemantic: await classifyWholeDiffSemantics({ status: 'error', files: [] }),
        };
    }
    try {
        // Decided before the blobs are read, not after the entities are built: a
        // caller who does not want format entities should not pay to extract them.
        // The paths stay in `changedFiles` — they did change, and a file that yields
        // no entity changes is already an ordinary outcome here (binary, unsupported).
        const includeFormats = options.formats ?? context.formats ?? true;
        const excluded = includeFormats
            ? undefined
            : new Set(changes.filter(change => isFormatOnlyPath(change.path)).map(change => change.path));
        const materializationBudget = context.materializationBudget ?? { bytesRead: 0 };
        const readOptions = {
            repoRoot: context.repoRoot,
            bounds,
            guardrails: context.guardrails,
            cache: context.cache,
            signal: context.signal,
            counters: context.counters,
            budget: materializationBudget,
            bodyCache: context.bodyCache,
            shouldExtract: (candidatePath) => !excluded?.has(candidatePath),
        };
        const requests = [];
        for (const change of changes) {
            if (change.oldBlobSha && !NULL_OID.test(change.oldBlobSha)) {
                requests.push({ blobSha: change.oldBlobSha, relativePath: change.oldPath ?? change.path });
            }
            if (change.newBlobSha && !NULL_OID.test(change.newBlobSha)) {
                requests.push({ blobSha: change.newBlobSha, relativePath: change.path });
            }
        }
        const read = await readBlobEntities(requests, readOptions);
        diagnostics.push(...read.diagnostics);
        const unsupported = unsupportedDiagnostic(read.unsupported);
        if (unsupported)
            diagnostics.push(unsupported);
        const workingTreeFiles = new Map();
        for (const change of changes) {
            if (change.status === 'deleted' || change.newBlobSha || (change.newMode && !change.newMode.startsWith('100'))) {
                continue;
            }
            if (workingTreeFiles.has(change.path))
                continue;
            workingTreeFiles.set(change.path, await materializeWorkingTreeFile(change.path, context, bounds, materializationBudget, diagnostics, wholeDiagnostics));
        }
        const maxEntities = options.maxEntities ?? bounds.maxEntitiesTotal;
        const result = [];
        const changedFiles = [];
        let omitted = 0;
        // Test classification is per file and asked once per path rather than once per
        // change, so a cross-file pairing can describe each side by its own file.
        const testCache = new Map();
        const isTestPath = (relativePath) => {
            const cached = testCache.get(relativePath);
            if (cached !== undefined)
                return cached;
            const classified = context.recognizer.classifyFile({ relativePath }).isTest;
            testCache.set(relativePath, classified);
            return classified;
        };
        // Phase 1 settles what one file can decide on its own, and parks the rest.
        const files = [];
        for (const change of changes) {
            changedFiles.push(change.path);
            if (excluded?.has(change.path))
                continue;
            const previous = change.oldBlobSha && !NULL_OID.test(change.oldBlobSha)
                ? (read.entities.get(blobEntityKey(change.oldBlobSha, change.oldPath ?? change.path))?.entities ?? [])
                : [];
            const current = change.newBlobSha && !NULL_OID.test(change.newBlobSha)
                ? (read.entities.get(blobEntityKey(change.newBlobSha, change.path))?.entities ?? [])
                : change.status === 'deleted'
                    ? []
                    : await extractWorkingTreeEntities(change.path, workingTreeFiles.get(change.path), readOptions, diagnostics);
            const pool = change.oldPath && change.oldPath !== change.path ? 'git-rename' : 'same-path';
            const candidates = previous.map(record => ({
                record,
                lineageId: context.state?.byStateKey.get(entityStateKey(record)),
                pool,
            }));
            const matched = matchFileEntities({
                previous: candidates,
                current,
                pool,
                thresholds,
                sourceFor: (record, side) => {
                    const content = side === 'previous'
                        ? change.oldBlobSha
                            ? read.contents.get(change.oldBlobSha)
                            : undefined
                        : change.newBlobSha
                            ? read.contents.get(change.newBlobSha)
                            : workingTreeFiles.get(change.path)?.text;
                    return content === undefined ? undefined : sliceEntitySource(content, record);
                },
                maxSimilarityCandidates: bounds.maxSimilarityCandidates,
                counters: context.counters,
            });
            if (matched.similaritySkipped) {
                diagnostics.push({
                    code: 'similarity-skipped',
                    message: `Similarity matching skipped for "${change.path}": ${matched.unmatchedCurrent.length} new and ${matched.unmatchedPrevious.length} removed entities exceed the ${bounds.maxSimilarityCandidates} candidate bound.`,
                    relativePath: change.path,
                    total: Math.max(matched.unmatchedCurrent.length, matched.unmatchedPrevious.length),
                    limit: bounds.maxSimilarityCandidates,
                });
            }
            if (context.observeUnchangedMatch) {
                for (const match of matched.matches) {
                    const classified = classifyEntityChange({ previous: match.previous.record, current: match.current });
                    if (isUnchangedForDiff(match.previous.record, match.current, classified.changeType, classified.flags)) {
                        context.observeUnchangedMatch(match.previous.record, match.current);
                    }
                }
            }
            files.push({ path: change.path, matched });
        }
        // Phase 2. An entity that vanished from one file and appeared in another is
        // one move, and no per-file view can see that — only a pass over every
        // leftover at once can, which is exactly what the history indexer does.
        const crossFile = matchLeftoversAcrossFiles(files, thresholds, bounds, context, diagnostics);
        // Phase 3 reports per file, in the order git listed them, so a cross-file move
        // is reported against the file the entity now lives in.
        for (const file of files) {
            const { matched } = file;
            for (const match of matched.matches) {
                if (result.length >= maxEntities) {
                    omitted++;
                    continue;
                }
                const change = describeMatch(match, context, isTestPath);
                if (change)
                    result.push(change);
            }
            for (const record of matched.unmatchedCurrent) {
                const moved = crossFile.byCurrent.get(record);
                if (result.length >= maxEntities) {
                    omitted++;
                    continue;
                }
                if (moved) {
                    const change = describeMatch(moved, context, isTestPath);
                    if (change)
                        result.push(change);
                    continue;
                }
                result.push({
                    changeType: matched.refusedCurrent.has(record) ? 'orphan' : 'added',
                    structuralChange: null,
                    confidence: matched.refusedCurrent.has(record) ? 0 : 1,
                    matchReason: 'none',
                    current: refFor(record, context, isTestPath(record.relativePath)),
                    flags: matched.refusedCurrent.has(record) ? { ambiguityRefused: true } : {},
                });
            }
            for (const candidate of matched.unmatchedPrevious) {
                // Claimed by the cross-file pass, and already reported as a move against
                // the file it moved into. Reporting it here too would be the deleted half
                // of the delete-plus-add this pass exists to remove.
                if (crossFile.claimedPrevious.has(candidate.record))
                    continue;
                if (result.length >= maxEntities) {
                    omitted++;
                    continue;
                }
                const refused = matched.refusedPrevious.has(candidate.record);
                result.push({
                    changeType: refused ? 'orphan' : 'deleted',
                    structuralChange: null,
                    lineageId: context.state?.byStateKey.get(entityStateKey(candidate.record)),
                    confidence: refused ? 0 : 1,
                    matchReason: 'none',
                    previous: refFor(candidate.record, context, isTestPath(candidate.record.relativePath)),
                    flags: refused ? { ambiguityRefused: true } : {},
                });
            }
        }
        if (omitted > 0) {
            diagnostics.push({
                code: 'entities-truncated',
                message: `Reported ${result.length} entity changes; ${omitted} more were omitted at the ${maxEntities} entity limit.`,
                kept: result.length,
                total: result.length + omitted,
                limit: maxEntities,
            });
        }
        const scopeGaps = [];
        let workspaceAfter;
        let workspaceGenerationBasis;
        if (workspaceBefore) {
            try {
                await context.wholeDiffTestHooks?.beforeFinalWorkspaceSnapshot?.();
                abortIfNeeded(context.signal);
                workspaceAfter = await captureWorkspaceSnapshot(relativePath, context, {
                    includeVisibility: range.kind === 'working-tree',
                });
            }
            catch (error) {
                if (isOccAbortError(error))
                    throw error;
                if (error instanceof GitPathError)
                    throw error;
                const diagnostic = {
                    code: 'evidence-unavailable',
                    message: `Could not verify the Git workspace generation: ${error instanceof Error ? error.message : String(error)}`,
                    ...(relativePath ? { relativePath } : {}),
                };
                diagnostics.push(diagnostic);
                wholeDiagnostics.push(diagnostic);
                scopeGaps.push({ relativePath: relativePath ?? '.', basis: 'unknown-status' });
                workspaceGenerationBasis = 'unknown-status';
            }
        }
        const workspaceEntries = [...(workspaceBefore?.entries ?? []), ...(workspaceAfter?.entries ?? [])];
        for (const entry of workspaceEntries) {
            if (entry.untracked && range.kind === 'working-tree') {
                scopeGaps.push({ relativePath: entry.relativePath, basis: 'untracked-not-in-scope' });
            }
            if (entry.unmerged)
                scopeGaps.push({ relativePath: entry.relativePath, basis: 'index-unmerged' });
            if (range.kind === 'working-tree' && entry.indexChanged && entry.worktreeChanged && !entry.unmerged) {
                scopeGaps.push({ relativePath: entry.relativePath, basis: 'unknown-status' });
            }
        }
        const hiddenPaths = range.kind === 'working-tree'
            ? hiddenVisibilityPaths([workspaceBefore?.visibility, workspaceAfter?.visibility])
            : [];
        if (hiddenPaths.length > 0) {
            const limit = Math.max(0, bounds.maxFilesPerCommit);
            const retained = hiddenPaths.slice(0, limit);
            for (const hiddenPath of retained) {
                scopeGaps.push({ relativePath: hiddenPath, basis: 'index-visibility-hidden' });
            }
            const diagnostic = {
                code: 'index-visibility-hidden',
                message: `Git index visibility flags hide ${hiddenPaths.length} relevant worktree path(s); retained ${retained.length} path detail(s): ${JSON.stringify(retained)}.`,
                kept: retained.length,
                total: hiddenPaths.length,
                limit,
            };
            diagnostics.push(diagnostic);
            wholeDiagnostics.push(diagnostic);
        }
        if (workspaceBefore && workspaceAfter) {
            const contentGenerationChanges = new Set();
            for (const [workingPath, materialized] of workingTreeFiles) {
                if (!materialized.state)
                    continue;
                try {
                    const current = await lstat(path.join(context.repoRoot, workingPath));
                    if (!sameFileState(materialized.state, current))
                        contentGenerationChanges.add(workingPath);
                }
                catch {
                    contentGenerationChanges.add(workingPath);
                }
            }
            const indexGenerationChanged = !workspaceBefore.indexRaw.equals(workspaceAfter.indexRaw) ||
                workspaceBefore.indexTreeOid !== workspaceAfter.indexTreeOid;
            const statusGenerationChanged = range.kind === 'working-tree' && !workspaceBefore.statusRaw.equals(workspaceAfter.statusRaw);
            const visibilityGenerationChanged = range.kind === 'working-tree' &&
                workspaceBefore.visibility !== undefined &&
                workspaceAfter.visibility !== undefined &&
                !workspaceBefore.visibility.raw.equals(workspaceAfter.visibility.raw);
            const generationChanged = indexGenerationChanged ||
                statusGenerationChanged ||
                visibilityGenerationChanged ||
                contentGenerationChanges.size > 0;
            if (generationChanged) {
                workspaceGenerationBasis = 'concurrent-mutation';
                const changedPaths = new Set(contentGenerationChanges);
                if (indexGenerationChanged) {
                    for (const entry of workspaceEntries)
                        changedPaths.add(entry.relativePath);
                }
                if (statusGenerationChanged) {
                    for (const changedPath of changedWorkspaceStatusPaths(workspaceBefore.entries, workspaceAfter.entries)) {
                        changedPaths.add(changedPath);
                    }
                }
                if (visibilityGenerationChanged && workspaceBefore.visibility && workspaceAfter.visibility) {
                    for (const changedPath of changedVisibilityPaths(workspaceBefore.visibility, workspaceAfter.visibility)) {
                        changedPaths.add(changedPath);
                    }
                }
                if (changedPaths.size === 0)
                    changedPaths.add(relativePath ?? '.');
                for (const changedPath of [...changedPaths].sort(compareGitPaths)) {
                    scopeGaps.push({ relativePath: changedPath, basis: 'concurrent-mutation' });
                }
            }
        }
        const diagnosticsByBlob = new Map();
        const diagnosticsByPath = new Map();
        for (const diagnostic of read.diagnostics) {
            const target = diagnostic.blobSha
                ? [diagnosticsByBlob, diagnostic.blobSha]
                : diagnostic.relativePath
                    ? [diagnosticsByPath, diagnostic.relativePath]
                    : undefined;
            if (!target)
                continue;
            const [index, key] = target;
            const existing = index.get(key);
            if (existing)
                existing.push(diagnostic);
            else
                index.set(key, [diagnostic]);
        }
        const unavailableBasisFor = (blobSha, pathForSide, materialized) => {
            if (!blobSha)
                return materialized?.unavailableBasis;
            if (read.buffers.has(blobSha))
                return undefined;
            // Blob-addressed evidence wins. A path-only diagnostic is eligible only
            // when it did not name another blob at that same path.
            const candidates = diagnosticsByBlob.get(blobSha) ?? diagnosticsByPath.get(pathForSide) ?? [];
            if (candidates.some(diagnostic => diagnostic.code === 'blob-too-large' ||
                diagnostic.code === 'blob-too-many-lines' ||
                diagnostic.code === 'blob-budget-exhausted')) {
                return 'resource-bound';
            }
            return 'blob-unavailable';
        };
        const wholeFiles = changes.map(change => {
            const previousPath = change.oldPath ?? change.path;
            const materialized = workingTreeFiles.get(change.path);
            const oldContent = change.oldBlobSha ? read.buffers.get(change.oldBlobSha) : undefined;
            const newContent = change.newBlobSha ? read.buffers.get(change.newBlobSha) : materialized?.buffer;
            const oldBasis = unavailableBasisFor(change.oldBlobSha, previousPath, undefined);
            const newBasis = unavailableBasisFor(change.newBlobSha, change.path, materialized);
            // Preserve a more specific acquisition failure when the same operation
            // also changes the generation; otherwise the generation receipt is what
            // invalidates an apparently complete row (including topology rows).
            const unavailableBasis = oldBasis ?? newBasis ?? workspaceGenerationBasis;
            return {
                relativePath: change.path,
                ...(change.oldPath ? { previousPath: change.oldPath } : {}),
                status: change.status,
                ...(change.oldMode ? { oldMode: change.oldMode } : {}),
                ...(change.newMode ? { newMode: change.newMode } : {}),
                ...(oldContent ? { oldContent } : {}),
                ...(newContent ? { newContent } : {}),
                ...(unavailableBasis ? { unavailableBasis } : {}),
                maxBytes: bounds.maxBlobBytes,
                maxLines: bounds.maxBlobLines,
            };
        });
        const untrackedPresent = workspaceEntries.some(entry => entry.untracked);
        const gitScope = {
            range,
            comparison: scopeComparison(range),
            includedLayers: scopeLayers(range),
            ...(relativePath ? { relativePath } : {}),
            implicit: options.base == null && options.head == null && options.staged !== true,
            ...(workspaceBefore?.indexTreeOid ? { indexTreeOid: workspaceBefore.indexTreeOid } : {}),
            untracked: range.kind === 'working-tree'
                ? untrackedPresent
                    ? 'excluded-present'
                    : 'excluded-none-observed'
                : 'not-applicable',
        };
        abortIfNeeded(context.signal);
        const wholeDiffSemantic = await classifyWholeDiffSemantics({
            status: 'resolved',
            gitScope,
            files: wholeFiles,
            diagnostics: wholeDiagnostics,
            unclassifiedFiles: uniqueScopeGaps(scopeGaps),
        });
        abortIfNeeded(context.signal);
        const resolved = {
            status: 'resolved',
            range: {
                base: range.resolvedBase ?? range.base ?? '',
                head: range.resolvedHead ?? range.head ?? '',
                kind: range.kind,
            },
            changedFiles,
            changes: result,
            truncated: omitted > 0,
            omitted,
            diagnostics,
        };
        return { ...resolved, semantic: classifyEntityDiffSemantics(resolved), wholeDiffSemantic };
    }
    catch (error) {
        const mapped = mapEntityGitError(error);
        const untrackedPresent = workspaceBefore?.entries.some(entry => entry.untracked) ?? false;
        const gitScope = {
            range,
            comparison: scopeComparison(range),
            includedLayers: scopeLayers(range),
            ...(relativePath ? { relativePath } : {}),
            implicit: options.base == null && options.head == null && options.staged !== true,
            ...(workspaceBefore?.indexTreeOid ? { indexTreeOid: workspaceBefore.indexTreeOid } : {}),
            untracked: range.kind === 'working-tree'
                ? untrackedPresent
                    ? 'excluded-present'
                    : 'excluded-none-observed'
                : 'not-applicable',
        };
        const errorResult = {
            status: 'error',
            ...mapped,
            range: {
                base: range.resolvedBase ?? range.base ?? '',
                head: range.resolvedHead ?? range.head ?? '',
                kind: range.kind,
            },
            changedFiles: [],
            changes: [],
            truncated: false,
            omitted: 0,
            diagnostics,
        };
        return {
            ...errorResult,
            semantic: classifyEntityDiffSemantics(errorResult),
            wholeDiffSemantic: await classifyWholeDiffSemantics({
                status: 'error',
                gitScope,
                files: [],
                diagnostics: wholeDiagnostics,
            }),
        };
    }
}
function sliceEntitySource(content, record) {
    const exact = exactSourceForRecord(content, record);
    if (exact !== undefined)
        return exact;
    const lines = content.split(/\r?\n/u);
    if (record.startLine > lines.length)
        return undefined;
    return lines.slice(record.startLine - 1, record.endLine).join('\n');
}
/**
 * Turn one settled pairing into a reportable change, or nothing.
 *
 * Shared by the per-file and cross-file passes so a move is classified by the
 * same precedence as every other pairing — the only difference between them is
 * which pool produced the candidate.
 */
function describeMatch(match, context, isTestPath) {
    const { changeType, flags } = classifyEntityChange({ previous: match.previous.record, current: match.current });
    if (isUnchangedForDiff(match.previous.record, match.current, changeType, flags))
        return undefined;
    // The previous side first, because that is the identity the lineage had going
    // in. A move or a rename retires that slot in the indexed head, so fall back
    // to where the entity now lives — otherwise exactly the changes that most need
    // a durable id are the ones reported without one.
    const lineageId = context.state?.byStateKey.get(entityStateKey(match.previous.record)) ??
        context.state?.byStateKey.get(entityStateKey(match.current));
    return {
        changeType,
        structuralChange: structuralTruth(flags),
        lineageId,
        confidence: MATCH_CONFIDENCE[match.reason],
        matchReason: match.reason,
        previous: refFor(match.previous.record, context, isTestPath(match.previous.record.relativePath)),
        current: refFor(match.current, context, isTestPath(match.current.relativePath)),
        flags,
    };
}
/**
 * The cross-file pass — the only place a diff may report an entity as moved.
 *
 * It sees leftovers only, so it can never take a pairing a per-file rung already
 * claimed, and it rejects same-path pairings because the per-file pass has
 * already ruled on those. `pool: 'cross-file'` gates off the qualified-name and
 * signature-name rungs inside `matchFileEntities`, so a move is accepted on hash
 * evidence alone and never on a shared name — across arbitrary files, two
 * entities called `parse` are not evidence of anything.
 *
 * No `sourceFor` is supplied, so the similarity rung does not run here. That is
 * deliberate: this path answers with no index behind it, and the rung that costs
 * the most and concludes the least is the wrong one to add to it. A genuine move
 * carries an identical or near-identical body, which the hash rungs already
 * catch.
 */
function matchLeftoversAcrossFiles(files, thresholds, bounds, context, diagnostics) {
    const claims = { byCurrent: new Map(), claimedPrevious: new Set() };
    const births = [];
    const deaths = [];
    for (const file of files) {
        births.push(...file.matched.unmatchedCurrent);
        for (const candidate of file.matched.unmatchedPrevious) {
            deaths.push({ ...candidate, pool: 'cross-file' });
        }
    }
    if (births.length === 0 || deaths.length === 0)
        return claims;
    const limit = bounds.maxCrossFileCandidates;
    if (births.length > limit || deaths.length > limit) {
        // Sampling the pool would make the outcome depend on iteration order, so a
        // range this large declines the pass outright and says so.
        diagnostics.push({
            code: 'cross-file-skipped',
            message: `Cross-file matching skipped: ${births.length} new and ${deaths.length} removed entities exceed the ${limit} candidate bound.`,
            total: Math.max(births.length, deaths.length),
            limit,
        });
        return claims;
    }
    const matched = matchFileEntities({
        previous: deaths,
        current: births,
        pool: 'cross-file',
        thresholds,
        counters: context.counters,
    });
    for (const match of matched.matches) {
        if (match.previous.record.relativePath === match.current.relativePath)
            continue;
        claims.byCurrent.set(match.current, match);
        claims.claimedPrevious.add(match.previous.record);
    }
    return claims;
}
/**
 * A pairing with no reportable change.
 *
 * A diff between two revisions must not list entities that merely shifted; the
 * indexer's `isUnchanged` cannot be reused directly because it has no view of
 * the file's declaration order, and here a bare index shift is not evidence of
 * a reordering either.
 */
function isUnchangedForDiff(previous, current, changeType, flags) {
    if (changeType === 'reordered' && !flags.bodyChanged && !flags.exportChanged)
        return true;
    return (previous.relativePath === current.relativePath &&
        previous.qualifiedName === current.qualifiedName &&
        previous.kind === current.kind &&
        !flags.bodyChanged &&
        previous.isExported === current.isExported);
}
async function extractWorkingTreeEntities(relativePath, materialized, options, diagnostics) {
    if (!materialized?.text)
        return [];
    try {
        abortIfNeeded(options.signal);
        const blob = await extractEntitiesFromBlob({
            repoRoot: options.repoRoot,
            relativePath,
            // Not a blob id: the working tree has no object. Prefixed so it can never
            // collide with a real one and never be persisted as content-addressed.
            blobSha: `worktree:${relativePath}`,
            content: materialized.text,
            byteLength: materialized.buffer?.length ?? Buffer.byteLength(materialized.text, 'utf8'),
            guardrails: options.guardrails,
            bounds: options.bounds,
            counters: options.counters,
        });
        abortIfNeeded(options.signal);
        return blob.entities;
    }
    catch (error) {
        if (isOccAbortError(error))
            throw error;
        diagnostics.push({
            code: 'evidence-unavailable',
            message: `Could not extract working-tree entities from ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
            relativePath,
        });
        return [];
    }
}
//# sourceMappingURL=diff.js.map