/**
 * BatchingApiGatewayArtifactWriter — W0056 prototype B.
 *
 * Drop-in replacement for `ApiGatewayArtifactWriter` that coalesces `emit()`
 * calls into batches, signs many URLs per Vercel round trip via
 * `POST /v1/runs/:runId/artifacts/batch/upload-urls` (W0052), and PUTs to GCS
 * with bounded concurrency.
 *
 * Why: the baseline in `docs/design-docs/artifact-upload-throughput.md` showed
 * that parallel single-URL signs trigger `429 Too Many Requests` on the Vercel
 * signing function. Batch signing eliminates one Vercel round trip per
 * artifact and makes client-side parallelism safe on the API Gateway path.
 *
 * Flushing:
 * - An `emit()` call pushes a `PendingEmit` onto a queue and returns a
 *   deferred Promise. The queue self-flushes via `queueMicrotask` when
 *   the first entry lands, and again whenever the buffer reaches
 *   `batchSize`.
 * - `writeManifest(...)` awaits any in-flight flush and drains the queue
 *   before writing the manifest through a single-URL sign (the manifest
 *   isn't in the registry's per-entry scheme).
 *
 * NDJSON streaming for `traces` is still not implemented here — the
 * upstream `ApiGatewayArtifactWriter` throws `NotImplementedError` and so
 * does this writer. Traces flow through the GCS-direct writer when ADC
 * credentials are present.
 */
import { ARTIFACT_REGISTRY, assertWritePolicyMatches, BULK_ENTRY_KEY, NotImplementedError, } from "../_vendor/ailf-core/index.js";
import { prepareUploadBody } from "./prepare-upload-body.js";
import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
/**
 * How many entries to bundle into a single `/batch/upload-urls` request.
 *
 * Raised from 32 to 256 after W0058 measurement: full-literacy producers
 * emit ~1,477 artifacts per run. With a tight batch size, the sign-RTT
 * count scales roughly as `artifactCount / batchSize`. At 32 we issued
 * ~420 sign requests and tripped Vercel's per-origin rate limits (71 %
 * sign-failure rate). At 256 we issue ~6 sign requests, well under any
 * per-origin burst cap. The gateway's batch route signs URLs locally
 * with `file.getSignedUrl` in `Promise.all`, so CPU scales sub-linearly
 * in request size — 256 entries fit comfortably in the default Vercel
 * function budget.
 */
