import path from 'node:path';
import { z } from 'zod';
import { estimateTokenCount } from '../tokens.js';
import { compareCodeUnits, formatPayloadJson } from '../utils.js';
import { getGitDiffFunctionContext, getGitDiffHunks, getGitDiffSummary } from '../git/api.js';
import { CodeCapabilitiesSchema, NormalizedSymbolKindSchema, toNormalizedSymbolKind } from './types.js';
import { rankNodesCached, RankOptionsSchema, RankResultSchema } from './rank.js';
import { GitDiffHunksSchema, GitRangeModeSchema, GitRangeSchema } from '../git/types.js';
export const ChangedSymbolSchema = z.object({
    nodeId: z.string().optional(),
    kind: NormalizedSymbolKindSchema,
    name: z.string(),
    containerName: z.string().optional(),
    file: z.string(),
    relativePath: z.string(),
    line: z.number().optional(),
    endLine: z.number().optional(),
    endLineApprox: z.boolean().optional(),
    changeKind: z.enum(['added', 'modified', 'removed', 'signature']),
    churn: z.object({ added: z.number(), deleted: z.number() }),
    rank: z.number(),
    isExported: z.boolean(),
    isNewExport: z.boolean(),
    signature: z.string().optional(),
    provenance: z.enum(['index', 'function-context', 'hunk-scan', 'base-index']),
});
export const FileChangeSchema = z.object({
    changeType: z.enum(['added', 'modified', 'deleted', 'renamed', 'copied']),
    path: z.string(),
    relativePath: z.string(),
    oldPath: z.string().optional(),
    churn: z.object({ added: z.number(), deleted: z.number(), binary: z.boolean() }),
    topLevelChurn: z.object({ added: z.number(), deleted: z.number() }),
    importsChanged: z.boolean(),
    symbolCount: z.number(),
    rank: z.number(),
});
export const CodeDiffResultSchema = z.object({
    range: GitRangeSchema,
    repoRoot: z.string(),
    files: z.array(FileChangeSchema),
    symbols: z.array(ChangedSymbolSchema),
    summary: z.object({
        filesChanged: z.number(),
        symbolsChanged: z.number(),
        added: z.number(),
        removed: z.number(),
        modified: z.number(),
        signatureChanges: z.number(),
        newExports: z.number(),
        removedExports: z.number(),
        totalChurn: z.object({ added: z.number(), deleted: z.number() }),
        droppedSymbols: z.number(),
        approxEndLines: z.number(),
    }),
    tokenizer: z.string().optional(),
    tokenBudget: z.number().optional(),
    tokensUsed: z.number(),
    truncated: z.boolean(),
    unmappedHunks: z.number(),
    capabilities: z.record(z.string(), CodeCapabilitiesSchema),
});
export const CodeDiffOptionsSchema = z.object({
    base: z.string().optional(),
    head: z.string().optional(),
    staged: z.boolean().optional(),
    /** How an explicit base/head is compared; `merge-base` by default. */
    rangeMode: GitRangeModeSchema.optional(),
    paths: z.array(z.string()).optional(),
    excludePaths: z.array(z.string()).optional(),
    mapTokens: z.number().int().positive().optional(),
    tokenizer: z.string().optional(),
    outputFormat: z.enum(['text', 'json']).optional(),
    pretty: z.boolean().optional(),
    rank: RankOptionsSchema.optional(),
});
const JOINABLE_NODE_TYPES = new Set(['function', 'class', 'interface', 'type-alias', 'enum', 'variable']);
function normalizeRelative(filePath) {
    return filePath.split(path.sep).join('/');
}
function absolutePathForGitPath(index, gitPath) {
    return path.resolve(index.repoRoot, gitPath);
}
function nodeDepth(node, byId) {
    let depth = 0;
    let current = node;
    const seen = new Set();
    while (current.containerId && !seen.has(current.containerId)) {
        seen.add(current.containerId);
        const parent = byId.get(current.containerId);
        if (!parent)
            break;
        depth++;
        current = parent;
    }
    return depth;
}
function fileLineCounts(index) {
    const counts = new Map();
    for (const file of index.files) {
        counts.set(file.path, file.lines?.length ?? 0);
    }
    return counts;
}
function buildIntervals(index) {
    const byId = new Map(index.nodes.map(node => [node.id, node]));
    const lineCounts = fileLineCounts(index);
    const byPath = new Map();
    for (const node of index.nodes) {
        if (!JOINABLE_NODE_TYPES.has(node.type))
            continue;
        if (node.line == null)
            continue;
        const relativePath = node.relativePath ?? normalizeRelative(path.relative(index.repoRoot, node.path));
        const interval = {
            node,
            start: node.line,
            end: node.endLine ?? node.line,
            endIsExact: node.endLine != null,
            depth: nodeDepth(node, byId),
            relativePath,
        };
        const existing = byPath.get(node.path) ?? [];
        existing.push(interval);
        byPath.set(node.path, existing);
    }
    for (const [filePath, intervals] of byPath) {
        intervals.sort((a, b) => a.start - b.start || b.end - a.end);
        const lineCount = lineCounts.get(filePath) ?? 0;
        for (let i = 0; i < intervals.length; i++) {
            const current = intervals[i];
            if (current.endIsExact) {
                current.end = lineCount > 0 ? Math.min(current.end, lineCount) : current.end;
                continue;
            }
            const next = intervals
                .slice(i + 1)
                .find(candidate => candidate.start > current.start && candidate.depth <= current.depth);
            const synthesized = next ? next.start - 1 : current.start;
            current.end =
                lineCount > 0
                    ? Math.min(Math.max(current.start, synthesized), lineCount)
                    : Math.max(current.start, synthesized);
        }
    }
    return byPath;
}
function allAddedLinesCover(interval, addedLines) {
    const added = new Set(addedLines);
    for (let line = interval.start; line <= interval.end; line++) {
        if (!added.has(line))
            return false;
    }
    return true;
}
function hasDeletedLineInside(interval, deletedLines) {
    return deletedLines.some(line => interval.start <= line && line <= interval.end);
}
function classifyChange(interval, hunk) {
    if (hunk.newLines > 0 &&
        hunk.addedLines.includes(interval.start) &&
        interval.start >= hunk.newStart &&
        interval.start <= hunk.newEnd &&
        (allAddedLinesCover(interval, hunk.addedLines) || !hasDeletedLineInside(interval, hunk.deletedLines))) {
        return 'added';
    }
    if (hunk.addedLines.includes(interval.start) || hunk.deletedLines.includes(interval.start)) {
        return 'signature';
    }
    return 'modified';
}
function changePriority(kind) {
    switch (kind) {
        case 'added':
            return 3;
        case 'signature':
            return 2;
        case 'modified':
            return 1;
        case 'removed':
            return 0;
    }
}
function intervalsForHunk(intervals, hunk) {
    if (hunk.newLines === 0) {
        const point = hunk.newStart;
        const containing = intervals
            .filter(interval => interval.start <= point && point <= interval.end)
            .sort((a, b) => b.start - a.start || a.end - b.end);
        if (containing[0])
            return [containing[0]];
        const preceding = intervals
            .filter(interval => interval.end < point)
            .sort((a, b) => b.end - a.end || b.start - a.start);
        return preceding[0] ? [preceding[0]] : [];
    }
    return intervals.filter(interval => interval.start <= hunk.newEnd && hunk.newStart <= interval.end);
}
function sortScore(symbol) {
    return (symbol.rank +
        (symbol.changeKind === 'signature' ? 15 : 0) +
        (symbol.isExported ? 10 : 0) +
        (symbol.changeKind === 'added' || symbol.changeKind === 'removed' ? 5 : 0));
}
function sortChangedSymbols(symbols) {
    return symbols.sort((a, b) => sortScore(b) - sortScore(a) || compareCodeUnits(a.relativePath, b.relativePath) || (a.line ?? 0) - (b.line ?? 0));
}
function symbolIdentity(symbol) {
    return `${symbol.relativePath}\0${symbol.kind}\0${symbol.name}`;
}
function inferLanguage(relativePath) {
    switch (path.extname(relativePath).toLowerCase()) {
        case '.js':
        case '.jsx':
        case '.mjs':
        case '.cjs':
            return 'javascript';
        case '.ts':
        case '.tsx':
        case '.mts':
        case '.cts':
            return 'typescript';
        case '.py':
            return 'python';
        case '.go':
            return 'go';
        case '.rs':
            return 'rust';
        case '.vue':
            return 'vue';
        default:
            return '';
    }
}
const TS_DECLARATION_PATTERNS = [
    { type: 'function', pattern: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\b/ },
    { type: 'class', pattern: /^(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)\b/ },
    { type: 'interface', pattern: /^(?:export\s+)?(?:default\s+)?interface\s+([A-Za-z_$][\w$]*)\b/ },
    { type: 'type-alias', pattern: /^(?:export\s+)?(?:default\s+)?type\s+([A-Za-z_$][\w$]*)\b/ },
    { type: 'enum', pattern: /^(?:export\s+)?(?:default\s+)?enum\s+([A-Za-z_$][\w$]*)\b/ },
    { type: 'variable', pattern: /^(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/ },
];
const PYTHON_DECLARATION_PATTERNS = [
    { type: 'function', pattern: /^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
    { type: 'class', pattern: /^class\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
];
const GO_DECLARATION_PATTERNS = [
    { type: 'function', pattern: /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\b/ },
    { type: 'class', pattern: /^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+(?:struct|interface)\b/ },
    { type: 'variable', pattern: /^(?:var|const)\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
];
const RUST_DECLARATION_PATTERNS = [
    { type: 'function', pattern: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
    { type: 'class', pattern: /^(?:pub(?:\([^)]*\))?\s+)?(?:struct|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
    { type: 'enum', pattern: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
    { type: 'variable', pattern: /^(?:pub(?:\([^)]*\))?\s+)?(?:let|const|static)\s+([A-Za-z_][A-Za-z0-9_]*)\b/ },
];
function declarationPatternsForLanguage(language) {
    switch (language) {
        case 'javascript':
        case 'typescript':
        case 'vue':
            return TS_DECLARATION_PATTERNS;
        case 'python':
            return PYTHON_DECLARATION_PATTERNS;
        case 'go':
            return GO_DECLARATION_PATTERNS;
        case 'rust':
            return RUST_DECLARATION_PATTERNS;
        default:
            return TS_DECLARATION_PATTERNS;
    }
}
function isExportedDeclaration(text, language) {
    if (language === 'rust')
        return /^(?:pub|pub\([^)]*\))\b/.test(text);
    return /^export\b/.test(text);
}
function scanRemovedDeclaration(text, language) {
    const trimmed = text.trim();
    if (!trimmed)
        return undefined;
    for (const { type, pattern } of declarationPatternsForLanguage(language)) {
        const match = trimmed.match(pattern);
        const name = match?.[1];
        if (!name)
            continue;
        return {
            type,
            name,
            isExported: isExportedDeclaration(trimmed, language),
            signature: trimmed,
        };
    }
    return undefined;
}
function recoverRemovedSymbols(index, functionContext, existingSymbols) {
    const liveKeys = new Set();
    const languageByPath = new Map(index.files.map(file => [file.path, file.language]));
    for (const node of index.nodes) {
        if (!JOINABLE_NODE_TYPES.has(node.type))
            continue;
        const relativePath = node.relativePath ?? normalizeRelative(path.relative(index.repoRoot, node.path));
        liveKeys.add(symbolIdentity({
            relativePath,
            kind: toNormalizedSymbolKind(node.type, node.containerName),
            name: node.name,
        }));
    }
    const existingKeys = new Set(existingSymbols.map(symbol => symbolIdentity(symbol)));
    const recovered = new Map();
    for (const file of functionContext.files) {
        const relativePath = file.oldPath ?? file.path;
        const absolutePath = absolutePathForGitPath(index, relativePath);
        const language = languageByPath.get(absolutePath) ?? inferLanguage(relativePath);
        for (const hunk of file.hunks) {
            for (const line of hunk.deletedLineTexts ?? []) {
                const declaration = scanRemovedDeclaration(line.text, language);
                if (!declaration)
                    continue;
                const kind = toNormalizedSymbolKind(declaration.type);
                const identity = symbolIdentity({ relativePath, kind, name: declaration.name });
                if (liveKeys.has(identity) || existingKeys.has(identity))
                    continue;
                const previous = recovered.get(identity);
                if (previous) {
                    previous.churn.deleted += hunk.deletedLines.length;
                    continue;
                }
                recovered.set(identity, {
                    kind,
                    name: declaration.name,
                    file: absolutePath,
                    relativePath,
                    line: line.line,
                    endLine: line.line,
                    changeKind: 'removed',
                    churn: { added: 0, deleted: hunk.deletedLines.length },
                    rank: 0,
                    isExported: declaration.isExported,
                    isNewExport: false,
                    signature: declaration.signature,
                    provenance: 'hunk-scan',
                });
            }
        }
    }
    return sortChangedSymbols([...recovered.values()]);
}
export function codeDiffSymbolLine(symbol) {
    const marker = symbol.changeKind === 'added'
        ? '+ added'
        : symbol.changeKind === 'removed'
            ? '- removed'
            : symbol.changeKind === 'signature'
                ? '~ signature'
                : '~ modified';
    const exported = symbol.isExported ? ', exported' : '';
    const approx = symbol.endLineApprox ? ', approx-end' : '';
    const location = symbol.line ? `${symbol.relativePath}:${symbol.line}` : symbol.relativePath;
    return `${marker} ${symbol.kind} ${symbol.name} (${location}) [rank ${symbol.rank.toFixed(1)}${exported}${approx}] +${symbol.churn.added}/-${symbol.churn.deleted}`;
}
/** Plain rendering charged by `computeCodeDiff` when `outputFormat` is text. */
export function renderCodeDiffText(result) {
    const lines = [
        `# Changed symbols (${result.range.describe})`,
        `${result.summary.filesChanged} files · ${result.summary.symbolsChanged} symbols · +${result.summary.totalChurn.added}/-${result.summary.totalChurn.deleted} · ${result.summary.newExports} new exports · ${result.summary.signatureChanges} signature changes`,
    ];
    if (result.truncated)
        lines.push(`truncated: dropped ${result.summary.droppedSymbols} symbol(s)`);
    let currentFile = '';
    for (const symbol of result.symbols) {
        if (symbol.relativePath !== currentFile) {
            currentFile = symbol.relativePath;
            lines.push('', `## ${currentFile}`);
        }
        lines.push(`  ${codeDiffSymbolLine(symbol)}`);
    }
    if (result.symbols.length === 0)
        lines.push('', 'No changed symbols admitted within the token budget.');
    return `${lines.join('\n')}\n`;
}
/** JSON serializer used both for budget admission and public rendering. */
export function renderCodeDiffJson(result, pretty = false) {
    return `${formatPayloadJson(result, pretty)}\n`;
}
function measureCodeDiff(result, format, countTokens, pretty = false) {
    const render = format === 'json' ? (value) => renderCodeDiffJson(value, pretty) : renderCodeDiffText;
    const seen = new Set();
    for (let attempt = 0; attempt < 32; attempt++) {
        const measured = countTokens(render(result));
        if (measured === result.tokensUsed)
            return measured;
        const state = `${result.tokensUsed}:${measured}`;
        if (seen.has(state)) {
            throw new Error('Could not stabilize code-diff token accounting; choose a deterministic tokenizer.');
        }
        seen.add(state);
        result.tokensUsed = measured;
    }
    throw new Error('Could not stabilize code-diff token accounting after 32 passes.');
}
export function joinHunksToSymbols(_index, _hunks, _rank) {
    return joinHunksToSymbolsWithStats(_index, _hunks, _rank).symbols;
}
/**
 * Join hunks to symbols and, alongside the changed symbols, report how many
 * hunks matched no indexed symbol interval. A hunk can overlap several
 * intervals, so this cannot be derived from the symbol count — it is counted
 * directly here where each hunk's interval matches are known.
 */
export function joinHunksToSymbolsWithStats(_index, _hunks, _rank) {
    GitDiffHunksSchema.parse(_hunks);
    RankResultSchema.parse(_rank);
    const intervalsByPath = buildIntervals(_index);
    const indexedPaths = new Set(_index.files.map(file => file.path));
    const rankByNodeId = new Map(_rank.nodes.map(node => [node.nodeId, node]));
    const changed = new Map();
    const hitsByFile = new Map();
    let unmappedHunks = 0;
    for (const file of _hunks.files) {
        const absolutePath = absolutePathForGitPath(_index, file.path);
        const intervals = intervalsByPath.get(absolutePath) ?? [];
        let fileHits = 0;
        for (const hunk of file.hunks) {
            const matches = intervalsForHunk(intervals, hunk);
            if (matches.length === 0)
                unmappedHunks++;
            fileHits += matches.length;
            for (const interval of matches) {
                const ranked = rankByNodeId.get(interval.node.id);
                const nextKind = classifyChange(interval, hunk);
                const existing = changed.get(interval.node.id);
                if (existing) {
                    existing.churn.added += hunk.addedLines.length;
                    existing.churn.deleted += hunk.deletedLines.length;
                    if (changePriority(nextKind) > changePriority(existing.changeKind)) {
                        existing.changeKind = nextKind;
                        existing.isNewExport = nextKind === 'added' && existing.isExported;
                    }
                    continue;
                }
                const isExported = ranked?.isExported ?? false;
                const symbol = {
                    nodeId: interval.node.id,
                    kind: toNormalizedSymbolKind(interval.node.type, interval.node.containerName),
                    name: interval.node.name,
                    file: interval.node.path,
                    relativePath: interval.node.relativePath ?? interval.relativePath,
                    line: interval.start,
                    endLine: interval.end,
                    endLineApprox: !interval.endIsExact,
                    changeKind: nextKind,
                    churn: { added: hunk.addedLines.length, deleted: hunk.deletedLines.length },
                    rank: ranked?.normalizedScore ?? 0,
                    isExported,
                    isNewExport: nextKind === 'added' && isExported,
                    provenance: 'index',
                };
                if (interval.node.containerName)
                    symbol.containerName = interval.node.containerName;
                if (interval.node.signature)
                    symbol.signature = interval.node.signature;
                changed.set(interval.node.id, symbol);
            }
        }
        hitsByFile.set(absolutePath, fileHits);
        if (file.hunks.length > 0 && indexedPaths.has(absolutePath) && intervals.length > 0 && fileHits === 0) {
            console.warn(`occ code diff: changed file ${file.path} has hunks but yielded zero symbol hits`);
        }
    }
    return { symbols: sortChangedSymbols([...changed.values()]), unmappedHunks };
}
export async function computeCodeDiff(_index, _options) {
    const gitOptions = {
        repoRoot: _index.repoRoot,
        base: _options.base,
        head: _options.head,
        staged: _options.staged,
        rangeMode: _options.rangeMode,
        paths: _options.paths,
        excludePaths: _options.excludePaths,
        signal: _options.signal,
    };
    const [summary, hunks, functionContext] = await Promise.all([
        getGitDiffSummary(gitOptions),
        getGitDiffHunks(gitOptions),
        getGitDiffFunctionContext(gitOptions),
    ]);
    const rank = rankNodesCached(_index, _options.rank);
    const { symbols: joinedSymbols, unmappedHunks } = joinHunksToSymbolsWithStats(_index, hunks, rank);
    const allSymbols = sortChangedSymbols([
        ...joinedSymbols,
        ...recoverRemovedSymbols(_index, functionContext, joinedSymbols),
    ]);
    const countTokens = _options.countTokens ?? ((text) => estimateTokenCount(text));
    const tokenBudget = _options.mapTokens;
    const outputFormat = _options.outputFormat ?? 'text';
    const pretty = _options.pretty ?? false;
    const fileScoreMap = new Map(rank.fileScores.map(file => [file.path, file]));
    const numstatByPath = new Map(summary.numstat.map(entry => [entry.path, entry]));
    const symbolCounts = new Map();
    for (const symbol of allSymbols) {
        symbolCounts.set(symbol.relativePath, (symbolCounts.get(symbol.relativePath) ?? 0) + 1);
    }
    const files = summary.files.map((file) => {
        const absolutePath = path.resolve(_index.repoRoot, file.path);
        const churn = numstatByPath.get(file.path);
        return {
            changeType: file.status === 'type-changed' || file.status === 'unmerged' || file.status === 'unknown'
                ? 'modified'
                : file.status,
            path: absolutePath,
            relativePath: file.path,
            oldPath: file.oldPath,
            churn: {
                added: churn?.added ?? 0,
                deleted: churn?.deleted ?? 0,
                binary: churn?.binary ?? file.binary ?? false,
            },
            topLevelChurn: { added: 0, deleted: 0 },
            importsChanged: false,
            symbolCount: symbolCounts.get(file.path) ?? 0,
            rank: fileScoreMap.get(absolutePath)?.normalizedScore ?? 0,
        };
    });
    const fixedSummary = {
        filesChanged: summary.filesChanged,
        symbolsChanged: allSymbols.length,
        added: allSymbols.filter(symbol => symbol.changeKind === 'added').length,
        removed: allSymbols.filter(symbol => symbol.changeKind === 'removed').length,
        modified: allSymbols.filter(symbol => symbol.changeKind === 'modified').length,
        signatureChanges: allSymbols.filter(symbol => symbol.changeKind === 'signature').length,
        newExports: allSymbols.filter(symbol => symbol.isNewExport).length,
        removedExports: allSymbols.filter(symbol => symbol.changeKind === 'removed' && symbol.isExported).length,
        totalChurn: { added: summary.totalAdded, deleted: summary.totalDeleted },
        approxEndLines: allSymbols.filter(symbol => symbol.endLineApprox).length,
    };
    const makeResult = (symbols) => {
        const droppedSymbols = allSymbols.length - symbols.length;
        const result = {
            range: summary.range,
            repoRoot: _index.repoRoot,
            files,
            symbols,
            summary: {
                ...fixedSummary,
                droppedSymbols,
            },
            tokenizer: _options.tokenizer,
            tokenBudget,
            tokensUsed: 0,
            truncated: droppedSymbols > 0,
            unmappedHunks,
            capabilities: _index.capabilities,
        };
        measureCodeDiff(result, outputFormat, countTokens, pretty);
        return result;
    };
    let result = makeResult([]);
    if (tokenBudget != null && result.tokensUsed > tokenBudget) {
        throw new Error(`The empty ${outputFormat} code-diff envelope requires ${result.tokensUsed} tokens, exceeding the ${tokenBudget}-token budget; increase --map-tokens${outputFormat === 'json' ? ' or use text output' : ''}.`);
    }
    const admitted = [];
    for (const symbol of allSymbols) {
        const trial = makeResult([...admitted, symbol]);
        if (tokenBudget != null && trial.tokensUsed > tokenBudget)
            continue;
        admitted.push(symbol);
        result = trial;
    }
    if (admitted.length === 0 && allSymbols.length === 0)
        result = makeResult([]);
    return result;
}
//# sourceMappingURL=diff.js.map