/**
 * Pipeline step: Publish evaluation report to the report store.
 *
 * Inlines the logic from the former pipeline/steps/publish-report-step.ts.
 * Uses ctx.reportStore and ctx.sinks from the composition root instead of
 * constructing ReportStore and loadSinks() internally.
 *
 * Design principles:
 * - P1: Reports are immutable events (write-once to Sanity)
 * - P5: Local-first (pipeline never fails because of a store write)
 * - P6: Sinks are fire-and-forget (failures logged, not thrown)
 */
import { readFileSync } from "fs";
import { resolve } from "path";
import { assoc, buildSlimReportSummary, } from "../../_vendor/ailf-core/index.js";
import { checkScoreSummaryValid } from "../../pipeline/checks.js";
import { computeContextHash } from "../../pipeline/context-hash.js";
import { buildProvenance, } from "../../pipeline/provenance.js";
import { generateReportTitle } from "../../pipeline/report-title.js";
import { stampReportValidity } from "../../pipeline/report-validity.js";
import { generateReportId } from "../../report-store.js";
import { withRetry } from "../../sinks/retry.js";
export class PublishReportStep {
    pipelineStart;
    options;
    name = "publish-report";
    optional = true;
    constructor(pipelineStart, options = {}) {
        this.pipelineStart = pipelineStart;
        this.options = options;
    }
    check() {
        return [];
    }
    async execute(ctx, state) {
        const start = Date.now();
        const { rootDir } = ctx.config;
        // Precondition: score summary exists
        const summaryIssues = checkScoreSummaryValid(rootDir);
        const summaryErrors = summaryIssues.filter((i) => i.severity === "error");
        if (summaryErrors.length > 0) {
            return {
                durationMs: Date.now() - start,
                error: `Score summary missing: ${summaryErrors.map((e) => e.message).join("; ")}`,
                status: "failed",
            };
        }
        // Read score summary
        let summary;
        try {
            const summaryPath = resolve(rootDir, "results", "latest", "score-summary.json");
            summary = JSON.parse(readFileSync(summaryPath, "utf-8"));
        }
        catch (err) {
            return {
                durationMs: Date.now() - start,
                error: `Failed to read score-summary.json: ${err instanceof Error ? err.message : String(err)}`,
                status: "failed",
            };
        }
        // Build provenance — prefer state values from upstream steps,
        // fall back to constructor options for backward compatibility
        const provenanceOptions = {
            evalFingerprint: state.evalFingerprint ?? this.options.evalFingerprint,
            promptfooUrls: state.promptfooUrls ?? this.options.promptfooUrls,
        };
        // Build auto-scope provenance from pipeline state
        const autoScope = buildReportAutoScope(state);
        const provenanceInput = buildProvenanceInput(summary, ctx, provenanceOptions, autoScope);
        const provenance = buildProvenance(provenanceInput);
        // Create report
        const now = new Date().toISOString();
        const reportId = generateReportId();
        const durationMs = Date.now() - this.pipelineStart;
        // Auto-compare against most recent comparable baseline.
        // Returns the comparison + baseline report ID for lineage tracking.
        //
        // When release auto-scope is active, the current experiment only covers
        // a subset of areas. We pass the evaluated area set so autoCompare can
        // scope the baseline to match — preventing mismatched areas from
        // polluting the overall delta.
        const evaluatedAreas = state.releaseAutoScope
            ? new Set(summary.scores.map((s) => s.feature))
            : undefined;
        const autoCompareResult = ctx.reportStore
            ? (await ctx.reportStore.autoCompare(summary, provenance, now, evaluatedAreas))
            : null;
        const comparison = autoCompareResult?.comparison ?? null;
        // Record which report we compared against in lineage
        if (autoCompareResult) {
            provenance.lineage = {
                ...provenance.lineage,
                comparedAgainst: autoCompareResult.baselineReportId,
            };
        }
        const title = generateReportTitle({ provenance });
        // W0051 Slice 3: transform the full pipeline-internal ScoreSummary into
        // the slim ReportSummary that lives on the Content Lake document.
        // Prose fields (grader reasons, failureModes full text, gap prose,
        // agentBehavior arrays) point at their external artifacts via
        // `id = manifestEntryKey`; Studio hydrates on drill-down.
        const slimSummary = buildSlimReportSummary(summary, ctx.config.mode);
        // Degraded-report detection (the "no tests on a scored report" symptom):
        // a full eval scored tests but the gap-analysis enrichment never landed.
        // Computed from the full summary read above — independent of which
        // upstream step skipped — so the marker fires regardless of the cause.
        const degraded = detectReportDegradation(summary);
        const report = {
            comparison: comparison ?? undefined,
            completedAt: now,
            ...(degraded ? { degraded } : {}),
            durationMs,
            id: reportId,
            provenance,
            summary: slimSummary,
            tag: this.options.publishTag ?? ctx.config.publishTag,
            title,
        };
        // Snapshot the artifact manifest from FinalizeRunStep's output (D0032).
        // The source of truth is `runs/{runId}/manifest.json` in GCS; the report
        // carries a denormalized copy so Studio can render drill-down state
        // without an extra GCS fetch.
        const artifactManifest = state.runManifest?.artifacts;
        if (artifactManifest && Object.keys(artifactManifest).length > 0) {
            report.artifactManifest = artifactManifest;
        }
        // When testOutputs was uploaded to GCS, strip responseOutput from the
        // inline testResults[] so the Content Lake document stays slim — the
        // full output lives in the GCS artifact. When no testOutputs artifact
        // exists, leave the inline shape intact so Studio's drill-down UI
        // falls back to it.
        if (artifactManifest?.testOutputs && slimSummary.testResults?.length) {
            report.summary = {
                ...slimSummary,
                testResults: slimSummary.testResults.map(slimTestResult),
            };
        }
        // Stamp the data-health `validity` axis (D0059) and normalize
        // `provenance.classification` on the report now that it is fully assembled
        // (degradation + slim summary settled). The verdict is server-computed
        // from the report's own data — never the caller envelope (D0037) — and
        // assessed at the report's completion time. From here on, the stamped
        // report is what reaches the snapshot artifact, the store, and the sinks.
        const stampedReport = stampReportValidity(report, now);
        // Share reportId with downstream steps (CallbackStep + orchestrator job update)
        state.reportId = reportId;
        // W0050 — migrated from ctx.collector.capture to the unified writer.
        // reportSnapshot: full Report JSON for replay (run-scoped, bulk).
        await ctx.artifactWriter.emit("reportSnapshot", assoc(ctx), stampedReport);
        // autoComparison: delta vs baseline (run-scoped, bulk, optional).
        if (comparison) {
            await ctx.artifactWriter.emit("autoComparison", assoc(ctx), comparison);
        }
        // Write to store (system of record — best-effort, P5)
        const sanityResult = ctx.reportStore
            ? await ctx.reportStore.write(stampedReport)
            : null;
        // Run sinks (fire-and-forget, P6)
        const publishResult = await runSinks(stampedReport, ctx);
        // sinkResults: per-sink outcome (run-scoped, per-entry keyed by sink name).
        for (const r of publishResult.sinkResults) {
            await ctx.artifactWriter.emit("sinkResults", assoc(ctx, { name: r.name }), {
                name: r.name,
                status: r.result.status,
                ...(r.result.status === "success" ? { detail: r.result.detail } : {}),
                ...(r.result.status === "failed" ? { error: r.result.error } : {}),
                ...(r.result.status === "skipped" ? { reason: r.result.reason } : {}),
            });
        }
        // Notifications (fire-and-forget, P6) — post the report to subscribed team
        // Slack channels via the bot token. A no-op when ctx.notifications is unset
        // (no SLACK_BOT_TOKEN), so report publishing is unaffected. The per-channel
        // outcomes are recorded as notificationResults artifacts, parallel to
        // sinkResults above (keyed by the channel key, e.g. `slack:C0123`).
        const notificationResults = (await ctx.notifications?.dispatchReport(stampedReport)) ?? [];
        for (const r of notificationResults) {
            await ctx.artifactWriter.emit("notificationResults", assoc(ctx, { name: r.channelKey }), {
                channelKey: r.channelKey,
                channelType: r.channelType,
                status: r.outcome.status,
                team: r.team,
                ...(r.outcome.status === "delivered"
                    ? { detail: r.outcome.detail }
                    : {}),
                ...(r.outcome.status === "failed" ? { error: r.outcome.error } : {}),
                ...(r.outcome.status === "skipped"
                    ? { reason: r.outcome.reason }
                    : {}),
            });
        }
        // Build result summary
        const parts = [];
        if (sanityResult) {
            parts.push(`report:${sanityResult}`);
        }
        else {
            parts.push("Sanity write skipped (no token or unreachable)");
        }
        if (comparison) {
            const delta = comparison.deltas.overall;
            const sign = delta >= 0 ? "+" : "";
            parts.push(`vs baseline: ${sign}${delta.toFixed(1)}`);
        }
        if (publishResult.sinkResults.length > 0) {
            const succeeded = publishResult.sinkResults.filter((r) => r.result.status === "success").length;
            const total = publishResult.sinkResults.length;
            parts.push(`sinks: ${succeeded}/${total}`);
        }
        if (notificationResults.length > 0) {
            const delivered = notificationResults.filter((r) => r.outcome.status === "delivered").length;
            parts.push(`notifications: ${delivered}/${notificationResults.length}`);
        }
        return {
            durationMs: Date.now() - start,
            status: "success",
            summary: `Published — ${parts.join(", ")}`,
        };
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Build the report's auto-scope provenance from pipeline state.
 *
 * Two independent state slices feed it, both written by FetchDocsStep:
 * - `releaseAutoScope` — set only when auto-scoping narrowed the run to
 *   release-affected tasks.
 * - `releaseImpact` — set whenever a source-level perspective diff ran,
 *   carrying the release's `uncovered` slice: docs the release modified
 *   that no evaluated task references (D0066's visibility net).
 *
 * When narrowing occurred the record reflects it (`enabled: true`) and the
 * uncovered slice rides along — including an empty array, which means
 * "checked, every release edit is covered". When no narrowing occurred but
 * uncovered docs exist (e.g. every task was affected, so auto-scope added
 * no value), a record with `enabled: false` and empty task-ID arrays still
 * reaches the report — an applied fix outside the evaluated set must never
 * be silently unmeasured.
 *
 * Exported for unit testing — production callers reach it via execute().
 */
export function buildReportAutoScope(state) {
    const uncovered = state.releaseImpact?.impact.uncovered;
    if (state.releaseAutoScope) {
        return {
            enabled: true,
            affectedTaskIds: state.releaseAutoScope.affectedTaskIds,
            skippedTaskIds: state.releaseAutoScope.skippedTaskIds,
            perspective: state.releaseAutoScope.perspective,
            impactSummary: {
                added: state.releaseAutoScope.impact.added.length,
                modified: state.releaseAutoScope.impact.modified.length,
                removed: state.releaseAutoScope.impact.removed.length,
            },
            ...(uncovered ? { uncovered } : {}),
        };
    }
    if (state.releaseImpact && uncovered && uncovered.length > 0) {
        const { perspective, impact } = state.releaseImpact;
        return {
            enabled: false,
            affectedTaskIds: [],
            skippedTaskIds: [],
            perspective,
            impactSummary: {
                added: impact.added.length,
                modified: impact.modified.length,
                removed: impact.removed.length,
            },
            uncovered,
        };
    }
    return undefined;
}
/**
 * Detect whether a report should publish as degraded.
 *
 * The symptom is a scored run whose per-test details never landed: a full
 * eval counted tests (`scores[].testCount > 0`) but `summary.testResults` is
 * absent because gap-analysis skipped or failed. Such a report renders an
 * empty "no tests" state in Studio despite carrying a score. Returns the
 * marker enumerating which enrichment surfaces are missing, or `undefined`
 * for a healthy report (or a run with no scored tests, where an empty report
 * is legitimate).
 *
 * Exported for unit testing — production callers reach it via execute().
 */
export function detectReportDegradation(summary) {
    const scoredTestCount = (summary.scores ?? []).reduce((n, s) => n + (typeof s.testCount === "number" ? s.testCount : 0), 0);
    const hasTestResults = (summary.testResults?.length ?? 0) > 0;
    if (scoredTestCount === 0 || hasTestResults)
        return undefined;
    // `testResults` is the load-bearing signal (its absence is the rendered
    // "no tests" symptom). The remaining fields are best-effort detail: some
    // are literacy-only (e.g. documentManifest), so they may appear here for a
    // degraded non-literacy run even though that mode never produces them.
    const missing = ["testResults"];
    if (!summary.failureModes)
        missing.push("failureModes");
    if (!summary.lowScoringJudgments?.length)
        missing.push("lowScoringJudgments");
    if (!summary.documentManifest?.length)
        missing.push("documentManifest");
    if (!summary.recommendations)
        missing.push("recommendations");
    return {
        reason: "enrichment-missing",
        missing,
        detail: `Evaluation scored ${scoredTestCount} test(s) but enrichment did not ` +
            `complete; per-test details and failure analysis are unavailable for ` +
            `this report.`,
    };
}
/**
 * Assemble provenance input from the score summary and pipeline context.
 *
 * Exported for unit testing — direct consumers should still call
 * `buildProvenance` (which calls this transitively via the publish step).
 */
export function buildProvenanceInput(summary, ctx, options, autoScope) {
    const areas = summary.scores.map((s) => s.feature);
    const mode = ctx.config.mode;
    // Read document IDs from config
    const sanityDocumentIds = ctx.config.sanityDocumentArgs;
    // Read task filter from config
    const taskIds = ctx.config.tasks;
    // Build source from summary metadata or config. Resolution order:
    //   1. summary.source — written by calculate-scores after a successful
    //      `loadSource` round-trip.
    //   2. ctx.config.source — the caller-requested source name. Preserves
    //      the user's intent when `loadSource` failed silently upstream
    //      (calculate-scores-step:104-108 swallows the throw, leaving
    //      summary.source undefined). Without this fallback, the report
    //      reads "production" regardless of what the dashboard sent.
    //   3. "production" — last-resort built-in default.
    //
    // Per-field fallbacks (dataset/projectId/perspective) only fire when
    // `summary.source` itself is absent — i.e. the loadSource throw was
    // swallowed. When summary.source is present, trust what the fetch
    // actually used; papering over a missing `perspective` from
    // `ctx.config.perspectiveOverride` makes provenance claim a release
    // was used when it wasn't (W0295).
    const sourceResolved = summary.source?.name !== undefined;
    if (!sourceResolved && ctx.config.source) {
        ctx.logger.warn(`[publish-report] summary.source is missing; falling back to ctx.config.source="${ctx.config.source}" for provenance.source.name`);
    }
    const source = {
        baseUrl: summary.source?.baseUrl ?? "https://www.sanity.io/docs",
        dataset: sourceResolved
            ? (summary.source.dataset ?? "next")
            : (ctx.config.datasetOverride ?? "next"),
        documentIds: [],
        llmsTxt: (summary.source?.baseUrl ?? "https://www.sanity.io/docs") + "/llms.txt",
        name: summary.source?.name ?? ctx.config.source ?? "production",
        perspective: sourceResolved
            ? summary.source.perspective
            : (ctx.config.perspectiveOverride ?? undefined),
        priorityDomain: "sanity.io",
        projectId: sourceResolved
            ? summary.source.projectId
            : (ctx.config.projectIdOverride ?? "3do82whm"),
        studioOrigin: "https://admin.sanity.io",
        urls: [],
    };
    // Debug runs don't store fingerprints
    const evalFingerprint = !ctx.config.debug?.enabled
        ? options.evalFingerprint
        : undefined;
    // Portable hash of the assembled per-task documentation context (the
    // canonical context files the agent saw). Proof for the doc-impact
    // apples-to-apples guarantee: injecting a doc moves this hash. Undefined for
    // non-literacy runs or when no canonical context was fetched (D0059).
    const contextHash = computeContextHash(ctx.config.rootDir);
    return {
        areas,
        autoScope,
        callerGit: ctx.config.callerGit,
        callerEnvelope: ctx.config.callerEnvelope,
        contextHash,
        evalFingerprint,
        mode,
        promptfooUrls: options.promptfooUrls,
        rootDir: ctx.config.rootDir,
        runId: ctx.runId,
        sanityDocumentIds,
        source,
        sourceReportId: ctx.config.sourceReportId,
        comparedAgainstReportId: ctx.config.compareBaselineReportId,
        injectedContextDocs: ctx.config.injectContextDocs,
        taskIds,
        variant: ctx.config.variant,
        requestedModelIds: ctx.config.models,
    };
}
/**
 * Strip the large responseOutput fields from a StoredTestResult so the
 * remaining object is safe to inline in the Content Lake document (D0030).
 * The full output lives in the GCS artifact uploaded by uploadTestOutputs.
 */
function slimTestResult(tr) {
    const { responseOutput: _o, responseOutputTruncated: _t, ...rest } = tr;
    return rest;
}
/**
 * Fan out a report to all configured sinks.
 *
 * Uses ctx.sinks from the composition root instead of loadSinks().
 * Each sink is run with retry logic (3 attempts, exponential backoff).
 * Failures are logged but never block the pipeline.
 */
async function runSinks(report, ctx) {
    const sinks = (ctx.sinks ?? []);
    const sinkResults = [];
    if (sinks.length === 0) {
        return { report, sinkResults };
    }
    // Health check all sinks first (non-blocking)
    for (const sink of sinks) {
        if (sink.healthCheck) {
            try {
                const health = await sink.healthCheck();
                if (!health.healthy) {
                    console.warn(`  ⚠️  Sink ${sink.name} health check failed: ${health.reason}`);
                }
            }
            catch (err) {
                console.warn(`  ⚠️  Sink ${sink.name} health check error: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    // Publish to all sinks in parallel (fire-and-forget with retries)
    const settled = await Promise.allSettled(sinks.map(async (sink) => {
        const result = await withRetry(() => sink.publish(report));
        return { name: sink.name, result };
    }));
    for (const outcome of settled) {
        if (outcome.status === "fulfilled") {
            sinkResults.push(outcome.value);
            const { name, result } = outcome.value;
            if (result.status === "failed") {
                console.warn(`  ⚠️  Sink ${name} failed: ${result.error}`);
            }
            else if (result.status === "skipped") {
                console.log(`  ⏭️  Sink ${name} skipped: ${result.reason}`);
            }
            else {
                console.log(`  ✅ Sink ${name} delivered${result.detail ? ` (${result.detail})` : ""}`);
            }
        }
        else {
            const error = outcome.reason instanceof Error
                ? outcome.reason.message
                : String(outcome.reason);
            sinkResults.push({
                name: "unknown",
                result: { error, status: "failed" },
            });
            console.warn(`  ⚠️  Sink delivery error: ${error}`);
        }
    }
    return { report, sinkResults };
}
