/**
 * BigQueryFactStore — provisions fact tables and MERGEs fact rows into
 * BigQuery (`data-platform-302218.ailf_facts`), the store side of the D0091
 * registry-driven fact loader.
 *
 * The fact-loader engine talks only to the `BigQueryFactStore` port. This
 * adapter owns two responsibilities:
 *
 *   1. **Schema reconciliation** (`ensureTable`). Create the table from the
 *      descriptor's declared `schema` if it does not exist (DAY-partitioned on
 *      `completed_at`, clustered on the declared fields), or additively
 *      `ADD COLUMN` any declared columns the live table lacks. Reconciliation
 *      is additive only: a declared column whose live type differs is a
 *      migration, surfaced as a loud error rather than silently retyped or
 *      coerced (D0091).
 *
 *   2. **Idempotent loading** (`mergeRows`). Batch-load the rows to a throwaway
 *      staging table, then `MERGE` into the partitioned target on the
 *      descriptor's natural key, partition-pruning on `completed_at`. Re-running
 *      the loader re-derives the same rows — matched keys update in place, so
 *      row counts are stable (safe retries, safe backfill).
 *
 * The pure builders (`toBigQueryField`, `dedupeByMergeKey`, `buildMergeSql`,
 * …) are exported so the SQL and schema mapping are unit-testable without a
 * BigQuery client; the class wires them to the SDK.
 *
 * @see docs/decisions/D0091-bigquery-analytics-fact-loader.md
 * @see packages/core/src/ports/bigquery-fact-store.ts
 */
import { BigQuery } from "@google-cloud/bigquery";
/**
 * Audit column stamped by the store at load time. The extractor is pure and
 * cannot read the clock, so if a descriptor declares `_loaded_at` the store
 * sets it to `CURRENT_TIMESTAMP()` on both insert and update rather than from
 * the source row. It is excluded from the staging load and never a merge key,
 * so stamping it does not change row counts (idempotency holds).
 */
export const LOADED_AT_COLUMN = "_loaded_at";
/**
 * BigQuery-backed {@link BigQueryFactStore}. Provisions tables from declared
 * schemas and loads rows idempotently via staging + MERGE.
 */
