/**
 * Pipeline step: Gap analysis (failure modes + remediation plan).
 *
 * Wraps the inline gap-analysis logic from pipeline-action.ts behind
 * the PipelineStep interface. This includes document manifest enrichment
 * and low-scoring judgment extraction.
 *
 * Document resolution uses two sources (layered):
 *   1. TaskSource (from AppContext) — the canonical source of task definitions
 *      including Content Lake, repo-based, and YAML tasks. This is the primary
 *      source for mapping task descriptions to canonical doc slugs.
 *   2. Local task YAML (via resolveMappings) — legacy fallback for tasks not
 *      found via the TaskSource adapter.
 *
 * This is an optional step — failure doesn't stop the pipeline.
 */
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "fs";
import { join, resolve } from "path";
import { assoc, isSlugRef } from "../../_vendor/ailf-core/index.js";
import { emitFileContents } from "../../artifact-capture/emit-file.js";
import { classifyEnrichmentInputs, degradedEnrichmentError, } from "../../pipeline/enrichment-preconditions.js";
import { allRequiredEvalRunsCached } from "../required-eval-runs.js";
export class GapAnalysisStep {
    name = "gap-analysis";
    optional = true;
    check(ctx) {
        const judgmentsPath = resolve(ctx.config.rootDir, "results", "latest", "grader-judgments.json");
        if (!existsSync(judgmentsPath)) {
            return [
                {
                    message: "No grader-judgments.json — run a full evaluation first",
                    severity: "warning",
                    source: "gap-analysis",
                },
            ];
        }
        return [];
    }
    async execute(ctx, state) {
        const root = ctx.config.rootDir;
        const start = Date.now();
        const judgmentsPath = resolve(root, "results", "latest", "grader-judgments.json");
        const scoreSummaryPath = resolve(root, "results", "latest", "score-summary.json");
        // Distinguish a legitimate skip (no graded eval ran this pipeline) from a
        // degraded run where a full eval scored tests but no judgments persisted.
        // The latter must fail loud — returning a benign `skipped` is what let
        // reports publish with a score but no test details.
        //
        // A remote cache hit restores score-summary.json (with testCount) from a
        // prior report but never writes grader-judgments.json, so absent judgments
        // are legitimate ONLY when every required sub-eval came from the cache. In a
        // hybrid full run (e.g. agentic cached, baseline evaluated fresh) the fresh
        // run's missing judgments are still degraded — gate the skip on ALL required
        // runs being cached, not merely any.
        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("gap-analysis", inputs.scoredTestCount),
            };
        }
        if (inputs.kind !== "ready") {
            return {
                status: "skipped",
                reason: "No grader-judgments.json — run a full evaluation first",
            };
        }
        if (!existsSync(scoreSummaryPath)) {
            return { status: "skipped", reason: "No score-summary.json" };
        }
        try {
            const { buildFailureModeReport, formatFailureModesConsole } = await import("../../pipeline/failure-modes.js");
            const { buildGapAnalysisReport, formatGapAnalysisConsole } = await import("../../pipeline/gap-analysis.js");
            const judgments = JSON.parse(readFileSync(judgmentsPath, "utf-8"));
            const scoreSummary = JSON.parse(readFileSync(scoreSummaryPath, "utf-8"));
            const failureModeReport = buildFailureModeReport(judgments, scoreSummary.scores);
            console.log(formatFailureModesConsole(failureModeReport));
            const gapReport = buildGapAnalysisReport(failureModeReport, scoreSummary.scores);
            console.log(formatGapAnalysisConsole(gapReport));
            // Write user-facing artifacts to outputDir (respects --output-dir)
            const outDir = ctx.config.outputDir;
            mkdirSync(outDir, { recursive: true });
            writeFileSync(join(outDir, "failure-modes.json"), JSON.stringify(failureModeReport, null, 2));
            writeFileSync(join(outDir, "gap-analysis.json"), JSON.stringify(gapReport, null, 2));
            // ── Document enrichment (literacy mode only) ──────────────
            // Non-literacy modes don't use canonical docs. Skip manifest
            // loading, doc-reference enrichment, and canonical doc mapping
            // entirely — those fields are only meaningful for literacy evals.
            const isLiteracyMode = ctx.config.mode === "literacy";
            let documentManifest;
            let enrichedScores = scoreSummary.scores;
            const descToDocRefs = new Map();
            if (isLiteracyMode) {
                const manifestPath = resolve(root, "contexts", "document-manifest.json");
                const manifestEntries = existsSync(manifestPath)
                    ? JSON.parse(readFileSync(manifestPath, "utf-8"))
                    : [];
                const refBySlug = new Map();
                for (const entry of manifestEntries) {
                    refBySlug.set(entry.slug, entry);
                }
                const resolveRefs = (slugs) => slugs
                    .map((slug) => {
                    const m = refBySlug.get(slug);
                    if (!m)
                        return { documentId: "", slug, title: slug };
                    return {
                        // Carry the producer-stamped locator (D0062) through the
                        // manifest→DocumentRef boundary. Top-level documentId/revision
                        // mirror the locator (both derive from the entry's _id/_rev),
                        // so the locator stays the single source of truth.
                        ...(m.locator ? { locator: m.locator } : {}),
                        documentId: m._id,
                        revision: m._rev,
                        slug: m.slug,
                        ...(m.path ? { path: m.path } : {}),
                        title: m.title,
                    };
                })
                    .filter((r) => r.documentId !== "");
                // ── Build description→docs mapping from TaskSource ─────────
                // Primary source: use the TaskSource adapter from AppContext.
                // This works with Content Lake, repo-based, and YAML tasks.
                // Judgments use task description as their taskId, so we build
                // maps keyed by both description and task ID for robust matching.
                const areaToDocRefs = new Map();
                let tasks = [];
                try {
                    tasks = (await ctx.taskSource.loadTasks()).filter((t) => t.mode === "literacy");
                }
                catch {
                    // TaskSource may not be available in all contexts (e.g., standalone
                    // gap analysis on cached results). Fall through to legacy fallback.
                }
                if (tasks.length > 0) {
                    // Group tasks by feature area and build slug maps
                    const byArea = new Map();
                    for (const task of tasks) {
                        const slugs = extractSlugsFromRefs(task.context?.docs ?? []);
                        const refs = resolveRefs(slugs);
                        // Map by title (what judgments use as taskId)
                        descToDocRefs.set(task.title, refs);
                        // Also map by task ID for prefix-based matching
                        descToDocRefs.set(task.id, refs);
                        // Group slugs by feature area
                        const area = task.area ?? "";
                        if (!byArea.has(area))
                            byArea.set(area, new Set());
                        for (const s of slugs)
                            byArea.get(area).add(s);
                    }
                    for (const [area, slugs] of byArea) {
                        areaToDocRefs.set(area, resolveRefs([...slugs]));
                    }
                }
                // Legacy fallback: merge in any tasks from local YAML that weren't
                // already covered by the TaskSource adapter.
                const { resolveMappings } = await import("../../pipeline/resolve-mappings.js");
                const mappings = resolveMappings(root);
                for (const [area, areaData] of Object.entries(mappings.feature_areas)) {
                    const areaSlugs = new Set();
                    for (const task of areaData.tasks) {
                        const taskSlugs = task.canonical_docs.map((d) => d.slug);
                        // Only add if not already mapped by the primary source
                        if (!descToDocRefs.has(task.description)) {
                            descToDocRefs.set(task.description, resolveRefs(taskSlugs));
                        }
                        for (const s of taskSlugs)
                            areaSlugs.add(s);
                    }
                    if (!areaToDocRefs.has(area)) {
                        areaToDocRefs.set(area, resolveRefs([...areaSlugs]));
                    }
                }
                documentManifest = resolveRefs([...refBySlug.keys()]);
                enrichedScores = scoreSummary.scores.map((s) => ({
                    ...s,
                    documents: areaToDocRefs.get(s.feature),
                }));
                // Pitfall #11 hallucination cross-check (GRAD-05). The required
                // `hallucinationCheckedAgainst` and per-citation `hallucinated`
                // fields are populated here because `extractGraderJudgments`
                // (the upstream emitter) does not have access to either the
                // task contextDocs map or the run's document manifest. The
                // populator mutates `judgments` in place and the rewrite below
                // persists the enrichment back to disk so downstream consumers
                // (Studio, gap-analysis followups) see populated fields.
                const taskDocSlugs = new Map();
                for (const [desc, refs] of descToDocRefs) {
                    taskDocSlugs.set(desc, refs
                        .map((r) => r.slug)
                        .filter((s) => typeof s === "string" && s.length > 0));
                }
                const manifestSlugs = (documentManifest ?? [])
                    .map((d) => d.slug)
                    .filter((s) => typeof s === "string" && s.length > 0);
                const { populateHallucinationFields } = await import("../../pipeline/calculate-scores.js");
                populateHallucinationFields(judgments, taskDocSlugs, manifestSlugs);
                // Atomic write — POSIX rename is atomic on the same filesystem,
                // so a mid-write failure leaves either the prior file or the new
                // file intact, never a half-written JSON document.
                const tmpPath = `${judgmentsPath}.tmp`;
                writeFileSync(tmpPath, JSON.stringify(judgments, null, 2));
                renameSync(tmpPath, judgmentsPath);
            }
            // ── Per-test results (D0029: model output + metadata) ──────
            const testResultsPath = resolve(root, "results", "latest", "test-results.json");
            let testResults;
            if (existsSync(testResultsPath)) {
                const rawTestResults = JSON.parse(readFileSync(testResultsPath, "utf-8"));
                // Enrich with context docs (literacy mode only)
                testResults = rawTestResults.map((tr) => {
                    if (!isLiteracyMode)
                        return tr;
                    const baseDesc = tr.taskId.replace(/\s*\((gold|baseline)\)\s*$/, "");
                    const contextDocs = descToDocRefs.get(baseDesc);
                    return contextDocs ? { ...tr, contextDocs } : tr;
                });
            }
            // ── Low-scoring judgments ────────────────────────────────────
            const LOW_SCORE_THRESHOLD = 70;
            const MAX_STORED_JUDGMENTS = 50;
            const normalizedJudgments = judgments.map((j) => ({
                ...j,
                score: j.score <= 1 && j.score > 0 ? Math.round(j.score * 100) : j.score,
            }));
            const lowScoringJudgments = normalizedJudgments
                .filter((j) => j.score < LOW_SCORE_THRESHOLD)
                .sort((a, b) => a.score - b.score)
                .slice(0, MAX_STORED_JUDGMENTS)
                .map((j) => {
                if (!isLiteracyMode)
                    return j;
                // Judgment taskId is the description with "(gold)" or "(baseline)" suffix
                const baseDesc = j.taskId.replace(/\s*\((gold|baseline)\)\s*$/, "");
                const contextDocs = descToDocRefs.get(baseDesc);
                return contextDocs ? { ...j, contextDocs } : j;
            });
            const enrichedSummary = {
                ...scoreSummary,
                ...(documentManifest !== undefined && { documentManifest }),
                failureModes: failureModeReport,
                lowScoringJudgments,
                scores: enrichedScores,
                ...(testResults !== undefined && { testResults }),
            };
            // Atomic write — see judgmentsPath above for rationale.
            const scoreSummaryTmpPath = `${scoreSummaryPath}.tmp`;
            writeFileSync(scoreSummaryTmpPath, JSON.stringify(enrichedSummary, null, 2));
            renameSync(scoreSummaryTmpPath, scoreSummaryPath);
            // W0051 Slice 2 — failureModes is per-entry keyed by {mode, category};
            // one entry per classified FailureModeType. Zero-count categories are
            // skipped to keep the manifest honest about what the run surfaced.
            const classifiedByCategory = new Map();
            for (const cj of failureModeReport.classifiedJudgments) {
                const cat = cj.classification.mode;
                const bucket = classifiedByCategory.get(cat) ?? [];
                bucket.push(cj);
                classifiedByCategory.set(cat, bucket);
            }
            for (const [category, classified] of classifiedByCategory) {
                if (classified.length === 0)
                    continue;
                await ctx.artifactWriter.emit("failureModes", assoc(ctx, { mode: ctx.config.mode, category }), {
                    category,
                    count: classified.length,
                    title: toTitleCase(category),
                    judgments: classified.map((c) => c.judgment),
                });
            }
            const gapReportPath = join(outDir, "gap-analysis.json");
            if (existsSync(gapReportPath)) {
                await emitFileContents(ctx.artifactWriter, "gapReport", assoc(ctx), gapReportPath);
            }
            const gapCount = gapReport.gaps.length;
            const classRate = failureModeReport.classificationRate.toFixed(0);
            return {
                durationMs: Date.now() - start,
                status: "success",
                summary: `${failureModeReport.totalJudgments} judgments analyzed (${classRate}% classified), ${gapCount} actionable gaps identified`,
            };
        }
        catch (err) {
            return {
                durationMs: Date.now() - start,
                error: err instanceof Error ? err.message : String(err),
                status: "failed",
            };
        }
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Render a kebab-case FailureModeType id as Title Case for the manifest
 * entry's display title (e.g. `"missing-docs"` → `"Missing Docs"`). Kept
 * local to the producer so the registry descriptor stays decoupled from
 * eval-side types.
 */
function toTitleCase(id) {
    return id
        .split("-")
        .map((w) => (w.length === 0 ? w : w[0].toUpperCase() + w.slice(1)))
        .join(" ");
}
/**
 * Extract slug strings from polymorphic canonical doc refs.
 *
 * Only slug-based refs produce a slug directly. Other ref types
 * (path, id, perspective) are resolved during doc fetching — their
 * slugs appear in the document manifest, not in the refs themselves.
 * For path refs, the final segment is used as the slug approximation.
 */
function extractSlugsFromRefs(refs) {
    const slugs = [];
    for (const ref of refs) {
        if (isSlugRef(ref)) {
            slugs.push(ref.slug);
        }
        else if ("path" in ref && typeof ref.path === "string") {
            // Path refs: use the final segment as the slug
            const segments = ref.path.split("/").filter(Boolean);
            if (segments.length > 0) {
                slugs.push(segments[segments.length - 1]);
            }
        }
        // id and perspective refs: slugs are resolved at fetch time
        // and appear in the document manifest — handled via refBySlug
    }
    return slugs;
}
