/**
 * Pipeline step: Per-judgment attribution ensemble (v0).
 *
 * Reads `grader-judgments.json` and `score-summary.json` from the latest
 * results, calls the pure `computeJudgmentAttribution(...)` helper for each
 * judgment, and emits:
 *
 *   - One `perEntryAttribution` artifact per judgment at
 *     `runs/{runId}/attribution/{entryKey}.json`
 *   - One `attributionMeta` artifact at
 *     `runs/{runId}/attribution-meta.json`
 *
 * Additionally, when any hallucinated citations are detected, the step
 * atomically rewrites `score-summary.json` to persist
 * `graderReliability.hallucinationCount` (D-05 — only this one direct-
 * mutation path uses the temp+rename pattern; all artifact emissions go
 * through `ctx.artifactWriter.emit` which handles atomicity internally).
 *
 * This step is `optional: true` — it self-skips when either
 * `grader-judgments.json` or `score-summary.json` is missing, so
 * non-graded runs are unaffected.
 *
 * Task → judgment join (D-10): `judgment.taskId` is the promptfoo row
 * description, which for literacy mode is `"${task.title} (gold|baseline)"`.
 * The join strips the variant suffix and looks up in a triple-keyed cache
 * by `task.title` (primary), `task.description`, and `task.id` (defensive
 * fallbacks for non-literacy modes).
 *
 * Retrieved signal (D-11/D-12): `FeatureAgentBehavior.feature` is the join
 * key — for literacy mode it equals `task.area` exactly (compiler propagates
 * `task.area → __featureArea → ab.feature`).
 *
 * @see docs/decisions/D0033-unified-artifact-writer.md
 * @see docs/decisions/D0049-shared-confidence-contract.md
 * @see docs/decisions/D0050-per-entry-attribution-layout.md
 * @see docs/decisions/D0052-judgment-ref-granularity.md
 */
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
import { resolve } from "node:path";
import { resolveDocRefToDocumentKey } from "../../_vendor/ailf-core/index.js";
import { baseDocumentId } from "../../_vendor/ailf-shared/index.js";
import { calibrationSetVersion, embeddingModel, ensembleVersion, } from "../../pipeline/attribution-versions.js";
import { V0_WEIGHTS, computeJudgmentAttribution, } from "../../pipeline/compute-attribution.js";
import { classifyEnrichmentInputs, degradedEnrichmentError, } from "../../pipeline/enrichment-preconditions.js";
import { allRequiredEvalRunsCached } from "../required-eval-runs.js";
// ---------------------------------------------------------------------------
// Step implementation
// ---------------------------------------------------------------------------
export class ComputeAttributionStep {
    name = "compute-attribution";
    optional = true;
    check(ctx) {
        const issues = [];
        const judgmentsPath = resolve(ctx.config.rootDir, "results", "latest", "grader-judgments.json");
        const summaryPath = resolve(ctx.config.rootDir, "results", "latest", "score-summary.json");
        if (!existsSync(judgmentsPath)) {
            issues.push({
                message: "No grader-judgments.json — attribution computation will skip",
                severity: "warning",
                source: "compute-attribution",
            });
            return issues;
        }
        // WARN 5: documentManifest must be present and non-empty (gap-analysis
        // enriches it). Without it, the canonical signal is permanently 0 and
        // the three-signal ensemble silently degrades to citation-only.
        if (existsSync(summaryPath)) {
            try {
                const summary = JSON.parse(readFileSync(summaryPath, "utf-8"));
                const dm = summary.documentManifest;
                if (!dm || dm.length === 0) {
                    issues.push({
                        message: "documentManifest is empty — attribution canonical signal will be permanently 0. Ensure gap-analysis runs before compute-attribution.",
                        severity: "warning",
                        source: "compute-attribution",
                    });
                }
            }
            catch {
                // Surfaces at execute() with a failed StepResult.
            }
        }
        return issues;
    }
    async execute(ctx, state) {
        const start = Date.now();
        const root = ctx.config.rootDir;
        const judgmentsPath = resolve(root, "results", "latest", "grader-judgments.json");
        const summaryPath = resolve(root, "results", "latest", "score-summary.json");
        // Mirror gap-analysis: a full eval that scored tests but persisted no
        // grader judgments is a degraded run, not a benign skip. Fail loud so the
        // outcome surfaces in pipeline-result and on the job document. A remote
        // cache hit restores score-summary.json without grader-judgments.json, so
        // missing judgments are legitimate ONLY when every required sub-eval came
        // from the cache — a hybrid full run with a freshly-evaluated sub-eval that
        // persisted no judgments is still degraded.
        const allCached = allRequiredEvalRunsCached(ctx.config, state);
        const inputs = classifyEnrichmentInputs(root);
        if (inputs.kind === "judgments-missing-after-eval" && !allCached) {
            return {
                durationMs: Date.now() - start,
                status: "failed",
                error: degradedEnrichmentError("compute-attribution", inputs.scoredTestCount),
            };
        }
        if (inputs.kind !== "ready") {
            return { status: "skipped", reason: "No grader-judgments.json" };
        }
        if (!existsSync(summaryPath)) {
            return { status: "skipped", reason: "No score-summary.json" };
        }
        try {
            const judgments = JSON.parse(readFileSync(judgmentsPath, "utf-8"));
            const summary = JSON.parse(readFileSync(summaryPath, "utf-8"));
            // D-10: judgment.taskId is the promptfoo row description, sometimes
            // suffixed "(gold)" / "(baseline)" by the literacy compiler
            // (literacy/compiler.ts:184). Build a triple-keyed task cache:
            //   - task.title  (primary for literacy — the compiler binds task.title
            //                  as the row description before appending the suffix)
            //   - task.description (defensive fallback for future modes)
            //   - task.id     (defensive fallback for KP / agent-harness modes)
            // Strip the variant suffix before lookup so the join matches.
            const tasksByKey = new Map();
            if (ctx.taskSource) {
                try {
                    const tasks = (await ctx.taskSource.loadTasks()).filter((t) => t.mode === "literacy");
                    for (const t of tasks) {
                        if (t.title)
                            tasksByKey.set(t.title, t);
                        if (t.description)
                            tasksByKey.set(t.description, t);
                        tasksByKey.set(t.id, t);
                    }
                }
                catch (err) {
                    // Surface the failure so operators see why the canonical signal
                    // is permanently 0 for this run. Behavior is unchanged — we
                    // degrade to an empty tasksByKey rather than failing the step.
                    ctx.logger.warn(`[compute-attribution] taskSource.loadTasks() failed; canonical signal will be 0 for all judgments. ${err instanceof Error ? err.message : String(err)}`);
                }
            }
            // D-12: FeatureAgentBehavior.feature is the correct field (NOT .area).
            // Verified at packages/core/src/types/index.ts:1180-1190.
            // D-11: for literacy mode, ab.feature === task.area via the compiler's
            // __featureArea propagation (literacy/compiler.ts:180).
            const visitedByFeature = new Map();
            for (const ab of summary.agentBehavior ?? []) {
                visitedByFeature.set(ab.feature, new Set(ab.docSlugsVisited ?? []));
            }
            // Resolve context + candidate docs on the cross-version **document key**
            // (D0084), not via a slug-only bridge. `manifestByDocKey` indexes every
            // manifest entry by `baseDocumentId(documentId)` — including slugless
            // non-article entries that the old slug index dropped, so a task pinning
            // such a doc only by `_id` is now reachable (the slug-bridge blind spot,
            // doctype-agnostic-eval.md "Implementation correction"). `slugToDocKey`
            // is the editorial-handle bridge for slug/path refs only, mirroring
            // `fetch-docs-step.ts`'s `buildSlugToDocumentKey` (first occurrence wins).
            const manifest = summary.documentManifest ?? [];
            const manifestByDocKey = new Map();
            const slugToDocKey = new Map();
            for (const ref of manifest) {
                const docKey = baseDocumentId(ref.documentId);
                if (!manifestByDocKey.has(docKey))
                    manifestByDocKey.set(docKey, ref);
                if (ref.slug && !slugToDocKey.has(ref.slug)) {
                    slugToDocKey.set(ref.slug, docKey);
                }
            }
            const reliability = { hallucinationCount: 0 };
            for (const j of judgments) {
                // D-10: strip variant suffix before lookup — mirrors calculate-scores.ts:696.
                const baseDesc = stripVariantSuffix(j.taskId);
                const task = tasksByKey.get(baseDesc) ?? tasksByKey.get(j.taskId);
                // Resolve context docs → DocumentRef[] on the document key. Each ref
                // resolves to its document key (id refs by `_id`, slug/path refs via
                // the editorial bridge); the key then looks up the manifest entry. An
                // id-pinned slugless non-article now contributes to `contextDocIds`,
                // where the prior slug-only path silently skipped it.
                const contextDocs = (task?.context?.docs ?? [])
                    .map((ref) => {
                    const docKey = resolveDocRefToDocumentKey(ref, slugToDocKey);
                    return docKey !== undefined
                        ? manifestByDocKey.get(docKey)
                        : undefined;
                })
                    .filter((r) => r !== undefined);
                const contextDocIds = new Set(contextDocs.map((r) => r.documentId));
                // D-11: retrieved signal keys by task.area (= ab.feature in literacy mode).
                // When agentBehavior is absent (baseline run), drop the retrieved signal
                // (pass undefined — Pitfall #4 / locked D-04).
                const area = task?.area;
                const retrievedSlugs = summary.agentBehavior === undefined || summary.agentBehavior === null
                    ? undefined
                    : area === undefined
                        ? new Set()
                        : (visitedByFeature.get(area) ?? new Set());
                // Candidate set: contextDocs ∪ docs in the manifest cited or retrieved.
                // Derived from the full manifest (by document key), NOT only the
                // slug-indexed entries — so a slugless non-article entry is reachable
                // as a candidate when cited (citations join on documentId, which is
                // type-agnostic). The retrieved axis stays slug-keyed because the agent
                // observes the live site by slug/URL (design §4); a slugless doc simply
                // contributes no retrieved signal, which is correct.
                const citedDocIds = new Set(j.docCitations.map((c) => c.documentId));
                const candidates = [
                    ...contextDocs,
                    ...manifest.filter((r) => !contextDocIds.has(r.documentId) &&
                        (citedDocIds.has(r.documentId) ||
                            (r.slug !== undefined &&
                                r.slug !== "" &&
                                (retrievedSlugs?.has(r.slug) ?? false)))),
                ];
                const judgmentAttribution = computeJudgmentAttribution(j, candidates, contextDocIds, retrievedSlugs, reliability, V0_WEIGHTS);
                // D-06: pass { run, name } where name is the entry key.
                // formatKeyFromAxes requires assoc.name for per-entry descriptors —
                // forgetting it causes a hard throw at emit time.
                const entryKey = `${j.taskId}--${j.modelId}--${j.dimension}`;
                await ctx.artifactWriter.emit("perEntryAttribution", { run: ctx.runId, name: entryKey }, judgmentAttribution);
            }
            await ctx.artifactWriter.emit("attributionMeta", { run: ctx.runId }, {
                ensembleVersion,
                embeddingModel,
                weights: V0_WEIGHTS,
                calibrationSetVersion,
            });
            // Atomically persist reliability.hallucinationCount onto score-summary.json
            // (D-05: the atomic write applies ONLY to this direct-mutation path;
            // ctx.artifactWriter.emit handles atomicity for all other writes).
            if (reliability.hallucinationCount > 0) {
                // Build the updated graderReliability by merging only the new count.
                // graderReliability.graderModel is a required field — spread to preserve
                // all existing required fields before adding/updating hallucinationCount.
                const existingReliability = summary.graderReliability;
                const updatedReliability = existingReliability
                    ? {
                        ...existingReliability,
                        hallucinationCount: (existingReliability.hallucinationCount ?? 0) +
                            reliability.hallucinationCount,
                    }
                    : {
                        // When there is no existing graderReliability, we cannot provide
                        // the required graderModel field from the run context here.
                        // Persist only the new counter under a minimal shape.
                        graderModel: "unknown",
                        hallucinationCount: reliability.hallucinationCount,
                    };
                const updated = {
                    ...summary,
                    graderReliability: updatedReliability,
                };
                const tmp = `${summaryPath}.tmp`;
                try {
                    writeFileSync(tmp, JSON.stringify(updated, null, 2));
                    renameSync(tmp, summaryPath);
                }
                catch (err) {
                    // Best-effort cleanup of the .tmp file so a stale leftover does
                    // not confuse subsequent runs (cross-device move, EACCES, ENOSPC
                    // after writeFileSync). Re-throw to surface the underlying error
                    // via the outer try/catch's failed StepResult.
                    try {
                        unlinkSync(tmp);
                    }
                    catch {
                        /* best-effort cleanup */
                    }
                    throw err;
                }
            }
            return {
                durationMs: Date.now() - start,
                status: "success",
                summary: `Attribution computed for ${judgments.length} judgments (${reliability.hallucinationCount} hallucinated citations)`,
            };
        }
        catch (err) {
            return {
                durationMs: Date.now() - start,
                error: err instanceof Error ? err.message : String(err),
                status: "failed",
            };
        }
    }
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
 * Strip the literacy compiler's variant suffix from a row description.
 * Mirrors the canonical Phase-3 strip site at
 * packages/eval/src/pipeline/calculate-scores.ts:696 (D-10).
 */
function stripVariantSuffix(taskId) {
    return taskId.replace(/\s*\((gold|baseline)\)\s*$/, "");
}
