/**
 * GcsArtifactWriter — writes AILF run artifacts + manifest directly to GCS.
 *
 * Uses Application Default Credentials (ADC). Used when the CLI runs in CI or
 * anywhere ADC is configured — the client talks to GCS without the API Gateway
 * acting as a middleman.
 *
 * Paths come from `ARTIFACT_REGISTRY` so writers, signers, and readers agree.
 *
 * ## W0049 API surface
 *
 * - `emit(type, association, payload)` — the canonical single-shot write.
 *   Dispatch on `descriptor.layout` is internal; callers pass axis values
 *   and the writer resolves the path.
 * - `appendNdjson(type, association, rows)` — streaming-append for `traces`.
 *   Each call writes a numbered part object (`.ndjson.part-NNNN`); the
 *   parts are composed into the final object lazily at `writeManifest` time
 *   via GCS object compose. When a stream accumulates > 32 parts the writer
 *   rolls up parts into intermediate composites to stay under the GCS
 *   compose cap.
 * - `writeBulk` / `writePerEntry` — @deprecated legacy surface. Removal in W0052.
 *
 * Design principles:
 * - P5: Non-blocking — upload failure returns null, never throws.
 * - Lazy client — Storage created on first write.
 *
 * @see docs/decisions/D0032-run-anchored-artifact-store.md
 * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
 */
import { Storage } from "@google-cloud/storage";
import { ARTIFACT_REGISTRY, assertWritePolicyMatches, buildManifestPreview, } from "../_vendor/ailf-core/index.js";
import { resolveUploadConcurrency } from "./parallel-emit.js";
import { prepareUploadBody } from "./prepare-upload-body.js";
import { redactArtifactData } from "./redact-artifact.js";
import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
/**
 * GCS's object compose operation accepts at most 32 source objects per call
 * ([docs](https://cloud.google.com/storage/docs/composite-objects)). When an
 * NDJSON stream accumulates more than 32 parts the writer rolls up groups of
 * 32 into intermediate composites and composes those — one extra round trip
 * per 32 additional parts.
 */
