/**
 * RelayClient — long-lived WebSocket connection to the Aexol backend's
 * `/agent-connection` endpoint.
 *
 * Responsibilities:
 *  - Open and maintain a single WS to the relay, authenticated with the
 *    machine JWT via `Authorization: Bearer <jwt>`.
 *  - Reply `{kind:"pong"}` to backend `{kind:"ping"}`. Backend closes
 *    `4408 heartbeat-timeout` if it doesn't hear from us within 90s; we
 *    rely on the backend pings rather than emitting our own (single source
 *    of liveness, no jitter on our side).
 *  - **Watchdog timer:** tracks `lastActivityMs` on every received frame /
 *    ping. Every 15 s, if no activity within `WATCHDOG_MS` (120 s = 2×
 *    backend timeout), force-closes the socket (4001 "watchdog-timeout")
 *    and triggers a reconnect. Detects silent backend death (Docker OOM
 *    kill, network partition without TCP RST) where the socket stays open
 *    but no data flows.
 *  - **Pre-reconnect health check:** before opening a new WS, does a quick
 *    HTTP GET to `backendUrl/health` with a 5 s timeout. If the backend is
 *    unhealthy or unreachable, skips the WS attempt and re-schedules. This
 *    avoids a slow TCP connect timeout (up to 75 s on some platforms) when
 *    the backend is down.
 *  - On unexpected close, reconnect forever with exponential backoff +
 *    ±20% jitter, capped at 30s. There is no "give up" state — if the
 *    machine is offline for hours, we just keep trying. Operators can
 *    `Ctrl-C` to stop.
 *  - Buffer outbound frames in a small queue while the socket is closed.
 *    Capped at 100 frames; oldest is dropped on overflow.
 *
 * Events (typed via `RelayClientEvents`):
 *  - `open`            - connected and welcomed
 *  - `welcome`         - backend `{kind:"welcome"}` frame
 *  - `frame`           - any non-pong, non-welcome frame
 *  - `close`           - socket closed (we may reconnect after)
 *  - `error`           - non-fatal error (parse failure, send failure)
 *  - `reconnect-scheduled` - emitted with the delay in ms before each retry
 *
 * Threading model: `node:events` is single-threaded; all callbacks fire on
 * the same event loop turn. Listeners must be cheap.
 */
import { EventEmitter } from "node:events";
import WebSocket from "ws";
/** Maximum outbound queue size while disconnected. */
const SEND_QUEUE_CAP = 100;
/** Backoff schedule (ms). The last value is the cap and is reused indefinitely. */
const RECONNECT_SCHEDULE = [1000, 2000, 5000, 15000, 30000];
/** ±20% jitter on each scheduled delay. */
const JITTER_RATIO = 0.2;
/**
 * Watchdog interval (ms). Every `WATCHDOG_INTERVAL_MS` we check whether
 * the socket has been silent longer than `WATCHDOG_SILENCE_MS`.
 */
const WATCHDOG_INTERVAL_MS = 15_000;
/**
 * Max silence before triggering watchdog reconnect (ms). 2× the backend's
 * 90 s heartbeat timeout = 120 s.
 */
