import { classifyDisposition, dispositionToLabel } from './disposition-taxonomy.js';
import { normalizeMatchedLine } from './windtunnel-firing.js';
/**
 * Extract the normalized ADDED (`+`) post-image rows of a unified-diff hunk.
 * Only `+`-prefixed content rows are eligible: context rows (leading space),
 * removed rows (`-`), the hunk header (`@@`), file headers (`+++`/`---`), and
 * the no-newline marker (`\`) are all excluded. Each eligible row is stripped of
 * its leading `+` and normalized with `normalizeMatchedLine` (the SAME rule the
 * firing's `matchedLine` is built with) so the content bind keys on identical
 * bytes.
 */
function addedHunkLines(diffHunk) {
    const added = new Set();
    for (const row of diffHunk.split('\n')) {
        if (row.charCodeAt(0) !== 0x2b /* '+' */)
            continue; // added rows only
        // The unified-diff file header is "+++ <path>" — three '+' THEN A SPACE. A real
        // added line such as `++i` appears as the diff row "+++i" (no space) and MUST
        // bind, so match the header by its trailing space, not a bare `+++` prefix (CR).
        if (/^\+\+\+ /.test(row))
            continue; // file header, not added content
        added.add(normalizeMatchedLine(row.slice(1)));
    }
    return added;
}
/**
 * Derive the cert-run ground-truth answer key from enumerated firings + frozen
 * held-out dispositions. Pure + deterministic — no I/O, no clock, no LLM.
 */
export function deriveLabelsFromDispositions(firings, dispositions) {
    // Index dispositions by PR. Fail loud on a duplicate-PR entry (greptile): a producer
    // bug in `fetch-dispositions`, or a partially-valid manual edit that still passes the
    // re-stamped `corpusDispositionsSha` gate, could ship two entries for one PR — and a
    // silent last-wins Map would drop the first entry's threads, so firings that should
    // bind to them get a wrong `no-matching-disposition` with no diagnostic. A structural
    // input-contract violation throws (Tenet 4); only DATA ambiguity fails soft to UNLABELED.
    const dispByPr = new Map();
    for (const d of dispositions) {
        if (dispByPr.has(d.pr)) {
            throw new Error(`deriveLabelsFromDispositions: duplicate disposition entry for PR ${d.pr} — the ` +
                `dispositions array must carry exactly one entry per PR (a producer or edit bug).`);
        }
        dispByPr.set(d.pr, d);
    }
    // Memoize the parsed added-line set per disposition THREAD: the span-join scans every
    // thread for each corpus firing, so without this `addedHunkLines` would re-split +
    // re-normalize the same `diffHunk` once per firing (GCA). The WeakMap keys on the
    // thread object, so it's GC-friendly and stays internal to this call (still pure).
    const addedLinesCache = new WeakMap();
    const getAddedLines = (thread) => {
        let cached = addedLinesCache.get(thread);
        if (!cached) {
            cached = addedHunkLines(thread.diffHunk);
            addedLinesCache.set(thread, cached);
        }
        return cached;
    };
    const labels = {};
    const evidence = [];
    const perRuleLabeled = {};
    const labelCounts = { TP: 0, FP: 0 };
    const unlabeledByReason = {
        'no-matching-disposition': 0,
        'ambiguous-multiple-dispositions': 0,
        'unlabeled-class': 0,
        'incidental-positive': 0,
    };
    let negativeFirings = 0;
    let corpusFirings = 0;
    let positiveFirings = 0;
    let boundCorpusFirings = 0;
    const emit = (firing, label, ev) => {
        labels[firing.labelId] = label;
        labelCounts[label] += 1;
        (perRuleLabeled[firing.ruleId] ??= { TP: 0, FP: 0 })[label] += 1;
        evidence.push(ev);
    };
    for (const firing of firings) {
        switch (firing.controlKind) {
            case 'negative':
                // Negative controls never label — the scorer culls the firing rule (S2/C5).
                negativeFirings += 1;
                break;
            case 'positive': {
                positiveFirings += 1;
                // TP structurally ONLY for the declared (pr, targetRuleId) target firing
                // (codex BLOCKING-1). An incidental non-target firing on a positive
                // fixture is NOT laundered TP — omit it and report; the scorer routes the
                // un-keyed firing to needsAdjudication.
                if (firing.targetRuleId !== undefined && firing.ruleId === firing.targetRuleId) {
                    emit(firing, 'TP', {
                        labelId: firing.labelId,
                        label: 'TP',
                        pr: firing.pr,
                        ruleId: firing.ruleId,
                        filePath: firing.filePath,
                        source: 'positive-control-target',
                    });
                }
                else {
                    unlabeledByReason['incidental-positive'] += 1;
                }
                break;
            }
            case 'corpus': {
                corpusFirings += 1;
                const disp = dispByPr.get(firing.pr);
                const bound = disp
                    ? disp.threads.filter((t) => t.path.replace(/\\/g, '/') === firing.filePath &&
                        getAddedLines(t).has(firing.matchedLine))
                    : [];
                if (bound.length !== 1) {
                    unlabeledByReason[bound.length === 0 ? 'no-matching-disposition' : 'ambiguous-multiple-dispositions'] += 1;
                    break;
                }
                const thread = bound[0];
                const label = dispositionToLabel(classifyDisposition(thread.comments));
                if (label === null) {
                    unlabeledByReason['unlabeled-class'] += 1;
                    break;
                }
                boundCorpusFirings += 1;
                emit(firing, label, {
                    labelId: firing.labelId,
                    label,
                    pr: firing.pr,
                    ruleId: firing.ruleId,
                    filePath: firing.filePath,
                    threadId: thread.threadId,
                    commentId: thread.comments[0]?.commentId,
                    source: 'corpus-disposition',
                });
                break;
            }
        }
    }
    const unlabeledFirings = unlabeledByReason['no-matching-disposition'] +
        unlabeledByReason['ambiguous-multiple-dispositions'] +
        unlabeledByReason['unlabeled-class'] +
        unlabeledByReason['incidental-positive'];
    const scoredDenominator = corpusFirings + positiveFirings;
    return {
        labels,
        evidence,
        diagnostics: {
            totalFirings: firings.length,
            negativeFirings,
            corpusFirings,
            positiveFirings,
            boundCorpusFirings,
            dispositionDensity: corpusFirings === 0 ? 0 : boundCorpusFirings / corpusFirings,
            unlabeledFirings,
            unlabeledRate: scoredDenominator === 0 ? 0 : unlabeledFirings / scoredDenominator,
            labelCounts,
            perRuleLabeled,
            unlabeledByReason,
        },
    };
}
//# sourceMappingURL=derive-labels.js.map