/**
 * 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 = {};
    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 k of Object.keys(this.accumulated)) {
            delete this.accumulated[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) {
        const existing = this.accumulated[type];
        if (!existing) {
            this.accumulated[type] = ref;
            return;
        }
        // Bulk: last-writer-wins. 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: 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());
        this.accumulated[type] = {
            ...existing,
            layout: "per-entry",
            entries,
            entryCount: entries.length,
            bytes: entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0),
            // `store`, `bucket`, `path` stay from the first ref — per-entry
            // paths are descriptor-derived and stable across calls.
        };
    }
}
