/**
 * Browser Proxy Service
 *
 * Connects to the Cloud Relay as an 'agent' and provides addressed messaging
 * to browser instances. Any Crewly agent can call browser tools through this
 * service without needing relay pairing.
 *
 * Architecture:
 *   Agent → /api/browser/* → BrowserProxyService → Cloud Relay → Extension
 *   Extension → Cloud Relay → BrowserProxyService → HTTP response → Agent
 *
 * @module services/browser/browser-proxy.service
 */
import { EventEmitter } from 'events';
import type { BrowserCommandResponse } from './browser-bridge.service.js';
/** Minimal info about a connected browser instance. */
export interface BrowserInstanceInfo {
    /** Persistent UUID of the extension install */
    instanceId: string;
    /** User-set friendly name */
    instanceName: string;
    /** Relay session ID */
    sessionId: string;
    /** ISO timestamp of last activity (heartbeat, command, or event) */
    lastSeenAt: string;
    /**
     * Optional stable identifier of the physical device the extension runs on.
     * Used to dedup re-install events that produce a fresh `instanceId` UUID
     * for the same underlying browser. Backward compatible: if absent on either
     * side of a comparison, dedup is skipped — older ext builds that do not
     * emit this field continue to behave exactly as before.
     *
     * Spec: `.crewly/specs/browser-ext-stale-status-fix-2026-04-30.md`.
     */
    deviceFingerprint?: string;
    /**
     * Optional observability tag from the relay registry: the device the
     * BROWSER (extension) registered from. Surfaced in /api/browser/status so
     * the UI can render "drivable from device X". Observability only — never a
     * routing input (DEVICE-OBSERVABILITY-NOT-ROUTING invariant).
     */
    deviceId?: string;
}
/** Connection state of the proxy to the cloud relay. */
export type ProxyConnectionState = 'disconnected' | 'connecting' | 'connected';
/**
 * Decode the `sub` (account id) claim from a JWT WITHOUT verifying the
 * signature — used only to derive a stable load-balancer stickiness key
 * (`?rk=`), never for any auth decision. Stable across token refreshes
 * (the `sub` doesn't change when the token rotates), which is exactly what
 * the sticky-by-account routing needs.
 *
 * @param token - a JWT (relay-signed access token), or null
 * @returns the `sub` string, or null if absent/unparseable
 */
export declare function decodeJwtSub(token: string | null): string | null;
/**
 * Function type for resolving fresh auth tokens.
 * Used to fetch updated JWT from CloudClientService on reconnect.
 *
 * @returns Fresh JWT token string, or null if unavailable
 */
export type TokenResolver = () => string | null;
/**
 * BrowserProxyService connects to the Cloud Relay and routes browser commands
 * to specific extension instances using addressed messaging (relay_to).
 *
 * Singleton — one relay connection per backend instance.
 */
