import { timeSampled } from "../../otel/src/maybe-time.js";
import { assert, unreachable } from "../../shared/src/asserts.js";
import { must } from "../../shared/src/must.js";
import { difference } from "../../shared/src/set-utils.js";
import { createPredicate, transformFilters, } from "../../zql/src/builder/filter.js";
import { makeComparator } from "../../zql/src/ivm/data.js";
import { buildSelectQuery, toSQLiteType, } from "./query-builder.js";
import { generateWithOverlay, generateWithStart, genPushAndWriteWithSplitEdit, } from "../../zql/src/ivm/memory-source.js";
import { Database, Statement } from "./db.js";
import { compile, format, sql } from "./internal/sql.js";
import { StatementCache } from "./internal/statement-cache.js";
let eventCount = 0;
/**
 * A source that is backed by a SQLite table.
 *
 * Values are written to the backing table _after_ being vended by the source.
 *
 * This ordering of events is to ensure self joins function properly. That is,
 * we can't reveal a value to an output before it has been pushed to that output.
 *
 * The code is fairly straightforward except for:
 * 1. Dealing with a `fetch` that has a basis of `before`.
 * 2. Dealing with compound orders that have differing directions (a ASC, b DESC, c ASC)
 *
 * See comments in relevant functions for more details.
 */
