/**
 * report-to-markdown.ts — Canonical PR-comment markdown renderer (W0150).
 *
 * Single source of truth for rendering an AILF report as PR-comment
 * markdown. Used by:
 *   - the API gateway (`/v1/reports/:id/markdown`)
 *   - the eval pipeline (`ailf pr-comment` CLI / `pipeline/pr-comment.ts`)
 *
 * Operates on a structurally lenient `RenderableReport` shape so callers
 * can pass either the persisted slim Report (Sanity doc) or an in-memory
 * envelope built from `score-summary.json` + `comparison-report.json`.
 *
 * Canonical formatting decisions (W0150):
 *   - Header: level-1 (`# {emoji} AI Literacy Score Report`).
 *   - Footer: markdown link form `[view detailed results](url)`. The link
 *     URL comes from `provenance.promptfooUrls[0].url`.
 *   - Source verification block (sourceVerification + sourceIsolation) is
 *     rendered when present on the summary — preserves info from agentic
 *     / sandboxed local-mode runs without breaking remote-mode reports
 *     that don't carry those fields.
 */
import { errorSummaryTrips } from "./error-summary-policy.js";
import { classifyCacheState, describeCacheState, } from "./report-cache-state.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function reportToMarkdown(report) {
    const md = new MarkdownBuilder();
    const summary = report.summary;
    const comparison = report.comparison;
    const provenance = report.provenance;
    // Header. When errors trip the shared B2 condition the run cannot read as
    // healthy — the status glyph becomes a ⚠️ warning affordance regardless of
    // the (score-excluding-errored) average (W-pr-comment-error-block).
    const errorSummary = summary?.errorSummary;
    const errorsTrip = errorSummaryTrips(errorSummary);
    const avgScore = summary?.overall?.avgScore;
    const headerEmoji = errorsTrip ? "⚠️" : scoreEmoji(avgScore);
    md.heading(1, `${headerEmoji} AI Literacy Score Report`);
    md.blank();
    // Subtitle line
    const parts = [];
    if (avgScore !== undefined) {
        parts.push(`**Overall: ${round(avgScore)}/100**`);
    }
    if (summary?.overall?.avgDocLift !== undefined) {
        parts.push(`Doc Lift: ${signedNum(round(summary.overall.avgDocLift))}`);
    }
    if (summary?.overall?.avgActualScore !== undefined) {
        parts.push(`Actual: ${round(summary.overall.avgActualScore)}/100`);
    }
    if (summary?.overall?.avgRetrievalGap !== undefined) {
        parts.push(`Ret. Gap: ${round(summary.overall.avgRetrievalGap)}`);
    }
    const scores = normalizeScores(summary?.scores);
    const testCount = scores.reduce((s, sc) => s + (sc.testCount ?? 0), 0);
    // Truthful count (W-pr-comment-error-block): when errorSummary is present the
    // scored count (sum of testCount, which excludes errored tests) understates
    // the run. Show total AND errored from errorSummary so a run that errored on
    // half its tests can no longer read as "16 tests". Fall back to the legacy
    // "N tests across M areas" phrasing when there is no error data.
    if (errorSummary) {
        parts.push(`${errorSummary.totalTestCount} tests · ${errorSummary.erroredTestCount} errored`);
    }
    else if (scores.length > 0) {
        parts.push(`${testCount} tests across ${scores.length} areas`);
    }
    const totalCost = computeTotalCost(scores, summary?.overall);
    if (totalCost > 0) {
        parts.push(`Cost: ${fmtCost(totalCost)}`);
    }
    if (parts.length > 0) {
        md.line(parts.join(" · "));
        md.blank();
    }
    renderMetadata(md, report);
    // Cache provenance — adjacent to the Duration/cost rows so a reader cannot
    // mistake inherited cost for this-run spend. The manifest is top-level on a
    // domain Report (pipeline path) and under `summary` on a persisted doc
    // (API path); read whichever is present (W-cache-provenance-notifications).
    const cacheRunId = provenance?.runId;
    if (cacheRunId) {
        const cacheNote = describeCacheState(classifyCacheState({
            currentRunId: cacheRunId,
            artifactManifest: report.artifactManifest ?? summary?.artifactManifest,
        }));
        if (cacheNote) {
            md.line(`> ♻️ **${cacheNote.label}** — ${cacheNote.summary}`);
            md.blank();
        }
    }
    const belowCritical = summary?.belowCritical ?? [];
    if (belowCritical.length > 0) {
        md.line(`> ⚠️ **Below critical threshold:** ${belowCritical.map((a) => `\`${a}\``).join(", ")}`);
        md.blank();
    }
    // Errors block — rendered only when the shared B2 condition trips, so a clean
    // run (or one below the error-rate threshold with no fully-errored task) is
    // unchanged from today (W-pr-comment-error-block).
    if (errorsTrip && errorSummary) {
        renderErrors(md, errorSummary);
    }
    renderSource(md, summary?.source);
    renderSourceVerification(md, summary?.sourceVerification, summary?.sourceIsolation);
    if (scores.length > 0) {
        renderScoreTable(md, scores);
    }
    const hasActual = scores.some((s) => s.actualScore !== undefined);
    if (hasActual) {
        renderThreeLayerDecomposition(md, scores);
    }
    else if (scores.length > 0) {
        renderCeilingDecomposition(md, scores);
    }
    renderPerModel(md, summary?.perModel);
    if (comparison) {
        renderComparison(md, comparison);
    }
    renderCostBreakdown(md, scores, summary?.overall);
    renderGapRecommendations(md, summary?.recommendations);
    renderLowScoringJudgments(md, summary?.lowScoringJudgments);
    renderRecommendations(md, scores, belowCritical);
    renderFooter(md, report, provenance);
    return md.toString();
}
// ---------------------------------------------------------------------------
// Section renderers
// ---------------------------------------------------------------------------
function renderMetadata(md, report) {
    const items = [];
    if (report.id) {
        const studioUrl = buildStudioUrl(report.id);
        items.push(["Report ID", `[\`${report.id}\`](${studioUrl})`]);
    }
    if (report.completedAt) {
        items.push(["Completed", fmtDate(report.completedAt)]);
    }
    if (typeof report.durationMs === "number") {
        items.push(["Duration", fmtDuration(report.durationMs)]);
    }
    if (report.tag) {
        items.push(["Tag", report.tag]);
    }
    const summary = report.summary;
    if (summary?.evaluationMode) {
        items.push(["Mode", summary.evaluationMode]);
    }
    if (items.length > 0) {
        md.table(["Field", "Value"], items);
        md.blank();
    }
}
function renderSource(md, source) {
    if (!source)
        return;
    const rows = [];
    if (source.name)
        rows.push(["Source", source.name]);
    if (source.baseUrl)
        rows.push(["Docs URL", source.baseUrl]);
    if (source.dataset)
        rows.push(["Dataset", source.dataset]);
    if (source.projectId)
        rows.push(["Project", source.projectId]);
    if (source.perspective)
        rows.push(["Perspective", source.perspective]);
    if (rows.length > 0) {
        md.details("🔧 Environment", () => {
            md.table(["Setting", "Value"], rows);
        });
        md.blank();
    }
}
function renderSourceVerification(md, verification, isolation) {
    if (!verification && !isolation)
        return;
    md.details("🔍 Source verification", () => {
        const rows = [];
        if (verification) {
            rows.push(["Source", verification.source]);
            rows.push(["Mode", verification.mode]);
            if (verification.allowedOrigins) {
                rows.push(["Sandbox", verification.allowedOrigins.join(", ")]);
            }
            if (verification.searchMode) {
                rows.push(["Search", verification.searchMode]);
            }
        }
        if (isolation) {
            const pct = Math.round(isolation.isolationScore * 100);
            const icon = isolation.offOrigin === 0 ? "✅" : "⚠️";
            rows.push([
                "Agent isolation",
                `${icon} ${pct}% (${isolation.onOrigin}/${isolation.total} on-origin)`,
            ]);
            if (isolation.offOrigin > 0) {
                rows.push([
                    "Off-origin fetches",
                    isolation.offOriginUrls.slice(0, 5).join(", "),
                ]);
            }
        }
        if (verification?.urlFetch) {
            const uf = verification.urlFetch;
            rows.push([
                "URL fetch",
                `${uf.totalFetched} fetched, ${uf.totalFailed} failed`,
            ]);
            for (const f of uf.fetchedUrls) {
                rows.push(["", `✅ ${f.url} (via ${f.method})`]);
            }
            for (const f of uf.failures) {
                rows.push([
                    "",
                    `⚠️ ${f.url}: ${f.error && f.error.length > 0 ? f.error : "unknown"}`,
                ]);
            }
        }
        md.table(["Setting", "Value"], rows);
    });
    md.blank();
}
/**
 * Errors block (W-pr-comment-error-block) — surfaces the tests that produced no
 * gradeable output so the PR author sees the break the score silently excluded.
 * Called only when {@link errorSummaryTrips} is true; mirrors the B3 Slack
 * errors section (fully-errored tasks with id + area + classification + a
 * truncated sample error, plus classification counts).
 */