const DEFAULT_BATCH_SIZE = 256;
const DEFAULT_PUT_CONCURRENCY = 8;
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export class BatchingApiGatewayArtifactWriter {
    options;
    pending = [];
    flushing = null;
    microtaskScheduled = false;
    writerSource;
    constructor(options) {
        this.options = {
            apiBaseUrl: options.apiBaseUrl,
            apiKey: options.apiKey,
            bucket: options.bucket,
            batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
            putConcurrency: options.putConcurrency ?? DEFAULT_PUT_CONCURRENCY,
            metrics: options.metrics ?? NO_OP_UPLOAD_METRICS,
        };
        this.writerSource = options.writerSource ?? "pipeline";
    }
    // ---- ArtifactWriter surface --------------------------------------------
    async emit(type, association, payload) {
        const descriptor = ARTIFACT_REGISTRY[type];
        assertWritePolicyMatches(this.writerSource, descriptor);
        const runId = association.run;
        if (!runId) {
            console.warn(`  ⚠️  emit("${type}"): association.run is required, skipping`);
            return null;
        }
        const entryKey = descriptor.layout === "bulk"
            ? BULK_ENTRY_KEY
            : descriptor.formatEntryKey(association);
        return new Promise((resolve) => {
            this.pending.push({
                type,
                runId,
                entryKey,
                payload,
                association,
                resolve,
            });
            this.scheduleFlush();
        });
    }
    async appendNdjson() {
        throw new NotImplementedError("BatchingApiGatewayArtifactWriter.appendNdjson is not supported. " +
            "NDJSON streaming for traces flows through GcsArtifactWriter when " +
            "ADC credentials are present.");
    }
    async writeManifest(runId, manifest) {
        await this.drain();
        return this.writeSingle(`/v1/runs/${encodeURIComponent(runId)}/artifacts/upload-url`, manifest, "manifest");
    }
    /** @deprecated — routes through `emit()` for backward compat. */
    async writeBulk(type, runId, data) {
        return this.emit(type, { run: runId }, data);
    }
    /**
     * @deprecated The legacy `writePerEntry` surface requires back-deriving an
     * `AssociationValues` from a wire entryKey, which the registry no longer
     * exposes. Producers on this writer must use `emit()`. All current producers
     * have already migrated.
     */
    async writePerEntry(type, _runId, _entries) {
        console.warn(`  ⚠️  BatchingApiGatewayArtifactWriter.writePerEntry("${type}"): ` +
            `deprecated path unsupported on batching writer — callers must use emit()`);
        return null;
    }
    // ---- Internals ---------------------------------------------------------
    scheduleFlush() {
        if (this.pending.length >= this.options.batchSize) {
            // Size threshold hit — kick off a drain immediately.
            void this.drain();
            return;
        }
        if (this.microtaskScheduled)
            return;
        this.microtaskScheduled = true;
        queueMicrotask(() => {
            this.microtaskScheduled = false;
            void this.drain();
        });
    }
    /**
     * Drain the pending queue with overlapped sign + PUT.
     *
     * Pre-pipeline behaviour was `sign(N) → put(N) → sign(N+1) → put(N+1)`.
     * After this change the order becomes
     * `sign(N) → (put(N) || sign(N+1)) → put(N+1)`:
     *
     * - We wait for stage N's sign to resolve, then launch stage N+1's sign
     *   immediately (before awaiting stage N's PUT phase). This is the moment
     *   emits that arrived while stage N's sign was in flight first become
     *   visible to `startNextStage`.
     * - Stage N's PUT fan-out then runs concurrently with stage N+1's sign
     *   request, removing the per-batch sign-RTT pre-amble that made the
     *   W0056 prototype ~10 % slower than the (unsafe) single-URL parallel
     *   variant.
     *
     * Invariants preserved from the pre-pipeline implementation:
     * - Emits resolve in batch-commit order within a stage.
     * - All entries in a `Stage` share one `runId` (enforced by `startNextStage`).
     * - A sign failure fails only that stage's group; later stages proceed.
     * - run() keeps looping until `pending` is empty AND no prefetched stage
     *   remains — so emits that arrive during a PUT phase are picked up in the
     *   next iteration.
     */
    async drain() {
        if (this.flushing) {
            await this.flushing;
            return;
        }
        const run = async () => {
            // Holds every `PendingEmit` that run() has already popped but not yet
            // resolved. If anything throws unexpectedly (e.g. an async sink throws
            // inside the PUT loop), the outer finally resolves each one to `null`
            // so the producer's `Promise.all([1500 emits])` can't hang forever.
            const unresolved = new Set();
            const releaseAll = (refIfResolved = null) => {
                for (const p of unresolved)
                    p.resolve(refIfResolved);
                unresolved.clear();
            };
            try {
                let prefetched = null;
                while (true) {
                    const stage = prefetched ?? this.startNextStage();
                    prefetched = null;
                    if (!stage)
                        return;
                    for (const p of stage.group)
                        unresolved.add(p);
                    const signed = await stage.signPromise;
                    // Overlap point: launch the next stage's sign BEFORE awaiting this
                    // stage's PUTs. At this point, any emits that arrived while we
                    // were awaiting `stage.signPromise` are visible in `pending`.
                    prefetched = this.startNextStage();
                    if (prefetched) {
                        for (const p of prefetched.group)
                            unresolved.add(p);
                    }
                    if (!signed) {
                        for (const p of stage.group) {
                            p.resolve(null);
                            unresolved.delete(p);
                        }
                        continue;
                    }
                    const pendingPuts = stage.group.map((p) => async () => {
                        const typeMap = signed.urls[p.type];
                        const signedUrl = typeMap ? typeMap[p.entryKey] : undefined;
                        if (!signedUrl) {
                            console.warn(`  ⚠️  Batch response missing URL for ${p.type}/${p.entryKey || "(bulk)"}`);
                            p.resolve(null);
                            unresolved.delete(p);
                            return;
                        }
                        const ref = await this.putToSignedUrl(signedUrl, p);
                        p.resolve(ref);
                        unresolved.delete(p);
                    });
                    await runWithConcurrency(pendingPuts, this.options.putConcurrency);
                }
            }
            finally {
                // Any PendingEmit still in `unresolved` reflects a code path that
                // didn't reach its `resolve(ref)`. Resolve to `null` so the producer
                // never hangs; the P5 non-blocking policy is the right default here.
                releaseAll(null);
            }
        };
        this.flushing = run();
        try {
            await this.flushing;
        }
        finally {
            this.flushing = null;
        }
    }
    /**
     * Pop the next same-runId batch (up to `batchSize` entries) off the queue
     * and launch its batch-sign request immediately. Returns a `Stage` whose
     * `signPromise` is already in flight, so the caller can start the sign for
     * the *next* batch while this one's PUTs are still pending.
     *
     * Returning `null` means the queue is empty.
     */
    startNextStage() {
        if (this.pending.length === 0)
            return null;
        const runId = this.pending[0].runId;
        const group = [];
        while (this.pending.length > 0 &&
            group.length < this.options.batchSize &&
            this.pending[0].runId === runId) {
            group.push(this.pending.shift());
        }
        const signPromise = this.batchSign(runId, this.buildSignBody(group));
        return { runId, group, signPromise };
    }
    buildSignBody(group) {
        const types = new Set();
        const keys = {};
        for (const p of group) {
            types.add(p.type);
            const descriptor = ARTIFACT_REGISTRY[p.type];
            if (descriptor.layout === "bulk")
                continue;
            let list = keys[p.type];
            if (!list) {
                list = [];
                keys[p.type] = list;
            }
            if (!list.includes(p.entryKey))
                list.push(p.entryKey);
        }
        return { types: Array.from(types), keys };
    }
    async batchSign(runId, body) {
        const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}/v1/runs/${encodeURIComponent(runId)}/artifacts/batch/upload-urls`;
        const start = Date.now();
        let success = false;
        try {
            const res = await fetch(url, {
                method: "POST",
                headers: {
                    Authorization: `Bearer ${this.options.apiKey}`,
                    "Content-Type": "application/json",
                },
                body: JSON.stringify(body),
            });
            if (!res.ok) {
                console.warn(`  ⚠️  Batch sign failed: ${res.status} ${res.statusText}`);
                return null;
            }
            // API envelope is flat: { object, apiVersion, createdAt, bucket, urls }.
            const parsed = (await res.json());
            if (parsed.object !== "batch_signed_upload_urls" ||
                typeof parsed.bucket !== "string" ||
                typeof parsed.urls !== "object" ||
                parsed.urls === null) {
                console.warn(`  ⚠️  Batch sign response malformed`);
                return null;
            }
            success = true;
            return { bucket: parsed.bucket, urls: parsed.urls };
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  Batch sign failed: ${message}`);
            return null;
        }
        finally {
            // One `sign` event per batch (not per artifact) — a natural
            // amortization signal in the metrics.
            this.options.metrics.record({
                phase: "sign",
                writer: "BatchingApiGatewayArtifactWriter",
                type: `batch(${body.types.length}-types)`,
                ms: Date.now() - start,
                success,
            });
        }
    }
    async putToSignedUrl(signed, pending) {
        const start = Date.now();
        let ok = false;
        // Tracked outside the try so the `finally` metrics event still gets a
        // bytes figure when serialization itself throws (circular payload,
        // bigint, etc.) — P5 requires we never hang the producer on a
        // pathological payload.
        let bytes = 0;
        try {
            // W0059 — redact + serialize through the shared helper so the
            // batching path strips secrets before they reach the signed GCS URL.
            const prepared = prepareUploadBody(pending.payload, "application/json");
            bytes = prepared.bytes;
            const res = await fetch(signed.url, {
                method: "PUT",
                body: prepared.body,
                headers: signed.requiredHeaders,
            });
            if (!res.ok) {
                console.warn(`  ⚠️  Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
                return null;
            }
            ok = true;
            const descriptor = ARTIFACT_REGISTRY[pending.type];
            if (descriptor.layout === "bulk") {
                return {
                    store: "gcs",
                    bucket: this.options.bucket,
                    path: signed.path,
                    bytes,
                    entryCount: entryCountOf(pending.payload),
                    layout: "bulk",
                };
            }
            return {
                store: "gcs",
                bucket: this.options.bucket,
                path: `runs/${pending.runId}/${descriptor.slug}`,
                bytes,
                entryCount: 1,
                layout: "per-entry",
                entries: [
                    {
                        key: pending.entryKey,
                        bytes,
                        association: pending.association,
                    },
                ],
            };
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
            return null;
        }
        finally {
            this.options.metrics.record({
                phase: "put",
                writer: "BatchingApiGatewayArtifactWriter",
                type: pending.type,
                ms: Date.now() - start,
                bytes,
                success: ok,
            });
        }
    }
    /** Single-URL sign + PUT — used for the manifest. */
    async writeSingle(uploadUrlPath, payload, metricType) {
        const signStart = Date.now();
        let signOk = false;
        let signed = null;
        try {
            const res = await fetch(`${this.options.apiBaseUrl.replace(/\/$/, "")}${uploadUrlPath}`, {
                method: "GET",
                headers: { Authorization: `Bearer ${this.options.apiKey}` },
            });
            if (!res.ok) {
                console.warn(`  ⚠️  Signed-URL request failed: ${res.status} ${res.statusText}`);
                return null;
            }
            const body = (await res.json());
            if (body.object !== "signed_upload_url" ||
                !body.url ||
                !body.path ||
                !body.bucket ||
                !body.requiredHeaders) {
                console.warn(`  ⚠️  Signed-URL response malformed`);
                return null;
            }
            signOk = true;
            signed = {
                url: body.url,
                path: body.path,
                bucket: body.bucket,
                requiredHeaders: body.requiredHeaders,
            };
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  Signed-URL request failed: ${message}`);
            return null;
        }
        finally {
            this.options.metrics.record({
                phase: "sign",
                writer: "BatchingApiGatewayArtifactWriter",
                type: metricType,
                ms: Date.now() - signStart,
                success: signOk,
            });
        }
        if (!signed)
            return null;
        // W0059 — manifest upload also flows through the shared helper so the
        // redaction policy covers every PUT the batching writer performs.
        const { body, bytes } = prepareUploadBody(payload, "application/json");
        const putStart = Date.now();
        let putOk = false;
        try {
            const res = await fetch(signed.url, {
                method: "PUT",
                body,
                headers: signed.requiredHeaders,
            });
            if (!res.ok) {
                console.warn(`  ⚠️  Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
                return null;
            }
            putOk = true;
            return {
                store: "gcs",
                bucket: signed.bucket,
                path: signed.path,
                bytes,
                layout: "bulk",
            };
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            console.warn(`  ⚠️  Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
            return null;
        }
        finally {
            this.options.metrics.record({
                phase: "put",
                writer: "BatchingApiGatewayArtifactWriter",
                type: metricType,
                ms: Date.now() - putStart,
                bytes,
                success: putOk,
            });
        }
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function runWithConcurrency(tasks, concurrency) {
    if (tasks.length === 0)
        return [];
    if (concurrency <= 1) {
        const out = [];
        for (const task of tasks)
            out.push(await task());
        return out;
    }
    const results = new Array(tasks.length);
    let cursor = 0;
    async function worker() {
        while (true) {
            const i = cursor++;
            if (i >= tasks.length)
                return;
            results[i] = await tasks[i]();
        }
    }
    const width = Math.min(concurrency, tasks.length);
    await Promise.all(Array.from({ length: width }, () => worker()));
    return results;
}
function entryCountOf(data) {
    if (typeof data === "object" &&
        data !== null &&
        "entries" in data &&
        typeof data.entries === "object") {
        return Object.keys(data.entries)
            .length;
    }
    return undefined;
}