export class TableSource {
    #dbCache = new WeakMap();
    #connections = [];
    #table;
    #columns;
    // Maps sorted columns JSON string (e.g. '["a","b"]) to Set of columns.
    #uniqueIndexes;
    #primaryKey;
    #logConfig;
    #lc;
    #stmts;
    #overlay;
    constructor(logContext, logConfig, db, tableName, columns, primaryKey) {
        this.#lc = logContext;
        this.#logConfig = logConfig;
        this.#table = tableName;
        this.#columns = columns;
        this.#uniqueIndexes = getUniqueIndexes(db, tableName);
        this.#primaryKey = primaryKey;
        this.#stmts = this.#getStatementsFor(db);
        assert(this.#uniqueIndexes.has(JSON.stringify([...primaryKey].sort())), `primary key ${primaryKey} does not have a UNIQUE index`);
    }
    get table() {
        return this.#table;
    }
    /**
     * Sets the db (snapshot) to use, to facilitate the Snapshotter leapfrog
     * algorithm for concurrent traversal of historic timelines.
     */
    setDB(db) {
        this.#stmts = this.#getStatementsFor(db);
    }
    #getStatementsFor(db) {
        const cached = this.#dbCache.get(db);
        if (cached) {
            return cached;
        }
        const stmts = {
            cache: new StatementCache(db),
            insert: db.prepare(compile(sql `INSERT INTO ${sql.ident(this.#table)} (${sql.join(Object.keys(this.#columns).map(c => sql.ident(c)), ', ')}) VALUES (${sql.__dangerous__rawValue(Array.from({ length: Object.keys(this.#columns).length })
                .fill('?')
                .join(','))})`)),
            delete: db.prepare(compile(sql `DELETE FROM ${sql.ident(this.#table)} WHERE ${sql.join(this.#primaryKey.map(k => sql `${sql.ident(k)}=?`), ' AND ')}`)),
            // If all the columns are part of the primary key, we cannot use UPDATE.
            update: Object.keys(this.#columns).length > this.#primaryKey.length
                ? db.prepare(compile(sql `UPDATE ${sql.ident(this.#table)} SET ${sql.join(nonPrimaryKeys(this.#columns, this.#primaryKey).map(c => sql `${sql.ident(c)}=?`), ',')} WHERE ${sql.join(this.#primaryKey.map(k => sql `${sql.ident(k)}=?`), ' AND ')}`))
                : undefined,
            checkExists: db.prepare(compile(sql `SELECT 1 AS "exists" FROM ${sql.ident(this.#table)} WHERE ${sql.join(this.#primaryKey.map(k => sql `${sql.ident(k)}=?`), ' AND ')} LIMIT 1`)),
            getExisting: db.prepare(compile(sql `SELECT * FROM ${sql.ident(this.#table)} WHERE ${sql.join(this.#primaryKey.map(k => sql `${sql.ident(k)}=?`), ' AND ')}`)),
        };
        this.#dbCache.set(db, stmts);
        return stmts;
    }
    get #allColumns() {
        return sql.join(Object.keys(this.#columns).map(c => sql.ident(c)), sql `,`);
    }
    #getSchema(connection) {
        return {
            tableName: this.#table,
            columns: this.#columns,
            primaryKey: this.#selectPrimaryKeyFor(connection.sort),
            sort: connection.sort,
            relationships: {},
            isHidden: false,
            system: 'client',
            compareRows: connection.compareRows,
        };
    }
    connect(sort, filters, splitEditKeys, debug) {
        const transformedFilters = transformFilters(filters);
        const input = {
            getSchema: () => schema,
            fetch: req => this.#fetch(req, connection),
            cleanup: req => this.#cleanup(req, connection),
            setOutput: output => {
                connection.output = output;
            },
            destroy: () => {
                const idx = this.#connections.indexOf(connection);
                assert(idx !== -1, 'Connection not found');
                this.#connections.splice(idx, 1);
            },
            fullyAppliedFilters: !transformedFilters.conditionsRemoved,
        };
        const connection = {
            input,
            debug,
            output: undefined,
            sort,
            splitEditKeys,
            filters: transformedFilters.filters
                ? {
                    condition: transformedFilters.filters,
                    predicate: createPredicate(transformedFilters.filters),
                }
                : undefined,
            compareRows: makeComparator(sort),
        };
        const schema = this.#getSchema(connection);
        this.#connections.push(connection);
        return input;
    }
    toSQLiteRow(row) {
        return Object.fromEntries(Object.entries(row).map(([key, value]) => [
            key,
            toSQLiteType(value, this.#columns[key].type),
        ]));
    }
    #cleanup(req, connection) {
        return this.#fetch(req, connection);
    }
    *#fetch(req, connection) {
        const { sort, debug } = connection;
        const query = this.#requestToSQL(req, connection.filters?.condition, sort);
        const sqlAndBindings = format(query);
        const cachedStatement = this.#stmts.cache.get(sqlAndBindings.text);
        try {
            cachedStatement.statement.safeIntegers(true);
            const rowIterator = cachedStatement.statement.iterate(...sqlAndBindings.values);
            const callingConnectionIndex = this.#connections.indexOf(connection);
            assert(callingConnectionIndex !== -1, 'Connection not found');
            const comparator = makeComparator(sort, req.reverse);
            debug?.initQuery(this.#table, sqlAndBindings.text);
            yield* generateWithStart(generateWithOverlay(req.start?.row, this.#mapFromSQLiteTypes(this.#columns, rowIterator, sqlAndBindings.text, debug), req.constraint, this.#overlay, callingConnectionIndex, comparator, connection.filters?.predicate), req.start, comparator);
        }
        finally {
            this.#stmts.cache.return(cachedStatement);
        }
    }
    *#mapFromSQLiteTypes(valueTypes, rowIterator, query, debug) {
        let result;
        try {
            do {
                result = timeSampled(this.#lc, ++eventCount, this.#logConfig.ivmSampling, () => rowIterator.next(), this.#logConfig.slowRowThreshold, () => `table-source.next took too long for ${query}. Are you missing an index?`);
                if (result.done) {
                    break;
                }
                const row = fromSQLiteTypes(valueTypes, result.value, this.#table);
                debug?.rowVended(this.#table, query, row);
                yield row;
            } while (!result.done);
        }
        finally {
            rowIterator.return?.();
        }
    }
    push(change) {
        for (const _ of this.genPush(change)) {
            // Nothing to do.
        }
    }
    *genPush(change) {
        const exists = (row) => this.#stmts.checkExists.get(...toSQLiteTypes(this.#primaryKey, row, this.#columns))?.exists === 1;
        const setOverlay = (o) => (this.#overlay = o);
        const writeChange = (c) => this.#writeChange(c);
        if (change.type === 'set') {
            const existing = this.#stmts.getExisting.get(...toSQLiteTypes(this.#primaryKey, change.row, this.#columns));
            if (existing !== undefined) {
                change = {
                    type: 'edit',
                    oldRow: existing,
                    row: change.row,
                };
            }
            else {
                change = {
                    type: 'add',
                    row: change.row,
                };
            }
        }
        yield* genPushAndWriteWithSplitEdit(this.#connections, change, exists, setOverlay, writeChange);
    }
    #writeChange(change) {
        switch (change.type) {
            case 'add':
                this.#stmts.insert.run(...toSQLiteTypes(Object.keys(this.#columns), change.row, this.#columns));
                break;
            case 'remove':
                this.#stmts.delete.run(...toSQLiteTypes(this.#primaryKey, change.row, this.#columns));
                break;
            case 'edit': {
                // If the PK is the same, use UPDATE.
                if (canUseUpdate(change.oldRow, change.row, this.#columns, this.#primaryKey)) {
                    const mergedRow = {
                        ...change.oldRow,
                        ...change.row,
                    };
                    const params = [
                        ...nonPrimaryValues(this.#columns, this.#primaryKey, mergedRow),
                        ...toSQLiteTypes(this.#primaryKey, mergedRow, this.#columns),
                    ];
                    must(this.#stmts.update).run(params);
                }
                else {
                    this.#stmts.delete.run(...toSQLiteTypes(this.#primaryKey, change.oldRow, this.#columns));
                    this.#stmts.insert.run(...toSQLiteTypes(Object.keys(this.#columns), change.row, this.#columns));
                }
                break;
            }
            default:
                unreachable(change);
        }
    }
    #getRowStmtCache = new Map();
    #getRowStmt(keyCols) {
        const keyString = JSON.stringify(keyCols);
        let stmt = this.#getRowStmtCache.get(keyString);
        if (!stmt) {
            stmt = compile(sql `SELECT ${this.#allColumns} FROM ${sql.ident(this.#table)} WHERE ${sql.join(keyCols.map(k => sql `${sql.ident(k)}=?`), sql ` AND`)}`);
            this.#getRowStmtCache.set(keyString, stmt);
        }
        return stmt;
    }
    /**
     * Retrieves a row from the backing DB by a unique key, or `undefined` if such a
     * row does not exist. This is not used in the IVM pipeline but is useful
     * for retrieving data that is consistent with the state (and type
     * semantics) of the pipeline. Note that this key may not necessarily correspond
     * to the `primaryKey` with which this TableSource.
     */
    getRow(rowKey) {
        const keyCols = Object.keys(rowKey);
        const keyVals = Object.values(rowKey);
        const stmt = this.#getRowStmt(keyCols);
        const row = this.#stmts.cache.use(stmt, cached => cached.statement.safeIntegers(true).get(keyVals));
        if (row) {
            return fromSQLiteTypes(this.#columns, row, this.#table);
        }
        return row;
    }
    #requestToSQL(request, filters, order) {
        return buildSelectQuery(this.#table, this.#columns, request.constraint, filters, order, request.reverse, request.start);
    }
    #selectPrimaryKeyFor(sort) {
        const columns = new Set(sort.map(([col]) => col));
        for (const uniqueColumns of this.#uniqueIndexes.values()) {
            if (difference(uniqueColumns, columns).size === 0) {
                assert(uniqueColumns.size > 0);
                return [...uniqueColumns];
            }
        }
        throw new Error(`Cannot orderBy(${JSON.stringify(sort.map(([c]) => c))}). ` +
            (sort.length === 1
                ? `The column must be unique. `
                : `One or more columns must form a unique index. `) +
            `Did you forget to include a primary key or ` +
            `(non-null) unique index on the "${this.#table}" table?`);
    }
}
function getUniqueIndexes(db, tableName) {
    const sqlAndBindings = format(sql `
    SELECT idx.name, json_group_array(col.name) as columnsJSON
      FROM sqlite_master as idx
      JOIN pragma_index_list(idx.tbl_name) AS info ON info.name = idx.name
      JOIN pragma_index_info(idx.name) as col
      WHERE idx.tbl_name = ${tableName} AND
            idx.type = 'index' AND 
            info."unique" != 0
      GROUP BY idx.name
      ORDER BY idx.name`);
    const stmt = db.prepare(sqlAndBindings.text);
    const indexes = stmt.all(...sqlAndBindings.values);
    return new Map(indexes.map(({ columnsJSON }) => {
        const columns = JSON.parse(columnsJSON);
        const set = new Set(columns);
        return [JSON.stringify(columns.sort()), set];
    }));
}
export function toSQLiteTypes(columns, row, columnTypes) {
    return columns.map(col => toSQLiteType(row[col], columnTypes[col].type));
}
export function toSQLiteTypeName(type) {
    switch (type) {
        case 'boolean':
            return 'INTEGER';
        case 'number':
            return 'REAL';
        case 'string':
            return 'TEXT';
        case 'null':
            return 'NULL';
        case 'json':
            return 'TEXT';
    }
}
export function fromSQLiteTypes(valueTypes, row, tableName) {
    const newRow = {};
    for (const key of Object.keys(row)) {
        const valueType = valueTypes[key];
        if (valueType === undefined) {
            const columnList = Object.keys(valueTypes).sort().join(', ');
            throw new Error(`Invalid column "${key}" for table "${tableName}". Synced columns include ${columnList}`);
        }
        newRow[key] = fromSQLiteType(valueType.type, row[key], key, tableName);
    }
    return newRow;
}
function fromSQLiteType(valueType, v, column, tableName) {
    if (v === null) {
        return null;
    }
    switch (valueType) {
        case 'boolean':
            return !!v;
        case 'number':
        case 'string':
        case 'null':
            if (typeof v === 'bigint') {
                if (v > Number.MAX_SAFE_INTEGER || v < Number.MIN_SAFE_INTEGER) {
                    throw new UnsupportedValueError(`value ${v} (in ${tableName}.${column}) is outside of supported bounds`);
                }
                return Number(v);
            }
            return v;
        case 'json':
            try {
                return JSON.parse(v);
            }
            catch (error) {
                const errorMessage = error instanceof Error ? error.message : String(error);
                throw new UnsupportedValueError(`Failed to parse JSON for ${tableName}.${column}: ${errorMessage}`, { cause: error });
            }
    }
}
export class UnsupportedValueError extends Error {
}
function canUseUpdate(oldRow, row, columns, primaryKey) {
    for (const pk of primaryKey) {
        if (oldRow[pk] !== row[pk]) {
            return false;
        }
    }
    return Object.keys(columns).length > primaryKey.length;
}
function nonPrimaryValues(columns, primaryKey, row) {
    return nonPrimaryKeys(columns, primaryKey).map(c => toSQLiteType(row[c], columns[c].type));
}
function nonPrimaryKeys(columns, primaryKey) {
    return Object.keys(columns).filter(c => !primaryKey.includes(c));
}
//# sourceMappingURL=table-source.js.map