"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccountSubscriptionManager = void 0;
const buffer_1 = require("buffer");
const subscriptionHandle_1 = require("./subscriptionHandle");
const utils_1 = require("./utils");
const wsCadenceOptions_1 = require("./wsCadenceOptions");
const wsReconnectLoop_1 = require("./wsReconnectLoop");
function computeShortestThrottle(throttleMsDemands) {
    let shortest;
    for (const demand of throttleMsDemands.values()) {
        if (shortest === undefined || demand < shortest)
            shortest = demand;
    }
    return shortest;
}
/** Latest hook in insertion order — preserves "last subscriber wins" semantics
 *  while ensuring removed listeners' hooks no longer fire (see PR #563 review). */
function pickLatestReconnectHook(onReconnectDemands) {
    let latest;
    for (const hook of onReconnectDemands.values())
        latest = hook;
    return latest;
}
/** First scheduler in insertion order (oldest demand). Subscribers without a
 *  scheduler don't contribute, so an early no-scheduler subscriber doesn't
 *  block a later one from setting the active scheduler. */
function pickFirstScheduler(schedulerDemands) {
    for (const scheduler of schedulerDemands.values())
        return scheduler;
    return undefined;
}
/** Bounded exponential backoff for reconnect-hook retries (uses the same
 *  cap as runReconnectLoop's stream backoff so worst-case wait is bounded
 *  symmetrically). */