function renderErrors(md, es) {
    const rate = Math.round(es.errorRate * 100);
    md.line(`> ⚠️ **Test errors:** ${es.erroredTestCount} of ${es.totalTestCount} tests errored (${rate}%) — excluded from the score above.`);
    md.blank();
    if (es.fullyErroredTasks.length > 0) {
        md.heading(3, "⚠️ Errored tasks");
        md.blank();
        md.table(["Task", "Area", "Classification", "Sample error"], es.fullyErroredTasks.map((t) => [
            `\`${t.taskId}\``,
            t.area,
            `\`${t.classification}\``,
            truncateSample(t.sampleError),
        ]));
        md.blank();
    }
    const counts = Object.entries(es.byClassification)
        .filter(([, n]) => n > 0)
        .map(([k, n]) => `\`${k}\` ×${n}`)
        .join(" · ");
    if (counts.length > 0) {
        md.line(`*By classification: ${counts}*`);
        md.blank();
    }
}
function renderScoreTable(md, scores) {
    const sorted = [...scores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0));
    md.heading(3, "Scores by Feature Area");
    md.blank();
    const headers = [
        "Feature",
        "Score",
        "Grade",
        "Task",
        "Code",
        "Docs",
        "Doc Lift",
        "Tests",
    ];
    const rows = sorted.map((s) => [
        s.feature ?? "—",
        `**${round(s.totalScore)}**`,
        `${gradeEmoji(s.totalScore)} ${gradeLetter(s.totalScore)}`,
        String(round(s.taskCompletion)),
        String(round(s.codeCorrectness)),
        String(round(s.docCoverage)),
        liftArrow(s.docLift),
        String(s.testCount ?? 0),
    ]);
    md.table(headers, rows);
    md.blank();
    const negAreas = sorted.filter((s) => s.negativeDocLift);
    for (const s of negAreas) {
        md.line(`> 🚨 **Negative Doc Lift:** \`${s.feature}\` (${s.docLift}) — docs hurt performance. Floor: ${s.floorScore}, Ceiling: ${s.ceilingScore}`);
    }
    if (negAreas.length > 0)
        md.blank();
}
function renderCeilingDecomposition(md, scores) {
    const sorted = [...scores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0));
    md.details("📊 Ceiling decomposition", () => {
        md.table(["Feature", "Floor", "Ceiling", "Doc Lift", "Quality Gap"], sorted.map((s) => [
            s.feature ?? "—",
            String(s.floorScore ?? 0),
            String(s.ceilingScore ?? 0),
            liftArrow(s.docLift),
            String(s.docQualityGap ?? 0),
        ]));
    });
    md.blank();
}
function renderThreeLayerDecomposition(md, scores) {
    const sorted = [...scores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0));
    md.heading(3, "🔬 Three-Layer Decomposition");
    md.blank();
    md.table([
        "Feature",
        "Floor",
        "Ceiling",
        "Actual",
        "Doc Lift",
        "Retr. Gap",
        "Infra %",
    ], sorted.map((s) => {
        const actualStr = s.actualScore !== undefined ? String(s.actualScore) : "—";
        const gapStr = s.retrievalGap !== undefined ? signedNum(s.retrievalGap) : "—";
        const infraStr = s.infrastructureEfficiency != null
            ? `${Math.round(s.infrastructureEfficiency * 100)}%`
            : "—";
        const flag = s.invertedRetrievalGap ? " 🔄" : "";
        return [
            s.feature ?? "—",
            String(s.floorScore ?? 0),
            String(s.ceilingScore ?? 0),
            actualStr,
            liftArrow(s.docLift),
            `${gapStr}${flag}`,
            infraStr,
        ];
    }));
    md.blank();
    md.details("📖 What do these numbers mean?", () => {
        md.line("- **Floor:** Model performance without any documentation (training data only)");
        md.line("- **Ceiling:** Model performance with perfect documentation (hand-picked, injected)");
        md.line("- **Actual:** Model performance when finding docs on its own (like real users)");
        md.line("- **Doc Lift:** Documentation quality contribution (Ceiling − Floor)");
        md.line("- **Retr. Gap:** Quality lost in discovery (Ceiling − Actual)");
        md.line("- **Infra %:** Fraction of doc quality reaching agents (Actual ÷ Ceiling)");
    });
    md.blank();
}
function renderPerModel(md, perModel) {
    if (!perModel || perModel.length <= 1)
        return;
    const sorted = [...perModel].sort((a, b) => b.overall.avgScore - a.overall.avgScore);
    md.details("🤖 Per-model scores", () => {
        md.table(["Model", "Score", "Doc Lift", "Tests", "Cost"], sorted.map((e) => [
            e.label || e.modelId,
            `**${round(e.overall.avgScore)}**`,
            signedNum(round(e.overall.avgDocLift)),
            String(e.overall.testCount),
            e.overall.cost ? fmtCost(e.overall.cost) : "—",
        ]));
        md.blank();
        for (const entry of sorted) {
            const name = entry.label || entry.modelId;
            md.line(`**${name}** (\`${entry.modelId}\`):`);
            md.blank();
            md.table(["Feature", "Score", "Task", "Code", "Docs", "Lift"], entry.scores.map((s) => [
                s.feature ?? "—",
                `**${s.totalScore ?? 0}**`,
                String(s.taskCompletion ?? 0),
                String(s.codeCorrectness ?? 0),
                String(s.docCoverage ?? 0),
                signedNum(s.docLift),
            ]));
            md.blank();
        }
    });
    md.blank();
}
function renderComparison(md, cmp) {
    const overallDelta = cmp.deltas.overall;
    const icon = overallDelta > cmp.noiseThreshold
        ? "📈"
        : overallDelta < -cmp.noiseThreshold
            ? "📉"
            : "➡️";
    md.heading(3, "📊 Score Comparison");
    md.blank();
    const baselineScore = cmp.baseline?.overall.avgScore;
    const experimentScore = cmp.experiment?.overall.avgScore;
    if (baselineScore !== undefined && experimentScore !== undefined) {
        md.line(`**Overall: ${round(baselineScore)} → ${round(experimentScore)}** (${icon} ${signedNum(round(overallDelta))})`);
    }
    else {
        md.line(`**Overall delta: ${signedNum(round(overallDelta))}** (${icon}, threshold ±${cmp.noiseThreshold})`);
    }
    md.blank();
    const hasActualDeltas = cmp.areas.some((a) => a.actualDelta !== undefined);
    if (hasActualDeltas) {
        md.table([
            "Feature",
            "Baseline",
            "Current",
            "Delta",
            "Actual Δ",
            "Ret. Gap Δ",
            "Infra Δ",
        ], cmp.areas.map((a) => {
            const changeIcon = changeEmoji(a.change);
            const actualStr = a.actualDelta !== undefined ? signedNum(round(a.actualDelta)) : "—";
            const retGapStr = a.retrievalGapDelta !== undefined
                ? signedNum(round(a.retrievalGapDelta))
                : "—";
            const infraStr = a.infrastructureEfficiencyDelta !== undefined
                ? `${a.infrastructureEfficiencyDelta > 0 ? "+" : ""}${Math.round(a.infrastructureEfficiencyDelta * 100)}pp`
                : "—";
            return [
                a.area,
                String(a.baseline),
                String(a.experiment),
                a.delta !== undefined
                    ? `${changeIcon} ${signedNum(round(a.delta))}`
                    : "—",
                actualStr,
                retGapStr,
                infraStr,
            ];
        }));
    }
    else {
        md.table(["Feature", "Baseline", "Current", "Delta", "Task", "Code", "Docs"], cmp.areas.map((a) => {
            const changeIcon = changeEmoji(a.change);
            // TODO(multi-mode): Literacy-specific dimension keys.
            const taskDelta = a.dimensions?.taskCompletion?.delta;
            const codeDelta = a.dimensions?.codeCorrectness?.delta;
            const docDelta = a.dimensions?.docCoverage?.delta;
            return [
                a.area,
                String(a.baseline),
                String(a.experiment),
                a.delta !== undefined
                    ? `${changeIcon} ${signedNum(round(a.delta))}`
                    : "—",
                taskDelta !== undefined ? signedNum(round(taskDelta)) : "—",
                codeDelta !== undefined ? signedNum(round(codeDelta)) : "—",
                docDelta !== undefined ? signedNum(round(docDelta)) : "—",
            ];
        }));
    }
    md.blank();
    const summaryParts = [];
    if (cmp.improved && cmp.improved.length > 0) {
        summaryParts.push(`📈 ${cmp.improved.length} improved`);
    }
    if (cmp.regressed && cmp.regressed.length > 0) {
        summaryParts.push(`📉 ${cmp.regressed.length} regressed`);
    }
    if (cmp.unchanged && cmp.unchanged.length > 0) {
        summaryParts.push(`➡️ ${cmp.unchanged.length} unchanged`);
    }
    if (cmp.notEvaluated && cmp.notEvaluated.length > 0) {
        summaryParts.push(`⏭️ ${cmp.notEvaluated.length} not evaluated`);
    }
    if (summaryParts.length > 0) {
        const thresholdNote = cmp.noiseThresholdEmpirical
            ? ` (empirical threshold: ±${cmp.noiseThreshold.toFixed(1)})`
            : ` (threshold: ±${cmp.noiseThreshold})`;
        md.line(summaryParts.join(" · ") + thresholdNote);
        md.blank();
    }
    md.details("Dimension averages", () => {
        const dim = cmp.deltas.perDimension ?? {};
        const rows = Object.entries(dim).map(([k, v]) => [
            dimensionLabel(k),
            signedNum(round(v)),
        ]);
        rows.push(["Doc Lift", signedNum(round(cmp.deltas.docLift))]);
        md.table(["Dimension", "Delta"], rows);
    });
    md.blank();
}
function renderCostBreakdown(md, scores, overall) {
    const providerCost = scores.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
    const graderCost = overall?.cost?.graderTotal ?? 0;
    const combinedCost = providerCost + graderCost;
    if (combinedCost <= 0)
        return;
    md.details("💰 Eval cost breakdown", () => {
        const rows = [
            ["Provider (model inference)", fmtCost(providerCost)],
        ];
        if (graderCost > 0) {
            const label = overall?.cost?.graderModel ?? "unknown";
            rows.push([`Grader (${label})`, fmtCost(graderCost)]);
        }
        rows.push(["**Total**", `**${fmtCost(combinedCost)}**`]);
        md.table(["Category", "Cost"], rows);
        md.blank();
        const sorted = [...scores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0));
        md.line("**Provider cost by feature area:**");
        md.blank();
        md.table(["Feature", "Tests", "Cost", "Avg/Test"], sorted.map((s) => {
            const avgCost = (s.testCount ?? 0) > 0 ? (s.totalCost ?? 0) / (s.testCount ?? 1) : 0;
            return [
                s.feature ?? "—",
                String(s.testCount ?? 0),
                fmtCost(s.totalCost ?? 0),
                fmtCost(avgCost),
            ];
        }));
    });
    md.blank();
}
function renderGapRecommendations(md, recommendations) {
    if (!recommendations)
        return;
    const top3 = recommendations.top3 ?? [];
    const counts = recommendations.counts ?? {};
    if (top3.length === 0 && Object.keys(counts).length === 0)
        return;
    const lift = recommendations.totalPotentialLift ?? 0;
    const totalGaps = recommendations.totalGaps ?? top3.length;
    const liftSuffix = lift > 0 ? ` (+${lift.toFixed(1)} pts potential lift)` : "";
    md.details(`📋 Recommendations — ${totalGaps} gaps${liftSuffix}`, () => {
        md.blank();
        if (top3.length > 0) {
            md.line(`**Top ${top3.length} by priority:**`);
            md.blank();
            md.table(["#", "Area", "Failure Mode", "Priority"], top3.map((gap, i) => [
                String(i + 1),
                gap.area,
                gap.title,
                String(gap.priority),
            ]));
            md.blank();
        }
        const countEntries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
        if (countEntries.length > 0) {
            md.line("**Gap counts by area:**");
            md.blank();
            md.table(["Area", "Gaps"], countEntries.map(([area, n]) => [area, String(n)]));
        }
    });
    md.blank();
}
function renderLowScoringJudgments(md, judgments) {
    if (!judgments || judgments.length === 0)
        return;
    const byArea = new Map();
    for (const j of judgments) {
        const sep = j.taskId.indexOf(" - ");
        const area = sep > 0 ? j.taskId.substring(0, sep) : j.taskId;
        if (!byArea.has(area))
            byArea.set(area, []);
        byArea.get(area).push(j);
    }
    const groups = [...byArea.entries()]
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([area, js]) => [area, js.sort((a, b) => a.score - b.score)]);
    md.details(`🔍 Low-Scoring Judgments (${judgments.length} below 70)`, () => {
        md.blank();
        for (const [area, areaJudgments] of groups) {
            md.heading(4, `${area} (${areaJudgments.length})`);
            md.blank();
            for (const j of areaJudgments) {
                const sep = j.taskId.indexOf(" - ");
                const taskName = sep > 0 ? j.taskId.substring(sep + 3) : j.taskId;
                const dimLabel = dimensionLabel(j.dimension);
                md.line(`**${gradeEmoji(j.score)} ${j.score}** · ${dimLabel} · ${taskName} · \`${j.modelId}\``);
                md.blank();
                const reasonLines = j.reason
                    .split("\n")
                    .map((l) => `> ${l}`)
                    .join("\n");
                md.line(reasonLines);
                md.blank();
                const jDocs = j.contextDocs ?? j.canonicalDocs;
                if (jDocs && jDocs.length > 0) {
                    const docList = jDocs.map((d) => `\`${d.slug}\``).join(", ");
                    md.line(`*Expected docs: ${docList}*`);
                    md.blank();
                }
            }
        }
    });
    md.blank();
}
function renderRecommendations(md, scores, belowCritical) {
    const sorted = [...scores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0));
    const needsRecs = belowCritical.length > 0 || sorted.some((s) => (s.totalScore ?? 100) < 70);
    if (!needsRecs)
        return;
    md.heading(3, "💡 Recommendations");
    md.blank();
    for (const s of sorted) {
        const score = s.totalScore ?? 0;
        if (score < 50) {
            md.line(`- 🔴 **${s.feature}** (score: ${round(score)}) — needs significant doc improvements.`);
            if ((s.codeCorrectness ?? 0) < 10) {
                md.line(`  Code correctness is very low (${round(s.codeCorrectness)}) — add more complete code examples.`);
            }
            if ((s.docCoverage ?? 0) < 10) {
                md.line(`  Doc coverage is very low (${round(s.docCoverage)}) — key APIs/patterns may be missing from docs.`);
            }
        }
        else if (score < 70) {
            md.line(`- 🟠 **${s.feature}** (score: ${round(score)}) — has room for improvement.`);
            if ((s.codeCorrectness ?? 0) < 15) {
                md.line(`  Code correctness (${round(s.codeCorrectness)}) could improve with better code examples.`);
            }
        }
    }
    md.blank();
}
function renderFooter(md, report, provenance) {
    md.line("---");
    const date = report.completedAt ? fmtDate(report.completedAt) : undefined;
    const parts = [
        `[AI Literacy Framework](https://github.com/sanity-labs/ai-literacy-framework)`,
    ];
    if (date)
        parts.push(date);
    const promptfooUrl = provenance?.promptfooUrls?.[0]?.url;
    if (typeof promptfooUrl === "string" && promptfooUrl.length > 0) {
        parts.push(`[view detailed results](${promptfooUrl})`);
    }
    md.line(`*Generated by ${parts.join(" · ")}*`);
}
function buildStudioUrl(reportId) {
    const origin = process.env.SANITY_STUDIO_ORIGIN ?? "https://admin.sanity.io";
    return `${origin}/ailf/report/${reportId}`;
}
// ---------------------------------------------------------------------------
// MarkdownBuilder
// ---------------------------------------------------------------------------
class MarkdownBuilder {
    lines = [];
    blank() {
        this.lines.push("");
    }
    details(summary, body) {
        this.lines.push("<details>");
        this.lines.push(`<summary>${summary}</summary>`);
        this.lines.push("");
        body();
        this.lines.push("</details>");
    }
    heading(level, text) {
        this.lines.push(`${"#".repeat(level)} ${text}`);
    }
    line(text) {
        this.lines.push(text);
    }
    table(headers, rows) {
        this.lines.push(`| ${headers.join(" | ")} |`);
        this.lines.push(`|${headers.map(() => "---").join("|")}|`);
        for (const row of rows) {
            this.lines.push(`| ${row.join(" | ")} |`);
        }
    }
    toString() {
        return this.lines.join("\n") + "\n";
    }
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function changeEmoji(change) {
    if (change === "improved")
        return "📈";
    if (change === "regressed")
        return "📉";
    if (change === "not-evaluated")
        return "⏭️";
    return "➡️";
}
function computeTotalCost(scores, overall) {
    const provider = scores.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
    const grader = overall?.cost?.graderTotal ?? 0;
    return provider + grader;
}
function dimensionLabel(dim) {
    return dim
        .split("-")
        .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
        .join(" ");
}
function fmtCost(cost) {
    if (cost === 0)
        return "$0.00";
    if (cost < 0.01)
        return `$${cost.toFixed(4)}`;
    return `$${cost.toFixed(2)}`;
}
function fmtDate(iso) {
    try {
        return new Date(iso).toLocaleString("en-US", {
            day: "numeric",
            hour: "numeric",
            minute: "2-digit",
            month: "short",
            timeZone: "UTC",
            timeZoneName: "short",
            year: "numeric",
        });
    }
    catch {
        return iso;
    }
}
function fmtDuration(ms) {
    if (ms < 1000)
        return `${ms}ms`;
    const secs = ms / 1000;
    if (secs < 60)
        return `${secs.toFixed(1)}s`;
    const mins = Math.floor(secs / 60);
    const remSecs = Math.round(secs % 60);
    return `${mins}m ${remSecs}s`;
}
function gradeEmoji(score) {
    if (score === undefined)
        return "—";
    if (score >= 80)
        return "✅";
    if (score >= 70)
        return "🟡";
    if (score >= 50)
        return "🟠";
    return "🔴";
}
function gradeLetter(score) {
    if (score === undefined)
        return "—";
    if (score >= 80)
        return "A";
    if (score >= 70)
        return "B";
    if (score >= 50)
        return "C";
    return "D";
}
function liftArrow(lift) {
    if (lift === undefined)
        return "—";
    const rounded = Math.round(lift);
    if (rounded > 0)
        return `📈 +${rounded}`;
    if (rounded < 0)
        return `📉 ${rounded}`;
    return "➡️ 0";
}
function normalizeScores(raw) {
    if (!Array.isArray(raw))
        return [];
    return raw.filter((s) => s != null && typeof s === "object");
}
function round(n) {
    if (n === undefined)
        return 0;
    return Math.round(n);
}
function scoreEmoji(avg) {
    if (avg === undefined)
        return "📊";
    if (avg >= 75)
        return "🟢";
    if (avg >= 60)
        return "🟡";
    if (avg >= 45)
        return "🟠";
    return "🔴";
}
function signedNum(n) {
    if (n === undefined)
        return "—";
    return n > 0 ? `+${n}` : String(n);
}
/** Max characters of a sample error to inline in the errors table. */
const SAMPLE_ERROR_MAX = 200;
/**
 * Collapse whitespace, escape pipes (so the sample can't break the markdown
 * table), and truncate a sample error with an ellipsis. Mirrors the B3 Slack
 * errors section's truncation.
 */
function truncateSample(text) {
    const collapsed = text.replace(/\s+/g, " ").trim().replace(/\|/g, "\\|");
    return collapsed.length > SAMPLE_ERROR_MAX
        ? `${collapsed.slice(0, SAMPLE_ERROR_MAX - 1)}…`
        : collapsed;
}
