/**
 * pipeline/eval-fingerprint.ts
 *
 * Computes a deterministic fingerprint of all inputs that affect evaluation
 * output. Used for cross-environment cache lookup: when running in CI, the
 * pipeline can query the Sanity Content Lake for a previous report with an
 * identical fingerprint and skip the expensive eval step.
 *
 * The fingerprint captures:
 * - Evaluation mode (baseline, observed, agentic)
 * - Grader model identity (different graders score differently)
 * - The task set that was actually loaded for this run, in its canonical
 *   shape (taken straight from `ctx.taskSource.loadTasks(filter)` so that
 *   Studio-authored task edits in the Content Lake are picked up — pre-v2
 *   the fingerprint walked `tasks/` on disk and missed them entirely).
 * - Repo-tracked config (models, prompts, rubrics) and reference solutions.
 * - Fetched canonical doc content (contexts/canonical/*.md).
 *
 * The fingerprint intentionally EXCLUDES:
 * - Source name/URL (content matters, not origin).
 * - Git metadata (informational, not eval-affecting).
 * - Trigger type (manual vs CI → same inputs → same results).
 * - Report tags (human labels).
 *
 * @see docs/design-docs/content-lake-eval-caching.md
 */
import { createHash } from "crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join, relative, resolve } from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
 * Version prefix for the fingerprint hash. Bumping this invalidates all
 * existing fingerprints in the Content Lake without needing to clear the
 * store. Change this when adding new inputs to the hash.
 *
 * v2 (2026-04-29): tasks now sourced from ctx.taskSource (not on-disk
 * files), file paths normalized to rootDir-relative, grader passed
 * through verbatim instead of the literal string "default".
 *
 * v3 (2026-05-06): grader-context policy ("rubric-only" vs "with-docs")
 * affects rubricPrompt content and therefore eval output, so it must be
 * hashed. Bumping invalidates v2 fingerprints.
 *
 * v4 (2026-05-08): grader-context default flipped from "rubric-only" to
 * "with-docs" (W0200 / DOC-2117) after PR #555 validation receipts. The
 * undefined-equivalence at the EvalConfig boundary moves with it, so
 * cached v3 fingerprints would no longer match the same EvalConfig.
 * Bumping invalidates v3 fingerprints.
 */
const FINGERPRINT_VERSION = "eval-fingerprint-v4";
/**
 * Compute a deterministic SHA-256 fingerprint of all evaluation inputs.
 *
 * Identical inputs always produce the same fingerprint, regardless of the
 * environment (local, CI, etc.). Cross-environment portability relies on
 * (a) tasks coming from the same Content Lake source and (b) file paths
 * being hashed as rootDir-relative.
 *
 * @returns SHA-256 hex string (64 characters)
 */
export function computeEvalFingerprint(input) {
    const { graderModel, mode, rootDir, tasks } = input;
    const graderContext = input.graderContext ?? "with-docs";
    const hash = createHash("sha256");
    hash.update(`version:${FINGERPRINT_VERSION}\n`);
    hash.update(`mode:${mode}\n`);
    hash.update(`grader:${graderModel}\n`);
    hash.update(`graderContext:${graderContext}\n`);
    hash.update(`tasks:${hashTaskSet(tasks)}\n`);
    // Hash repo-tracked + fetched files. Paths are stored as rootDir-relative
    // so a CI runner at /home/runner/... and a laptop at /Users/... produce
    // the same hash for byte-identical content.
    const filePaths = collectFingerprintFilePaths(rootDir);
    for (const p of [...filePaths].sort(byteCompare)) {
        hash.update(`path:${relative(rootDir, p)}\n`);
        if (existsSync(p)) {
            hash.update(readFileSync(p));
        }
        else {
            hash.update("__missing__\n");
        }
        hash.update("\n---\n");
    }
    return hash.digest("hex");
}
/**
 * Collect repo-tracked + fetched file paths that contribute to the
 * fingerprint. Tasks are NOT collected here — they come from
 * `ctx.taskSource.loadTasks()` and flow into the hash via the `tasks`
 * input on `computeEvalFingerprint`.
 *
 * Exported for the debug-fingerprint diagnostic script.
 */
