import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { z } from 'zod';
import { buildCertifyingCorpus } from './spine-cert-corpus.js';
import { buildReplayAdapters } from './spine-cert-record.js';
import { ReplayArtifactSchema } from './spine-llm-replay.js';
// ─── Fixture file names under the gate-1 dir ─────────
const SPLIT_FILE = 'split.json';
/** The frozen llm-replay fixture — produced by `record` (A2), sealed into the lock by `freeze`. */
export const REPLAY_FILE = 'llm-replay.v1.json';
const CONTENT_FILE = 'review-content.json';
export const PR_DIFFS_FILE = 'pr-diffs.json';
/** The cert-run answer key — produced by `derive-labels` (5d-iii), read by the run. */
export const GROUND_TRUTH_FILE = 'ground-truth-labels.json';
/** The frozen held-out disposition provenance — produced by `fetch-dispositions` (5d-ii). */
export const CORPUS_DISPOSITIONS_FILE = 'corpus-dispositions.json';
const LEDGERS_FILE = 'miner-ledgers.json';
// ─── Fixture schemas (the committed cert-run inputs) ──
const ReviewThreadContentSchema = z.object({
    pr: z.number().int().positive(),
    mergeCommitSha: z.string().regex(/^[0-9a-f]{40}$/),
    threads: z.array(z.object({
        path: z.string(),
        comments: z.array(z.object({ author: z.string(), body: z.string() })),
        isResolved: z.boolean(),
        isOutdated: z.boolean(),
    })),
});
const ResolvedPrDiffSchema = z.object({
    pr: z.number().int().positive(),
    diff: z.string(),
    controlKind: z.enum(['corpus', 'positive', 'negative']),
    targetRuleId: z.string().optional(),
});
// firingLabelId → TP|FP
const GroundTruthSchema = z.record(z.enum(['TP', 'FP']));
/** A zero-network ReviewThreadSource backed by committed, frozen review content. */
function frozenSourceFrom(contents) {
    const byPr = new Map(contents.map((c) => [c.pr, c]));
    return {
        async fetch(pr) {
            const content = byPr.get(pr);
            return content
                ? { kind: 'ok', content }
                : { kind: 'unreachable', detail: `no frozen review content for pr ${pr}` };
        },
    };
}
/**
 * Load + validate the committed cert-run fixture inputs from the gate-1 dir.
 * Async so the `@mmnto/totem` runtime values (schema + error class) are
 * dynamically imported per the CLI lazy-import convention, not pulled in at
 * module load.
 */