export declare class BrowserProxyService {
    private static instance;
    private readonly logger;
    /** WebSocket connection to cloud relay */
    private ws;
    /** Current relay session ID (assigned after registration) */
    private sessionId;
    /** Connection state */
    private state;
    /** Map of commandId → pending command awaiting response */
    private pendingCommands;
    /** Known browser instances from relay browser_list + browser_event */
    private instances;
    /** Command counter for unique IDs */
    private commandCounter;
    /** Heartbeat interval timer */
    private heartbeatTimer;
    /** Reconnect timer */
    private reconnectTimer;
    /**
     * Heartbeat tick counter. Resets on each `startHeartbeat()` call (i.e. on
     * fresh registration). Used to fire a defensive `list_browsers` re-issue
     * every {@link BROWSER_PROXY_CONSTANTS.LIST_REFRESH_EVERY_N_HEARTBEATS}
     * ticks so the local `instances` Map stays bounded against the 5-min
     * sweep purge even when the cloud relay drops `browser_event:updated`
     * pushes (observed during 2026-04-30 SteamFun customer demo).
     */
    private heartbeatTickCounter;
    /**
     * Wall-clock TTL sweep timer. Runs every {@link BROWSER_PROXY_CONSTANTS.SWEEP_INTERVAL_MS}
     * and purges any `BrowserInstanceInfo` whose `lastSeenAt` exceeds
     * {@link BROWSER_PROXY_CONSTANTS.STALE_PURGE_THRESHOLD_MS}. Independent of
     * the heartbeat — guards against the relay dropping a `disconnected` event.
     */
    private sweepTimer;
    /** Auth token for relay registration */
    private authToken;
    /** Relay WebSocket URL */
    private relayUrl;
    /** Whether auto-reconnect is enabled */
    private autoReconnect;
    /** Guard flag to prevent double handleDisconnect calls */
    private disconnecting;
    /** Consecutive reconnect attempt counter (for exponential backoff) */
    private reconnectAttempts;
    /** Callback to resolve a fresh token on reconnect (avoids stale JWT) */
    private tokenResolver;
    /**
     * Stable device identity tag attached to register frames for OBSERVABILITY
     * only (so the relay/UI can render "drivable from: macbookpro.lan"). Never
     * used for routing — DEVICE-OBSERVABILITY-NOT-ROUTING invariant. Loaded
     * lazily from DeviceIdentityService on first connect; falls back to a
     * synthesized id if identity lookup fails.
     */
    private deviceId;
    /**
     * Heartbeat-ack watchdog timer. Armed when a `heartbeat` is sent and cleared
     * when the matching `heartbeat_ack` arrives. If it fires (no ack within
     * {@link BROWSER_PROXY_CONSTANTS.HEARTBEAT_ACK_DEADLINE_MS}) the relay socket
     * is treated as dead/half-open and the proxy reconnects proactively rather
     * than riding the socket until the relay's 180s 4002 eviction. LIVENESS
     * invariant — see the long-term browser-relay fix design.
     */
    private heartbeatAckTimer;
    /**
     * Event bus for browser-instance lifecycle. Subscribers (currently the
     * BrowserRelayAdapter) listen here for `instance_connected` and
     * `instance_disconnected` so they can mirror the proxy's instances Map
     * without re-implementing the relay listener.
     *
     * Why this exists (2026-05-23): the adapter previously listened to
     * CloudSyncService device events to learn about extensions, but Sync
     * only tracks orchestrators — never browser-role devices. As a result
     * BrowserBridge.getStatus() reported `relayDeviceId: null` even when the
     * proxy was actively talking to a paired extension, and agent dispatch
     * via the bridge silently failed. Routing through this emitter lets the
     * adapter learn from the proxy's direct relay channel (the canonical
     * source of truth for browser presence) instead of from Sync.
     *
     * Events:
     *   - `instance_connected({ instanceId, instanceName, sessionId? })`
     *   - `instance_disconnected({ instanceId, instanceName? })`
     *
     * Both events fire from `handleBrowserEvent` and from `handleBrowserList`
     * diff (in case the relay drops a `browser_event` push and only resends
     * the full list).
     */
    readonly events: EventEmitter<[never]>;
    private constructor();
    /**
     * Get the singleton instance.
     *
     * @returns BrowserProxyService instance
     */
    static getInstance(): BrowserProxyService;
    /**
     * Reset singleton instance (for testing).
     */
    static resetInstance(): void;
    /**
     * Register a token resolver function that provides fresh JWTs on reconnect.
     * This avoids reconnect loops caused by stale expired tokens.
     *
     * Typically called once during server startup with:
     *   `proxy.setTokenResolver(() => CloudClientService.getInstance().getToken())`
     *
     * @param resolver - Function returning a fresh JWT or null
     */
    setTokenResolver(resolver: TokenResolver): void;
    /**
     * Build the relay register frame for role 'agent'.
     *
     * Attaches the stable {@link deviceId} for observability (so the relay and
     * UI can show which device a backend is reachable from) and a `pairingCode`
     * derived from the deviceId rather than a throwaway `Date.now()` value, so
     * the same backend presents a consistent identity across re-registers.
     *
     * @returns The register message object to send to the relay
     */
    private buildRegisterFrame;
    /**
     * Lazily resolve and cache the stable device id from DeviceIdentityService.
     * Best-effort: on failure leaves {@link deviceId} null and register frames
     * simply omit the observability tag (backward compatible).
     */
    private ensureDeviceId;
    /**
     * Called externally when the auth token has been refreshed (e.g., by CloudClientService).
     * Updates the stored token so the next heartbeat/reconnect uses the fresh one.
     *
     * Behavior depends on current state:
     * - **connected**: re-registers with the relay using the new token immediately
     *   to prevent the relay from closing the connection due to an expired token.
     * - **disconnected** (with autoReconnect): triggers a reconnect using the fresh
     *   token. This handles the case where handleDisconnect gave up because no
     *   valid token was available at the time of disconnection.
     * - **connecting**: does nothing extra — the ongoing connection attempt will
     *   pick up the new token.
     *
     * @param newToken - Fresh JWT auth token
     */
    updateToken(newToken: string): void;
    /**
     * Connect to the cloud relay WebSocket server.
     *
     * Registers as role 'agent' with a pairing code matching the backend identity.
     * After connection, requests the browser instance list and subscribes to events.
     *
     * @param token - JWT auth token for relay registration
     * @param relayUrl - Optional relay URL override
     */
    /**
     * Append the account-stickiness key (`?rk=<userId>`) to the relay URL.
     *
     * The cloud relay runs as 2+ nodes behind an nginx `hash $arg_rk consistent`
     * upstream. browserRegistry is per-process in-memory (no cross-node sharing),
     * so a backend agent and its account's browser MUST land on the SAME node to
     * pair. We pin by account: derive the userId from the relay JWT's `sub` claim
     * (stable across token refreshes — unlike the rotating token itself) and pass
     * it as `?rk=`. The Chrome extension passes the SAME key, so both pin to one
     * node. If the token can't be decoded, we omit `rk` (nginx hashes empty → all
     * keyless clients co-locate, still consistent). The `ws` lib strips the query
     * before matching path '/relay', so this is transparent to the relay server.
     *
     * @param baseUrl - the relay wss URL (e.g. wss://api.crewlyai.com/relay)
     * @param token - the relay-signed JWT whose `sub` is the account id
     * @returns the URL with `?rk=<sub>` appended when derivable, else baseUrl
     */
    private buildStickyRelayUrl;
    connect(token: string, relayUrl?: string): void;
    /**
     * Disconnect from the cloud relay.
     */
    disconnect(): void;
    /**
     * Check if the proxy is connected to the relay and has browser instances available.
     *
     * @returns True if connected and at least one browser instance is known
     */
    isAvailable(): boolean;
    /**
     * Check if the proxy has a live relay connection (even if no browsers registered yet).
     *
     * @returns True if WebSocket to relay is connected
     */
    isConnected(): boolean;
    /**
     * Get the current connection state.
     *
     * @returns Connection state string
     */
    getState(): ProxyConnectionState;
    /**
     * Get the list of known browser instances.
     *
     * @returns Array of browser instance info
     */
    getInstances(): BrowserInstanceInfo[];
    /**
     * Get this backend's stable device id, when resolved.
     *
     * Observability only — surfaced in /api/browser/status so the UI can show
     * which device a browser is "drivable from". Never a routing input.
     *
     * @returns The device id, or null if not yet resolved
     */
    getDeviceId(): string | null;
    /**
     * Send a browser command to a specific instance (or the only connected one).
     *
     * Routes via relay_to addressed messaging. The command is forwarded by the
     * relay server to the matching browser extension, which executes the tool
     * and returns the response via relay_response.
     *
     * @param tool - Tool name (e.g. 'navigate', 'screenshot', 'getTabs')
     * @param params - Tool parameters
     * @param instance - Target instance name or ID (optional if only 1 connected)
     * @param timeoutMs - Command timeout in milliseconds
     * @param agentName - Agent name for extension banner display
     * @returns Response from the Chrome Extension
     * @throws Error if no instances available, target not found, or timeout
     */
    sendCommand(tool: string, params?: Record<string, unknown>, instance?: string, timeoutMs?: number, agentName?: string, agentSession?: string): Promise<BrowserCommandResponse>;
    /**
     * Resolve which browser instance to target.
     *
     * Auto-select rules when no explicit `instance` is provided:
     *   - 0 instances → null (caller raises "No browser instances connected")
     *   - 1 instance  → that instance
     *   - N>1 instances → the freshest by `lastSeenAt`. This handles two cases:
     *       (a) Multiple Chromes connected (user gets the most-recently-active one).
     *       (b) Ghost entries left behind by a relay that has not yet pruned a
     *           stale session after the extension reconnected with a new sessionId.
     *           The freshest entry is the live one.
     *     Ties on `lastSeenAt` are broken by insertion order (Map iteration).
     *
     * @param instance - Explicit instance name/ID, or undefined for auto-select
     * @returns Matching BrowserInstanceInfo or null
     */
    private resolveInstance;
    /**
     * Handle incoming WebSocket message from the relay.
     *
     * @param raw - Raw JSON string
     */
    private handleMessage;
    /**
     * Handle relay error messages. AUTH_FAILED errors trigger an immediate
     * token refresh attempt before the relay closes the connection.
     *
     * @param msg - Error message from relay
     */
    private handleRelayError;
    /**
     * Handle browser_list message from relay.
     *
     * Behavior:
     * 1. Clear and rebuild the local instance Map from the relay snapshot.
     *    `deviceFingerprint`, when present on incoming entries, is preserved
     *    via the spread so the dedup pass below can use it.
     * 2. Group entries by `deviceFingerprint`. For each group with more than
     *    one entry (relay snapshot contains a ghost row alongside the live
     *    row, e.g. extension was reinstalled and the relay still caches the
     *    previous session), invoke `dedupByFingerprint` with the freshest
     *    entry in the group — the freshest "wins" and older rows are removed.
     *
     * @param instances - Array of browser instance info from relay
     */
    private handleBrowserList;
    /**
     * Handle browser_event (connected/disconnected/updated) from relay.
     *
     * On `connected`, propagates the optional `deviceFingerprint` from the
     * relay payload onto the new entry, then runs `dedupByFingerprint` so
     * an older entry with the same fingerprint (e.g. previous install of
     * the extension on this device) is collapsed in favor of the new one.
     *
     * @param msg - Event message
     */
    private handleBrowserEvent;
    /**
     * Handle a relay payload (response from a browser instance).
     *
     * @param payload - JSON string containing the command response
     */
    private handleRelayPayload;
    /**
     * Send a raw JSON message to the relay WebSocket.
     *
     * @param msg - Message object to serialize and send
     */
    private sendRaw;
    /**
     * Start heartbeat interval to keep relay connection alive.
     *
     * Beyond the basic relay keepalive, every Nth tick (controlled by
     * {@link BROWSER_PROXY_CONSTANTS.LIST_REFRESH_EVERY_N_HEARTBEATS}) we
     * re-issue `list_browsers` so the local `instances` Map is refreshed
     * even if the cloud relay drops `browser_event:updated` pushes. This
     * is a defensive sync against a real bug observed during the 2026-04-30
     * SteamFun customer demo where ext registrations were correctly synced
     * on initial connect but `lastSeenAt` was never refreshed by ongoing
     * heartbeats — leading to the 5-min sweep purging live ext entries and
     * surfacing 503 NO_BROWSER_CLIENT to callers.
     *
     * Resets the tick counter so a re-`start` (e.g. after reconnect) does
     * not accidentally fire the refresh on the very first tick post-reset.
     */
    private startHeartbeat;
    /**
     * Stop heartbeat interval and disarm the ack watchdog.
     */
    private stopHeartbeat;
    /**
     * Arm the heartbeat-ack watchdog if it is not already armed.
     *
     * Crucially, this does NOT reset an already-running watchdog: the deadline
     * is anchored to the FIRST un-acked heartbeat. If it reset on every
     * heartbeat send, a half-open socket (where sends silently succeed into the
     * void) would keep pushing the deadline forward and the watchdog would never
     * fire — re-opening the exact half-open-socket churn this guards against.
     * A received `heartbeat_ack` clears the watchdog, after which the next
     * heartbeat re-arms it fresh.
     */
    private armHeartbeatAckWatchdog;
    /**
     * Clear the heartbeat-ack watchdog timer if armed. Safe to call when no
     * timer is running (null guard).
     */
    private clearHeartbeatAckWatchdog;
    /**
     * Start the wall-clock TTL sweep timer.
     *
     * Independent of the heartbeat. Every
     * {@link BROWSER_PROXY_CONSTANTS.SWEEP_INTERVAL_MS} (default 60s) it walks
     * `this.instances` and deletes any entry whose `lastSeenAt` is older than
     * {@link BROWSER_PROXY_CONSTANTS.STALE_PURGE_THRESHOLD_MS} (default 5 min).
     *
     * Why: the relay is supposed to push a `browser_event:disconnected` when
     * an extension drops, but in practice that event is occasionally lost
     * (network blip, ext crash without graceful close). Without a sweep, the
     * `instances` Map accumulates phantom entries that the Cloud Portal then
     * renders as "Online" even though their `lastSeenAt` is hours stale.
     *
     * Idempotent: calls `stopSweepTimer()` first to avoid stacking intervals
     * on an already-running proxy (e.g. on reconnect).
     */
    private startSweepTimer;
    /**
     * Stop the wall-clock TTL sweep timer. Safe to call when no timer is
     * running (no-op via the null guard).
     */
    private stopSweepTimer;
    /**
     * Collapse older entries in `this.instances` that share a `deviceFingerprint`
     * with the incoming entry.
     *
     * Use case: a user reinstalls / updates the Chrome extension. The fresh
     * install generates a new `instanceId` UUID and registers via the relay.
     * Without dedup, the Cloud Portal would show two rows for the same
     * physical browser — the new live one and the old phantom one — until
     * the TTL sweep eventually purges the old row 5 min later.
     *
     * Backward compatible: if `incoming.deviceFingerprint` is falsy or empty,
     * this is a no-op. Older ext builds that do not yet emit `deviceFingerprint`
     * therefore continue to behave exactly as before — the TTL sweep alone
     * still cleans them up over the 5-min threshold.
     *
     * @param incoming - Entry that was just registered/refreshed
     */
    private dedupByFingerprint;
    /**
     * Handle disconnection — clean up and optionally reconnect with backoff.
     *
     * Protected against double-invocation (e.g. error + close events) via
     * the `disconnecting` guard flag.
     *
     * @param closeCode - WebSocket close code (if available)
     */
    private handleDisconnect;
    /**
     * Full cleanup — close WebSocket, stop timers, clear state.
     */
    private cleanup;
}
//# sourceMappingURL=browser-proxy.service.d.ts.map