const GCS_COMPOSE_MAX = 32;
export class GcsArtifactWriter {
    client = null;
    options;
    ndjsonStreams = new Map();
    metrics;
    /**
     * Bounds concurrent GCS PUTs.
     *
     * Producers after W0058 fire all emits synchronously and `Promise.all`
     * once at the end — without an internal limiter, a full-literacy run
     * would fan ~1,500 concurrent `file.save` calls, saturating the client
     * connection pool and GCS's per-prefix write quota. The limiter
     * preserves the pre-W0058 effective concurrency (8) that Prototype A
     * measured safe, without forcing the producer to own that knob.
     */
    limiter;
    writerSource;
    retryPolicy;
    constructor(options) {
        this.options = options;
        this.metrics = options.metrics ?? NO_OP_UPLOAD_METRICS;
        this.writerSource = options.writerSource ?? "pipeline";
        if (options.storage) {
            this.client = options.storage;
        }
        this.limiter = new ConcurrencyLimiter(resolveUploadConcurrency());
        this.retryPolicy = {
            maxAttempts: Math.max(1, Math.floor(options.retry?.maxAttempts ?? 3)),
            baseDelayMs: Math.max(0, options.retry?.baseDelayMs ?? 250),
        };
    }
    reportProgress(ref) {
        const progress = this.options.progress;
        if (!progress)
            return;
        progress.reporter.phaseProgress({
            phaseId: progress.phaseId,
            items: 1,
            bytes: ref.bytes,
            label: ref.path,
        });
    }
    // ---- Canonical W0049 API ------------------------------------------------
    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;
        }
        // W0059 — redact + serialize through the shared helper so the three
        // writer implementations can't drift. Preview reads the pre-redaction
        // payload (same as local writer — the preview carries a descriptor-
        // controlled summary bounded by capBytes, not the raw entry bytes).
        const { body } = prepareUploadBody(payload, descriptor.mime);
        const preview = buildManifestPreview(descriptor, payload);
        if (descriptor.layout === "bulk") {
            const extra = descriptor.extractPathArgs?.(association, payload) ?? [];
            const path = descriptor.objectPath(runId, ...extra);
            const ref = await this.putBody(path, body, {
                layout: "bulk",
                mime: descriptor.mime,
                entryCount: entryCountOf(payload),
                type,
            });
            if (!ref)
                return null;
            const finalRef = preview === undefined ? ref : { ...ref, preview };
            this.reportProgress(finalRef);
            return finalRef;
        }
        // per-entry
        const entryKey = descriptor.formatEntryKey(association);
        const path = descriptor.objectPath(runId, entryKey);
        const ref = await this.putBody(path, body, {
            layout: "per-entry",
            mime: descriptor.mime,
            type,
        });
        if (!ref)
            return null;
        const finalRef = {
            ...ref,
            path: `runs/${runId}/${descriptor.slug}`,
            entryCount: 1,
            entries: [
                {
                    key: entryKey,
                    bytes: ref.bytes ?? 0,
                    association,
                    ...(preview === undefined ? {} : { preview }),
                },
            ],
        };
        this.reportProgress(finalRef);
        return finalRef;
    }
    async appendNdjson(type, association, rows) {
        const descriptor = ARTIFACT_REGISTRY[type];
        assertWritePolicyMatches(this.writerSource, descriptor);
        if (descriptor.mime !== "application/x-ndjson") {
            console.warn(`  ⚠️  appendNdjson("${type}"): descriptor mime is ${descriptor.mime}, not application/x-ndjson — skipping`);
            return null;
        }
        const runId = association.run;
        if (!runId) {
            console.warn(`  ⚠️  appendNdjson("${type}"): association.run is required, skipping`);
            return null;
        }
        if (rows.length === 0)
            return null;
        const entryKey = descriptor.formatEntryKey(association);
        const finalPath = descriptor.objectPath(runId, entryKey);
        const streamKey = `${type}::${entryKey}`;
        let state = this.ndjsonStreams.get(streamKey);
        if (!state) {
            state = { finalPath, partCount: 0, totalBytes: 0 };
            this.ndjsonStreams.set(streamKey, state);
        }
        const partPath = `${finalPath}.part-${String(state.partCount).padStart(4, "0")}`;
        // AC10 — redact per row before serializing the NDJSON batch.
        const redactedRows = rows.map((r) => redactArtifactData(r));
        const body = redactedRows.map((r) => JSON.stringify(r)).join("\n") + "\n";
        const bytes = Buffer.byteLength(body, "utf-8");
        const saveOutcome = await this.limiter.run(async () => {
            const start = Date.now();
            const storage = this.getClient();
            const outcome = await saveWithRetry(() => storage
                .bucket(this.options.bucket)
                .file(partPath)
                .save(body, { contentType: "application/x-ndjson" }), this.retryPolicy, (attempt, err) => console.warn(`  ⚠️  NDJSON part upload attempt ${attempt} failed (retrying): ${partPath} — ${errMessage(err)}`));
            this.metrics.record({
                phase: "put",
                writer: "GcsArtifactWriter",
                type: `${type}:ndjson-part`,
                ms: Date.now() - start,
                bytes,
                success: outcome.ok,
            });
            if (!outcome.ok) {
                console.warn(`  ⚠️  NDJSON part upload failed after ${outcome.attempts} attempt${outcome.attempts === 1 ? "" : "s"} (non-blocking): ${partPath} — ${errMessage(outcome.lastError)}`);
                return { ok: false };
            }
            return { ok: true };
        });
        if (!saveOutcome.ok)
            return null;
        state.partCount++;
        state.totalBytes += bytes;
        const ref = {
            store: "gcs",
            bucket: this.options.bucket,
            path: `runs/${runId}/${descriptor.slug}`,
            bytes: state.totalBytes,
            entryCount: 1,
            layout: "per-entry",
            entries: [
                {
                    key: entryKey,
                    bytes: state.totalBytes,
                    association,
                },
            ],
        };
        this.reportProgress(ref);
        return ref;
    }
    async writeManifest(runId, manifest) {
        await this.finalizeNdjsonStreams();
        const path = `runs/${runId}/manifest.json`;
        const ref = await this.putBody(path, JSON.stringify(manifest), {
            layout: "bulk",
            mime: "application/json",
            type: "manifest",
        });
        if (ref)
            this.reportProgress(ref);
        return ref;
    }
    // ---- ArtifactObjectChecker (D0057) --------------------------------------
    /**
     * Existence probe used by the cache-hit restore prune (D0057). Unlike the
     * write methods (P5 non-blocking — swallow errors, return null), this
     * resolves `false` ONLY for a definitively-absent object and **throws** on
     * any other failure (auth / network / quota) so the caller can fail open
     * and keep the ref rather than dropping a real artifact on a transient
     * blip. `file.exists()` rejects only on real errors; a missing object
     * resolves `[false]`.
     */
    async objectExists(path) {
        const storage = this.getClient();
        const [exists] = await storage
            .bucket(this.options.bucket)
            .file(path)
            .exists();
        return exists;
    }
    // ---- Deprecated legacy surface (W0052) ----------------------------------
    /** @deprecated Use `emit()` instead. Routes through the same GCS I/O. */
    async writeBulk(type, runId, data) {
        const descriptor = ARTIFACT_REGISTRY[type];
        if (descriptor.layout !== "bulk") {
            console.warn(`  ⚠️  writeBulk("${type}"): descriptor layout is "${descriptor.layout}", not "bulk" — skipping`);
            return null;
        }
        const path = descriptor.objectPath(runId);
        const { body } = prepareUploadBody(data, descriptor.mime);
        return this.putBody(path, body, {
            layout: "bulk",
            mime: descriptor.mime,
            entryCount: entryCountOf(data),
            type,
        });
    }
    /** @deprecated Use `emit()` per entry instead. */
    async writePerEntry(type, runId, entries) {
        const descriptor = ARTIFACT_REGISTRY[type];
        if (descriptor.layout !== "per-entry") {
            console.warn(`  ⚠️  writePerEntry("${type}"): descriptor layout is "${descriptor.layout}", not "per-entry" — skipping`);
            return null;
        }
        if (!descriptor.parseEntryKey) {
            console.warn(`  ⚠️  writePerEntry("${type}"): descriptor has no parseEntryKey`);
            return null;
        }
        const storage = this.getClient();
        const uploaded = [];
        let totalBytes = 0;
        for (const entry of entries) {
            const parsed = descriptor.parseEntryKey(entry.key);
            if (!parsed.ok) {
                console.warn(`  ⚠️  Skipping entry with invalid key "${entry.key}": ${parsed.reason}`);
                continue;
            }
            const path = descriptor.objectPath(runId, entry.key);
            const { body, bytes } = prepareUploadBody(entry.data, descriptor.mime);
            const start = Date.now();
            let success = false;
            try {
                await storage
                    .bucket(this.options.bucket)
                    .file(path)
                    .save(body, { contentType: descriptor.mime });
                uploaded.push({ key: entry.key, bytes });
                totalBytes += bytes;
                success = true;
            }
            catch (err) {
                const message = err instanceof Error ? err.message : String(err);
                console.warn(`  ⚠️  Artifact entry upload failed (non-blocking): ${path} — ${message}`);
            }
            finally {
                this.metrics.record({
                    phase: "put",
                    writer: "GcsArtifactWriter",
                    type,
                    ms: Date.now() - start,
                    bytes,
                    success,
                });
            }
        }
        if (uploaded.length === 0)
            return null;
        return {
            store: "gcs",
            bucket: this.options.bucket,
            path: `runs/${runId}/${descriptor.slug}`,
            bytes: totalBytes,
            entryCount: uploaded.length,
            layout: "per-entry",
            entries: uploaded,
        };
    }
    // ---- Internals ----------------------------------------------------------
    /**
     * Compose all buffered NDJSON streams into their final objects. Called
     * from `writeManifest` so the manifest is the single sync point for
     * NDJSON finalization.
     *
     * When `partCount > GCS_COMPOSE_MAX`, parts are rolled up in groups of
     * `GCS_COMPOSE_MAX` into intermediate composites, then composed. This
     * stays under the per-call source cap at the cost of one extra round
     * trip per 32 additional parts.
     */
    async finalizeNdjsonStreams() {
        for (const state of this.ndjsonStreams.values()) {
            if (state.partCount === 0)
                continue;
            const storage = this.getClient();
            const bucket = storage.bucket(this.options.bucket);
            const partPaths = Array.from({ length: state.partCount }, (_, i) => `${state.finalPath}.part-${String(i).padStart(4, "0")}`);
            const start = Date.now();
            let success = false;
            try {
                const finalPath = await composeInGroups(bucket, partPaths, state.finalPath);
                if (finalPath !== state.finalPath) {
                    console.warn(`  ⚠️  NDJSON compose produced "${finalPath}" (expected "${state.finalPath}")`);
                }
                success = true;
            }
            catch (err) {
                const message = err instanceof Error ? err.message : String(err);
                console.warn(`  ⚠️  NDJSON compose failed (non-blocking): ${state.finalPath} — ${message}`);
            }
            finally {
                this.metrics.record({
                    phase: "compose",
                    writer: "GcsArtifactWriter",
                    type: `parts=${state.partCount}`,
                    ms: Date.now() - start,
                    bytes: state.totalBytes,
                    success,
                });
            }
        }
        this.ndjsonStreams.clear();
    }
    async putBody(path, body, meta) {
        const bytes = Buffer.byteLength(body, "utf-8");
        return this.limiter.run(async () => {
            const start = Date.now();
            const storage = this.getClient();
            const outcome = await saveWithRetry(() => storage
                .bucket(this.options.bucket)
                .file(path)
                .save(body, { contentType: meta.mime }), this.retryPolicy, (attempt, err) => console.warn(`  ⚠️  Artifact upload attempt ${attempt} failed (retrying): ${path} — ${errMessage(err)}`));
            this.metrics.record({
                phase: "put",
                writer: "GcsArtifactWriter",
                type: meta.type,
                ms: Date.now() - start,
                bytes,
                success: outcome.ok,
            });
            if (!outcome.ok) {
                console.warn(`  ⚠️  Artifact upload failed after ${outcome.attempts} attempt${outcome.attempts === 1 ? "" : "s"} (non-blocking): ${path} — ${errMessage(outcome.lastError)}`);
                return null;
            }
            return {
                store: "gcs",
                bucket: this.options.bucket,
                path,
                bytes,
                entryCount: meta.entryCount,
                layout: meta.layout,
            };
        });
    }
    getClient() {
        if (this.client)
            return this.client;
        this.client = new Storage();
        return this.client;
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Simple bounded-concurrency scheduler. Tasks beyond the limit queue
 * FIFO; each completion wakes exactly one waiter. Stays FIFO-fair because
 * no timer is involved. Used by `GcsArtifactWriter` to cap concurrent
 * GCS I/O after producers stopped self-bounding via `parallelMap`.
 */
class ConcurrencyLimiter {
    inflight = 0;
    waiters = [];
    limit;
    constructor(limit) {
        // Clamp at 1 so a misconfigured `resolveUploadConcurrency()` (0 or
        // negative) cannot deadlock — the admission check in `run()` is
        // `inflight >= limit`, so `limit=0` would wedge every caller.
        this.limit = Math.max(1, Math.floor(limit));
    }
    async run(task) {
        if (this.inflight >= this.limit) {
            await new Promise((resolve) => this.waiters.push(resolve));
        }
        this.inflight++;
        try {
            return await task();
        }
        finally {
            this.inflight--;
            const next = this.waiters.shift();
            if (next)
                next();
        }
    }
}
function errMessage(err) {
    return err instanceof Error ? err.message : String(err);
}
/**
 * Network-level codes worth retrying. A one-off `write EPIPE` on a resumable
 * upload — 1 of 128 in the incident that motivated this — is transient and
 * succeeds on a second attempt; permanent 4xx (auth, not-found, bad request)
 * are not retried because they never will. `ERR_STREAM_PREMATURE_CLOSE` is the
 * code Node attaches when a response body stream is cut mid-flight — observed
 * on the google-auth-library `oauth2/v4/token` fetch, where a sustained ~3-min
 * token-endpoint blip failed 95/95 uploads of run_20260625T084753Z_0fk47ex2
 * and (because this class wasn't recognized) none were retried, silently
 * publishing a report with zero persisted artifacts.
 */
const TRANSIENT_NETWORK_CODES = new Set([
    "EPIPE",
    "ECONNRESET",
    "ETIMEDOUT",
    "ECONNREFUSED",
    "ERR_STREAM_PREMATURE_CLOSE",
]);
/**
 * Classify a PUT failure as transient (retry) vs. permanent (give up). GCS's
 * `ApiError` carries the HTTP status in a numeric `code`; Node socket errors
 * carry a string `code` (or surface the reason only in the message, e.g.
 * "socket hang up" or "Premature close"). HTTP 5xx / 429 are retryable;
 * deterministic 4xx are not. google-auth-library / gaxios wrap the underlying
 * socket fault as `cause` (the outer error is then a generic "invalid response
 * body" / "request failed" with no useful `code`), so recurse one level to
 * catch wrapped transients.
 */
function isTransientUploadError(err) {
    if (typeof err !== "object" || err === null)
        return false;
    const code = err.code;
    if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
        return true;
    if (typeof code === "number" &&
        (code === 429 || (code >= 500 && code <= 599)))
        return true;
    const message = err instanceof Error ? err.message : "";
    if (/socket hang up|premature close|EPIPE|ECONNRESET|ETIMEDOUT|ECONNREFUSED|ERR_STREAM_PREMATURE_CLOSE/i.test(message))
        return true;
    const cause = err.cause;
    if (cause && cause !== err)
        return isTransientUploadError(cause);
    return false;
}
const sleep = (ms) => ms <= 0
    ? Promise.resolve()
    : new Promise((resolve) => setTimeout(resolve, ms));
/**
 * Run `doSave` up to `policy.maxAttempts`, retrying only transient failures
 * with exponential backoff (`baseDelayMs * 2^(attempt-1)`). `onRetry` fires
 * before each backoff so the caller can log the failed attempt. Resolves with
 * the final outcome and never throws — preserves the writer's P5 non-blocking
 * contract (failures warn and degrade, they don't abort the pipeline).
 */
async function saveWithRetry(doSave, policy, onRetry) {
    let attempt = 0;
    for (;;) {
        attempt++;
        try {
            await doSave();
            return { ok: true, attempts: attempt };
        }
        catch (err) {
            if (attempt >= policy.maxAttempts || !isTransientUploadError(err)) {
                return { ok: false, attempts: attempt, lastError: err };
            }
            onRetry(attempt, err);
            await sleep(policy.baseDelayMs * 2 ** (attempt - 1));
        }
    }
}
function entryCountOf(data) {
    if (typeof data === "object" &&
        data !== null &&
        "entries" in data &&
        typeof data.entries === "object") {
        return Object.keys(data.entries)
            .length;
    }
    return undefined;
}
/**
 * Compose a list of GCS parts into a destination object. When the part count
 * exceeds `GCS_COMPOSE_MAX`, roll up groups into intermediate composites and
 * recurse until a single compose call suffices.
 *
 * Returns the destination path on success. Intermediate composites are
 * written to `{dest}.roll-{n}` and left in place — they are cheap, and
 * keeping them simplifies failure recovery.
 */
async function composeInGroups(bucket, partPaths, destPath) {
    if (partPaths.length === 0) {
        throw new Error(`composeInGroups: no parts to compose for ${destPath}`);
    }
    if (partPaths.length === 1) {
        // Single part — server-side copy into place. Avoids streaming the
        // object through this process (unlike download + re-upload) and
        // preserves the source object's Content-Type / metadata on the
        // destination (W0049 review finding C2).
        await bucket.file(partPaths[0]).copy(bucket.file(destPath));
        return destPath;
    }
    if (partPaths.length <= GCS_COMPOSE_MAX) {
        const sources = partPaths.map((p) => bucket.file(p));
        await bucket.combine(sources, bucket.file(destPath));
        return destPath;
    }
    // >32 parts: roll up in groups of GCS_COMPOSE_MAX and recurse.
    const rollups = [];
    for (let i = 0; i < partPaths.length; i += GCS_COMPOSE_MAX) {
        const group = partPaths.slice(i, i + GCS_COMPOSE_MAX);
        const rollupPath = `${destPath}.roll-${String(i / GCS_COMPOSE_MAX).padStart(4, "0")}`;
        const sources = group.map((p) => bucket.file(p));
        await bucket.combine(sources, bucket.file(rollupPath));
        rollups.push(rollupPath);
    }
    return composeInGroups(bucket, rollups, destPath);
}
