import { createSyncularSyncAttempt, syncularDiagnosticAttemptFields, } from './diagnostics.js';
import { createSyncularWorkerErrorPayload, SYNCULAR_WORKER_PROTOCOL_VERSION, } from './worker-protocol.js';
export class SyncularWorkerRealtimeController {
    controllerOptions;
    #socket;
    #state = 'disconnected';
    #options;
    #reconnectTimer;
    #reconnectCatchupTimer;
    #heartbeatTimer;
    #reconnectAttempts = 0;
    #stopped = true;
    #syncInFlight;
    #syncAgainMessage;
    #syncJitterTimers = new Set();
    #setTimeout;
    #clearTimeout;
    #random;
    constructor(controllerOptions) {
        this.controllerOptions = controllerOptions;
        this.#setTimeout =
            controllerOptions.setTimeout ??
                globalThis.setTimeout.bind(globalThis);
        this.#clearTimeout =
            controllerOptions.clearTimeout ??
                globalThis.clearTimeout.bind(globalThis);
        this.#random = controllerOptions.random ?? Math.random;
    }
    start(options) {
        if (!this.controllerOptions.getConfig()) {
            throw createSyncularWorkerErrorPayload('worker.not_open', 'Syncular worker client is not open');
        }
        this.stop();
        this.#options = options;
        this.#stopped = false;
        this.#reconnectAttempts = 0;
        this.#connect();
    }
    stop() {
        this.#stopped = true;
        this.#syncAgainMessage = undefined;
        this.#syncInFlight = undefined;
        this.#clearSyncJitterTimers();
        this.#clearReconnectCatchupTimer();
        this.#clearReconnectTimer();
        this.#clearHeartbeatTimer();
        this.#closeSocket();
        this.#setState('disconnected');
    }
    sendPresence(action, scopeKey, metadata) {
        const socket = this.#socket;
        if (!socket || this.#state !== 'connected') {
            this.#diagnostic({
                level: 'warn',
                code: 'realtime.presence_not_connected',
                message: 'Syncular realtime presence send skipped while disconnected',
                details: { action, scopeKey },
            });
            return;
        }
        socket.send(JSON.stringify({
            type: 'presence',
            action,
            scopeKey,
            ...(metadata === undefined ? {} : { metadata }),
        }));
    }
    #connect() {
        const config = this.controllerOptions.getConfig();
        const options = this.#options;
        if (!config || !options || this.#stopped)
            return;
        this.#clearReconnectTimer();
        this.#clearHeartbeatTimer();
        this.#setState('connecting');
        let socket;
        try {
            socket = this.controllerOptions.createWebSocket(resolveSyncularRealtimeUrl(config, options, this.controllerOptions.getLocationOrigin()));
        }
        catch {
            this.#diagnostic({
                level: 'warn',
                code: 'realtime.connect_failed',
                message: 'Syncular realtime websocket creation failed',
                details: { attempt: this.#reconnectAttempts + 1 },
            });
            this.#scheduleReconnect();
            return;
        }
        this.#socket = socket;
        socket.onopen = () => {
            if (socket !== this.#socket)
                return;
            this.#reconnectAttempts = 0;
            this.#setState('connected');
            this.#resetHeartbeatTimer();
        };
        socket.onmessage = (event) => {
            if (socket !== this.#socket)
                return;
            this.#resetHeartbeatTimer();
            void this.#handleMessage(socket, event.data);
        };
        socket.onerror = () => {
            if (socket !== this.#socket)
                return;
            this.#diagnostic({
                level: 'warn',
                code: 'realtime.socket_error',
                message: 'Syncular realtime websocket reported an error',
            });
            this.#reconnect();
        };
        socket.onclose = () => {
            if (socket !== this.#socket)
                return;
            this.#reconnect();
        };
    }
    async #handleMessage(socket, data) {
        const bytes = await readRealtimeMessageBytes(data);
        if (socket !== this.#socket)
            return;
        if (bytes && isSyncPackBytes(bytes)) {
            this.#diagnostic({
                level: 'debug',
                code: 'realtime.sync_wakeup',
                message: 'Syncular realtime binary sync-pack received',
                details: {
                    bytes: bytes.byteLength,
                },
            });
            this.#clearReconnectCatchupTimer();
            this.#scheduleSync({ syncPackBytes: bytes });
            return;
        }
        const text = bytes ? new TextDecoder().decode(bytes) : data;
        if (text == null)
            return;
        if (typeof text !== 'string')
            return;
        let message;
        try {
            message = JSON.parse(text);
        }
        catch {
            return;
        }
        const helloMessage = readSyncularRealtimeHelloMessage(message);
        if (helloMessage) {
            this.#diagnostic({
                level: 'debug',
                code: 'realtime.hello',
                message: 'Syncular realtime session accepted',
                details: { ...helloMessage },
            });
            this.#scheduleHelloCatchup(helloMessage);
            return;
        }
        const presenceMessage = readSyncularRealtimePresenceMessage(message);
        if (presenceMessage) {
            this.controllerOptions.postEvent({
                protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
                type: 'presenceEvent',
                ...presenceMessage,
            });
            return;
        }
        const syncMessage = readSyncularRealtimeSyncMessage(message);
        if (syncMessage) {
            this.#diagnostic({
                level: 'debug',
                code: 'realtime.sync_wakeup',
                message: 'Syncular realtime sync wakeup received',
                details: {
                    reason: syncMessage.reason ?? null,
                    requiresPull: syncMessage.requiresPull === true,
                    droppedCount: syncMessage.droppedCount ?? 0,
                    payloadBytes: syncMessage.payloadBytes ?? null,
                },
            });
            this.#clearReconnectCatchupTimer();
            this.#scheduleRealtimeWakeupSync(syncMessage);
        }
    }
    #reconnect() {
        this.#clearReconnectCatchupTimer();
        this.#clearHeartbeatTimer();
        this.#closeSocket();
        this.#setState('disconnected');
        this.#scheduleReconnect();
    }
    #scheduleReconnect() {
        if (this.#stopped)
            return;
        this.#clearReconnectTimer();
        const initial = this.#options?.initialReconnectDelayMs ?? 1_000;
        const max = this.#options?.maxReconnectDelayMs ?? 30_000;
        const factor = this.#options?.reconnectBackoffFactor ?? 2;
        const baseDelay = Math.min(initial * factor ** this.#reconnectAttempts, max);
        const jitterRatio = Math.max(0, this.#options?.reconnectJitterRatio ?? 0);
        const jitter = baseDelay * jitterRatio * this.#random();
        const delay = Math.min(baseDelay + jitter, max);
        this.#diagnostic({
            level: 'info',
            code: 'realtime.reconnect_scheduled',
            message: 'Syncular realtime reconnect scheduled',
            details: {
                attempt: this.#reconnectAttempts + 1,
                baseDelayMs: baseDelay,
                jitterMs: jitter,
                delayMs: delay,
            },
        });
        this.#reconnectAttempts += 1;
        this.#reconnectTimer = this.#setTimeout(() => this.#connect(), delay);
    }
    #scheduleHelloCatchup(message) {
        this.#clearReconnectCatchupTimer();
        if (!message.requiresSync)
            return;
        const maxJitter = Math.max(0, this.#options?.pullRecoveryJitterMs ?? 0);
        const delay = maxJitter > 0 ? maxJitter * this.#random() : 10;
        const cursor = Number.isFinite(message.latestCursor)
            ? message.latestCursor
            : message.cursor;
        const syncMessage = {
            reason: 'reconnect-catchup',
            requiresPull: true,
            ...(Number.isFinite(cursor) ? { cursor } : {}),
        };
        this.#diagnostic({
            level: 'debug',
            code: 'realtime.reconnect_catchup_scheduled',
            message: 'Syncular realtime reconnect catch-up scheduled from hello',
            details: {
                cursor: syncMessage.cursor ?? null,
                delayMs: delay,
                maxJitterMs: maxJitter,
            },
        });
        this.#reconnectCatchupTimer = this.#setTimeout(() => {
            this.#reconnectCatchupTimer = undefined;
            this.#scheduleSync(syncMessage);
        }, delay);
    }
    #scheduleSync(message) {
        if (this.#stopped)
            return;
        if (this.#syncInFlight) {
            this.#syncAgainMessage = mergeRealtimeSyncMessages(this.#syncAgainMessage, message);
            return;
        }
        this.#syncInFlight = this.#runSync(message);
    }
    #scheduleRealtimeWakeupSync(message) {
        const maxJitter = Math.max(0, this.#options?.pullRecoveryJitterMs ?? 0);
        if (maxJitter <= 0 || !realtimeMessageRequiresPull(message)) {
            this.#scheduleSync(message);
            return;
        }
        const delay = maxJitter * this.#random();
        this.#diagnostic({
            level: 'debug',
            code: 'realtime.pull_recovery_jitter_scheduled',
            message: 'Syncular realtime HTTP pull recovery delayed by jitter',
            details: {
                delayMs: delay,
                maxJitterMs: maxJitter,
                reason: message.reason ?? null,
                cursor: message.cursor ?? null,
            },
        });
        const timer = this.#setTimeout(() => {
            this.#syncJitterTimers.delete(timer);
            this.#scheduleSync(message);
        }, delay);
        this.#syncJitterTimers.add(timer);
    }
    async #runSync(message) {
        const socket = this.#socket;
        let result;
        try {
            result = await this.#syncForMessage(message);
            if (this.#stopped)
                return;
            this.#ackRealtimeCursor(socket, message, result);
            this.controllerOptions.postEvent({
                protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
                type: 'bootstrapChanged',
                bootstrap: result.bootstrap,
            });
            if (result.changedTables.length > 0 || result.changedRows.length > 0) {
                this.controllerOptions.postEvent({
                    protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
                    type: 'rowsChanged',
                    source: 'remotePull',
                    changedTables: result.changedTables,
                    changedRows: result.changedRows,
                    changedRowsTruncated: result.changedRowsTruncated,
                });
            }
            const events = this.controllerOptions
                .getClient()
                .drainLiveQueryEvents();
            if (events.length > 0) {
                this.controllerOptions.postEvent({
                    protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
                    type: 'liveQueryEvents',
                    events,
                });
            }
        }
        catch {
            this.#diagnostic({
                level: 'warn',
                code: 'realtime.sync_pull_failed',
                message: 'Syncular realtime-triggered pull failed',
            });
            // Realtime is best-effort. Explicit sync methods still surface errors.
        }
        finally {
            this.#syncInFlight = undefined;
            const nextMessage = this.#syncAgainMessage;
            this.#syncAgainMessage = undefined;
            if (nextMessage &&
                !this.#stopped &&
                !syncResultCoversRealtimeMessage(result, nextMessage)) {
                this.#scheduleSync(nextMessage);
            }
        }
    }
    #ackRealtimeCursor(socket, message, result) {
        if (!socket || socket !== this.#socket)
            return;
        const subscriptionCursor = result.subscriptions.reduce((cursor, subscription) => Number.isFinite(subscription.nextCursor)
            ? Math.max(cursor, subscription.nextCursor)
            : cursor, -1);
        const messageCursor = Number.isFinite(message?.cursor)
            ? message.cursor
            : -1;
        const cursor = Math.max(subscriptionCursor, messageCursor);
        if (!Number.isSafeInteger(cursor) || cursor < 0)
            return;
        try {
            socket.send(JSON.stringify({ type: 'ack', cursor }));
            this.#diagnostic({
                level: 'debug',
                code: 'realtime.ack_sent',
                message: 'Syncular realtime cursor ack sent',
                details: { cursor },
            });
        }
        catch {
            this.#diagnostic({
                level: 'warn',
                code: 'realtime.ack_failed',
                message: 'Syncular realtime cursor ack failed',
                details: { cursor },
            });
        }
    }
    async #syncForMessage(message) {
        const client = this.controllerOptions.getClient();
        if (message && realtimeMessageRequiresPull(message)) {
            const syncAttempt = createSyncularSyncAttempt();
            this.#diagnostic({
                level: 'debug',
                code: 'realtime.pull_required',
                message: 'Syncular realtime event requires HTTP pull recovery',
                ...syncularDiagnosticAttemptFields(syncAttempt),
                details: {
                    cursor: message.cursor ?? null,
                    reason: message.reason ?? null,
                    droppedCount: message.droppedCount ?? 0,
                    payloadBytes: message.payloadBytes ?? null,
                },
            });
            return client.syncPull({ syncAttempt });
        }
        if (message?.syncPackBytes &&
            typeof client.applyRealtimeSyncPack === 'function') {
            try {
                const result = await client.applyRealtimeSyncPack(message.syncPackBytes);
                this.#diagnostic({
                    level: 'debug',
                    code: 'realtime.binary_applied',
                    message: 'Syncular realtime binary sync-pack applied',
                    details: {
                        bytes: message.syncPackBytes.byteLength,
                        totalMs: result.timings.totalMs,
                        syncPackDecodeMs: result.timings.syncPackDecodeMs,
                        pullTransformMs: result.timings.pullTransformMs,
                        integrityVerifyMs: result.timings.integrityVerifyMs,
                        pullApplyMs: result.timings.pullApplyMs,
                        commitApplyMs: result.timings.commitApplyMs,
                        subscriptionStateMs: result.timings.subscriptionStateMs,
                        notifyMs: result.timings.notifyMs,
                        changedRows: result.changedRows.length,
                        changedRowsTruncated: result.changedRowsTruncated,
                    },
                });
                return result;
            }
            catch {
                this.#diagnostic({
                    level: 'warn',
                    code: 'realtime.binary_apply_failed',
                    message: 'Syncular realtime binary sync-pack apply failed; using HTTP pull recovery',
                    details: {
                        bytes: message.syncPackBytes.byteLength,
                    },
                });
            }
        }
        const syncAttempt = createSyncularSyncAttempt();
        this.#diagnostic({
            level: 'debug',
            code: 'realtime.pull_required',
            message: 'Syncular realtime event requires HTTP pull recovery',
            ...syncularDiagnosticAttemptFields(syncAttempt),
            details: {
                cursor: message?.cursor ?? null,
                reason: message?.syncPackBytes ? 'binary-apply-failed' : null,
                droppedCount: message?.droppedCount ?? 0,
                payloadBytes: message?.payloadBytes ?? null,
            },
        });
        return client.syncPull({ syncAttempt });
    }
    #clearReconnectTimer() {
        if (!this.#reconnectTimer)
            return;
        this.#clearTimeout(this.#reconnectTimer);
        this.#reconnectTimer = undefined;
    }
    #clearReconnectCatchupTimer() {
        if (!this.#reconnectCatchupTimer)
            return;
        this.#clearTimeout(this.#reconnectCatchupTimer);
        this.#reconnectCatchupTimer = undefined;
    }
    #clearHeartbeatTimer() {
        if (!this.#heartbeatTimer)
            return;
        this.#clearTimeout(this.#heartbeatTimer);
        this.#heartbeatTimer = undefined;
    }
    #clearSyncJitterTimers() {
        for (const timer of this.#syncJitterTimers) {
            this.#clearTimeout(timer);
        }
        this.#syncJitterTimers.clear();
    }
    #resetHeartbeatTimer() {
        this.#clearHeartbeatTimer();
        const heartbeatTimeoutMs = this.#options?.heartbeatTimeoutMs ?? 60_000;
        if (heartbeatTimeoutMs <= 0)
            return;
        this.#heartbeatTimer = this.#setTimeout(() => this.#reconnect(), heartbeatTimeoutMs);
    }
    #closeSocket() {
        if (!this.#socket)
            return;
        const socket = this.#socket;
        this.#socket = undefined;
        socket.onopen = null;
        socket.onmessage = null;
        socket.onerror = null;
        socket.onclose = null;
        try {
            socket.close();
        }
        catch {
            // best effort
        }
    }
    #setState(state) {
        if (this.#state === state)
            return;
        this.#state = state;
        this.controllerOptions.postEvent({
            protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
            type: 'realtimeState',
            state,
        });
    }
    #diagnostic(event) {
        this.controllerOptions.postDiagnostic?.({
            ...event,
            source: 'realtime',
        });
    }
}
function realtimeMessageRequiresPull(message) {
    return message.requiresPull === true || (message.droppedCount ?? 0) > 0;
}
function mergeRealtimeSyncMessages(current, next) {
    if (!current)
        return next ?? {};
    if (!next)
        return current;
    const cursor = current.cursor === undefined
        ? next.cursor
        : next.cursor === undefined
            ? current.cursor
            : Math.max(current.cursor, next.cursor);
    return {
        cursor,
        reason: next.reason ?? current.reason,
        requiresPull: current.requiresPull === true || next.requiresPull === true,
        droppedCount: Math.max(current.droppedCount ?? 0, next.droppedCount ?? 0),
        payloadBytes: next.payloadBytes ?? current.payloadBytes,
        syncPackBytes: next.syncPackBytes ?? current.syncPackBytes,
    };
}
function syncResultCoversRealtimeMessage(result, message) {
    if (!result || message.cursor === undefined)
        return false;
    return syncResultCursor(result) >= message.cursor;
}
function syncResultCursor(result) {
    return result.subscriptions.reduce((cursor, subscription) => Number.isFinite(subscription.nextCursor)
        ? Math.max(cursor, subscription.nextCursor)
        : cursor, -1);
}
export function resolveSyncularRealtimeUrl(config, options, locationOrigin) {
    const url = new URL(options.wsUrl ?? config.baseUrl, locationOrigin);
    if (!options.wsUrl) {
        url.pathname = `${url.pathname.replace(/\/$/, '')}/realtime`;
    }
    if (url.protocol === 'http:')
        url.protocol = 'ws:';
    if (url.protocol === 'https:')
        url.protocol = 'wss:';
    url.searchParams.set('clientId', config.clientId);
    url.searchParams.set('transportPath', 'direct');
    url.searchParams.set('syncPackEncoding', 'binary-sync-pack-v1');
    for (const [key, value] of Object.entries(options.params ?? {})) {
        if (value)
            url.searchParams.set(key, value);
    }
    return url.toString();
}
export function isSyncularRealtimeSyncMessage(value) {
    return readSyncularRealtimeSyncMessage(value) !== null;
}
function readSyncularRealtimeHelloMessage(value) {
    if (!value || typeof value !== 'object')
        return null;
    if (value.event !== 'hello')
        return null;
    const data = value.data;
    if (!data || typeof data !== 'object')
        return {};
    const record = data;
    return {
        protocolVersion: typeof record.protocolVersion === 'number'
            ? record.protocolVersion
            : undefined,
        sessionId: typeof record.sessionId === 'string' ? record.sessionId : undefined,
        shardKey: typeof record.shardKey === 'string' ? record.shardKey : undefined,
        cursor: typeof record.cursor === 'number' ? record.cursor : undefined,
        latestCursor: typeof record.latestCursor === 'number' ? record.latestCursor : undefined,
        scopeCount: typeof record.scopeCount === 'number' ? record.scopeCount : undefined,
        requiresSync: typeof record.requiresSync === 'boolean'
            ? record.requiresSync
            : undefined,
        syncPackEncoding: typeof record.syncPackEncoding === 'string'
            ? record.syncPackEncoding
            : record.syncPackEncoding === null
                ? null
                : undefined,
    };
}
function readSyncularRealtimeSyncMessage(value) {
    if (!value || typeof value !== 'object')
        return null;
    if (value.event !== 'sync')
        return null;
    const data = value.data;
    if (!data || typeof data !== 'object')
        return {};
    const record = data;
    const cursor = typeof record.cursor === 'number' ? record.cursor : undefined;
    const reason = typeof record.reason === 'string' ? record.reason : null;
    const requiresPull = typeof record.requiresPull === 'boolean' ? record.requiresPull : undefined;
    const droppedCount = typeof record.droppedCount === 'number' &&
        Number.isSafeInteger(record.droppedCount)
        ? record.droppedCount
        : undefined;
    const payloadBytes = typeof record.payloadBytes === 'number' &&
        Number.isSafeInteger(record.payloadBytes)
        ? record.payloadBytes
        : undefined;
    return {
        cursor,
        reason,
        requiresPull,
        droppedCount,
        payloadBytes,
    };
}
function readSyncularRealtimePresenceMessage(value) {
    if (!value || typeof value !== 'object')
        return null;
    if (value.event !== 'presence')
        return null;
    const data = value.data;
    if (!data || typeof data !== 'object')
        return null;
    const presence = data.presence;
    if (!presence || typeof presence !== 'object')
        return null;
    const record = presence;
    const action = record.action;
    if (action !== 'join' &&
        action !== 'leave' &&
        action !== 'update' &&
        action !== 'snapshot') {
        return null;
    }
    if (typeof record.scopeKey !== 'string' || !record.scopeKey)
        return null;
    return {
        action,
        scopeKey: record.scopeKey,
        clientId: typeof record.clientId === 'string' ? record.clientId : undefined,
        actorId: typeof record.actorId === 'string' ? record.actorId : undefined,
        metadata: objectRecordOrUndefined(record.metadata),
        entries: Array.isArray(record.entries)
            ? record.entries.flatMap(readPresenceEntry)
            : undefined,
    };
}
function readPresenceEntry(value) {
    if (!value || typeof value !== 'object')
        return [];
    const record = value;
    if (typeof record.clientId !== 'string')
        return [];
    if (typeof record.actorId !== 'string')
        return [];
    return [
        {
            clientId: record.clientId,
            actorId: record.actorId,
            joinedAt: typeof record.joinedAt === 'number' ? record.joinedAt : Date.now(),
            metadata: objectRecordOrUndefined(record.metadata),
        },
    ];
}
function objectRecordOrUndefined(value) {
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
        return undefined;
    }
    return { ...value };
}
async function readRealtimeMessageBytes(data) {
    if (data instanceof ArrayBuffer)
        return new Uint8Array(data);
    if (ArrayBuffer.isView(data)) {
        return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
    }
    if (typeof Blob !== 'undefined' && data instanceof Blob) {
        return new Uint8Array(await data.arrayBuffer());
    }
    return null;
}
function isSyncPackBytes(bytes) {
    return (bytes.byteLength >= 4 &&
        bytes[0] === 0x53 &&
        bytes[1] === 0x53 &&
        bytes[2] === 0x50 &&
        bytes[3] === 0x31);
}
//# sourceMappingURL=worker-realtime.js.map