export class BigQueryFactStoreAdapter {
    client;
    options;
    /**
     * Specs captured from `ensureTable`, so `mergeRows` can resolve a table's
     * dataset, partition field, and column list without re-reading metadata.
     * The engine always `ensureTable`s before `mergeRows`; a `mergeRows` for an
     * un-ensured table throws (mirroring the in-memory double's contract).
     */
    specs = new Map();
    constructor(options) {
        this.options = options;
        this.client = options.bigquery ?? null;
    }
    // ---- BigQueryFactStore port ---------------------------------------------
    async ensureTable(spec) {
        // Names go raw into DDL/DML SQL below. They originate from code-authored
        // descriptors, not user input, so this is a fail-fast guard against a
        // typo'd identifier — it turns broken SQL into a clear startup error.
        assertSafeIdentifier("table", spec.table);
        assertSafeIdentifier("partition field", spec.partitionField);
        for (const col of spec.schema)
            assertSafeIdentifier("column", col.name);
        for (const cf of spec.clusterFields)
            assertSafeIdentifier("cluster field", cf);
        const table = this.getClient().dataset(spec.dataset).table(spec.table);
        const [exists] = await table.exists();
        if (!exists) {
            await table.create({
                schema: { fields: spec.schema.map(toBigQueryField) },
                timePartitioning: { type: "DAY", field: spec.partitionField },
                ...(spec.clusterFields.length > 0
                    ? { clustering: { fields: [...spec.clusterFields] } }
                    : {}),
            });
            this.specs.set(spec.table, spec);
            return;
        }
        // Reconcile additively against the live schema.
        const [metadata] = await table.getMetadata();
        const liveFields = metadata.schema?.fields ?? [];
        const liveByName = new Map(liveFields.map((f) => [f.name, f]));
        const toAdd = reconcileColumns(spec.schema, liveByName, spec.table);
        if (toAdd.length > 0) {
            await table.setMetadata({
                schema: { fields: [...liveFields, ...toAdd] },
            });
        }
        this.specs.set(spec.table, spec);
    }
    async mergeRows(table, rows, mergeKey) {
        const spec = this.specs.get(table);
        if (!spec) {
            throw new Error(`mergeRows: table ${table} was not ensured`);
        }
        if (rows.length === 0) {
            return { table, inserted: 0, updated: 0 };
        }
        for (const k of mergeKey)
            assertSafeIdentifier("merge key", k);
        // MERGE rejects duplicate source keys ("must match at most one source row
        // for each target row"), so collapse the batch to one row per key first.
        const deduped = dedupeByMergeKey(rows, mergeKey);
        const bq = this.getClient();
        const dataset = bq.dataset(spec.dataset);
        const dataColumns = spec.schema
            .map((c) => c.name)
            .filter((name) => name !== LOADED_AT_COLUMN);
        const hasLoadedAt = spec.schema.some((c) => c.name === LOADED_AT_COLUMN);
        // Stage the deduped rows in a throwaway table (auto-expiring in case the
        // drop below fails), loaded from NDJSON (a batch load, not a streaming
        // insert — cheaper and re-derivable).
        const stagingName = stagingTableName(table);
        const staging = dataset.table(stagingName);
        await staging.create({
            schema: {
                fields: spec.schema
                    .filter((c) => c.name !== LOADED_AT_COLUMN)
                    .map(toBigQueryField),
            },
            expirationTime: String(Date.now() + STAGING_TTL_MS),
        });
        try {
            await loadRowsNdjson(staging, deduped, dataColumns);
            const bounds = partitionBounds(deduped, spec.partitionField);
            const target = qualified(this.options.project, spec.dataset, table);
            const stagingFq = qualified(this.options.project, spec.dataset, stagingName);
            const params = {};
            if (bounds) {
                params.min_partition = bq.timestamp(bounds.min);
                params.max_partition = bq.timestamp(bounds.max);
            }
            // Count matched keys first → updated; inserted is the remainder. MERGE
            // does not report insert/update counts separately, so this pre-count is
            // the only way to split them.
            const matchCountSql = buildMatchCountSql({
                target,
                staging: stagingFq,
                mergeKey,
                partitionField: spec.partitionField,
                hasBounds: Boolean(bounds),
            });
            const [countRows] = await bq.query({
                query: matchCountSql,
                params,
                ...(this.options.location ? { location: this.options.location } : {}),
            });
            const updated = Number(countRows[0]?.matched ?? 0);
            // Clamp: `updated` counts matched target rows and the MERGE keeps one row
            // per key, so `updated <= deduped.length` in steady state. A pre-existing
            // stray duplicate could push `updated` higher; clamp so the reported
            // `inserted` never goes negative (the MERGE itself stays key-correct).
            const inserted = Math.max(0, deduped.length - updated);
            const mergeSql = buildMergeSql({
                target,
                staging: stagingFq,
                columns: spec.schema.map((c) => c.name),
                mergeKey,
                partitionField: spec.partitionField,
                hasBounds: Boolean(bounds),
                stampLoadedAt: hasLoadedAt,
            });
            await bq.query({
                query: mergeSql,
                params,
                ...(this.options.location ? { location: this.options.location } : {}),
            });
            return { table, inserted, updated };
        }
        finally {
            // Best-effort cleanup; the expirationTime backstops a failed drop.
            await staging.delete({ ignoreNotFound: true }).catch(() => { });
        }
    }
    // ---- Internals ----------------------------------------------------------
    getClient() {
        if (this.client)
            return this.client;
        this.client = new BigQuery({
            projectId: this.options.project,
            ...(this.options.credentials
                ? { keyFilename: this.options.credentials }
                : {}),
        });
        return this.client;
    }
}
const STAGING_TTL_MS = 60 * 60 * 1000;
/** A BigQuery unquoted identifier: leading letter/underscore, then word chars. */
const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
 * Reject an identifier that would break the raw SQL the store builds. Table and
 * column names come from code-authored `FactTableDescriptor`s (not user input),
 * so this is a fail-fast against a descriptor typo, not an injection defense —
 * a name with a space or backtick becomes a clear error, not broken SQL.
 */
