/**
 * Fact-loader engine (D0091).
 *
 * The stable, table-agnostic half of the loader. Given a registry of
 * `FactTableDescriptor`s and the two ports, it — per run — reads the manifest,
 * fetches only the artifacts the active descriptors declare, runs every
 * descriptor whose sources are all present, validates each emitted row against
 * the descriptor's `rowSchema`, and groups rows per table. It then ensures
 * each table and MERGEs the accumulated rows.
 *
 * It contains no table or column names: everything specific comes from the
 * descriptors. Adding a table or a column never touches this file.
 *
 * Error posture (design § Error handling): per-run isolation. A run that
 * cannot be read or whose rows fail `rowSchema` is skipped-and-reported; the
 * batch continues. A run missing an artifact type simply produces zero rows
 * for the tables that need it — absence is normal, not an error.
 *
 * Pure of I/O specifics: it awaits ports, never touches GCS/BigQuery directly.
 *
 * @see docs/decisions/D0091-bigquery-analytics-fact-loader.md
 * @see docs/design-docs/bigquery-analytics-fact-loader.md
 */
import { toFactTableSpec, } from "./descriptor.js";
// ---------------------------------------------------------------------------
// Engine
// ---------------------------------------------------------------------------
/**
 * Project the declared fact tables from a set of runs into the store.
 *
 * Runs are processed independently: a failure in one is recorded and skipped,
 * never aborting the batch. Rows from successful runs are grouped per table and
 * MERGEd once per table (batch load) so re-running is idempotent.
 */
export async function loadFacts(deps, input) {
    const { reader, store, registry, logger } = deps;
    // table -> accumulated rows across all successful runs
    const rowsByTable = new Map();
    const outcomes = [];
    for (const runId of input.runIds) {
        try {
            const runRows = await extractRun(runId, registry, reader);
            let total = 0;
            for (const [table, rows] of runRows) {
                const bucket = rowsByTable.get(table) ?? [];
                bucket.push(...rows);
                rowsByTable.set(table, bucket);
                total += rows.length;
            }
            outcomes.push({ runId, status: "loaded", rows: total });
        }
        catch (err) {
            const reason = err instanceof Error ? err.message : String(err);
            logger?.warn(`fact-loader: skipping run ${runId}: ${reason}`);
            outcomes.push({ runId, status: "skipped", reason });
        }
    }
    // Provision + load per table. Every descriptor's table is ensured (so a
    // table with zero rows this batch still exists); rows are MERGEd where present.
    const tables = [];
    for (const descriptor of registry) {
        await store.ensureTable(toFactTableSpec(descriptor));
        const rows = rowsByTable.get(descriptor.table) ?? [];
        if (rows.length === 0) {
            tables.push({
                table: descriptor.table,
                dataset: descriptor.dataset,
                inserted: 0,
                updated: 0,
            });
            continue;
        }
        const result = await store.mergeRows(descriptor.table, rows, [
            ...descriptor.mergeKey,
        ]);
        tables.push({
            table: descriptor.table,
            dataset: descriptor.dataset,
            inserted: result.inserted,
            updated: result.updated,
        });
    }
    const loaded = outcomes.filter((o) => o.status === "loaded").length;
    const skipped = outcomes.length - loaded;
    logger?.info(`fact-loader: loaded ${loaded}, skipped ${skipped} across ${registry.length} table(s)`);
    return { loaded, skipped, outcomes, tables };
}
// ---------------------------------------------------------------------------
// Per-run extraction
// ---------------------------------------------------------------------------
/**
 * Read one run and produce its rows grouped by table. Throws on any
 * unrecoverable condition (missing/corrupt manifest, unreadable artifact,
 * row-schema drift) so the caller isolates the run.
 */
async function extractRun(runId, registry, reader) {
    const manifest = await reader.readManifest(runId);
    const present = new Set();
    for (const [type, ref] of Object.entries(manifest.artifacts)) {
        if (ref != null)
            present.add(type);
    }
    const reportId = manifest.reportIds?.[0];
    const completedAt = manifest.createdAt;
    // Fetch each artifact type at most once per run, shared across descriptors.
    const fetched = new Map();
    const fetch = async (type) => {
        const cached = fetched.get(type);
        if (cached)
            return cached;
        const entries = await reader.readArtifact(runId, type);
        fetched.set(type, entries);
        return entries;
    };
    const runRows = new Map();
    for (const descriptor of registry) {
        // Partial-run handling: a descriptor runs only when every source artifact
        // it declares is present. Otherwise it yields zero rows, no error.
        const runnable = descriptor.sourceArtifacts.every((t) => present.has(t));
        if (!runnable)
            continue;
        const byType = new Map();
        for (const type of descriptor.sourceArtifacts) {
            byType.set(type, await fetch(type));
        }
        const ctx = {
            runId,
            reportId,
            completedAt,
            artifacts: {
                has: (type) => (byType.get(type)?.length ?? 0) > 0,
                // The reader deserializes payloads to `unknown`; the descriptor owns
                // the shape of the artifact it declared as a source, so this generic
                // narrowing is the extractor's responsibility, validated on output.
                entries: (type) => (byType.get(type) ?? []),
            },
        };
        // rowSchema guards the extractor -> schema boundary (D0045). Drift throws,
        // which skips-and-reports the whole run rather than loading a bad row.
        const rows = descriptor
            .extract(ctx)
            .map((row) => descriptor.rowSchema.parse(row));
        const bucket = runRows.get(descriptor.table) ?? [];
        bucket.push(...rows);
        runRows.set(descriptor.table, bucket);
    }
    return runRows;
}