const WATCHDOG_SILENCE_MS = 120_000;
/** HTTP timeout for the pre-reconnect health check. */
const HEALTH_CHECK_TIMEOUT_MS = 5_000;
export class RelayClient extends EventEmitter {
    relayUrl;
    machineJwt;
    backendUrl;
    WS;
    fetchImpl;
    logger;
    exit;
    ws = null;
    disposed = false;
    reconnectAttempt = 0;
    reconnectTimer = null;
    sendQueue = [];
    /** Timestamp of last received frame / ping — drives the watchdog. */
    lastActivityMs = 0;
    watchdogTimer = null;
    constructor(opts) {
        super();
        this.relayUrl = opts.relayUrl;
        this.machineJwt = opts.machineJwt;
        this.backendUrl = opts.backendUrl;
        this.WS = opts.webSocketImpl ?? WebSocket;
        this.fetchImpl = opts.fetchImpl ?? fetch;
        this.logger = opts.logger ?? console;
        this.exit = opts.exit ?? ((code) => process.exit(code));
    }
    /** Open the connection. Idempotent — calling twice is a no-op. */
    connect() {
        if (this.disposed)
            return;
        if (this.ws)
            return;
        this.openSocket();
    }
    /**
     * Send a frame. If the socket is open it goes immediately; otherwise
     * it's queued (capped at 100 — oldest dropped) and flushed on the next
     * successful `open`.
     *
     * Returns `true` if the frame was sent or queued, `false` if it was
     * dropped due to dispose.
     */
    send(frame) {
        if (this.disposed)
            return false;
        const wire = typeof frame === "string" ? frame : JSON.stringify(frame);
        const ws = this.ws;
        if (ws && ws.readyState === WebSocket.OPEN) {
            try {
                ws.send(wire);
                return true;
            }
            catch (err) {
                this.emit("error", err);
                return false;
            }
        }
        // Queue it.
        if (this.sendQueue.length >= SEND_QUEUE_CAP) {
            this.sendQueue.shift();
        }
        this.sendQueue.push(wire);
        return true;
    }
    /**
     * Close the connection and stop reconnecting. After dispose, this client
     * is dead — create a new one to reconnect.
     */
    dispose() {
        if (this.disposed)
            return;
        this.disposed = true;
        if (this.reconnectTimer) {
            clearTimeout(this.reconnectTimer);
            this.reconnectTimer = null;
        }
        if (this.watchdogTimer) {
            clearInterval(this.watchdogTimer);
            this.watchdogTimer = null;
        }
        const ws = this.ws;
        this.ws = null;
        if (ws) {
            try {
                ws.close(1000, "client disposed");
            }
            catch {
                // ignore — best-effort
            }
        }
        this.sendQueue = [];
    }
    // -------------------------------------------------------------------------
    // Internals
    // -------------------------------------------------------------------------
    openSocket() {
        const ws = new this.WS(this.relayUrl, {
            headers: { Authorization: `Bearer ${this.machineJwt}` },
        });
        this.ws = ws;
        // Seed the watchdog timer so a hanging connect handshake also triggers
        // a timeout (no on("open") to bump activity). If the handshake succeeds
        // quickly, `on("open")` resets this.
        this.lastActivityMs = Date.now();
        this.startWatchdog();
        ws.on("open", () => {
            this.lastActivityMs = Date.now();
            this.reconnectAttempt = 0;
            // Flush any queued frames.
            const queued = this.sendQueue;
            this.sendQueue = [];
            for (const wire of queued) {
                try {
                    ws.send(wire);
                }
                catch (err) {
                    // Re-queue on failure and stop draining; the next open will retry.
                    this.sendQueue = queued.slice(queued.indexOf(wire));
                    this.emit("error", err);
                    return;
                }
            }
            this.emit("open");
        });
        ws.on("message", (data) => {
            this.lastActivityMs = Date.now();
            let parsed;
            try {
                parsed = JSON.parse(data.toString());
            }
            catch (err) {
                this.emit("error", err);
                return;
            }
            // Heartbeat: reply pong inline, do NOT propagate.
            if (parsed && parsed.kind === "ping") {
                try {
                    ws.send(JSON.stringify({ kind: "pong" }));
                }
                catch (err) {
                    this.emit("error", err);
                }
                return;
            }
            if (parsed && parsed.kind === "welcome") {
                this.emit("welcome", parsed);
                return;
            }
            this.emit("frame", parsed);
        });
        ws.on("error", (err) => {
            // `ws` emits 'error' before 'close'; we don't reconnect here — the
            // 'close' handler is the single reconnect entry point so we don't
            // double-schedule.
            this.emit("error", err);
        });
        ws.on("close", (code, reason) => {
            this.ws = null;
            this.stopWatchdog();
            const reasonStr = reason?.toString() ?? "";
            this.emit("close", { code, reason: reasonStr });
            if (this.disposed)
                return;
            // Backend evicts older WS sessions when a newer registration arrives
            // for the same machineId (see backend `registry.ts: register()`).
            // If we reconnect here we'll just kick out the new instance, which
            // will then reconnect and kick us out — an infinite ping-pong.
            // Exit instead so the operator (or process supervisor) notices.
            if (code === 1000 && reasonStr === "replaced-by-newer-registration") {
                this.logger.error("\n✗ Another `spectral serve` instance has registered for this machine.");
                this.logger.error("  This instance is exiting to avoid a reconnect loop.");
                this.logger.error("  If you want to run multiple instances, use distinct `--machine-name` values.\n");
                this.dispose();
                this.exit(1);
                return;
            }
            // Machine was revoked by the team admin from the Aexol Studio panel
            // (backend sets `KnownMachine.revokedAt` and closes the socket with
            // 4001 "machine-revoked"). Exit immediately — the machine JWT is now
            // invalid and re-registration requires a fresh `spectral serve`.
            if (code === 4001 && reasonStr === "machine-revoked") {
                this.logger.error("\n✗ This machine has been disconnected from the Aexol Studio panel.");
                this.logger.error("  Run `spectral serve` again to re-register.\n");
                this.dispose();
                this.exit(1);
                return;
            }
            this.scheduleReconnect();
        });
    }
    // --- watchdog -------------------------------------------------------------
    startWatchdog() {
        if (this.watchdogTimer)
            return;
        this.watchdogTimer = setInterval(() => {
            if (this.disposed)
                return;
            const elapsed = Date.now() - this.lastActivityMs;
            if (elapsed > WATCHDOG_SILENCE_MS) {
                this.logger.warn(`Watchdog: no relay activity for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
                const ws = this.ws;
                this.ws = null;
                if (ws) {
                    try {
                        ws.close(4001, "watchdog-timeout");
                    }
                    catch {
                        // ignore
                    }
                }
            }
        }, WATCHDOG_INTERVAL_MS);
    }
    stopWatchdog() {
        if (this.watchdogTimer) {
            clearInterval(this.watchdogTimer);
            this.watchdogTimer = null;
        }
    }
    /**
     * Pre-reconnect health check: quick HTTP GET to `<backendUrl>/health`.
     * Returns true when the backend is reachable (or when no backendUrl is
     * configured, which skips the check entirely). Returns false when the
     * backend is unhealthy/down — the caller should re-schedule.
     */
    async healthCheck() {
        if (!this.backendUrl)
            return true; // no backend → no health check
        const url = `${this.backendUrl.replace(/\/$/, "")}/health`;
        try {
            const ctrl = new AbortController();
            const timeout = setTimeout(() => ctrl.abort(), HEALTH_CHECK_TIMEOUT_MS);
            const res = await this.fetchImpl(url, {
                method: "GET",
                signal: ctrl.signal,
            });
            clearTimeout(timeout);
            if (!res.ok) {
                this.logger.warn(`Health check: ${url} returned ${res.status}, will retry`);
                return false;
            }
            // Backend is reachable — proceed to WS.
            return true;
        }
        catch (err) {
            // AbortError (timeout), fetch error (connection refused, DNS, etc.)
            // all mean the backend is not reachable. Log compactly and retry.
            const msg = err?.name === "AbortError"
                ? "timeout"
                : (err instanceof Error ? err.message : String(err));
            this.logger.warn(`Health check: ${url} unreachable (${msg}), will retry`);
            return false;
        }
    }
    async scheduleReconnect() {
        if (this.disposed)
            return;
        if (this.reconnectTimer)
            return;
        // Pre-reconnect health check — avoids wasting time on a slow TCP
        // connect when the backend is down. The health endpoint is a cheap
        // HTTP GET; if even that fails, skip the WS attempt entirely.
        const healthy = await this.healthCheck();
        if (!healthy) {
            // Backend unreachable — use a fixed 5 s retry instead of advancing
            // the backoff schedule (this is an environment problem, not a WS
            // handshake problem).
            const idx = Math.min(this.reconnectAttempt, RECONNECT_SCHEDULE.length - 1);
            const delay = RECONNECT_SCHEDULE[idx];
            this.reconnectAttempt++;
            this.emit("reconnect-scheduled", { delayMs: delay, attempt: this.reconnectAttempt });
            this.reconnectTimer = setTimeout(() => {
                this.reconnectTimer = null;
                void this.scheduleReconnect().catch(() => { });
            }, delay);
            return;
        }
        const idx = Math.min(this.reconnectAttempt, RECONNECT_SCHEDULE.length - 1);
        const base = RECONNECT_SCHEDULE[idx];
        const jitter = base * JITTER_RATIO * (Math.random() * 2 - 1);
        const delay = Math.max(0, Math.round(base + jitter));
        this.reconnectAttempt++;
        this.emit("reconnect-scheduled", { delayMs: delay, attempt: this.reconnectAttempt });
        // IMPORTANT: do NOT `unref()` this timer. `spectral serve` is a long-
        // lived daemon and the reconnect timer is frequently the ONLY thing
        // keeping the event loop alive between a WS close and the next open
        // (the SessionStreamManager holds nothing while idle). An unref'd
        // timer lets Node decide the loop is empty and exit the process,
        // which manifests as the daemon silently dying after a network blip.
        // Tests dispose the client explicitly via `dispose()`, which clears
        // this timer, so they still terminate cleanly.
        this.reconnectTimer = setTimeout(() => {
            this.reconnectTimer = null;
            if (this.disposed)
                return;
            try {
                this.openSocket();
            }
            catch (err) {
                // A synchronous throw from the WebSocket constructor (DNS, bad
                // URL, etc.) must not kill the daemon — log it and re-schedule
                // so the backoff continues forever.
                this.emit("error", err);
                void this.scheduleReconnect().catch(() => { });
            }
        }, delay);
    }
}