export function assertSafeIdentifier(kind, name) {
    if (!SAFE_IDENTIFIER_RE.test(name)) {
        throw new Error(`fact-loader ${kind} "${name}" is not a safe BigQuery identifier (expected /^[A-Za-z_][A-Za-z0-9_]*$/)`);
    }
}
/**
 * Map a declared column to a BigQuery schema field. The `BigQueryFieldType`
 * union is already BigQuery-native, so `type` passes through; mode defaults to
 * NULLABLE.
 */
export function toBigQueryField(col) {
    return {
        name: col.name,
        type: col.type,
        mode: col.mode ?? "NULLABLE",
        ...(col.description ? { description: col.description } : {}),
    };
}
/**
 * Canonicalize a BigQuery field type for drift comparison. Live metadata may
 * report legacy names (INTEGER/FLOAT/BOOLEAN) even for columns created with the
 * standard-SQL names (INT64/FLOAT64/BOOL), so both sides are normalized before
 * comparison.
 */
export function normalizeFieldType(type) {
    const upper = type.toUpperCase();
    switch (upper) {
        case "INTEGER":
            return "INT64";
        case "FLOAT":
            return "FLOAT64";
        case "BOOLEAN":
            return "BOOL";
        default:
            return upper;
    }
}
/**
 * Compute the columns to add to an existing table: declared columns absent
 * from the live schema. Throws on a declared/live type mismatch (a migration,
 * not a silent retype). Added columns are forced NULLABLE — BigQuery cannot add
 * a REQUIRED column to a populated table, and there is nothing to backfill.
 */
function reconcileColumns(declared, liveByName, table) {
    const toAdd = [];
    for (const col of declared) {
        const live = liveByName.get(col.name);
        if (!live) {
            toAdd.push({ ...toBigQueryField(col), mode: "NULLABLE" });
            continue;
        }
        if (live.type &&
            normalizeFieldType(live.type) !== normalizeFieldType(col.type)) {
            throw new Error(`fact table ${table} column "${col.name}" type drift: live ${normalizeFieldType(live.type)} vs declared ${normalizeFieldType(col.type)} — additive reconciliation cannot retype a column; a type change is a follow-up migration (D0091)`);
        }
    }
    return toAdd;
}
/** Stable string key from a row's merge-key columns. */
export function mergeKeyString(row, mergeKey) {
    return mergeKey.map((k) => JSON.stringify(row[k] ?? null)).join(" ");
}
/** Collapse rows to one per merge-key tuple (last wins), preserving order. */
export function dedupeByMergeKey(rows, mergeKey) {
    const byKey = new Map();
    for (const row of rows)
        byKey.set(mergeKeyString(row, mergeKey), row);
    return [...byKey.values()];
}
/**
 * Min/max of the partition column across rows, as ISO timestamps, or null when
 * no row carries a usable value (in which case the MERGE runs unpruned).
 *
 * IDEMPOTENCY INVARIANT: the bounds come from the incoming batch and prune the
 * target's partitions in both the match-count and the MERGE `ON` clause. This
 * is correct only while the partition value is functionally determined by the
 * merge key — i.e. re-deriving the same logical row always yields the same
 * `completed_at`. In v1 that holds because every merge key is run-scoped and
 * `completed_at` is the run's fixed completion time. A future descriptor whose
 * partition column can drift for a stable merge key would let the prune miss
 * the pre-existing target row and double-insert it in another partition — such
 * a descriptor must include the partition column in its merge key.
 */
