import * as Comlink from 'comlink';
import fs from 'node:fs/promises';
import * as path from 'node:path';
import { Worker } from 'node:worker_threads';
import { BaseObserver } from '@powersync/common';
import { AsyncResource } from 'node:async_hooks';
import { isBundledToCommonJs } from '../utils/modules.js';
import { RemoteConnection } from './RemoteConnection.js';
const READ_CONNECTIONS = 5;
const defaultDatabaseImplementation = {
    type: 'better-sqlite3'
};
/**
 * Adapter for better-sqlite3
 */
export class WorkerConnectionPool extends BaseObserver {
    options;
    name;
    readConnections;
    writeConnection;
    readQueue = [];
    writeQueue = [];
    constructor(options) {
        super();
        if (options.readWorkerCount != null && options.readWorkerCount < 1) {
            throw `Needs at least one worker for reads, got ${options.readWorkerCount}`;
        }
        this.options = options;
        this.name = options.dbFilename;
    }
    async initialize() {
        let dbFilePath = this.options.dbFilename;
        if (this.options.dbLocation !== undefined) {
            // Make sure the dbLocation exists, we get a TypeError from better-sqlite3 otherwise.
            let directoryExists = false;
            try {
                const stat = await fs.stat(this.options.dbLocation);
                directoryExists = stat.isDirectory();
            }
            catch (_) {
                // If we can't even stat, the directory won't be accessible to SQLite either.
            }
            if (!directoryExists) {
                throw new Error(`The dbLocation directory at "${this.options.dbLocation}" does not exist. Please create it before opening the PowerSync database!`);
            }
            dbFilePath = path.join(this.options.dbLocation, dbFilePath);
        }
        const openWorker = async (isWriter) => {
            const isCommonJsModule = isBundledToCommonJs;
            let worker;
            const workerName = isWriter ? `write ${dbFilePath}` : `read ${dbFilePath}`;
            const workerFactory = this.options.openWorker ?? ((...args) => new Worker(...args));
            if (isCommonJsModule) {
                worker = workerFactory(path.resolve(__dirname, 'DefaultWorker.cjs'), { name: workerName });
            }
            else {
                worker = workerFactory(new URL('./DefaultWorker.js', import.meta.url), { name: workerName });
            }
            const listeners = new WeakMap();
            const comlink = Comlink.wrap({
                postMessage: worker.postMessage.bind(worker),
                addEventListener: (type, listener) => {
                    let resolved = 'handleEvent' in listener ? listener.handleEvent.bind(listener) : listener;
                    // Comlink wants message events, but the message event on workers in Node returns the data only.
                    if (type === 'message') {
                        const original = resolved;
                        resolved = (data) => {
                            original({ data });
                        };
                    }
                    listeners.set(listener, resolved);
                    worker.addListener(type, resolved);
                },
                removeEventListener: (type, listener) => {
                    const resolved = listeners.get(listener);
                    if (!resolved) {
                        return;
                    }
                    worker.removeListener(type, resolved);
                }
            });
            worker.once('error', (e) => {
                console.error('Unexpected PowerSync database worker error', e);
            });
            const database = (await comlink.open({
                path: dbFilePath,
                isWriter,
                implementation: this.options.implementation ?? defaultDatabaseImplementation
            }));
            if (isWriter) {
                await database.execute("SELECT powersync_update_hooks('install');", []);
            }
            const connection = new RemoteConnection(worker, comlink, database);
            if (this.options.initializeConnection) {
                await this.options.initializeConnection(connection, isWriter);
            }
            if (!isWriter) {
                await connection.execute('pragma query_only = true');
            }
            else {
                // We only need to enable this on the writer connection.
                // We can get `database is locked` errors if we enable this on concurrently opening read connections.
                await connection.execute('pragma journal_mode = WAL');
            }
            return connection;
        };
        // Open the writer first to avoid multiple threads enabling WAL concurrently (causing "database is locked" errors).
        this.writeConnection = await openWorker(true);
        const createWorkers = [];
        const amountOfReaders = this.options.readWorkerCount ?? READ_CONNECTIONS;
        for (let i = 0; i < amountOfReaders; i++) {
            createWorkers.push(openWorker(false));
        }
        this.readConnections = await Promise.all(createWorkers);
    }
    async close() {
        await this.writeConnection.close();
        for (const connection of this.readConnections) {
            await connection.close();
        }
    }
    readLock(fn, _options) {
        let resolveConnectionPromise;
        const connectionPromise = new Promise((resolve, _reject) => {
            resolveConnectionPromise = AsyncResource.bind(resolve);
        });
        const connection = this.readConnections.find((connection) => !connection.isBusy);
        if (connection) {
            connection.isBusy = true;
            resolveConnectionPromise(connection);
        }
        else {
            this.readQueue.push(resolveConnectionPromise);
        }
        return (async () => {
            const connection = await connectionPromise;
            try {
                return await fn(connection);
            }
            finally {
                const next = this.readQueue.shift();
                if (next) {
                    next(connection);
                }
                else {
                    connection.isBusy = false;
                }
            }
        })();
    }
    writeLock(fn, _options) {
        let resolveLockPromise;
        const lockPromise = new Promise((resolve, _reject) => {
            resolveLockPromise = AsyncResource.bind(resolve);
        });
        if (!this.writeConnection.isBusy) {
            this.writeConnection.isBusy = true;
            resolveLockPromise();
        }
        else {
            this.writeQueue.push(resolveLockPromise);
        }
        return (async () => {
            await lockPromise;
            try {
                try {
                    return await fn(this.writeConnection);
                }
                finally {
                    const serializedUpdates = await this.writeConnection.executeRaw("SELECT powersync_update_hooks('get');", []);
                    const updates = JSON.parse(serializedUpdates[0][0]);
                    if (updates.length > 0) {
                        const event = {
                            tables: updates,
                            groupedUpdates: {},
                            rawUpdates: []
                        };
                        this.iterateListeners((cb) => cb.tablesUpdated?.(event));
                    }
                }
            }
            finally {
                const next = this.writeQueue.shift();
                if (next) {
                    next();
                }
                else {
                    this.writeConnection.isBusy = false;
                }
            }
        })();
    }
    readTransaction(fn, _options) {
        return this.readLock((ctx) => this.internalTransaction(ctx, fn));
    }
    writeTransaction(fn, _options) {
        return this.writeLock((ctx) => this.internalTransaction(ctx, fn));
    }
    async internalTransaction(connection, fn) {
        let finalized = false;
        const commit = async () => {
            if (!finalized) {
                finalized = true;
                await connection.execute('COMMIT');
            }
            return { rowsAffected: 0 };
        };
        const rollback = async () => {
            if (!finalized) {
                finalized = true;
                await connection.execute('ROLLBACK');
            }
            return { rowsAffected: 0 };
        };
        try {
            await connection.execute('BEGIN');
            const result = await fn({
                execute: (query, params) => connection.execute(query, params),
                executeRaw: (query, params) => connection.executeRaw(query, params),
                executeBatch: (query, params) => connection.executeBatch(query, params),
                get: (query, params) => connection.get(query, params),
                getAll: (query, params) => connection.getAll(query, params),
                getOptional: (query, params) => connection.getOptional(query, params),
                commit,
                rollback
            });
            await commit();
            return result;
        }
        catch (ex) {
            try {
                await rollback();
            }
            catch (ex2) {
                // In rare cases, a rollback may fail.
                // Safe to ignore.
            }
            throw ex;
        }
    }
    getAll(sql, parameters) {
        return this.readLock((ctx) => ctx.getAll(sql, parameters));
    }
    getOptional(sql, parameters) {
        return this.readLock((ctx) => ctx.getOptional(sql, parameters));
    }
    get(sql, parameters) {
        return this.readLock((ctx) => ctx.get(sql, parameters));
    }
    execute(query, params) {
        return this.writeLock((ctx) => ctx.execute(query, params));
    }
    executeRaw(query, params) {
        return this.writeLock((ctx) => ctx.executeRaw(query, params));
    }
    executeBatch(query, params) {
        return this.writeTransaction((ctx) => ctx.executeBatch(query, params));
    }
    async refreshSchema() {
        await this.writeConnection.refreshSchema();
        for (const readConnection of this.readConnections) {
            await readConnection.refreshSchema();
        }
    }
}
//# sourceMappingURL=WorkerConnectionPool.js.map