export async function loadCertRunFixtures(gate1Dir, opts) {
    const { SplitArtifactSchema, TotemError, classifyAuthorKind, normalizeReviewChrome } = await import('@mmnto/totem');
    const { enrichComment } = await import('./spine-review-thread-source.js');
    const enrich = { classifyAuthorKind, normalizeReviewChrome };
    const readRaw = (file) => {
        try {
            return fs.readFileSync(file, 'utf-8');
        }
        catch (err) {
            throw new TotemError('CONFIG_INVALID', `Cert-run fixture missing: ${file}`, 'Ensure the gate-1 fixture set exists and is readable.', err);
        }
    };
    const parseJson = (raw, file) => {
        try {
            return JSON.parse(raw);
        }
        catch (err) {
            throw new TotemError('CONFIG_INVALID', `Cert-run fixture is not valid JSON (${file})`, 'Re-freeze the gate-1 fixtures with `spine windtunnel record`.', err);
        }
    };
    const loadJson = (file) => parseJson(readRaw(file), file);
    const split = SplitArtifactSchema.parse(loadJson(path.join(gate1Dir, SPLIT_FILE)));
    const artifact = ReplayArtifactSchema.parse(loadJson(path.join(gate1Dir, REPLAY_FILE)));
    // The committed content.json stores RAW comments (author + body + flags). ENRICH
    // each comment with `authorKind` + `normalizedBody` (slice β) via the SAME
    // `enrichComment` the live adapter uses, RE-DERIVED with the CURRENT normalizer —
    // so the replay-time `extractorInputKey` (keyed on `normalizedBody`) matches what
    // record stamped, and a normalizer change re-keys → a MISS forces a re-record
    // (Tenet-15). Any `authorKind`/`normalizedBody` already in the JSON is ignored
    // (the schema parses the raw shape), so a stale stored value can never be served.
    const rawContent = z
        .array(ReviewThreadContentSchema)
        .parse(loadJson(path.join(gate1Dir, CONTENT_FILE)));
    const content = rawContent.map((c) => ({
        ...c,
        threads: c.threads.map((t) => ({
            ...t,
            comments: t.comments.map((cm) => enrichComment(enrich, cm.author, cm.body)),
        })),
    }));
    // #2225 (#709 fold-2): verify-then-parse the SCORING source on a SINGLE read — the
    // digest must cover the exact bytes that get parsed + scored, not a separate read
    // (CR: no check/use split). CRLF→LF normalized to match the producer's LF-stamped
    // digest. The absent-from-lock case is the caller's precondition (it omits the sha).
    const prDiffsFile = path.join(gate1Dir, PR_DIFFS_FILE);
    const prDiffsRaw = readRaw(prDiffsFile);
    if (opts?.expectedPrDiffsSha !== undefined) {
        const actual = createHash('sha256')
            .update(prDiffsRaw.replace(/\r\n/g, '\n'), 'utf-8')
            .digest('hex');
        if (actual !== opts.expectedPrDiffsSha) {
            throw new TotemError('CONFIG_INVALID', `Certifying run: pr-diffs.json integrity FAILED — expected ${opts.expectedPrDiffsSha}, got ` +
                `${actual} (the frozen scoring corpus was tampered or re-serialized).`, 'Restore the frozen pr-diffs.json or re-materialize the cert corpus.');
        }
    }
    const prDiffs = z.array(ResolvedPrDiffSchema).parse(parseJson(prDiffsRaw, prDiffsFile));
    // 5d-iii circularity guard: the label-deriver reuses this loader to enumerate
    // firings byte-identically to the run, but it PRODUCES ground-truth-labels.json
    // — so it must NOT read it (the file may not yet exist on a first derive, and
    // reading it would make the deriver depend on its own output). `skipGroundTruth`
    // returns an empty map; the run omits the flag and loads the frozen answer key.
    if (opts?.skipGroundTruth) {
        return { split, artifact, content, prDiffs, groundTruth: new Map() };
    }
    // #709 5d-iii-ii: verify-then-parse the ANSWER KEY on a SINGLE read — the run grades
    // firings against the frozen ground-truth-labels.json, so the digest must cover the
    // exact bytes parsed + scored, not a separate read (mirror prDiffsSha — no check/use
    // split). CRLF→LF normalized to match the deriver's LF-stamped digest. The absent-from-
    // lock case is the caller's precondition (it omits the sha). The deriver never reaches
    // here — `skipGroundTruth` returns above, because it PRODUCES this file (circularity).
    const groundTruthFile = path.join(gate1Dir, GROUND_TRUTH_FILE);
    const groundTruthRaw = readRaw(groundTruthFile);
    if (opts?.expectedGroundTruthSha !== undefined) {
        const actual = createHash('sha256')
            .update(groundTruthRaw.replace(/\r\n/g, '\n'), 'utf-8')
            .digest('hex');
        if (actual !== opts.expectedGroundTruthSha) {
            throw new TotemError('CONFIG_INVALID', `Certifying run: ground-truth-labels.json integrity FAILED — expected ${opts.expectedGroundTruthSha}, got ` +
                `${actual} (the frozen answer key was tampered or re-serialized).`, 'Re-derive the answer key with `spine windtunnel derive-labels` or re-materialize the cert corpus.');
        }
    }
    const gtRecord = GroundTruthSchema.parse(parseJson(groundTruthRaw, groundTruthFile));
    const groundTruth = new Map(Object.entries(gtRecord));
    return { split, artifact, content, prDiffs, groundTruth };
}
/**
 * Build the Stage-4 verifier deps (listFiles/readFile over the frozen post-image
 * at `asOf`) — the SHARED constructor both the certifying run and the 5d-iii
 * deriver use, so the archived-rule exclusion (hence the scored rule set, hence
 * the firing labelIds) is identical. With an lc clone, files resolve via
 * `git ls-tree`/`git show` at `asOf` (a local clone — zero network); without one,
 * Stage-4 sees no files (rules read as untested, NOT archived). Tests inject a
 * fake `Stage4VerifierDeps` into `assembleCertifyingCorpus` directly instead.
 */
export function buildGate1Stage4Deps(lcDir, asOf, safeExec) {
    if (lcDir) {
        return {
            // The `--` separator keeps the revision unambiguous from any path args and blocks
            // arg injection (CR coding guideline) — this shared path decides which files
            // Stage-4 sees for BOTH the run and the deriver, so rev/path ambiguity here would
            // shift archived-rule decisions and the derived labels.
            listFiles: async () => safeExec('git', ['ls-tree', '-r', '--name-only', asOf, '--'], { cwd: lcDir })
                .split('\n')
                .filter(Boolean),
            readFile: async (f) => safeExec('git', ['show', `${asOf}:${f.replace(/\\/g, '/')}`, '--'], { cwd: lcDir }),
            workingDirectory: lcDir,
        };
    }
    return {
        // No lc clone → Stage-4 sees no files (rules read as 'no-matches' / untested,
        // NOT archived) — the wind-tunnel firing/scoring still runs.
        listFiles: async () => [],
        readFile: async (f) => {
            const { TotemError } = await import('@mmnto/totem');
            throw new TotemError('CONFIG_INVALID', `Cert run: no lc clone (--lc-dir) — cannot read ${f} for Stage-4.`, 'Provide the lc clone via --lc-dir or the TOTEM_LC_DIR environment variable.');
        },
    };
}
/** Derive the SplitLedger from the loaded split + the lock's resolved corpus. */
function splitLedgerFrom(split, lock) {
    return {
        split,
        corpus: lock.corpus.resolvedPrs.map((p) => p.pr),
        corpusMergeCommits: lock.corpus.resolvedPrs.map((p) => ({
            pr: p.pr,
            mergeCommit: p.mergeCommit,
        })),
    };
}
/**
 * Assemble the REPLAY-mode `CertifyingCorpus` (rules + prDiffs + provenance) from
 * the committed gate-1 fixtures — the SHARED path both the certifying run (via
 * `buildReplayCorpusProvider`) and the 5d-iii label-deriver call, so the corpus
 * they enumerate firings over is byte-identical (the answer-key labelIds the
 * deriver mints are the ones the run looks up). The deriver passes
 * `skipGroundTruth: true` — it PRODUCES `ground-truth-labels.json`, so it must
 * not read it (circularity guard); the run omits the flag and loads the frozen
 * answer key. Returns the fold-I ledgers too; the caller decides whether to emit
 * them (the run does; the deriver discards — they belong to the run artifact).
 */
