/**
 * Frontend WebSocket client — portable, Svelte-reactive, implements `WebsocketConnection`.
 *
 * Plain class with `$state` runes (no Cell inheritance, no app coupling).
 * Drop into any SvelteKit frontend as the underlying connection for
 * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
 * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
 * server revokes auth), exposes reactive status for UI indicators, and ships
 * three correctness primitives default-on:
 *
 * - `FrontendWebsocketClient.request` — promise-based JSON-RPC with
 *   auto-assigned ids and a pending-id map. Intercepts responses on the
 *   message path so request/response correlation is transport-level rather
 *   than re-invented per consumer.
 * - **Durable queue** — `request()` calls made while disconnected buffer up
 *   to `DEFAULT_QUEUE_MAX_SIZE` requests and flush on reopen. Overflow
 *   rejects with `queue_overflow`. Raw `FrontendWebsocketClient.send`
 *   is drop-on-disconnect (fire-and-forget notifications want that).
 * - **Activity-aware heartbeat** — idles fire a shared `heartbeat` request;
 *   receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT` closes
 *   with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT` and lets auto-reconnect
 *   pick back up.
 *
 * @module
 */
import { BROWSER } from 'esm-env';
import { JSONRPC_VERSION } from '../http/jsonrpc.js';
import { JSONRPC_ERROR_CODES, ThrownJsonrpcError, jsonrpc_errors } from '../http/jsonrpc_errors.js';
import { WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, WS_CLOSE_SESSION_REVOKED } from './transports.js';
import { cancel_action_spec } from './cancel.js';
import { heartbeat_action_spec } from './heartbeat.js';
/** Default WebSocket close code (normal closure). */
export const DEFAULT_CLOSE_CODE = 1000;
/** Base reconnect delay in ms. */
export const DEFAULT_RECONNECT_DELAY = 1000;
/** Max reconnect delay in ms (cap on exponential backoff). */
export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
/** Exponential backoff factor: delay = base * factor^(attempt-1). */
export const DEFAULT_BACKOFF_FACTOR = 1.5;
/** Idle interval before sending a heartbeat (ms). */
export const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
/** Max receive silence before closing with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT` (ms). */
export const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000;
/** Default bound on buffered requests while disconnected. Overflow rejects. */
export const DEFAULT_QUEUE_MAX_SIZE = 100;
/**
 * Reactive WebSocket client implementing `WebsocketConnection`.
 *
 * Construct with a URL and optional config; call `connect()` to open the
 * socket and begin auto-reconnect. Register message/error handlers via
 * `add_message_handler` / `add_error_handler` — both return unsubscribe
 * functions. `FrontendWebsocketTransport` consumes this as its connection.
 *
 * Session-revocation close codes (`WS_CLOSE_SESSION_REVOKED`) put the client
 * in a permanently-closed state; reconnecting would just loop on 401.
 */
