/**
 * GcsRunArtifactReader — reads run manifests and artifact payloads back from
 * `gs://ailf-artifacts`, the read-side counterpart of `GcsArtifactWriter`
 * (D0091).
 *
 * The fact-loader engine talks only to the `RunArtifactReader` port, so this
 * adapter is the single place that knows about GCS object layout. It resolves
 * paths from the manifest the writer produced:
 *   - bulk artifacts live at the single object `ref.path`;
 *   - per-entry artifacts live at `{ref.path}/{sanitizedKey}.{ext}`, one object
 *     per catalog entry.
 *
 * `ref.path` is authoritative for resolution (D0040/W0135): a cache-inherited
 * artifact carries a `sourceRunId` and its `path` already points at the origin
 * run's prefix, so the reader extracts the storage run from `ref.path` and
 * rebuilds the object path via the registry's `objectPath` builder rather than
 * assuming the requesting run's prefix.
 *
 * Payloads are JSON-deserialized (or NDJSON-split) to `unknown` — structural
 * parse only. The descriptor's `rowSchema` validates the extractor's output
 * downstream (the engine's D0045 trust boundary); the reader never coerces.
 *
 * Beyond the two port methods, `listCandidateRuns` resolves the loader's work
 * list straight from GCS — the run prefixes whose manifest records a
 * `reportSnapshot` — so the loader is independent of Airbyte.
 *
 * All network I/O runs under a shared {@link ConcurrencyLimiter}, mirroring the
 * writer, so a backfill fanning over hundreds of runs stays within the GCS
 * connection pool and per-prefix quota.
 *
 * @see docs/decisions/D0091-bigquery-analytics-fact-loader.md
 * @see packages/eval/src/artifact-capture/gcs-artifact-writer.ts
 */
import { Storage } from "@google-cloud/storage";
import { ARTIFACT_REGISTRY, runId as parseRunId, } from "../_vendor/ailf-core/index.js";
import { ConcurrencyLimiter } from "./concurrency-limiter.js";
const DEFAULT_READ_CONCURRENCY = 8;
/**
 * Reads a run's manifest and declared artifact payloads from GCS. Implements
 * the {@link RunArtifactReader} port and adds {@link listCandidateRuns} for
 * building the loader's work list.
 */
