import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js';
import { DiffTriggerOperation } from './TriggerManager.js';
export const DEFAULT_TRIGGER_MANAGER_CONFIGURATION = {
    useStorageByDefault: false
};
const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes
/**
 * @internal
 * @experimental
 */
export class TriggerManagerImpl {
    options;
    schema;
    defaultConfig;
    cleanupTimeout;
    isDisposed;
    constructor(options) {
        this.options = options;
        this.schema = options.schema;
        options.db.registerListener({
            schemaChanged: (schema) => {
                this.schema = schema;
            }
        });
        this.isDisposed = false;
        /**
         * Configure a cleanup to run on an interval.
         * The interval is configured using setTimeout to take the async
         * execution time of the callback into account.
         */
        this.defaultConfig = DEFAULT_TRIGGER_MANAGER_CONFIGURATION;
        const cleanupCallback = async () => {
            this.cleanupTimeout = null;
            if (this.isDisposed) {
                return;
            }
            try {
                await this.cleanupResources();
            }
            catch (ex) {
                this.db.logger.error(`Caught error while attempting to cleanup triggers`, ex);
            }
            finally {
                // if not closed, set another timeout
                if (this.isDisposed) {
                    return;
                }
                this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
            }
        };
        this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
    }
    get db() {
        return this.options.db;
    }
    async getUUID() {
        const { id: uuid } = await this.db.get(/* sql */ `
      SELECT
        uuid () as id
    `);
        // Replace dashes with underscores for SQLite table/trigger name compatibility
        return uuid.replace(/-/g, '_');
    }
    async removeTriggers(tx, triggerIds) {
        for (const triggerId of triggerIds) {
            await tx.execute(/* sql */ `DROP TRIGGER IF EXISTS ${triggerId}; `);
        }
    }
    dispose() {
        this.isDisposed = true;
        if (this.cleanupTimeout) {
            clearTimeout(this.cleanupTimeout);
        }
    }
    /**
     * Updates default config settings for platform specific use-cases.
     */
    updateDefaults(config) {
        this.defaultConfig = {
            ...this.defaultConfig,
            ...config
        };
    }
    generateTriggerName(operation, destinationTable, triggerId) {
        return `__ps_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`;
    }
    /**
     * Cleanup any SQLite triggers or tables that are no longer in use.
     */
    async cleanupResources() {
        // we use the database here since cleanupResources is called during the PowerSyncDatabase initialization
        await this.db.database.writeLock(async (ctx) => {
            /**
             * Note: We only cleanup persisted triggers. These are tracked in the sqlite_master table.
             * temporary triggers will not be affected by this.
             * Query all triggers that match our naming pattern
             */
            const triggers = await ctx.getAll(/* sql */ `
        SELECT
          name
        FROM
          sqlite_master
        WHERE
          type = 'trigger'
          AND name LIKE '__ps_temp_trigger_%'
      `);
            /** Use regex to extract table names and IDs from trigger names
             * Trigger naming convention: __ps_temp_trigger_<operation>__<destination_table>__<id>
             */
            const triggerPattern = /^__ps_temp_trigger_(?:insert|update|delete)__(.+)__([a-f0-9_]{36})$/i;
            const trackedItems = new Map();
            for (const trigger of triggers) {
                const match = trigger.name.match(triggerPattern);
                if (match) {
                    const [, table, id] = match;
                    // Collect all trigger names for each id combo
                    const existing = trackedItems.get(id);
                    if (existing) {
                        existing.triggerNames.push(trigger.name);
                    }
                    else {
                        trackedItems.set(id, { table, id, triggerNames: [trigger.name] });
                    }
                }
            }
            for (const trackedItem of trackedItems.values()) {
                // check if there is anything holding on to this item
                const hasClaim = await this.options.claimManager.checkClaim(trackedItem.id);
                if (hasClaim) {
                    // This does not require cleanup
                    continue;
                }
                this.db.logger.debug(`Clearing resources for trigger ${trackedItem.id} with table ${trackedItem.table}`);
                // We need to delete the triggers and table
                for (const triggerName of trackedItem.triggerNames) {
                    await ctx.execute(`DROP TRIGGER IF EXISTS ${triggerName}`);
                }
                await ctx.execute(`DROP TABLE IF EXISTS ${trackedItem.table}`);
            }
        });
    }
    async createDiffTrigger(options) {
        await this.db.waitForReady();
        const { source, destination, columns, when, hooks, manageDestinationExternally = false, 
        // Fall back to the provided default if not given on this level
        useStorage = this.defaultConfig.useStorageByDefault } = options;
        const operations = Object.keys(when);
        if (operations.length == 0) {
            throw new Error('At least one WHEN operation must be specified for the trigger.');
        }
        /**
         * The clause to use when executing
         * CREATE ${tableTriggerTypeClause} TABLE
         * OR
         * CREATE ${tableTriggerTypeClause} TRIGGER
         */
        const tableTriggerTypeClause = !useStorage ? 'TEMP' : '';
        const whenClauses = Object.fromEntries(Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]));
        /**
         * Allow specifying the View name as the source.
         * We can lookup the internal table name from the schema.
         */
        const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
        if (!sourceDefinition) {
            throw new Error(`Source table or view "${source}" not found in the schema.`);
        }
        const replicatedColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
        const internalSource = sourceDefinition.internalName;
        const triggerIds = [];
        const id = await this.getUUID();
        const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null;
        /**
         * We default to replicating all columns if no columns array is provided.
         */
        const jsonFragment = (source = 'NEW') => {
            if (columns == null) {
                // Track all columns
                return `${source}.data`;
            }
            else if (columns.length == 0) {
                // Don't track any columns except for the id
                return `'{}'`;
            }
            else {
                // Filter the data by the replicated columns
                return `json_object(${replicatedColumns.map((col) => `'${col}', json_extract(${source}.data, '$.${col}')`).join(', ')})`;
            }
        };
        const disposeWarningListener = this.db.registerListener({
            schemaChanged: () => {
                this.db.logger.warn(`The PowerSync schema has changed while previously configured triggers are still operational. This might cause unexpected results.`);
            }
        });
        /**
         * Declare the cleanup function early since if any of the init steps fail,
         * we need to ensure we can cleanup the created resources.
         * We unfortunately cannot rely on transaction rollback.
         */
        const cleanup = async () => {
            disposeWarningListener();
            return this.db.writeLock(async (tx) => {
                await this.removeTriggers(tx, triggerIds);
                if (!manageDestinationExternally) {
                    await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
                }
                await releaseStorageClaim?.();
            });
        };
        const setup = async (tx) => {
            // Allow user code to execute in this lock context before the trigger is created.
            await hooks?.beforeCreate?.(tx);
            if (!manageDestinationExternally) {
                await tx.execute(/* sql */ `
          CREATE ${tableTriggerTypeClause} TABLE ${destination} (
            operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
            id TEXT,
            operation TEXT,
            timestamp TEXT,
            value TEXT,
            previous_value TEXT
          )
        `);
            }
            if (operations.includes(DiffTriggerOperation.INSERT)) {
                const insertTriggerId = this.generateTriggerName(DiffTriggerOperation.INSERT, destination, id);
                triggerIds.push(insertTriggerId);
                await tx.execute(/* sql */ `
          CREATE ${tableTriggerTypeClause} TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[DiffTriggerOperation.INSERT]} BEGIN
          INSERT INTO
            ${destination} (id, operation, timestamp, value)
          VALUES
            (
              NEW.id,
              'INSERT',
              strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
              ${jsonFragment('NEW')}
            );

          END
        `);
            }
            if (operations.includes(DiffTriggerOperation.UPDATE)) {
                const updateTriggerId = this.generateTriggerName(DiffTriggerOperation.UPDATE, destination, id);
                triggerIds.push(updateTriggerId);
                await tx.execute(/* sql */ `
          CREATE ${tableTriggerTypeClause} TRIGGER ${updateTriggerId} AFTER
          UPDATE ON ${internalSource} ${whenClauses[DiffTriggerOperation.UPDATE]} BEGIN
          INSERT INTO
            ${destination} (id, operation, timestamp, value, previous_value)
          VALUES
            (
              NEW.id,
              'UPDATE',
              strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
              ${jsonFragment('NEW')},
              ${jsonFragment('OLD')}
            );

          END;
        `);
            }
            if (operations.includes(DiffTriggerOperation.DELETE)) {
                const deleteTriggerId = this.generateTriggerName(DiffTriggerOperation.DELETE, destination, id);
                triggerIds.push(deleteTriggerId);
                // Create delete trigger for basic JSON
                await tx.execute(/* sql */ `
          CREATE ${tableTriggerTypeClause} TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[DiffTriggerOperation.DELETE]} BEGIN
          INSERT INTO
            ${destination} (id, operation, timestamp, value)
          VALUES
            (
              OLD.id,
              'DELETE',
              strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
              ${jsonFragment('OLD')}
            );

          END;
        `);
            }
        };
        try {
            await this.db.writeLock(setup);
            return cleanup;
        }
        catch (error) {
            try {
                await cleanup();
            }
            catch (cleanupError) {
                throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
            }
            throw error;
        }
    }
    async trackTableDiff(options) {
        const { source, when, columns, hooks, throttleMs = DEFAULT_WATCH_THROTTLE_MS } = options;
        await this.db.waitForReady();
        /**
         * Allow specifying the View name as the source.
         * We can lookup the internal table name from the schema.
         */
        const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
        if (!sourceDefinition) {
            throw new Error(`Source table or view "${source}" not found in the schema.`);
        }
        // The columns to present in the onChange context methods.
        // If no array is provided, we use all columns from the source table.
        const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
        const id = await this.getUUID();
        const destination = `__ps_temp_track_${source}_${id}`;
        // register an onChange before the trigger is created
        const abortController = new AbortController();
        const abortOnChange = () => abortController.abort();
        this.db.onChange({
            // Note that the onChange events here have their execution scheduled.
            // Callbacks are throttled and are sequential.
            onChange: async () => {
                if (abortController.signal.aborted)
                    return;
                // Run the handler in a write lock to keep the state of the
                // destination table consistent.
                await this.db.writeTransaction(async (tx) => {
                    const callbackResult = await options.onChange({
                        ...tx,
                        destinationTable: destination,
                        withDiff: async (query, params, options) => {
                            // Wrap the query to expose the destination table
                            const operationIdSelect = options?.castOperationIdAsText
                                ? 'id, operation, CAST(operation_id AS TEXT) as operation_id, timestamp, value, previous_value'
                                : '*';
                            const wrappedQuery = /* sql */ `
                  WITH
                    DIFF AS (
                      SELECT
                        ${operationIdSelect}
                      FROM
                        ${destination}
                      ORDER BY
                        operation_id ASC
                    ) ${query}
                `;
                            return tx.getAll(wrappedQuery, params);
                        },
                        withExtractedDiff: async (query, params) => {
                            // Wrap the query to expose the destination table
                            const wrappedQuery = /* sql */ `
                  WITH
                    DIFF AS (
                      SELECT
                        id,
                        ${contextColumns.length > 0
                                ? `${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},`
                                : ''} operation_id as __operation_id,
                        operation as __operation,
                        timestamp as __timestamp,
                        previous_value as __previous_value
                      FROM
                        ${destination}
                      ORDER BY
                        __operation_id ASC
                    ) ${query}
                `;
                            return tx.getAll(wrappedQuery, params);
                        }
                    });
                    // Clear the destination table after processing
                    await tx.execute(/* sql */ `DELETE FROM ${destination};`);
                    return callbackResult;
                });
            }
        }, { tables: [destination], signal: abortController.signal, throttleMs });
        try {
            const removeTrigger = await this.createDiffTrigger({
                source,
                destination,
                columns: contextColumns,
                when,
                hooks
            });
            return async () => {
                abortOnChange();
                await removeTrigger();
            };
        }
        catch (error) {
            try {
                abortOnChange();
            }
            catch (cleanupError) {
                throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
            }
            throw error;
        }
    }
}
//# sourceMappingURL=TriggerManagerImpl.js.map