import { randomId } from '@syncular/core';
import { createMutationsApi, } from './mutations.js';
export class SyncularCommandHistoryError extends Error {
    code;
    commandId;
    constructor(code, message, options = {}) {
        super(message);
        this.name = 'SyncularCommandHistoryError';
        this.code = code;
        this.commandId = options.commandId;
    }
}
export function createSyncularCommandHistory(options) {
    const store = new CommandHistoryStore(options);
    const history = new CommandHistory(options, store);
    return {
        history,
        wrapMutations(mutations, scope) {
            return createMutationsApi(createTrackedCommit(mutations, scope, history));
        },
    };
}
function createTrackedCommit(mutations, scope, history) {
    return async (fn) => {
        await history.ensureReady();
        const recorder = new PendingCommandRecorder(history);
        const { result, commit } = await mutations.$commit(async (tx) => fn(createTrackedTx(tx, recorder)));
        await recorder.recordCommitted(scope, commit);
        return { result, receipt: commit, meta: undefined };
    };
}
function createTrackedTx(tx, recorder) {
    const tableCache = new Map();
    return new Proxy({}, {
        get(_target, prop) {
            if (prop === 'then')
                return undefined;
            if (typeof prop !== 'string')
                return undefined;
            const cached = tableCache.get(prop);
            if (cached)
                return cached;
            const tableTx = tx[prop];
            if (!tableTx)
                return undefined;
            const tracked = createTrackedTableTx(prop, tableTx, recorder);
            tableCache.set(prop, tracked);
            return tracked;
        },
    });
}
function createTrackedTableTx(table, tableTx, recorder) {
    return {
        async insert(values) {
            const id = await tableTx.insert(values);
            await recorder.track(table, id);
            return id;
        },
        async insertMany(rows) {
            const ids = await tableTx.insertMany(rows);
            for (const id of ids)
                await recorder.track(table, id);
            return ids;
        },
        async update(id, patch, options) {
            await recorder.track(table, id);
            await tableTx.update(id, patch, options);
        },
        async delete(id, options) {
            await recorder.track(table, id);
            await tableTx.delete(id, options);
        },
        async upsert(id, patch, options) {
            await recorder.track(table, id);
            await tableTx.upsert(id, patch, options);
        },
    };
}
class PendingCommandRecorder {
    history;
    #entries = new Map();
    constructor(history) {
        this.history = history;
    }
    async track(table, rowId) {
        const key = `${table}\0${rowId}`;
        if (this.#entries.has(key))
            return;
        this.#entries.set(key, {
            table,
            rowId,
            before: await this.history.readRow(table, rowId),
        });
    }
    async recordCommitted(scope, receipt) {
        if (this.#entries.size === 0)
            return;
        const entries = [];
        for (const entry of this.#entries.values()) {
            const after = await this.history.readRow(entry.table, entry.rowId);
            if (sameSnapshot(entry.before, after))
                continue;
            entries.push({ ...entry, after });
        }
        if (entries.length === 0)
            return;
        await this.history.record(scope, entries, receipt);
    }
}
class CommandHistory {
    options;
    store;
    constructor(options, store) {
        this.options = options;
        this.store = store;
    }
    ensureReady() {
        return this.store.ensureReady();
    }
    readRow(table, rowId) {
        const config = this.options.tableConfig[table];
        if (!config) {
            throw new SyncularCommandHistoryError('sync.command_history_table_unsupported', `Cannot record undo history for unsupported table ${table}`);
        }
        return readCurrentRow(this.options.client, table, config.primaryKeyColumn ?? 'id', rowId);
    }
    record(scope, entries, receipt) {
        return this.store.insert(scope, entries, receipt);
    }
    async canUndo() {
        await this.ensureReady();
        return (await this.store.latest('done')) !== null;
    }
    async canRedo() {
        await this.ensureReady();
        return (await this.store.latest('undone')) !== null;
    }
    async undoLast() {
        await this.ensureReady();
        const command = await this.store.latest('done');
        if (!command) {
            throw new SyncularCommandHistoryError('sync.command_history_empty', 'There is no Syncular command to undo');
        }
        const entries = decodeEntries(command);
        this.assertSafeReplay(command.id, entries);
        await this.assertCurrentRows(command.id, entries, 'after');
        const commit = await this.applySnapshots(command.mutation_scope, [...entries].reverse().map((entry) => ({
            table: entry.table,
            rowId: entry.rowId,
            snapshot: entry.before,
        })));
        await this.store.mark(command.id, 'undone', commit);
        return { ...commit, commandId: command.id };
    }
    async redoLast() {
        await this.ensureReady();
        const command = await this.store.latest('undone');
        if (!command) {
            throw new SyncularCommandHistoryError('sync.command_history_empty', 'There is no Syncular command to redo');
        }
        const entries = decodeEntries(command);
        this.assertSafeReplay(command.id, entries);
        await this.assertCurrentRows(command.id, entries, 'before');
        const commit = await this.applySnapshots(command.mutation_scope, entries.map((entry) => ({
            table: entry.table,
            rowId: entry.rowId,
            snapshot: entry.after,
        })));
        await this.store.mark(command.id, 'done', commit);
        return { ...commit, commandId: command.id };
    }
    async assertCurrentRows(commandId, entries, expected) {
        for (const entry of entries) {
            const config = this.options.tableConfig[entry.table];
            if (!config) {
                throw new SyncularCommandHistoryError('sync.command_history_table_unsupported', `Cannot replay undo history for unsupported table ${entry.table}`, { commandId });
            }
            const current = await this.readRow(entry.table, entry.rowId);
            if (!sameReplaySnapshot(config, current, entry[expected])) {
                throw new SyncularCommandHistoryError('sync.command_history_conflict', `Cannot ${expected === 'after' ? 'undo' : 'redo'} Syncular command ${commandId}; ${entry.table}.${entry.rowId} changed since the command was recorded`, { commandId });
            }
        }
    }
    assertSafeReplay(commandId, entries) {
        for (const entry of entries) {
            const config = this.options.tableConfig[entry.table];
            if (!config) {
                throw new SyncularCommandHistoryError('sync.command_history_table_unsupported', `Cannot replay undo history for unsupported table ${entry.table}`, { commandId });
            }
            const unsafeFields = unsafeChangedFields(config, entry);
            if (unsafeFields.length > 0) {
                throw new SyncularCommandHistoryError('sync.command_history_unsafe_field', `Cannot replay Syncular command ${commandId}; ${entry.table}.${entry.rowId} changed unsafe fields: ${unsafeFields.join(', ')}`, { commandId });
            }
        }
    }
    async applySnapshots(scope, snapshots) {
        const mutations = scope === 'leasedMutations'
            ? this.options.leasedMutations
            : this.options.mutations;
        const { commit } = await mutations.$commit(async (tx) => {
            for (const snapshot of snapshots) {
                const table = tx[snapshot.table];
                if (!table) {
                    throw new SyncularCommandHistoryError('sync.command_history_table_unsupported', `Cannot apply undo history for unsupported table ${snapshot.table}`);
                }
                if (snapshot.snapshot === null) {
                    await table.delete(snapshot.rowId);
                }
                else {
                    await table.upsert(snapshot.rowId, this.mutationPayloadForSnapshot(snapshot.table, snapshot.snapshot));
                }
            }
        });
        return commit;
    }
    mutationPayloadForSnapshot(table, snapshot) {
        const config = this.options.tableConfig[table];
        if (!config) {
            throw new SyncularCommandHistoryError('sync.command_history_table_unsupported', `Cannot apply undo history for unsupported table ${table}`);
        }
        const payload = { ...snapshot };
        delete payload[config.primaryKeyColumn ?? 'id'];
        if (config.serverVersionColumn)
            delete payload[config.serverVersionColumn];
        for (const field of config.crdtYjsFields ?? []) {
            delete payload[field.stateColumn];
        }
        return payload;
    }
}
class CommandHistoryStore {
    options;
    #ready;
    constructor(options) {
        this.options = options;
    }
    ensureReady() {
        this.#ready ??= this.#ensureReady();
        return this.#ready;
    }
    async insert(scope, entries, receipt) {
        await this.ensureReady();
        const now = this.now();
        const id = this.options.idFactory?.() ?? `cmd_${randomId()}`;
        const client = this.unsafeClient();
        await client.executeUnsafeSql("delete from sync_command_history where state = 'undone'");
        await client.executeUnsafeSql(`insert into sync_command_history (
        id,
        mutation_scope,
        state,
        entries_json,
        client_commit_id,
        created_at,
        updated_at
      ) values (?, ?, 'done', ?, ?, ?, ?)`, [id, scope, JSON.stringify(entries), receipt.clientCommitId, now, now]);
    }
    async latest(state) {
        await this.ensureReady();
        const result = await this.options.client.executeSql(`select id, mutation_scope, state, entries_json, client_commit_id,
              undo_client_commit_id, redo_client_commit_id, created_at, updated_at
       from sync_command_history
       where state = ?
       order by updated_at desc, created_at desc, id desc
       limit 1`, [state]);
        return result.rows[0] ?? null;
    }
    async mark(id, state, receipt) {
        await this.ensureReady();
        const replayColumn = state === 'undone' ? 'undo_client_commit_id' : 'redo_client_commit_id';
        await this.unsafeClient().executeUnsafeSql(`update sync_command_history
       set state = ?, updated_at = ?, ${replayColumn} = ?
       where id = ?`, [state, this.now(), receipt.clientCommitId, id]);
    }
    async #ensureReady() {
        const client = this.unsafeClient();
        await client.executeUnsafeSql(`create table if not exists sync_command_history (
        id text primary key,
        mutation_scope text not null,
        state text not null check (state in ('done', 'undone')),
        entries_json text not null,
        client_commit_id text not null,
        undo_client_commit_id text null,
        redo_client_commit_id text null,
        created_at integer not null,
        updated_at integer not null
      )`);
        await client.executeUnsafeSql(`create index if not exists idx_sync_command_history_state_updated
       on sync_command_history (state, updated_at, created_at)`);
    }
    unsafeClient() {
        const client = this.options.client;
        if (typeof client.executeUnsafeSql === 'function') {
            return client;
        }
        throw new SyncularCommandHistoryError('sync.command_history_storage_unavailable', 'Syncular command history requires internal SQLite write access');
    }
    now() {
        return Math.trunc(this.options.nowMs?.() ?? Date.now());
    }
}
async function readCurrentRow(client, table, idColumn, rowId) {
    const result = await client.executeSql(`select * from ${quoteIdentifier(table)} where ${quoteIdentifier(idColumn)} = ? limit 1`, [rowId]);
    const row = result.rows[0];
    return row ? normalizeSnapshot(row) : null;
}
function decodeEntries(row) {
    const entries = JSON.parse(row.entries_json);
    if (!Array.isArray(entries)) {
        throw new SyncularCommandHistoryError('sync.command_history_conflict', `Syncular command history row ${row.id} is malformed`, { commandId: row.id });
    }
    return entries;
}
function normalizeSnapshot(row) {
    return Object.fromEntries(Object.entries(row).sort(([a], [b]) => a.localeCompare(b)));
}
function sameSnapshot(left, right) {
    return stableJson(left) === stableJson(right);
}
function sameReplaySnapshot(config, left, right) {
    return (stableJson(replayComparableSnapshot(config, left)) ===
        stableJson(replayComparableSnapshot(config, right)));
}
function replayComparableSnapshot(config, snapshot) {
    if (snapshot === null)
        return null;
    const comparable = { ...snapshot };
    if (config.serverVersionColumn)
        delete comparable[config.serverVersionColumn];
    for (const field of config.crdtYjsFields ?? []) {
        delete comparable[field.stateColumn];
    }
    return comparable;
}
function unsafeChangedFields(config, entry) {
    const rowLifecycleReplay = entry.before === null || entry.after === null;
    const unsafeFields = new Set();
    for (const column of config.blobColumns ?? [])
        unsafeFields.add(column);
    for (const field of config.encryptedFields ?? [])
        unsafeFields.add(field.field);
    if (!rowLifecycleReplay) {
        for (const field of config.crdtYjsFields ?? []) {
            unsafeFields.add(field.field);
            unsafeFields.add(field.stateColumn);
        }
    }
    const changed = [];
    for (const field of unsafeFields) {
        const before = entry.before?.[field];
        const after = entry.after?.[field];
        if (stableJson(nullishToNull(before)) === stableJson(nullishToNull(after))) {
            continue;
        }
        if (rowLifecycleReplay && before == null && after == null)
            continue;
        changed.push(field);
    }
    return changed;
}
function nullishToNull(value) {
    return value == null ? null : value;
}
function stableJson(value) {
    return JSON.stringify(sortValue(value));
}
function sortValue(value) {
    if (Array.isArray(value))
        return value.map(sortValue);
    if (!value || typeof value !== 'object')
        return value;
    return Object.fromEntries(Object.entries(value)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([key, nested]) => [key, sortValue(nested)]));
}
function quoteIdentifier(identifier) {
    return `"${identifier.replaceAll('"', '""')}"`;
}
//# sourceMappingURL=command-history.js.map