export async function assembleCertifyingCorpus(opts, lock) {
    const { TotemError } = await import('@mmnto/totem');
    const expectedHash = lock.controls.integrity.llmReplaySha;
    if (!expectedHash) {
        throw new TotemError('CONFIG_INVALID', 'Certifying run: lock is missing controls.integrity.llmReplaySha (L2) — the frozen ' +
            'llm-replay fixture cannot be integrity-checked.', 'Re-freeze the lock after a `record` run.');
    }
    // #2225 (#709 fold-2): hash-bind the SCORING source. `pr-diffs.json` is otherwise
    // unprotected — `fixtureSha` covers only the control dirs — so a silent mutation of
    // any row (advisory-window OR control) would corrupt the answer key while the
    // control-dir gate stays green. The absent-from-lock case is a lock precondition
    // (checked here); `loadCertRunFixtures` verifies the digest on the SAME read it
    // parses — no check/use split (CR). Fail loud (strategy ruled: verify at freeze AND run).
    const expectedPrDiffsSha = lock.controls.integrity.prDiffsSha;
    if (!expectedPrDiffsSha) {
        throw new TotemError('CONFIG_INVALID', 'Certifying run: lock is missing controls.integrity.prDiffsSha (#709 fold-2) — the ' +
            'pr-diffs.json scoring source cannot be integrity-checked.', 'Re-materialize the cert corpus with `spine windtunnel materialize`.');
    }
    // #709 5d-iii-ii: the ANSWER KEY digest is run-critical like prDiffsSha — the run reads
    // the MATERIALIZED frozen labels (never re-derives), so without this the cert could grade
    // against a tampered/stale answer key while every other gate stays green. Gated on the RUN
    // path: the deriver (skipGroundTruth) PRODUCES ground-truth-labels.json + stamps
    // groundTruthSha, so on a first derive the lock legitimately has none yet.
    const expectedGroundTruthSha = lock.controls.integrity.groundTruthSha;
    if (!opts.skipGroundTruth && !expectedGroundTruthSha) {
        throw new TotemError('CONFIG_INVALID', 'Certifying run: lock is missing controls.integrity.groundTruthSha (#709 5d-iii) — the ' +
            'ground-truth-labels.json answer key cannot be integrity-checked.', 'Re-derive the answer key with `spine windtunnel derive-labels` (it stamps groundTruthSha) and re-freeze.');
    }
    const { split, artifact, content, prDiffs, groundTruth } = await loadCertRunFixtures(opts.gate1Dir, {
        expectedPrDiffsSha,
        expectedGroundTruthSha: opts.skipGroundTruth ? undefined : expectedGroundTruthSha,
        skipGroundTruth: opts.skipGroundTruth,
    });
    const { extractor, classifier } = buildReplayAdapters(artifact, expectedHash);
    return buildCertifyingCorpus({
        split,
        splitLedger: splitLedgerFrom(split, lock),
        source: frozenSourceFrom(content),
        extractor,
        classifier,
        seedClassesProvided: opts.seedClassesProvided ?? false,
        stage4: opts.stage4,
        now: opts.now,
        prDiffs,
        groundTruth,
    });
}
/**
 * Build the REPLAY-mode `CertifyingCorpusProvider` the certifying run injects: a thin
 * wrapper over `assembleCertifyingCorpus` (fixture-load + replay adapters + the
 * `buildCertifyingCorpus` composition) that also emits the fold-I miner ledgers
 * (default: `miner-ledgers.json`) for §7 observability. The deriver calls
 * `assembleCertifyingCorpus` directly instead (it skips ground-truth + discards the
 * ledgers, which belong to the run artifact).
 */
export function buildReplayCorpusProvider(opts) {
    return async (lock) => {
        const { corpus, ledgers } = await assembleCertifyingCorpus(opts, lock);
        // fold-I (§7): emit the miner ledgers, observable beside the cert-run report.
        if (opts.onLedgers) {
            opts.onLedgers(ledgers);
        }
        else {
            fs.mkdirSync(opts.gate1Dir, { recursive: true });
            fs.writeFileSync(path.join(opts.gate1Dir, LEDGERS_FILE), JSON.stringify(ledgers, null, 2), 'utf-8');
        }
        return corpus;
    };
}
//# sourceMappingURL=spine-cert-run-corpus.js.map