"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccountSubscriptionManager = exports.WS_RECONNECT_DELAY_MS = void 0;
const buffer_1 = require("buffer");
const utils_1 = require("./utils");
exports.WS_RECONNECT_DELAY_MS = 500;
class AccountSubscriptionManager {
    subscriptions = new Map();
    nextListenerId = 0;
    wsRpc;
    onError;
    onLog;
    reconnectDelayMs;
    constructor(config) {
        this.wsRpc = config.wsRpc;
        this.onError = config.onError ?? utils_1.noop;
        this.onLog = config.onLog ?? utils_1.noop;
        this.reconnectDelayMs = config.reconnectDelayMs ?? exports.WS_RECONNECT_DELAY_MS;
    }
    /**
     * Subscribe to program account changes. Returns an unsubscribe function.
     *
     * The first subscriber for a given key starts the WS connection; the last
     * unsubscribe tears it down. Late subscribers immediately receive any
     * cached account data from previous notifications.
     */
    subscribeProgramAccounts(programId, filters, callback, options) {
        const commitment = options?.commitment ?? 'processed';
        const key = this.buildKey(programId, commitment, filters);
        const listenerId = this.nextListenerId++;
        const sub = this.subscriptions.get(key) ?? this.createSubscription(key, programId, filters, commitment, options?.throttleMs);
        sub.listeners.set(listenerId, callback);
        if (options?.onReconnect)
            sub.onReconnect = options.onReconnect;
        if (sub.listeners.size === 1)
            this.startWs(sub);
        for (const [addr, cached] of sub.buffers) {
            try {
                callback(addr, cached.buffer, cached.slot);
            }
            catch (e) {
                this.onError(e);
            }
        }
        return () => {
            const current = this.subscriptions.get(key);
            if (!current)
                return;
            current.listeners.delete(listenerId);
            if (current.listeners.size === 0) {
                this.teardown(current);
                this.subscriptions.delete(key);
            }
        };
    }
    /**
     * Immediately flush any throttled pending notifications for the subscription
     * matching the given programId + filters. Resets the throttle timer so the
     * next notification after flush waits the full interval again.
     *
     * No-op if the subscription doesn't exist or has no pending notifications.
     */
    /**
     * Immediately flush any throttled pending notifications.
     * When commitment is provided, flushes only that specific subscription.
     * When omitted, flushes all matching programId+filters across all commitments.
     */
    flushPending(programId, filters, commitment) {
        if (commitment) {
            const key = this.buildKey(programId, commitment, filters);
            const sub = this.subscriptions.get(key);
            if (sub)
                this.flushSub(sub);
        }
        else {
            const serializedFilters = (0, utils_1.toJson)(filters, true);
            for (const sub of this.subscriptions.values()) {
                if (sub.programId === programId && sub.serializedFilters === serializedFilters) {
                    this.flushSub(sub);
                }
            }
        }
    }
    getMode(programId, filters, commitment) {
        return this.findSubscription(programId, filters, commitment)?.mode ?? 'idle';
    }
    /** Tear down all subscriptions. Useful for shutdown and test cleanup. */
    destroy() {
        for (const [key, sub] of this.subscriptions) {
            this.teardown(sub);
            this.subscriptions.delete(key);
        }
    }
    /** Resolves when the WS subscription for the given programId + filters is established. */
    async waitForConnection(programId, filters, commitment) {
        const sub = this.findSubscription(programId, filters, commitment);
        if (!sub)
            return;
        await sub.connectedPromise;
    }
    buildKey(programId, commitment, filters) {
        return `${programId}:${commitment}:${(0, utils_1.toJson)(filters, true)}`;
    }
    /**
     * Find a subscription by programId + filters.
     * When commitment is provided, does a direct Map lookup (O(1)).
     * When omitted, scans all commitments and returns the first match.
     */
    findSubscription(programId, filters, commitment) {
        if (commitment) {
            return this.subscriptions.get(this.buildKey(programId, commitment, filters));
        }
        const serializedFilters = (0, utils_1.toJson)(filters, true);
        for (const sub of this.subscriptions.values()) {
            if (sub.programId === programId && sub.serializedFilters === serializedFilters) {
                return sub;
            }
        }
        return undefined;
    }
    flushSub(sub) {
        if (sub.pendingNotifications.size === 0)
            return;
        this.onLog('[WS] Flushing', sub.pendingNotifications.size, 'throttled notifications');
        if (sub.throttleTimer) {
            clearTimeout(sub.throttleTimer);
            sub.throttleTimer = undefined;
        }
        this.flushPendingNotifications(sub);
    }
    createSubscription(key, programId, filters, commitment, throttleMs) {
        let connectedResolve;
        const connectedPromise = new Promise((resolve) => {
            connectedResolve = resolve;
        });
        const sub = {
            key,
            programId,
            filters,
            serializedFilters: (0, utils_1.toJson)(filters, true),
            commitment,
            listeners: new Map(),
            buffers: new Map(),
            mode: 'idle',
            abortController: undefined,
            throttleMs,
            pendingNotifications: new Map(),
            connectedResolve,
            connectedPromise,
        };
        this.subscriptions.set(key, sub);
        return sub;
    }
    startWs(sub) {
        const abortController = new AbortController();
        sub.abortController = abortController;
        sub.mode = 'ws';
        const wsLoop = async () => {
            let isFirstConnect = true;
            while (!abortController.signal.aborted) {
                try {
                    // On reconnect (not first connect), call the hook so consumers can
                    // refresh local state that went stale during the WS downtime.
                    // If onReconnect throws, the catch block handles it (logs via onError,
                    // sleeps, retries). This is intentional — a failed state refresh should
                    // not prevent the WS from reconnecting on the next loop iteration.
                    if (!isFirstConnect && sub.onReconnect) {
                        this.onLog('[WS] Calling onReconnect before re-subscribing');
                        await sub.onReconnect();
                    }
                    isFirstConnect = false;
                    this.onLog('[WS] Connecting programSubscribe for', sub.programId);
                    const notifications = await this.wsRpc
                        .programNotifications(sub.programId, {
                        commitment: sub.commitment,
                        encoding: 'base64',
                        filters: sub.filters,
                    })
                        .subscribe({ abortSignal: abortController.signal });
                    this.onLog('[WS] Subscription established, listening...');
                    sub.connectedResolve?.();
                    sub.connectedResolve = undefined;
                    for await (const notification of notifications) {
                        const { pubkey, account } = notification.value;
                        if (!account)
                            continue;
                        this.handleNotification(sub, pubkey, buffer_1.Buffer.from(account.data[0], 'base64'), notification.context.slot);
                    }
                    // Stream ended cleanly (server closed) — treat as disconnect, sleep before retry
                    this.onLog('[WS] Stream ended cleanly, reconnecting...');
                    await (0, utils_1.sleep)(this.reconnectDelayMs);
                }
                catch (e) {
                    if (abortController.signal.aborted)
                        break;
                    this.onError(e);
                    await (0, utils_1.sleep)(this.reconnectDelayMs);
                }
            }
            sub.mode = 'idle';
        };
        wsLoop().catch(this.onError);
    }
    handleNotification(sub, addr, buffer, slot) {
        const cached = sub.buffers.get(addr);
        if (cached && slot < cached.slot)
            return;
        if (cached && (0, utils_1.blobEquals)(cached.buffer, buffer)) {
            cached.slot = slot;
            return;
        }
        sub.buffers.set(addr, { buffer, slot });
        if (sub.throttleMs) {
            sub.pendingNotifications.set(addr, { buffer, slot });
            if (!sub.throttleTimer) {
                sub.throttleTimer = setTimeout(() => {
                    sub.throttleTimer = undefined;
                    this.flushPendingNotifications(sub);
                }, sub.throttleMs);
            }
        }
        else {
            this.fireCallbacks(sub, addr, buffer, slot);
        }
    }
    flushPendingNotifications(sub) {
        for (const [addr, { buffer, slot }] of sub.pendingNotifications) {
            this.fireCallbacks(sub, addr, buffer, slot);
        }
        sub.pendingNotifications.clear();
    }
    fireCallbacks(sub, addr, buffer, slot) {
        for (const cb of sub.listeners.values()) {
            try {
                cb(addr, buffer, slot);
            }
            catch (e) {
                this.onError(e);
            }
        }
    }
    teardown(sub) {
        sub.abortController?.abort();
        sub.abortController = undefined;
        if (sub.throttleTimer)
            clearTimeout(sub.throttleTimer);
        sub.throttleTimer = undefined;
        sub.pendingNotifications.clear();
        sub.buffers.clear();
        sub.mode = 'idle';
    }
}
exports.AccountSubscriptionManager = AccountSubscriptionManager;
//# sourceMappingURL=accountSubscriptionManager.js.map