export class FrontendWebsocketClient {
    #url;
    #auto_reconnect;
    #reconnect_delay;
    #reconnect_delay_max;
    #backoff_factor;
    #heartbeat_enabled;
    #heartbeat_interval;
    #heartbeat_receive_timeout;
    #queue_enabled;
    #queue_max_size;
    #log;
    #next_request_id = 0;
    #pending = new Map();
    #queue = [];
    #heartbeat_timer = null;
    /** Epoch ms of the last outgoing send — used by the heartbeat activity check. */
    #last_send_time = null;
    /** Epoch ms of the last incoming message — used by the heartbeat activity check. */
    #last_receive_time = null;
    ws = $state.raw(null);
    status = $state.raw('initial');
    reconnect_count = $state.raw(0);
    current_reconnect_delay = $state.raw(0);
    /** Epoch ms of the most recent successful open. Never cleared on close. */
    last_connect_time = $state.raw(null);
    /** Epoch ms of the most recent close event or client-initiated close. */
    last_close_time = $state.raw(null);
    /** Close code from the most recent close. Initial `null` means "never closed." */
    last_close_code = $state.raw(null);
    /** Reason string from the most recent close event (may be empty). */
    last_close_reason = $state.raw(null);
    /**
     * The error thrown by the most recent attempted `send()`, or `null` if the
     * most recent attempt succeeded or none has been attempted yet. Populated
     * when the underlying `ws.send` throws (e.g., buffer full, serialization
     * error); reset to `null` on the next successful send. Not touched when
     * `send()` short-circuits because the socket is not connected — consult
     * `connected` for that case. Wrappers surfacing per-message failure
     * reasons can read this after a `false` return from `send()`.
     */
    last_send_error = $state.raw(null);
    #reconnect_timeout = null;
    #reconnect_scheduled_at = null;
    #revoked = $state.raw(false);
    #message_handlers = new Set();
    #error_handlers = new Set();
    connected = $derived(this.status === 'connected');
    constructor(url, options = {}) {
        this.#url = url;
        const reconnect = options.reconnect;
        this.#auto_reconnect = reconnect !== false;
        const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
        this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
        this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
        this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
        const heartbeat = options.heartbeat;
        this.#heartbeat_enabled = heartbeat !== false;
        const heartbeat_config = typeof heartbeat === 'object' && heartbeat !== null ? heartbeat : {};
        this.#heartbeat_interval = heartbeat_config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
        this.#heartbeat_receive_timeout =
            heartbeat_config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
        const queue = options.queue;
        this.#queue_enabled = queue !== false;
        const queue_config = typeof queue === 'object' ? queue : {};
        this.#queue_max_size = queue_config.max_size ?? DEFAULT_QUEUE_MAX_SIZE;
        this.#log = options.log ?? null;
    }
    /**
     * Swap the auto-reconnect policy in place. Accepts the same shape as the
     * constructor's `reconnect` option: `false` disables reconnect, `true` or
     * `null`/omitted restores the defaults, or a config object customizes
     * specific fields (missing fields fall back to defaults, not "keep
     * current" — each call defines the whole policy atomically, same as the
     * constructor).
     *
     * In-flight reconnect schedules are **monotonically shortened**: the
     * effective total wait from arm-time never exceeds what the new policy
     * prescribes. If the new target is already past the time already
     * elapsed, the reconnect fires immediately (on the next tick). The wait
     * is never extended.
     *
     * Turning reconnect off while a reconnect timer is pending cancels that
     * timer and transitions status to `closed` (since the lie of
     * `'reconnecting'` would be visible to UI indicators). Turning it back on
     * does not synthesize a reconnect — wait for the next close.
     *
     * @mutates this - replaces the reconnect policy fields; may also reset
     *   `status`, `reconnect_count`, and `current_reconnect_delay` when an
     *   in-flight reconnect timer is cancelled or rescheduled
     */
    set_reconnect(reconnect = null) {
        const next_auto = reconnect !== false;
        const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
        this.#auto_reconnect = next_auto;
        this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
        this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
        this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
        if (this.#reconnect_timeout === null)
            return;
        if (!next_auto) {
            this.#cancel_reconnect();
            this.status = 'closed';
            this.#reset_reconnect_counters();
            return;
        }
        // Auto-reconnect still on: monotonically shorten the pending wait if
        // the new policy would produce a shorter total wait from arm-time.
        // Never extends.
        const scheduled_at = this.#reconnect_scheduled_at ?? Date.now();
        const elapsed = Math.max(0, Date.now() - scheduled_at);
        const remaining = Math.max(0, this.current_reconnect_delay - elapsed);
        const new_target = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** Math.max(0, this.reconnect_count - 1)));
        const new_remaining = Math.max(0, new_target - elapsed);
        if (new_remaining >= remaining)
            return;
        clearTimeout(this.#reconnect_timeout);
        this.current_reconnect_delay = new_target;
        // Keep #reconnect_scheduled_at at the original arm time so subsequent
        // set_reconnect calls compute elapsed against a stable origin.
        this.#reconnect_timeout = setTimeout(() => {
            this.#reconnect_timeout = null;
            this.#reconnect_scheduled_at = null;
            this.connect();
        }, new_remaining);
    }
    /**
     * Swap the heartbeat policy in place. Accepts the same shape as the
     * constructor's `heartbeat` option: `false` disables the timer, `true` or
     * `null`/omitted restores the defaults, or a config object customizes
     * specific fields (missing fields fall back to defaults, not "keep
     * current" — each call defines the whole policy atomically, same as the
     * constructor and `set_reconnect`).
     *
     * When connected, the live timer is restarted immediately so the new
     * `interval` / `receive_timeout` take effect without a reconnect; when
     * disconnected, just stashes the policy for the next open.
     *
     * @mutates this - replaces the heartbeat policy fields; restarts the
     *   live heartbeat timer when connected
     */
    set_heartbeat(heartbeat = null) {
        this.#heartbeat_enabled = heartbeat !== false;
        const config = typeof heartbeat === 'object' && heartbeat !== null ? heartbeat : {};
        this.#heartbeat_interval = config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
        this.#heartbeat_receive_timeout = config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
        if (this.connected) {
            this.#start_heartbeat();
        }
        else {
            this.#cancel_heartbeat();
        }
    }
    /**
     * Cancel a scheduled reconnect without closing the client or disabling
     * auto-reconnect. Transitions status from `reconnecting` → `closed` and
     * resets the backoff counters — the next close still triggers a fresh
     * reconnect cycle under the current policy. No-op when no reconnect is
     * pending.
     *
     * Use this when UI state asks "stop trying for now" without the finality
     * of `disconnect` (which also rejects pending/queued requests and
     * clears heartbeat) or the policy change of `set_reconnect(false)`
     * (which disables future reconnects). The queue stays intact so that
     * calling `connect` later flushes buffered work.
     *
     * @mutates this - clears the pending reconnect timer, sets `status` to
     *   `closed`, resets `reconnect_count` and `current_reconnect_delay`
     */
    cancel_reconnect() {
        if (this.#reconnect_timeout === null)
            return;
        this.#cancel_reconnect();
        this.status = 'closed';
        this.#reset_reconnect_counters();
    }
    get url() {
        return this.#url;
    }
    /**
     * Whether the server has permanently closed the session. Once `true`, all
     * `connect()` calls are no-ops. Distinct from `status:'closed'`, which
     * reflects any closed state (incl. user-initiated `disconnect()`).
     */
    get revoked() {
        return this.#revoked;
    }
    /**
     * Open the WebSocket. No-op on SSR, or if the session has been revoked.
     * Cancels any pending reconnect and tears down any existing connection first;
     * an open prior socket is closed with a normal-closure code.
     *
     * @mutates this - replaces `ws`, sets `status` to `connecting` (or
     *   `closed` on construction failure), and on construction failure may
     *   schedule a reconnect (mutating `reconnect_count` /
     *   `current_reconnect_delay`)
     */
    connect() {
        if (!BROWSER)
            return;
        if (this.#revoked)
            return;
        this.#cancel_reconnect();
        this.#teardown(DEFAULT_CLOSE_CODE);
        try {
            this.status = 'connecting';
            const ws = new WebSocket(this.#url);
            this.ws = ws;
            ws.addEventListener('open', this.#handle_open);
            ws.addEventListener('close', this.#handle_close);
            ws.addEventListener('error', this.#handle_error);
            ws.addEventListener('message', this.#handle_message);
        }
        catch (error) {
            this.#log?.error('[socket] failed to create WebSocket:', error);
            this.ws = null;
            if (this.#auto_reconnect) {
                this.#schedule_reconnect();
            }
            else {
                this.status = 'closed';
            }
        }
    }
    /**
     * Close the WebSocket, cancel any pending reconnect, and reset the reconnect
     * backoff counters. Puts the client in `closed` status; call `connect()` to
     * reopen. Safe to call more than once.
     *
     * @mutates this - clears `ws`, heartbeat, and reconnect timers; sets
     *   `status` to `closed`; rejects every pending and queued request with
     *   `service_unavailable`
     */
    disconnect(code = DEFAULT_CLOSE_CODE) {
        this.#cancel_reconnect();
        this.#cancel_heartbeat();
        this.#teardown(code);
        this.status = 'closed';
        this.#reset_reconnect_counters();
        this.#reject_all('client disconnected', jsonrpc_errors.service_unavailable);
    }
    /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
    [Symbol.dispose]() {
        this.disconnect();
    }
    send(data) {
        if (!this.connected || !this.ws)
            return false;
        try {
            this.ws.send(JSON.stringify(data));
            this.last_send_error = null;
            this.#last_send_time = Date.now();
            return true;
        }
        catch (error) {
            this.#log?.error('[socket] send failed:', error);
            this.last_send_error = error instanceof Error ? error : new Error(String(error));
            return false;
        }
    }
    /**
     * Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
     * id (or uses an explicit one supplied via `options.id` — used by
     * `FrontendWebsocketTransport` which delegates to this method and has its
     * own peer-minted UUID), tracks the pending promise, and resolves when the
     * server sends a matching response.
     *
     * Callers supplying an explicit `options.id` are responsible for
     * uniqueness — the pending map is keyed by id, and a duplicate silently
     * overwrites the prior entry. Auto-minted ids are monotonic and never
     * collide with themselves or with peer-minted UUIDs (the types differ:
     * integer vs string).
     *
     * While the socket is disconnected, the request is buffered in a bounded
     * queue (default-on, `DEFAULT_QUEUE_MAX_SIZE`) and flushed on reopen. Pass
     * `{queue: false}` to reject immediately when disconnected — used
     * internally by the heartbeat, which must not fight the queue for the
     * disconnect-detection slot.
     *
     * On `AbortSignal` fire: rejects the local promise *and* sends the shared
     * `cancel` notification (`cancel_action_spec.method`) so the server-side
     * dispatcher can abort the matching handler's `ctx.signal`. Suppressed
     * for queued-but-never-sent (server doesn't know about it) and
     * response-beat-cancel races.
     *
     * @mutates this - inserts the new pending entry into `#pending` (or
     *   buffers into `#queue` when disconnected) and may bump
     *   `#next_request_id`, `#last_send_time`, `last_send_error`
     * @throws ThrownJsonrpcError on the returned promise — never thrown
     *   synchronously. Rejection codes:
     *   - `unauthenticated` — session revoked (entry check or close code)
     *   - `request_cancelled` — caller's `AbortSignal` fired
     *   - `queue_overflow` — durable queue full
     *   - `service_unavailable` — socket not connected / closed / torn down
     *     mid-flight
     *   - `internal_error` — `ws.send` threw (serialization, buffer full)
     *   - server's wire code verbatim — JSON-RPC error frame from peer
     */
    request(method, params = {}, options = {}) {
        return new Promise((resolve, reject) => {
            const resolve_typed = resolve;
            const reject_typed = reject;
            if (this.#revoked) {
                reject_typed(jsonrpc_errors.unauthenticated('[socket] session revoked'));
                return;
            }
            const { signal = null } = options;
            if (signal?.aborted) {
                reject_typed(this.#build_abort_error(method));
                return;
            }
            const id = options.id ?? ++this.#next_request_id;
            const frame = { jsonrpc: JSONRPC_VERSION, id, method, params };
            // Bind the signal listener up-front so `#detach_signal` can find it by
            // reference regardless of which settlement path runs (inline send,
            // queued flush, close-time reject).
            let pending = null;
            const signal_handler = signal
                ? () => {
                    if (!pending)
                        return;
                    // `Map.delete` returns true iff the entry existed — which
                    // is our signal that the frame was actually written to
                    // the socket (pending-only tracks in-flight). If it was
                    // only queued (never sent), the server doesn't know
                    // about it and doesn't need a cancel. If the response
                    // beat the abort, `#handle_message` already deleted the
                    // entry and detached this listener, so this closure
                    // never runs in that race.
                    const was_in_flight = this.#pending.delete(id);
                    this.#drop_queued(id);
                    this.#detach_signal(pending);
                    pending = null;
                    reject_typed(this.#build_abort_error(method));
                    if (was_in_flight)
                        this.#send_cancel(id);
                }
                : null;
            if (signal && signal_handler)
                signal.addEventListener('abort', signal_handler);
            pending = { method, resolve: resolve_typed, reject: reject_typed, signal, signal_handler };
            const should_queue = options.queue !== false && this.#queue_enabled;
            if (this.connected && this.ws) {
                const sent = this.send(frame);
                if (sent) {
                    this.#pending.set(id, pending);
                    return;
                }
                // Send failed mid-connected (serialization, buffer full). Requeue if
                // the queue is on, otherwise reject — this socket is in an odd
                // state but the caller asked for non-durable semantics.
                if (should_queue) {
                    this.#enqueue({ ...pending, id, frame });
                    return;
                }
                this.#detach_signal(pending);
                reject_typed(jsonrpc_errors.internal_error(`[socket] send failed for ${method}`));
                return;
            }
            if (should_queue) {
                this.#enqueue({ ...pending, id, frame });
                return;
            }
            this.#detach_signal(pending);
            reject_typed(jsonrpc_errors.service_unavailable(`[socket] not connected (method=${method})`));
        });
    }
    add_message_handler(handler) {
        this.#message_handlers.add(handler);
        return () => this.#message_handlers.delete(handler);
    }
    add_error_handler(handler) {
        this.#error_handlers.add(handler);
        return () => this.#error_handlers.delete(handler);
    }
    #build_abort_error(method) {
        return jsonrpc_errors.request_cancelled(`[socket] request aborted (method=${method})`);
    }
    /**
     * Fire-and-forget cancel notification to the server. The dispatcher
     * looks up the matching pending handler's per-request `AbortController`
     * and aborts it; unknown ids no-op. Drops silently when disconnected —
     * the server's own socket-close path will abort any in-flight handlers.
     */
    #send_cancel(request_id) {
        this.send({
            jsonrpc: JSONRPC_VERSION,
            method: cancel_action_spec.method,
            params: { request_id },
        });
    }
    #detach_signal(pending) {
        if (pending.signal && pending.signal_handler) {
            pending.signal.removeEventListener('abort', pending.signal_handler);
        }
    }
    #enqueue(queued) {
        if (this.#queue.length >= this.#queue_max_size) {
            this.#detach_signal(queued);
            queued.reject(jsonrpc_errors.queue_overflow(`[socket] request queue overflow (method=${queued.method}, max=${this.#queue_max_size})`));
            return;
        }
        this.#queue.push(queued);
    }
    #drop_queued(id) {
        const index = this.#queue.findIndex((q) => q.id === id);
        if (index !== -1)
            this.#queue.splice(index, 1);
    }
    #flush_queue() {
        if (!this.connected || !this.ws)
            return;
        const queued = this.#queue;
        this.#queue = [];
        for (const q of queued) {
            if (q.signal?.aborted) {
                this.#detach_signal(q);
                q.reject(this.#build_abort_error(q.method));
                continue;
            }
            const sent = this.send(q.frame);
            if (sent) {
                this.#pending.set(q.id, {
                    method: q.method,
                    resolve: q.resolve,
                    reject: q.reject,
                    signal: q.signal,
                    signal_handler: q.signal_handler,
                });
            }
            else {
                this.#detach_signal(q);
                q.reject(jsonrpc_errors.internal_error(`[socket] queued request send failed (method=${q.method})`));
            }
        }
    }
    #reject_all(reason, make_error) {
        const pending = this.#pending;
        this.#pending = new Map();
        for (const [id, p] of pending) {
            this.#detach_signal(p);
            p.reject(make_error(`[socket] ${reason} (method=${p.method}, id=${id})`));
        }
        const queued = this.#queue;
        this.#queue = [];
        for (const q of queued) {
            this.#detach_signal(q);
            q.reject(make_error(`[socket] ${reason} (method=${q.method})`));
        }
    }
    #reject_pending_only(reason, make_error) {
        // Socket closed but auto-reconnect will try again — pending requests were
        // in flight on the old socket so we can't correlate them after reopen;
        // queued requests haven't been sent yet and stay buffered for the flush.
        const pending = this.#pending;
        this.#pending = new Map();
        for (const [id, p] of pending) {
            this.#detach_signal(p);
            p.reject(make_error(`[socket] ${reason} (method=${p.method}, id=${id})`));
        }
    }
    #start_heartbeat() {
        this.#cancel_heartbeat();
        if (!this.#heartbeat_enabled)
            return;
        const now = Date.now();
        this.#last_send_time = now;
        this.#last_receive_time = now;
        // Run the check at half the interval so any event-loop blockage pauses
        // the timer itself; a dead-because-blocked socket is close enough to
        // dead-because-unresponsive that closing is arguably correct.
        const tick = Math.max(100, Math.floor(this.#heartbeat_interval / 2));
        this.#heartbeat_timer = setInterval(() => this.#heartbeat_tick(), tick);
    }
    #cancel_heartbeat() {
        if (this.#heartbeat_timer !== null) {
            clearInterval(this.#heartbeat_timer);
            this.#heartbeat_timer = null;
        }
    }
    #heartbeat_tick() {
        if (!this.connected || !this.ws)
            return;
        const now = Date.now();
        const last_receive = this.#last_receive_time ?? now;
        if (now - last_receive >= this.#heartbeat_receive_timeout) {
            this.#log?.info(`[socket] receive timeout (${now - last_receive}ms) — closing ${WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}`);
            try {
                this.ws.close(WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, 'client heartbeat timeout');
            }
            catch (error) {
                this.#log?.error('[socket] heartbeat timeout close failed:', error);
            }
            return;
        }
        const last_activity = Math.max(this.#last_send_time ?? 0, last_receive);
        if (now - last_activity >= this.#heartbeat_interval) {
            // Fire-and-forget the heartbeat. If it fails (network, serialization),
            // receive-silence detection above will close the socket on the next
            // tick. No queue — the heartbeat is the thing that tells us the
            // queue needs flushing, it must not fight the queue for the slot.
            void this.request(heartbeat_action_spec.method, {}, { queue: false }).catch((error) => {
                this.#log?.debug('[socket] heartbeat request failed:', error);
            });
        }
    }
    #teardown(close_code) {
        if (!this.ws)
            return;
        this.ws.removeEventListener('open', this.#handle_open);
        this.ws.removeEventListener('close', this.#handle_close);
        this.ws.removeEventListener('error', this.#handle_error);
        this.ws.removeEventListener('message', this.#handle_message);
        this.#cancel_heartbeat();
        if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
            try {
                this.ws.close(close_code);
            }
            catch (error) {
                this.#log?.error('[socket] close failed:', error);
            }
            // Listeners are gone, so `#handle_close` won't fire for this close —
            // record it here so the client-initiated close is still observable,
            // and reject any pending requests that can never resolve now.
            this.#record_close(close_code, '');
            this.#reject_pending_only(`socket torn down (code ${close_code})`, jsonrpc_errors.service_unavailable);
        }
        this.ws = null;
    }
    #record_close(code, reason) {
        this.last_close_time = Date.now();
        this.last_close_code = code;
        this.last_close_reason = reason;
    }
    #schedule_reconnect() {
        if (!this.#auto_reconnect || this.#revoked)
            return;
        this.#cancel_reconnect();
        this.reconnect_count++;
        this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
        this.status = 'reconnecting';
        this.#reconnect_scheduled_at = Date.now();
        this.#reconnect_timeout = setTimeout(() => {
            this.#reconnect_timeout = null;
            this.#reconnect_scheduled_at = null;
            this.connect();
        }, this.current_reconnect_delay);
    }
    #cancel_reconnect() {
        if (this.#reconnect_timeout !== null) {
            clearTimeout(this.#reconnect_timeout);
            this.#reconnect_timeout = null;
        }
        this.#reconnect_scheduled_at = null;
    }
    /** Reset the reactive reconnect counters — the pair always travels together. */
    #reset_reconnect_counters() {
        this.reconnect_count = 0;
        this.current_reconnect_delay = 0;
    }
    #handle_open = (_event) => {
        this.status = 'connected';
        this.#reset_reconnect_counters();
        this.last_connect_time = Date.now();
        this.#cancel_reconnect();
        this.#start_heartbeat();
        // Flush buffered requests before anyone else can observe the open state.
        this.#flush_queue();
    };
    #handle_close = (event) => {
        // Drop the dead-socket reference so consumers reading `client.ws` never
        // see a CLOSED WebSocket during the reconnect window.
        this.ws = null;
        this.#record_close(event.code, event.reason);
        this.#cancel_heartbeat();
        // Session revocation is terminal — reconnecting would 401 in a loop.
        if (event.code === WS_CLOSE_SESSION_REVOKED) {
            this.#revoked = true;
            this.status = 'closed';
            this.#cancel_reconnect();
            this.#reset_reconnect_counters();
            this.#reject_all('session revoked', jsonrpc_errors.unauthenticated);
            return;
        }
        // Pending in-flight requests can't be correlated post-reconnect; reject
        // them. Queue stays so the flush on reopen replays unsent work.
        this.#reject_pending_only(`connection closed (code ${event.code})`, jsonrpc_errors.service_unavailable);
        // Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
        // a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
        if (this.#auto_reconnect) {
            this.#schedule_reconnect();
        }
        else {
            this.status = 'closed';
            this.#reject_all('connection closed, auto-reconnect disabled', jsonrpc_errors.service_unavailable);
        }
    };
    #handle_error = (event) => {
        this.#log?.error('[socket] websocket error:', event);
        for (const handler of this.#error_handlers) {
            try {
                handler(event);
            }
            catch (error) {
                this.#log?.error('[socket] error handler threw:', error);
            }
        }
        // Browsers fire `close` after error; reconnect logic lives there.
    };
    #handle_message = (event) => {
        this.#last_receive_time = Date.now();
        // Intercept JSON-RPC responses for pending `request()` calls. Parse
        // defensively — if the frame isn't valid JSON or isn't a response, fall
        // through to the registered message handlers (which still see every
        // notification, plus any stray response we don't own).
        let json;
        try {
            json = JSON.parse(String(event.data));
        }
        catch {
            json = undefined;
        }
        if (typeof json === 'object' &&
            json !== null &&
            'id' in json &&
            ('result' in json || 'error' in json)) {
            const id = json.id;
            if (id !== null) {
                const pending = this.#pending.get(id);
                if (pending) {
                    this.#pending.delete(id);
                    this.#detach_signal(pending);
                    if ('error' in json && json.error) {
                        const err = json.error;
                        // Preserve the server's wire code verbatim so the transport's
                        // catch block re-emits the same code in its envelope. Fall
                        // back to `internal_error` only when the frame is malformed.
                        const wire_code = typeof err.code === 'number'
                            ? err.code
                            : JSONRPC_ERROR_CODES.internal_error;
                        const wire_message = typeof err.message === 'string' && err.message.length > 0
                            ? err.message
                            : 'unknown error';
                        pending.reject(new ThrownJsonrpcError(wire_code, wire_message, err.data));
                    }
                    else {
                        pending.resolve(json.result);
                    }
                    return;
                }
            }
        }
        for (const handler of this.#message_handlers) {
            try {
                handler(event);
            }
            catch (error) {
                this.#log?.error('[socket] message handler threw:', error);
            }
        }
    };
}
/**
 * Project `SocketStatus` onto fuz_util's `AsyncStatus` — the
 * 5-way → 4-way mapping every consumer re-derives to surface connection state
 * to UI (loading indicators, retry banners). Collapses `reconnecting` into
 * `failure` (UI shows "lost, retrying") and splits `closed` by `revoked` so
 * a terminal session-revocation read as `failure` while a clean client-
 * initiated close reads as `initial` (the "not connected, not trying" state).
 *
 * @param revoked - whether the session has been permanently revoked
 *   (typically `FrontendWebsocketClient.revoked`)
 */
export const socket_status_to_async_status = (status, revoked) => {
    switch (status) {
        case 'initial':
            return 'initial';
        case 'connecting':
            return 'pending';
        case 'connected':
            return 'success';
        case 'reconnecting':
            return 'failure';
        case 'closed':
            return revoked ? 'failure' : 'initial';
    }
};