export function collectFingerprintFilePaths(rootDir) {
    const r = (rel) => resolve(rootDir, rel);
    const paths = [];
    // Config files (any of the supported extensions)
    const configNames = ["models", "prompts", "rubrics"];
    const configExts = [".ts", ".js", ".yaml", ".yml", ".json"];
    for (const name of configNames) {
        for (const ext of configExts) {
            const p = r(`config/${name}${ext}`);
            if (existsSync(p))
                paths.push(p);
        }
    }
    // Reference solutions — recursive (mixed languages, nested by area)
    const refDir = r("canonical/reference-solutions");
    if (existsSync(refDir))
        collectFilesRecursive(refDir, paths);
    // Canonical context files — the fetched documentation content. These
    // change whenever the Content Lake source shifts, so they capture
    // doc-level edits that the task set itself wouldn't reflect.
    const canonicalDir = r("contexts/canonical");
    if (existsSync(canonicalDir)) {
        const contextFiles = readdirSync(canonicalDir)
            .filter((f) => f.endsWith(".md"))
            .sort(byteCompare);
        for (const f of contextFiles)
            paths.push(join(canonicalDir, f));
    }
    return paths;
}
// ---------------------------------------------------------------------------
// Canonical serialization — byte-stable across runtimes
// ---------------------------------------------------------------------------
/**
 * Compare two strings by their UTF-8 byte representation.
 *
 * Why this and not `localeCompare` or default `<`: `localeCompare` calls
 * into ICU, whose tables can vary between Node builds (small-icu vs
 * full-icu) and platforms. The default JS string comparison uses UTF-16
 * code units, which diverges from UTF-8 byte order for surrogate pairs.
 * `Buffer.compare` on UTF-8 is well-defined and runtime-independent —
 * the right primitive when the result feeds a hash.
 */
function byteCompare(a, b) {
    return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
}
/**
 * Recursively normalize `value` for stable serialization: object keys
 * sorted by UTF-8 byte order; arrays preserve order (the caller decides
 * whether to pre-sort).
 */
function canonicalize(value) {
    if (Array.isArray(value))
        return value.map(canonicalize);
    if (value !== null && typeof value === "object") {
        const out = {};
        const obj = value;
        for (const k of Object.keys(obj).sort(byteCompare)) {
            out[k] = canonicalize(obj[k]);
        }
        return out;
    }
    return value;
}
/**
 * Hash a task set in a way that's invariant under source ordering and
 * optional-field-spread reorder.
 *
 * Each task is canonicalized once, then the array is sorted by
 * `(id, canonical-json)`. The secondary sort key matters: the Content
 * Lake currently has duplicate `ailf.task` documents that share the
 * same `id.current` but differ in body (DOC-2096). With only the id as
 * the sort key, two such duplicates compare equal and their relative
 * order falls back to GROQ's input order — which is undefined for
 * equal `(area, id)` rows, so the hash could shift between runs over
 * the same dataset. Tiebreaking on the serialized content makes the
 * hash deterministic even in the presence of dup-id rows.
 */
function hashTaskSet(tasks) {
    const serialized = tasks.map((t) => ({
        id: t.id,
        json: JSON.stringify(canonicalize(t)),
    }));
    serialized.sort((a, b) => byteCompare(a.id, b.id) || byteCompare(a.json, b.json));
    const arrayJson = "[" + serialized.map((e) => e.json).join(",") + "]";
    return createHash("sha256").update(arrayJson).digest("hex");
}
// ---------------------------------------------------------------------------
// File walk
// ---------------------------------------------------------------------------
/**
 * Recursively collect all file paths under a directory.
 * Skips hidden files and directories (starting with '.').
 */
function collectFilesRecursive(dir, paths) {
    const entries = readdirSync(dir).sort(byteCompare);
    for (const entry of entries) {
        if (entry.startsWith("."))
            continue;
        const fullPath = join(dir, entry);
        const stat = statSync(fullPath);
        if (stat.isDirectory()) {
            collectFilesRecursive(fullPath, paths);
        }
        else if (stat.isFile()) {
            paths.push(fullPath);
        }
    }
}
