/**
 * accumulating-artifact-writer.ts
 *
 * Decorator that wraps any `ArtifactWriter` and accumulates every
 * successful `emit()` / `appendNdjson()` return into a run-scoped
 * manifest slice. FinalizeRunStep reads the accumulator at the end of
 * a pipeline and writes a `runs/{runId}/manifest.json` populated with
 * one entry per produced artifact type.
 *
 * Before W0051 revisit: only `calculate-scores-step` registered its
 * ref (for `testOutputs`) into `state.artifactRefs`; every other
 * producer discarded the returned ref. The result was empty
 * `Report.artifactManifest` fields and no per-entry preview lookup
 * for Studio hooks. Wrapping the writer at the composition-root level
 * closes that gap without per-producer bookkeeping.
 *
 * Merging rules (per type):
 *   - `bulk`: last-writer-wins. A pipeline that emits the same bulk
 *     artifact twice overwrites — matches GCS semantics.
 *   - `per-entry`: entries accumulate into a keyed map. A later emit
 *     at the same `entries[].key` replaces the earlier one.
 *
 * The decorator holds no disk state; the `_resetAccumulated()` hook is
 * for unit tests that rerun emit sequences within a single writer.
 *
 * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M5)
 */
export class AccumulatingArtifactWriter {
    /**
     * Exposed so composition-root tests can assert on the underlying backend
     * (LocalFilesystemArtifactWriter, FanoutArtifactWriter, etc.) without
     * plumbing a separate accessor. Treat as read-only.
     */
    inner;
    accumulated = {};
    /**
     * Entry keys (per type) that have a confirmed successful gcs write. Drives
     * the local-only warning: any entry key in a gcs-canonical section that is
     * absent here only ever landed on the local fallback.
     */
    gcsEntryKeys = {};
    /**
     * Entry keys already named in a local-only warning, so each key is reported
     * at most once even as later merges re-walk the section.
     */
    warnedLocalOnly = {};
    constructor(inner) {
        this.inner = inner;
    }
    /** Snapshot of every ref produced this far, keyed by artifact type. */
    getAccumulatedArtifactRefs() {
        return { ...this.accumulated };
    }
    /** Test-only. Clears accumulated refs without touching the inner writer. */
    _resetAccumulated() {
        for (const map of [
            this.accumulated,
            this.gcsEntryKeys,
            this.warnedLocalOnly,
        ]) {
            for (const k of Object.keys(map)) {
                delete map[k];
            }
        }
    }
    /**
     * Merge externally-supplied refs into the accumulator without touching
     * the inner backend. Used by `RunEvalStep`'s cache-hit branch (D0040 /
     * W0135) to restore a cached report's `Report.artifactManifest` so the
     * new run's `RunManifest` advertises the cached artifacts via cross-run
     * paths instead of skipping them entirely.
     *
     * The injected refs already carry `path`, `bucket`, `entries`, etc. as
     * the source run wrote them — we don't synthesize new paths, we copy.
     * Refs typically carry `sourceRunId` (set by `remapToCacheHitRefs`) so
     * downstream tooling can follow the lineage.
     */
    injectAccumulated(refs) {
        for (const [type, ref] of Object.entries(refs)) {
            if (!ref)
                continue;
            this.mergeRef(type, ref);
        }
    }
    // ---- ArtifactWriter surface --------------------------------------------
    async emit(type, association, payload) {
        const ref = await this.inner.emit(type, association, payload);
        if (ref)
            this.mergeRef(type, ref);
        return ref;
    }
    async appendNdjson(type, association, rows) {
        const ref = await this.inner.appendNdjson(type, association, rows);
        if (ref)
            this.mergeRef(type, ref);
        return ref;
    }
    async writeManifest(runId, manifest) {
        return this.inner.writeManifest(runId, manifest);
    }
    /** @deprecated — forwarded to the inner writer without accumulation. */
    async writeBulk(type, runId, data) {
        const ref = await this.inner.writeBulk(type, runId, data);
        if (ref)
            this.mergeRef(type, ref);
        return ref;
    }
    /** @deprecated — forwarded to the inner writer without accumulation. */
    async writePerEntry(type, runId, entries) {
        const ref = await this.inner.writePerEntry(type, runId, entries);
        if (ref)
            this.mergeRef(type, ref);
        return ref;
    }
    // ---- Merge rules --------------------------------------------------------
    mergeRef(type, ref) {
        // Bulk: last-writer-wins, first or not. A step that re-emits a bulk
        // artifact (e.g. a rerun of calculate-scores) overwrites the earlier body
        // on disk, so the manifest reflects the latest.
        if (ref.layout === "bulk") {
            this.accumulated[type] = ref;
            return;
        }
        // Per-entry from here. Record which entry keys carry a confirmed gcs
        // write before merging so the local-only warning has the full picture
        // regardless of merge order.
        if (ref.store === "gcs") {
            const seen = (this.gcsEntryKeys[type] ??= new Set());
            for (const e of ref.entries ?? [])
                seen.add(e.key);
        }
        const existing = this.accumulated[type];
        if (!existing) {
            this.accumulated[type] = ref;
            this.warnLocalOnlyEntries(type, ref);
            return;
        }
        // Merge entries by key. Duplicate keys replace (last write wins at that
        // key — matches local/gcs overwrite semantics).
        const merged = new Map();
        for (const e of existing.entries ?? [])
            merged.set(e.key, e);
        for (const e of ref.entries ?? [])
            merged.set(e.key, e);
        const entries = Array.from(merged.values());
        // Section identity (store/bucket/path) prefers the remote store: a gcs
        // section is never downgraded to local by a later local-fallback ref, and
        // a local section upgrades to gcs the moment a gcs ref merges in. A single
        // transient GCS failure used to pin a 128-entry section to "local" — and
        // hide the dashboard's GCS link — because the failed entry's local ref
        // resolved first. Per-entry store tracking in the manifest is out of scope
        // (touches core types + Studio/dashboard consumers); a section-level
        // preference plus the warning below is the minimal honest fix.
        const identity = existing.store === "gcs" ? existing : ref;
        const updated = {
            ...existing,
            store: identity.store,
            bucket: identity.bucket,
            path: identity.path,
            layout: "per-entry",
            entries,
            entryCount: entries.length,
            bytes: entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0),
        };
        this.accumulated[type] = updated;
        this.warnLocalOnlyEntries(type, updated);
    }
    /**
     * When a section is canonically on gcs but holds entries whose only
     * successful write was the local fallback, log a warning naming those keys
     * so partial remote coverage is visible in run logs. Each key is named at
     * most once. A fully-local section (no gcs credentials) is expected and not
     * worth a warning.
     */
    warnLocalOnlyEntries(type, section) {
        if (section.store !== "gcs")
            return;
        const gcsKeys = this.gcsEntryKeys[type];
        const warned = (this.warnedLocalOnly[type] ??= new Set());
        const localOnly = [];
        for (const e of section.entries ?? []) {
            if (gcsKeys?.has(e.key))
                continue;
            if (warned.has(e.key))
                continue;
            localOnly.push(e.key);
            warned.add(e.key);
        }
        if (localOnly.length === 0)
            return;
        const noun = localOnly.length === 1 ? "entry" : "entries";
        console.warn(`  ⚠️  Artifact section "${type}" is canonical on gcs but ${localOnly.length} ${noun} ` +
            `only wrote to the local fallback (remote upload failed): ${localOnly.join(", ")}`);
    }
}
