"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HubWsClient = void 0;
const ws_1 = __importDefault(require("ws"));
const events_1 = require("events");
const log_1 = require("./log");
// Long-lived WebSocket to appengine. Reconnects with exponential backoff on
// disconnect (5s → 60s ceiling). Sends `hello` on connect, `heartbeat`
// every config.heartbeatMs. Surfaces inbound `command` messages via the
// `command` event so the dispatcher (in index.ts) can handle them and
// post the response back via this.send().
const VERSION = '0.1.0';
class HubWsClient extends events_1.EventEmitter {
    constructor(config) {
        super();
        this.ws = null;
        this.reconnectTimer = null;
        this.heartbeatTimer = null;
        this.stopped = false;
        // Bounded buffer for `event` messages that the agent tried to send while
        // the WS was down (server restart, network blip, etc.). Replayed on the
        // next successful `welcome`. Heartbeats / command-responses are NOT
        // buffered — they're tied to live connections and stale on replay.
        this.eventBuffer = [];
        this.config = config;
        this.reconnectDelay = config.reconnectMs ?? 5000;
    }
    setEndpointStatusProvider(fn) {
        this.endpointStatusProvider = fn;
    }
    sendHeartbeat() {
        let endpointStatus;
        try {
            endpointStatus = this.endpointStatusProvider?.();
        }
        catch {
            endpointStatus = undefined;
        }
        this.send({ type: 'heartbeat', ts: new Date().toISOString(), ...(endpointStatus ? { endpointStatus } : {}) });
    }
    start() {
        this.connect();
    }
    stop() {
        this.stopped = true;
        if (this.reconnectTimer)
            clearTimeout(this.reconnectTimer);
        if (this.heartbeatTimer)
            clearInterval(this.heartbeatTimer);
        this.ws?.close();
    }
    send(msg) {
        if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
            // Buffer scan/event messages so they survive a brief disconnect;
            // everything else (heartbeats, command responses) is dropped because
            // it's only meaningful to the connection that was alive at send time.
            if (msg.type === 'event') {
                if (this.eventBuffer.length >= HubWsClient.EVENT_BUFFER_MAX) {
                    this.eventBuffer.shift();
                }
                this.eventBuffer.push(msg);
                log_1.log.warn('hub: event buffered (offline)', {
                    kind: msg.kind,
                    buffered: this.eventBuffer.length,
                });
            }
            else {
                log_1.log.warn('hub: send dropped (not connected)', { type: msg.type });
            }
            return false;
        }
        try {
            this.ws.send(JSON.stringify(msg));
            return true;
        }
        catch (e) {
            log_1.log.error('hub: send failed', { msg: e.message });
            return false;
        }
    }
    emitEvent(kind, endpointName, data) {
        const ev = { type: 'event', kind, endpointName, data };
        this.send(ev);
    }
    flushEventBuffer() {
        if (this.eventBuffer.length === 0)
            return;
        if (!this.ws || this.ws.readyState !== ws_1.default.OPEN)
            return;
        const pending = this.eventBuffer;
        this.eventBuffer = [];
        log_1.log.info('hub: replaying buffered events', { count: pending.length });
        for (const ev of pending) {
            try {
                this.ws.send(JSON.stringify(ev));
            }
            catch (e) {
                log_1.log.error('hub: replay failed, requeuing remainder', { msg: e.message });
                // Push the failed one back plus anything we hadn't reached yet.
                const idx = pending.indexOf(ev);
                this.eventBuffer.unshift(...pending.slice(idx));
                return;
            }
        }
    }
    connect() {
        if (this.stopped)
            return;
        const url = this.toWsUrl(this.config.serverUrl);
        log_1.log.info('hub: connecting', { url });
        try {
            this.ws = new ws_1.default(url, {
                headers: {
                    'x-hub-name': this.config.hubName,
                    'x-hub-key': this.config.apiKey,
                    'x-orgid': this.config.orgId,
                },
            });
        }
        catch (e) {
            log_1.log.error('hub: ws constructor failed', { msg: e.message });
            this.scheduleReconnect();
            return;
        }
        this.ws.on('open', () => {
            log_1.log.info('hub: connected');
            this.reconnectDelay = this.config.reconnectMs ?? 5000; // reset backoff
            this.send({
                type: 'hello',
                hubName: this.config.hubName,
                apiKey: this.config.apiKey,
                version: VERSION,
                endpoints: this.config.endpoints,
            });
            this.startHeartbeat();
        });
        this.ws.on('message', (raw) => {
            let msg = null;
            try {
                msg = JSON.parse(raw.toString());
            }
            catch (e) {
                log_1.log.warn('hub: invalid frame', { msg: e.message });
                return;
            }
            if (!msg || !('type' in msg))
                return;
            this.handleMessage(msg);
        });
        this.ws.on('close', (code, reason) => {
            log_1.log.warn('hub: disconnected', { code, reason: reason.toString() });
            this.stopHeartbeat();
            this.scheduleReconnect();
        });
        this.ws.on('error', (e) => {
            log_1.log.error('hub: ws error', { msg: e.message });
            // close handler will fire next; reconnect is scheduled there.
        });
    }
    handleMessage(msg) {
        switch (msg.type) {
            case 'welcome':
                if (!msg.ok) {
                    log_1.log.error('hub: server rejected hello', { error: msg.error });
                }
                else {
                    log_1.log.info('hub: welcomed', { hubId: msg.hubId });
                    this.flushEventBuffer();
                    this.emit('welcomed', msg);
                }
                break;
            case 'command':
                this.emit('command', msg);
                break;
            case 'ping':
                this.sendHeartbeat();
                break;
            case 'update-config':
                // Server is pushing a new endpoints list. Hand off to the main loop
                // (index.ts) which owns the dispatcher context + scanner readers
                // and knows how to hot-swap them. Agent ack flows back via
                // emitConfigUpdated() once the host has applied the change.
                this.emit('update-config', msg);
                break;
        }
    }
    startHeartbeat() {
        this.stopHeartbeat();
        // Immediate first beat so the server gets real endpoint status within
        // a second of connecting, not after the first 30s interval.
        this.sendHeartbeat();
        this.heartbeatTimer = setInterval(() => {
            this.sendHeartbeat();
        }, this.config.heartbeatMs ?? 30000);
    }
    stopHeartbeat() {
        if (this.heartbeatTimer) {
            clearInterval(this.heartbeatTimer);
            this.heartbeatTimer = null;
        }
    }
    scheduleReconnect() {
        if (this.stopped)
            return;
        if (this.reconnectTimer)
            return;
        const delay = this.reconnectDelay;
        log_1.log.info('hub: reconnecting in ' + delay + 'ms');
        this.reconnectTimer = setTimeout(() => {
            this.reconnectTimer = null;
            // Exponential backoff up to 60s.
            this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
            this.connect();
        }, delay);
    }
    toWsUrl(serverUrl) {
        const trimmed = serverUrl.replace(/\/+$/, '');
        if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
            return trimmed.endsWith('/ws/hub') ? trimmed : `${trimmed}/ws/hub`;
        }
        if (trimmed.startsWith('http://'))
            return `ws://${trimmed.slice(7)}/ws/hub`;
        if (trimmed.startsWith('https://'))
            return `wss://${trimmed.slice(8)}/ws/hub`;
        return `wss://${trimmed}/ws/hub`;
    }
}
exports.HubWsClient = HubWsClient;
HubWsClient.EVENT_BUFFER_MAX = 200;
//# sourceMappingURL=ws-client.js.map