/**
 * cache-hit-restore.ts — helpers for the eval cache-hit branch in
 * `RunEvalStep`. Stamps `sourceRunId` onto a cached report's artifact
 * refs so the new run's manifest advertises the cached artifacts via
 * cross-run lineage instead of pointing at GCS objects that were never
 * written under the new runId.
 *
 * @see docs/decisions/D0040-artifact-ref-source-run-id.md
 * @see docs/design-docs/cache-hit-artifact-restoration.md
 */
import { ARTIFACT_REGISTRY, runId as parseRunId, } from "../_vendor/ailf-core/index.js";
/**
 * Copy an artifact manifest verbatim and stamp `sourceRunId` on every ref
 * that doesn't already carry one.
 *
 * The ref's `path`, `bucket`, `entries`, `bytes`, `preview`, etc. travel
 * unchanged — they already point at the source run's storage. `sourceRunId`
 * is added so retention/GC and observability tooling can follow the
 * cross-run dependency.
 *
 * **Transitive lineage.** When a cached report's refs already carry a
 * `sourceRunId` (because that report was itself a cache hit), we preserve it.
 * `opts.sourceRunId` is only the *immediate* cache parent; if the cached
 * report's refs already point at the ultimate source run, blindly overwriting
 * would drop the lineage one hop per cache propagation and 404 readers that
 * trust `sourceRunId` for path reconstruction.
 *
 * Invariant maintained across any number of cache hops: every ref's
 * `sourceRunId` equals the runId encoded in its `path` (= where the bytes
 * physically live).
 *
 * **Post-hoc artifacts are dropped.** Refs whose descriptor has
 * `writePolicy: "post-hoc"` (e.g. `diagnosis`) are skipped: the cached
 * report's slot points at the *previous* run's path, but the post-hoc
 * producer fires again on the new run and emits a fresh ref anchored at
 * the new runId. Injecting the cached cross-run ref into the accumulator
 * makes `FinalizeRunStep` embed the stale path into the on-GCS
 * `runs/<newRunId>/manifest.json`; the post-hoc emit then only patches the
 * Sanity report doc, leaving the GCS manifest stale. Dropping the ref
 * here keeps the GCS manifest consistent with the cache-miss shape (no
 * post-hoc slot until the post-hoc emit lands), and the reader-side
 * fallback resolves diagnosis via the Sanity doc, which the post-hoc
 * patch keeps correct.
 *
 * Pure function; safe to call without side effects.
 */
export function remapToCacheHitRefs(source, opts) {
    const out = {};
    for (const [type, ref] of Object.entries(source)) {
        if (!ref)
            continue;
        const descriptor = ARTIFACT_REGISTRY[type];
        if (descriptor?.writePolicy === "post-hoc")
            continue;
        const typed = ref;
        out[type] = {
            ...typed,
            sourceRunId: typed.sourceRunId ?? opts.sourceRunId,
        };
    }
    return out;
}
/**
 * Drop artifact refs (and per-entry entries) a cached report over-claimed —
 * entries whose backing object was never written under the source run's
 * storage prefix (D0040 / D0057, W0350).
 *
 * A degraded source run can publish a manifest that lists `rawResults`
 * entries with no GCS object behind them; `remapToCacheHitRefs` copies those
 * phantom entries forward into the new run's manifest, and the read side then
 * signs URLs that 404 ("the specified key does not exist"). Pruning here, at
 * the cache-hit restore boundary, removes the over-claim at the source so the
 * written manifest's `entryCount` / `entries[]` reflect only artifacts that
 * actually resolve — instead of pushing per-object HEAD checks onto the hot
 * signing path (W0350 AC 3).
 *
 * Resolution mirrors the gateway: a per-entry object lives at
 * `descriptor.objectPath(sourceRunId, entry.key)`, where `sourceRunId` is the
 * runId encoded in `ref.path` (preferred — structurally tied to where bytes
 * physically live) falling back to `ref.sourceRunId` (the lineage hint).
 *
 * **Fail open.** `checker.objectExists` throws when existence can't be
 * determined (auth / network / quota). A throw KEEPS the ref/entry — we never
 * drop a real artifact on a transient blip; the read side already tolerates a
 * rare residual 404 (W0349). Only a definitive `false` drops an entry.
 *
 * Pure w.r.t. its inputs: returns a fresh manifest, never mutates `source`.
 */
export async function pruneToResolvableRefs(source, checker) {
    const out = {};
    let droppedEntries = 0;
    let droppedRefs = 0;
    for (const [type, ref] of Object.entries(source)) {
        if (!ref)
            continue;
        const artifactType = type;
        const descriptor = ARTIFACT_REGISTRY[artifactType];
        // Bulk: a single object at ref.path.
        if (ref.layout === "bulk") {
            if (await existsOrKeep(checker, ref.path))
                out[artifactType] = ref;
            else
                droppedRefs++;
            continue;
        }
        // Per-entry: each entry is its own object under the source run prefix.
        const sourceRunId = resolveSourceRunId(ref);
        const entries = ref.entries ?? [];
        if (!descriptor || sourceRunId === undefined || entries.length === 0) {
            // Can't resolve per-entry object paths — fail open, keep verbatim.
            out[artifactType] = ref;
            continue;
        }
        const keptFlags = await Promise.all(entries.map(async (entry) => {
            let objectPath;
            try {
                objectPath = descriptor.objectPath(sourceRunId, entry.key);
            }
            catch {
                return true; // malformed key — fail open rather than drop
            }
            return existsOrKeep(checker, objectPath);
        }));
        const kept = entries.filter((_, i) => keptFlags[i]);
        if (kept.length === entries.length) {
            out[artifactType] = ref;
            continue;
        }
        droppedEntries += entries.length - kept.length;
        if (kept.length === 0) {
            droppedRefs++; // nothing resolvable — drop the whole over-claimed ref
            continue;
        }
        out[artifactType] = {
            ...ref,
            entries: kept,
            entryCount: kept.length,
            bytes: kept.reduce((sum, e) => sum + (e.bytes ?? 0), 0),
        };
    }
    return { manifest: out, droppedEntries, droppedRefs };
}
/**
 * Resolve where a ref's bytes physically live. Prefers the runId encoded in
 * `ref.path` (validated through the canonical parser so a malformed manifest
 * path can't propagate into a synthesized object name) over the
 * `ref.sourceRunId` lineage hint — matching the gateway's resolution order.
 */
function resolveSourceRunId(ref) {
    const fromPath = /^runs\/([^/]+)/.exec(ref.path)?.[1];
    if (fromPath) {
        const parsed = parseRunId(fromPath);
        if (parsed.ok)
            return parsed.value;
    }
    return ref.sourceRunId;
}
/**
 * `checker.objectExists` wrapper that returns `true` (keep) on a thrown,
 * indeterminate result — only a definitive `false` drops the artifact.
 */
async function existsOrKeep(checker, path) {
    try {
        return await checker.objectExists(path);
    }
    catch {
        return true;
    }
}