const RECONNECT_HOOK_MAX_RETRIES = 5;
const RECONNECT_HOOK_MAX_DELAY_MS = 30_000;
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 ?? wsReconnectLoop_1.WS_RECONNECT_DELAY_MS;
    }
    /**
     * Subscribe to program account changes. First subscriber for a key starts
     * the WS; last unsubscribe tears it down. Late subscribers replay any
     * cached account data immediately.
     */
    subscribeProgramAccounts(programId, filters, callback, options) {
        // Validate before any state is created — bad input throws at the
        // entrypoint instead of leaving a partial subscription behind.
        (0, wsCadenceOptions_1.validateThrottleMs)(options?.throttleMs, 'AccountSubscriptionManager.subscribeProgramAccounts');
        (0, wsCadenceOptions_1.assertCadenceOptions)('AccountSubscriptionManager.subscribeProgramAccounts', options?.scheduler, options?.throttleMs);
        const commitment = options?.commitment ?? 'processed';
        const key = this.buildKey(programId, commitment, filters);
        const listenerId = this.nextListenerId++;
        let subscription = this.subscriptions.get(key);
        const subscriptionWasCreated = !subscription;
        if (!subscription) {
            subscription = this.createSubscription(key, programId, filters, commitment);
        }
        else if (options?.scheduler &&
            subscription.activeScheduler &&
            subscription.activeScheduler !== options.scheduler) {
            // A scheduler is already active and the new subscriber passed a
            // DIFFERENT one. We still record the demand so it becomes active if
            // the original scheduler's owner unsubscribes — but it won't take
            // effect right now. Log to surface the coordination gap.
            this.onLog('[AccountSubscriptionManager] Subscriber passed a scheduler different from the currently-active one for this key — buffer drains will continue on the active scheduler. The new scheduler will take over only if every listener owning the active one unsubscribes.');
        }
        // Wrap the per-listener mutations + register() calls so a throw (e.g.
        // user passed a destroyed scheduler whose register() raises) cannot
        // poison the subscription with a ghost listener. Without this, the
        // ghost would leave `listeners.size > 0` and every subsequent normal
        // subscriber would see size > 1 → skip startWs → silent WS stall.
        try {
            if (options?.scheduler) {
                subscription.schedulerDemands.set(listenerId, options.scheduler);
            }
            if (options?.throttleMs !== undefined) {
                subscription.throttleMsDemands.set(listenerId, options.throttleMs);
                this.recomputeEffectiveThrottle(subscription);
            }
            subscription.listeners.set(listenerId, callback);
            if (options?.onReconnect) {
                subscription.onReconnectDemands.set(listenerId, options.onReconnect);
            }
            // Recompute active scheduler now that demands changed. May start using
            // a scheduler for the first time (if first scheduler-providing
            // subscriber just joined) — recomputeEffectiveThrottle won't pick this
            // up on its own when throttleMs wasn't passed.
            this.recomputeActiveScheduler(subscription);
        }
        catch (e) {
            // Roll back EVERY per-listener mutation so the failure looks like the
            // subscribe never happened. Rollback steps themselves run inside
            // try/catch so a doubly-bad scheduler (rollback recompute throws too)
            // still cleans the maps and surfaces both errors via onError instead
            // of leaving the subscription in a worse state than where we started.
            subscription.listeners.delete(listenerId);
            subscription.onReconnectDemands.delete(listenerId);
            subscription.schedulerDemands.delete(listenerId);
            if (subscription.throttleMsDemands.delete(listenerId)) {
                try {
                    this.recomputeEffectiveThrottle(subscription);
                }
                catch (rollbackErr) {
                    this.onError(rollbackErr);
                }
            }
            try {
                this.recomputeActiveScheduler(subscription);
            }
            catch (rollbackErr) {
                this.onError(rollbackErr);
            }
            // If this call created the subscription and rollback drained it, drop
            // the key entirely — otherwise a future subscribe sees a zombie entry.
            if (subscriptionWasCreated && subscription.listeners.size === 0) {
                this.teardown(subscription);
                this.subscriptions.delete(key);
            }
            throw e;
        }
        if (subscription.listeners.size === 1)
            this.startWs(subscription);
        for (const [address, cached] of subscription.cachedValuesByAddress) {
            try {
                callback(address, cached.buffer, cached.slot);
            }
            catch (e) {
                this.onError(e);
            }
        }
        const unsubscribe = () => {
            const current = this.subscriptions.get(key);
            if (!current)
                return;
            current.listeners.delete(listenerId);
            current.onReconnectDemands.delete(listenerId);
            const schedulerDemandChanged = current.schedulerDemands.delete(listenerId);
            if (current.throttleMsDemands.delete(listenerId)) {
                this.recomputeEffectiveThrottle(current);
            }
            if (schedulerDemandChanged) {
                // Scheduler ownership may have transferred or fallen away — re-wire
                // the buffered-drain registration so we don't hold a dead reference
                // to a scheduler whose owner just left.
                this.recomputeActiveScheduler(current);
            }
            if (current.listeners.size === 0) {
                this.teardown(current);
                this.subscriptions.delete(key);
            }
        };
        const flush = () => {
            const current = this.subscriptions.get(key);
            if (current)
                this.flushSubscription(current);
        };
        return (0, subscriptionHandle_1.makeSubscriptionHandle)(unsubscribe, flush);
    }
    /**
     * Drain any buffered notifications immediately. With `commitment`, flushes
     * just that subscription; without, flushes all matching programId+filters
     * across all commitments.
     *
     * No-op if unbuffered or empty. Cadence timers keep ticking — next firing
     * may be a no-op since the buffer was just cleared.
     */
    flushPending(programId, filters, commitment) {
        if (commitment) {
            const key = this.buildKey(programId, commitment, filters);
            const subscription = this.subscriptions.get(key);
            if (subscription)
                this.flushSubscription(subscription);
        }
        else {
            const serializedFilters = (0, utils_1.toJson)(filters, true);
            for (const subscription of this.subscriptions.values()) {
                if (subscription.programId === programId && subscription.serializedFilters === serializedFilters) {
                    this.flushSubscription(subscription);
                }
            }
        }
    }
    getMode(programId, filters, commitment) {
        return this.findSubscription(programId, filters, commitment)?.mode ?? 'idle';
    }
    destroy() {
        for (const [key, subscription] of this.subscriptions) {
            this.teardown(subscription);
            this.subscriptions.delete(key);
        }
    }
    /** Resolves when the WS subscription is established.
     *
     *  Throws if no subscription matches `(programId, filters, commitment)` —
     *  silently resolving used to make caller typos (wrong commitment, missing
     *  prior `subscribeProgramAccounts`, stale filter array) look like a
     *  successful wait. Failing loudly surfaces the sequencing bug at the
     *  call site. */
    async waitForConnection(programId, filters, commitment) {
        const subscription = this.findSubscription(programId, filters, commitment);
        if (!subscription) {
            throw new Error(`AccountSubscriptionManager.waitForConnection: no subscription found for programId=${programId}` +
                `${commitment ? `, commitment=${commitment}` : ' (any commitment)'}. ` +
                'Call subscribeProgramAccounts() first, and ensure the filters/commitment match.');
        }
        await subscription.connectedPromise;
    }
    buildKey(programId, commitment, filters) {
        return `${programId}:${commitment}:${(0, utils_1.toJson)(filters, true)}`;
    }
    /** Direct lookup with `commitment`; fan-out scan without. */
    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 subscription of this.subscriptions.values()) {
            if (subscription.programId === programId && subscription.serializedFilters === serializedFilters) {
                return subscription;
            }
        }
        return undefined;
    }
    /** Re-wire cadence machinery after a demands change. Fast no-op if effective throttle didn't change. */
    recomputeEffectiveThrottle(subscription) {
        const newCadence = computeShortestThrottle(subscription.throttleMsDemands);
        if (newCadence === subscription.throttleMs)
            return;
        const previousCadence = subscription.throttleMs;
        subscription.throttleMs = newCadence;
        this.tearDownActiveCadence(subscription);
        if (subscription.activeScheduler && newCadence !== undefined) {
            this.attachToSchedulerGroup(subscription, newCadence);
        }
        else if (newCadence === undefined && previousCadence !== undefined) {
            // All throttled subscribers gone — flush so buffered data isn't orphaned.
            this.flushOrphanedBuffer(subscription);
        }
        else if (newCadence !== undefined && !subscription.activeScheduler) {
            // defined → defined transition with buffered data — re-arm at the new
            // rate so the buffer drains promptly instead of waiting for the next WS
            // notification (could be minutes for low-frequency accounts).
            this.rearmTimerForBufferedData(subscription, newCadence);
        }
    }
    /** Re-pick the active scheduler from current demands and re-wire the
     *  group registration if the choice changed. */
    recomputeActiveScheduler(subscription) {
        const newActive = pickFirstScheduler(subscription.schedulerDemands);
        if (newActive === subscription.activeScheduler)
            return;
        subscription.activeScheduler = newActive;
        // If we were attached to a scheduler group, that registration is now
        // stale (either pointed at a departed scheduler or needs to switch).
        if (subscription.unregisterFromScheduler !== undefined) {
            subscription.unregisterFromScheduler();
            subscription.unregisterFromScheduler = undefined;
        }
        if (newActive !== undefined && subscription.throttleMs !== undefined) {
            this.attachToSchedulerGroup(subscription, subscription.throttleMs);
        }
        else if (newActive === undefined && subscription.throttleMs !== undefined) {
            // Scheduler disappeared but throttle remains — fall back to the
            // self-managed timer so buffered notifications still drain.
            this.rearmTimerForBufferedData(subscription, subscription.throttleMs);
        }
    }
    tearDownActiveCadence(subscription) {
        if (subscription.throttleDrainTimer !== undefined) {
            clearTimeout(subscription.throttleDrainTimer);
            subscription.throttleDrainTimer = undefined;
        }
        if (subscription.unregisterFromScheduler !== undefined) {
            subscription.unregisterFromScheduler();
            subscription.unregisterFromScheduler = undefined;
        }
    }
    attachToSchedulerGroup(subscription, throttleMs) {
        if (!subscription.activeScheduler)
            return;
        subscription.unregisterFromScheduler = subscription.activeScheduler.register(() => {
            if (subscription.bufferedNotifications.size === 0)
                return;
            this.drainBufferedNotifications(subscription);
        }, throttleMs);
    }
    flushOrphanedBuffer(subscription) {
        if (subscription.bufferedNotifications.size > 0) {
            this.drainBufferedNotifications(subscription);
        }
    }
    rearmTimerForBufferedData(subscription, throttleMs) {
        if (subscription.bufferedNotifications.size > 0) {
            this.armThrottleDrainTimer(subscription, throttleMs);
        }
    }
    /** No-op if a timer is already armed. */
    armThrottleDrainTimer(subscription, throttleMs) {
        if (subscription.throttleDrainTimer !== undefined)
            return;
        subscription.throttleDrainTimer = setTimeout(() => {
            subscription.throttleDrainTimer = undefined;
            this.drainBufferedNotifications(subscription);
        }, throttleMs);
    }
    flushSubscription(subscription) {
        if (subscription.bufferedNotifications.size === 0)
            return;
        this.onLog('[WS] Flushing', subscription.bufferedNotifications.size, 'throttled notifications');
        if (subscription.throttleDrainTimer) {
            clearTimeout(subscription.throttleDrainTimer);
            subscription.throttleDrainTimer = undefined;
        }
        this.drainBufferedNotifications(subscription);
    }
    createSubscription(key, programId, filters, commitment) {
        let connectedResolve;
        const connectedPromise = new Promise((resolve) => {
            connectedResolve = resolve;
        });
        const subscription = {
            key,
            programId,
            filters,
            serializedFilters: (0, utils_1.toJson)(filters, true),
            commitment,
            listeners: new Map(),
            cachedValuesByAddress: new Map(),
            mode: 'idle',
            stopWsLoop: undefined,
            onReconnectDemands: new Map(),
            throttleMsDemands: new Map(),
            throttleMs: undefined,
            schedulerDemands: new Map(),
            activeScheduler: undefined,
            bufferedNotifications: new Map(),
            connectedResolve,
            connectedPromise,
            teardownAbortController: new AbortController(),
        };
        this.subscriptions.set(key, subscription);
        return subscription;
    }
    startWs(subscription) {
        subscription.mode = 'ws';
        subscription.stopWsLoop = (0, wsReconnectLoop_1.runReconnectLoop)({
            wsRpc: this.wsRpc,
            reconnectDelayMs: this.reconnectDelayMs,
            onError: this.onError,
            onLog: this.onLog,
            // Read the latest reconnect hook lazily so hooks added after the loop
            // starts are picked up on the next reconnect. The manager is the
            // documented "wrap with internal retry if mandatory" wrapper from
            // runReconnectLoop's onBeforeReconnect contract: a transient HTTP
            // refresh failure shouldn't cause the listener to resume with stale
            // state and misclassify subsequent fills.
            onBeforeReconnect: () => this.runReconnectHookWithRetry(subscription),
            onSubscribed: () => {
                this.onLog('[WS] Subscription established, listening...');
                subscription.connectedResolve?.();
                subscription.connectedResolve = undefined;
            },
            subscribe: (wsRpc, abortSignal) => {
                this.onLog('[WS] Connecting programSubscribe for', subscription.programId);
                return wsRpc
                    .programNotifications(subscription.programId, {
                    commitment: subscription.commitment,
                    encoding: 'base64',
                    filters: subscription.filters,
                })
                    .subscribe({ abortSignal });
            },
            onMessage: ({ context, value: { pubkey, account } }) => {
                if (!account)
                    return;
                this.handleNotification(subscription, pubkey, buffer_1.Buffer.from(account.data[0], 'base64'), context.slot);
            },
        });
    }
    handleNotification(subscription, address, buffer, slot) {
        const cached = subscription.cachedValuesByAddress.get(address);
        if (cached && slot < cached.slot)
            return;
        if (cached && (0, utils_1.blobEquals)(cached.buffer, buffer)) {
            cached.slot = slot;
            return;
        }
        subscription.cachedValuesByAddress.set(address, { buffer, slot });
        this.deliverByCadence(subscription, address, buffer, slot);
    }
    deliverByCadence(subscription, address, buffer, slot) {
        if (subscription.unregisterFromScheduler) {
            subscription.bufferedNotifications.set(address, { buffer, slot });
            return;
        }
        if (subscription.throttleMs !== undefined) {
            subscription.bufferedNotifications.set(address, { buffer, slot });
            this.armThrottleDrainTimer(subscription, subscription.throttleMs);
            return;
        }
        this.fireListeners(subscription, address, buffer, slot);
    }
    drainBufferedNotifications(subscription) {
        for (const [address, { buffer, slot }] of subscription.bufferedNotifications) {
            this.fireListeners(subscription, address, buffer, slot);
        }
        subscription.bufferedNotifications.clear();
    }
    fireListeners(subscription, address, buffer, slot) {
        for (const listener of subscription.listeners.values()) {
            try {
                listener(address, buffer, slot);
            }
            catch (e) {
                this.onError(e);
            }
        }
    }
    teardown(subscription) {
        // Abort first so any in-flight onReconnect retry exits its sleep loop
        // before we tear down the WS loop itself.
        subscription.teardownAbortController.abort();
        subscription.stopWsLoop?.();
        subscription.stopWsLoop = undefined;
        if (subscription.throttleDrainTimer)
            clearTimeout(subscription.throttleDrainTimer);
        subscription.throttleDrainTimer = undefined;
        subscription.unregisterFromScheduler?.();
        subscription.unregisterFromScheduler = undefined;
        subscription.bufferedNotifications.clear();
        subscription.cachedValuesByAddress.clear();
        subscription.mode = 'idle';
        // Settle any pending waitForConnection() — callers must NOT hang forever
        // if the subscription is torn down before the WS handshake completed.
        // Resolve (not reject) so existing await-chains don't need new catch
        // logic; awaiters can re-check manager state if they need certainty.
        subscription.connectedResolve?.();
        subscription.connectedResolve = undefined;
    }
    /** Sleep `ms`, but resolve immediately on abort — mirrors the pattern in
     *  runReconnectLoop so teardown cancels in-flight reconnect-hook retries. */
    static abortableSleep(ms, signal) {
        if (signal.aborted)
            return Promise.resolve();
        return new Promise((resolve) => {
            const onAbort = () => {
                clearTimeout(timer);
                resolve();
            };
            const timer = setTimeout(() => {
                signal.removeEventListener('abort', onAbort);
                resolve();
            }, ms);
            signal.addEventListener('abort', onAbort, { once: true });
        });
    }
    /** Calls the latest registered `onReconnect` hook with bounded retry-with-
     *  backoff. The manager is the documented wrapper for runReconnectLoop's
     *  best-effort `onBeforeReconnect` contract — failing the hook silently
     *  would let listeners resume with stale state (e.g. borrowOrderFills
     *  misclassifying new fills against pre-downtime obligations).
     *
     *  After {@link RECONNECT_HOOK_MAX_RETRIES} consecutive failures we give
     *  up and proceed anyway: an indefinitely failing hook would block the
     *  WS forever, which is worse than stale state. The error reporter sees
     *  every failure regardless. */
    async runReconnectHookWithRetry(subscription) {
        const signal = subscription.teardownAbortController.signal;
        let attempt = 0;
        while (!signal.aborted) {
            const hook = pickLatestReconnectHook(subscription.onReconnectDemands);
            if (!hook)
                return;
            try {
                this.onLog('[WS] Calling onReconnect before re-subscribing (attempt', attempt + 1, ')');
                await hook();
                return;
            }
            catch (e) {
                this.onError(e);
                attempt++;
                if (attempt >= RECONNECT_HOOK_MAX_RETRIES) {
                    this.onLog('[WS] onReconnect failed after', RECONNECT_HOOK_MAX_RETRIES, 'attempts — proceeding with cached state (consumer must reconcile)');
                    return;
                }
                const delay = Math.min(this.reconnectDelayMs * 2 ** attempt, RECONNECT_HOOK_MAX_DELAY_MS);
                await AccountSubscriptionManager.abortableSleep(delay, signal);
            }
        }
    }
}
exports.AccountSubscriptionManager = AccountSubscriptionManager;
//# sourceMappingURL=accountSubscriptionManager.js.map