export class GcsRunArtifactReader {
    client;
    bucket;
    limiter;
    /**
     * Per-run manifest memo. Both `readManifest` and every `readArtifact` call
     * for a run share one fetch — the engine reads the manifest then fetches
     * several artifact types per run, so this collapses N+1 GETs to one.
     * Manifests are immutable once finalized, so caching for the process
     * lifetime is safe. A rejected fetch stays rejected (the run is skipped).
     */
    manifestCache = new Map();
    constructor(options) {
        this.bucket = options.bucket;
        this.client = options.storage ?? null;
        this.limiter = new ConcurrencyLimiter(options.concurrency ?? DEFAULT_READ_CONCURRENCY);
    }
    // ---- RunArtifactReader port ---------------------------------------------
    async readManifest(runId) {
        return this.getManifest(runId);
    }
    async readArtifact(runId, type) {
        const manifest = await this.getManifest(runId);
        const ref = manifest.artifacts[type];
        // Absent artifact type is normal (a partial run) — empty, not a throw.
        if (!ref)
            return [];
        const descriptor = ARTIFACT_REGISTRY[type];
        if (ref.layout === "bulk") {
            const payload = await this.downloadBody(ref.path, descriptor.mime);
            return [
                {
                    key: descriptor.slug,
                    association: {},
                    payload,
                    sourceUri: gsUri(this.bucket, ref.path),
                },
            ];
        }
        // per-entry: one object per catalog entry, rooted at the ref's storage run.
        const entries = ref.entries ?? [];
        const storageRun = storageRunFor(ref.path, runId);
        return Promise.all(entries.map(async (entry) => {
            const objectPath = descriptor.objectPath(storageRun, entry.key);
            const payload = await this.downloadBody(objectPath, descriptor.mime);
            return {
                key: entry.key,
                association: entry.association ?? {},
                payload,
                sourceUri: gsUri(this.bucket, objectPath),
            };
        }));
    }
    // ---- Work-list resolution (beyond the port) -----------------------------
    /**
     * The loader's candidate runs: every run prefix under `runs/` whose manifest
     * records a `reportSnapshot` (i.e. the run published a report). Resolved
     * straight from GCS so the loader does not depend on Airbyte. Runs whose
     * manifest is missing or unreadable are skipped silently — a corrupt run is
     * not a candidate, and per-run parse failures surface later in the engine.
     */
    async listCandidateRuns() {
        const prefixes = await this.listRunPrefixes();
        const candidates = [];
        await Promise.all(prefixes.map(async (prefix) => {
            // prefix is like "runs/run_.../" — strip the container and trailing slash.
            const raw = prefix.slice("runs/".length).replace(/\/$/, "");
            const parsed = parseRunId(raw);
            if (!parsed.ok)
                return;
            const rid = parsed.value;
            try {
                const manifest = await this.getManifest(rid);
                if (manifest.artifacts.reportSnapshot)
                    candidates.push(rid);
            }
            catch {
                // Missing / unreadable manifest — not a candidate.
            }
        }));
        // Sorted so the work list is deterministic across runs (run ids are
        // lexicographically time-ordered, D0035).
        return candidates.sort();
    }
    // ---- Internals ----------------------------------------------------------
    getManifest(runId) {
        const cached = this.manifestCache.get(runId);
        if (cached)
            return cached;
        const pending = this.fetchManifest(runId);
        this.manifestCache.set(runId, pending);
        return pending;
    }
    async fetchManifest(runId) {
        const path = `runs/${runId}/manifest.json`;
        let text;
        try {
            text = await this.download(path);
        }
        catch (err) {
            throw new Error(`manifest for run ${runId} could not be read: ${errMessage(err)}`, { cause: err });
        }
        try {
            return JSON.parse(text);
        }
        catch (err) {
            throw new Error(`manifest for run ${runId} is not valid JSON: ${errMessage(err)}`, { cause: err });
        }
    }
    /**
     * Download and deserialize one artifact object. Dispatches on the
     * descriptor's declared mime so JSON bulks parse whole, NDJSON per-entry
     * streams parse into a row array, and text formats (markdown/yaml) return the
     * raw string. Throws on a present-but-unreadable object (the engine isolates
     * the run).
     */
    async downloadBody(path, mime) {
        let text;
        try {
            text = await this.download(path);
        }
        catch (err) {
            throw new Error(`artifact ${path} could not be read: ${errMessage(err)}`, { cause: err });
        }
        if (mime === "application/x-ndjson") {
            try {
                return parseNdjson(text);
            }
            catch (err) {
                throw new Error(`artifact ${path} is not valid NDJSON: ${errMessage(err)}`, { cause: err });
            }
        }
        if (mime === "application/json") {
            try {
                return JSON.parse(text);
            }
            catch (err) {
                throw new Error(`artifact ${path} is not valid JSON: ${errMessage(err)}`, { cause: err });
            }
        }
        // text/markdown, application/yaml — no structural parse.
        return text;
    }
    /** Raw object download to a UTF-8 string, bounded by the limiter. */
    async download(path) {
        return this.limiter.run(async () => {
            const [buf] = await this.getClient()
                .bucket(this.bucket)
                .file(path)
                .download();
            return buf.toString("utf8");
        });
    }
    /** List the immediate `runs/{runId}/` prefixes via a delimiter query. */
    async listRunPrefixes() {
        const bucket = this.getClient().bucket(this.bucket);
        const prefixes = [];
        let query = {
            prefix: "runs/",
            delimiter: "/",
            autoPaginate: false,
        };
        for (;;) {
            const [, nextQuery, apiResponse] = (await this.limiter.run(() => bucket.getFiles(query)));
            for (const p of apiResponse?.prefixes ?? [])
                prefixes.push(p);
            if (!nextQuery)
                break;
            query = nextQuery;
        }
        return prefixes;
    }
    getClient() {
        if (this.client)
            return this.client;
        this.client = new Storage();
        return this.client;
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * The run whose prefix an artifact's bytes physically live under. `ref.path`
 * is `runs/{runId}/…` and is authoritative even when a cache-inherited artifact
 * points at a different (source) run than the manifest it appears in. Falls
 * back to the requesting run when the path does not parse.
 */
function storageRunFor(refPath, fallback) {
    const segments = refPath.split("/");
    if (segments[0] === "runs" && segments[1]) {
        const parsed = parseRunId(segments[1]);
        if (parsed.ok)
            return parsed.value;
    }
    return fallback;
}
/** Parse newline-delimited JSON into an array of row objects (blank lines skipped). */
function parseNdjson(text) {
    const rows = [];
    for (const line of text.split("\n")) {
        const trimmed = line.trim();
        if (trimmed.length === 0)
            continue;
        rows.push(JSON.parse(trimmed));
    }
    return rows;
}
function gsUri(bucket, path) {
    return `gs://${bucket}/${path}`;
}
function errMessage(err) {
    return err instanceof Error ? err.message : String(err);
}