export function partitionBounds(rows, partitionField) {
    let min = null;
    let max = null;
    for (const row of rows) {
        const raw = row[partitionField];
        if (typeof raw !== "string" && !(raw instanceof Date))
            continue;
        const ms = raw instanceof Date ? raw.getTime() : Date.parse(raw);
        if (Number.isNaN(ms))
            continue;
        if (min === null || ms < min)
            min = ms;
        if (max === null || ms > max)
            max = ms;
    }
    if (min === null || max === null)
        return null;
    return { min: new Date(min).toISOString(), max: new Date(max).toISOString() };
}
/** `` `project.dataset.table` `` backtick-quoted for SQL. */
function qualified(project, dataset, table) {
    return `\`${project}.${dataset}.${table}\``;
}
/** Randomized, prefixed staging table name (BigQuery-safe identifier). */
function stagingTableName(table) {
    const rand = Math.random().toString(36).slice(2, 10);
    return `_stg_${table}_${Date.now()}_${rand}`;
}
/** ON-clause conjunction plus optional partition-pruning predicate. */
function onClause(mergeKey, partitionField, hasBounds) {
    const keyEq = mergeKey.map((k) => `T.${k} = S.${k}`).join(" AND ");
    if (!hasBounds)
        return keyEq;
    return `${keyEq} AND T.${partitionField} BETWEEN @min_partition AND @max_partition`;
}
/**
 * `SELECT COUNT(*)` of target rows whose key is present in staging (partition-
 * pruned when bounds are known). This is the pre-MERGE matched count → the
 * number of rows the MERGE will UPDATE rather than INSERT.
 */
export function buildMatchCountSql(args) {
    const { target, staging, mergeKey, partitionField, hasBounds } = args;
    const keyEq = mergeKey.map((k) => `T.${k} = S.${k}`).join(" AND ");
    const prune = hasBounds
        ? `T.${partitionField} BETWEEN @min_partition AND @max_partition AND `
        : "";
    return (`SELECT COUNT(*) AS matched FROM ${target} T ` +
        `WHERE ${prune}EXISTS (SELECT 1 FROM ${staging} S WHERE ${keyEq})`);
}
/**
 * Build the idempotent upsert `MERGE`. Matched keys update their non-key data
 * columns (and re-stamp `_loaded_at` when present); unmatched keys insert every
 * column. The WHEN MATCHED clause is omitted when there is nothing to update
 * (an all-key table with no audit column), degenerating to insert-if-absent.
 */
export function buildMergeSql(args) {
    const { target, staging, columns, mergeKey, partitionField, hasBounds, stampLoadedAt, } = args;
    const keySet = new Set(mergeKey);
    const dataColumns = columns.filter((c) => c !== LOADED_AT_COLUMN);
    const nonKeyData = dataColumns.filter((c) => !keySet.has(c));
    const updateAssignments = [
        ...nonKeyData.map((c) => `T.${c} = S.${c}`),
        ...(stampLoadedAt ? [`T.${LOADED_AT_COLUMN} = CURRENT_TIMESTAMP()`] : []),
    ];
    const insertColumns = [
        ...dataColumns,
        ...(stampLoadedAt ? [LOADED_AT_COLUMN] : []),
    ];
    const insertValues = [
        ...dataColumns.map((c) => `S.${c}`),
        ...(stampLoadedAt ? ["CURRENT_TIMESTAMP()"] : []),
    ];
    const matchedClause = updateAssignments.length > 0
        ? `WHEN MATCHED THEN UPDATE SET ${updateAssignments.join(", ")} `
        : "";
    return (`MERGE ${target} T USING ${staging} S ` +
        `ON ${onClause(mergeKey, partitionField, hasBounds)} ` +
        matchedClause +
        `WHEN NOT MATCHED THEN INSERT (${insertColumns.join(", ")}) ` +
        `VALUES (${insertValues.join(", ")})`);
}
/**
 * Batch-load rows into a staging table as newline-delimited JSON. Only the
 * declared data columns are serialized (`_loaded_at` is stamped by the MERGE,
 * not loaded). Resolves when the load job completes; rejects on error.
 */
async function loadRowsNdjson(table, rows, dataColumns) {
    await new Promise((resolve, reject) => {
        const stream = table.createWriteStream({
            sourceFormat: "NEWLINE_DELIMITED_JSON",
            writeDisposition: "WRITE_TRUNCATE",
        });
        stream.on("error", reject);
        stream.on("complete", () => resolve());
        for (const row of rows) {
            stream.write(`${JSON.stringify(projectRow(row, dataColumns))}\n`);
        }
        stream.end();
    });
}
/** Project a row to its declared data columns, dropping undefined values. */
function projectRow(row, dataColumns) {
    const out = {};
    for (const col of dataColumns) {
        const value = row[col];
        if (value !== undefined)
            out[col] = value;
    }
    return out;
}
