import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import WebSocketImpl from "ws";
import { CenterWorkerWsMessage, WorkerBootstrapResponseSchema, } from "dp-shared";
import { bootstrapResponseToConfig, } from "../config/center-config.js";
export class DuplicateWorkerConnectionError extends Error {
    code = "worker_already_connected";
    status;
    constructor(status, body) {
        super(`center rejected worker connection: ${status} worker_already_connected${body ? ` — ${body.slice(0, 500)}` : ""}`);
        this.name = "DuplicateWorkerConnectionError";
        this.status = status;
    }
}
const DEFAULT_ACK_TIMEOUT = 30_000;
const DEFAULT_RECONNECT_MIN = 1_000;
const DEFAULT_RECONNECT_MAX = 60_000;
export class CenterClient extends EventEmitter {
    centerUrl;
    workerKey;
    instanceId;
    workspace;
    headless;
    fetchImpl;
    wsFactory;
    logger;
    ackTimeoutMs;
    reconnectMinMs;
    reconnectMaxMs;
    ws = null;
    pendingAcks = new Map();
    reconnectAttempt = 0;
    reconnectTimer = null;
    shuttingDown = false;
    /** Most recent bootstrap result — used by center-frontend for config_etag comparisons. */
    lastBootstrap = null;
    constructor(opts, deps = {}) {
        super();
        this.centerUrl = opts.centerUrl.replace(/\/$/, "");
        this.workerKey = opts.workerKey;
        this.instanceId = randomUUID();
        this.workspace = opts.workspace;
        this.headless = opts.headless;
        this.fetchImpl = deps.fetchImpl ?? globalThis.fetch;
        this.wsFactory =
            deps.wsFactory ??
                ((url) => new WebSocketImpl(url));
        this.logger = deps.logger ?? {
            info: (m) => console.log(m),
            warn: (m) => console.warn(m),
            error: (m) => console.error(m),
        };
        this.ackTimeoutMs = opts.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT;
        this.reconnectMinMs = opts.reconnectMinMs ?? DEFAULT_RECONNECT_MIN;
        this.reconnectMaxMs = opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX;
    }
    /** REST: pull the operational config from center. Throws on auth or schema failure. */
    async bootstrap() {
        const url = this.httpUrl("/api/worker/bootstrap");
        const res = await this.fetchImpl(url, {
            method: "POST",
            headers: {
                authorization: `Bearer ${this.workerKey}`,
                accept: "application/json",
            },
        });
        if (!res.ok) {
            const text = await res.text().catch(() => "");
            throw new Error(`center bootstrap failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 500)}` : ""}`);
        }
        const json = (await res.json());
        const response = WorkerBootstrapResponseSchema.parse(json);
        const parsed = bootstrapResponseToConfig(response, {
            workspace: this.workspace,
            headless: this.headless,
        });
        this.lastBootstrap = parsed;
        return parsed;
    }
    /** Returns the most recent bootstrap result, or null if bootstrap() hasn't been called yet. */
    getLastBootstrap() {
        return this.lastBootstrap;
    }
    /** Returns the most recent bootstrap's center-only extras (etag, social accounts, session blob). */
    getExtras() {
        return this.lastBootstrap?.extras ?? null;
    }
    /** Open the WS to the WorkerConnection DO. Fire-and-forget; events emit on the EventEmitter. */
    connect() {
        this.shuttingDown = false;
        this.openSocket();
    }
    /** Open the WS and resolve only after the center accepts the connection lease. */
    async connectAndWait(timeoutMs = 30_000) {
        return new Promise((resolve, reject) => {
            let settled = false;
            let timer;
            const finish = (err) => {
                if (settled)
                    return;
                settled = true;
                clearTimeout(timer);
                this.off("open", onOpen);
                this.off("error", onError);
                this.off("close", onClose);
                if (err)
                    reject(err);
                else
                    resolve();
            };
            const onOpen = () => finish();
            const onError = (err) => finish(err);
            const onClose = (event) => finish(new Error(`CenterClient socket closed before open code=${event.code} reason=${event.reason}`));
            timer = setTimeout(() => finish(new Error("CenterClient connect timeout")), timeoutMs);
            this.once("open", onOpen);
            this.once("error", onError);
            this.once("close", onClose);
            this.connect();
        });
    }
    openSocket() {
        if (this.shuttingDown)
            return;
        const url = this.wsUrl("/ws/worker", {
            token: this.workerKey,
            instance_id: this.instanceId,
        });
        this.logger.info(`CenterClient connecting to ${this.centerUrl} (attempt #${this.reconnectAttempt + 1})`);
        let ws;
        try {
            ws = this.wsFactory(url);
        }
        catch (err) {
            this.emitError(err);
            this.scheduleReconnect();
            return;
        }
        this.ws = ws;
        this.installUnexpectedResponseHandler(ws);
        ws.addEventListener("open", () => {
            this.reconnectAttempt = 0;
            this.logger.info(`CenterClient socket open`);
            this.emit("open");
        });
        ws.addEventListener("message", (event) => {
            // `ws` package fires events with .data; the browser/CF native API uses
            // MessageEvent. Both have a `data` field with the payload as string.
            const data = event.data;
            this.handleIncoming(typeof data === "string" ? data : String(data));
        });
        ws.addEventListener("close", (event) => {
            const e = event;
            this.logger.warn(`CenterClient socket closed code=${e.code ?? "?"} reason=${e.reason ?? ""}`);
            this.ws = null;
            this.failAllPendingAcks(new Error("socket closed before ack"));
            this.emit("close", {
                code: e.code ?? 0,
                reason: e.reason ?? "",
                wasClean: e.wasClean ?? false,
            });
            this.scheduleReconnect();
        });
        ws.addEventListener("error", (event) => {
            const err = event instanceof Error
                ? event
                : new Error(event.message ?? "WebSocket error");
            this.emitError(err);
        });
    }
    scheduleReconnect() {
        if (this.shuttingDown)
            return;
        const delay = Math.min(this.reconnectMaxMs, this.reconnectMinMs * Math.pow(2, this.reconnectAttempt));
        this.reconnectAttempt += 1;
        this.logger.info(`CenterClient reconnecting in ${delay}ms`);
        this.reconnectTimer = setTimeout(() => {
            this.reconnectTimer = null;
            this.openSocket();
        }, delay);
    }
    installUnexpectedResponseHandler(ws) {
        const maybeNodeWs = ws;
        maybeNodeWs.on?.("unexpected-response", (_req, res) => {
            const status = res.statusCode ?? 0;
            const chunks = [];
            res.on?.("data", (chunk) => {
                if (typeof chunk === "string")
                    chunks.push(Buffer.from(chunk));
                else if (chunk instanceof Uint8Array)
                    chunks.push(Buffer.from(chunk));
            });
            res.on?.("end", () => {
                const body = Buffer.concat(chunks).toString("utf-8");
                if (status === 409 && body.includes("worker_already_connected")) {
                    this.shuttingDown = true;
                    this.emitError(new DuplicateWorkerConnectionError(status, body));
                    return;
                }
                this.emitError(new Error(`center websocket upgrade rejected: ${status}${body ? ` — ${body.slice(0, 500)}` : ""}`));
            });
        });
    }
    handleIncoming(text) {
        let parsed;
        try {
            parsed = JSON.parse(text);
        }
        catch (err) {
            this.emitError(new Error(`Malformed JSON from center: ${err.message}`));
            return;
        }
        const result = CenterWorkerWsMessage.safeParse(parsed);
        if (!result.success) {
            this.emitError(new Error(`Unknown message shape from center: ${result.error.message}`));
            return;
        }
        const msg = result.data;
        if (msg.type === "ack") {
            // See note in center-frontend.ts: zod's inferred discriminated union
            // doesn't co-narrow payload with type. Runtime-validated above.
            const p = msg.payload;
            this.handleAck(p.ref_id, p.error);
            return;
        }
        this.emit("message", msg);
    }
    handleAck(refId, errMsg) {
        const pending = this.pendingAcks.get(refId);
        if (!pending)
            return;
        clearTimeout(pending.timer);
        this.pendingAcks.delete(refId);
        if (errMsg)
            pending.reject(new Error(`center nack: ${errMsg}`));
        else
            pending.resolve();
    }
    failAllPendingAcks(err) {
        for (const [, pending] of this.pendingAcks) {
            clearTimeout(pending.timer);
            pending.reject(err);
        }
        this.pendingAcks.clear();
    }
    /**
     * Send an envelope. The id is generated if missing. When `expectAck=true`,
     * returns a promise that resolves on the matching ack or rejects after
     * `ackTimeoutMs` with a timeout error.
     */
    async send(msg, expectAck = false) {
        const ws = this.ws;
        if (!ws)
            throw new Error("CenterClient socket not open");
        const full = {
            id: msg.id ?? randomUUID(),
            ts: msg.ts ?? Date.now(),
            ...msg,
        };
        const payload = JSON.stringify(full);
        if (!expectAck) {
            ws.send(payload);
            return;
        }
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pendingAcks.delete(full.id);
                reject(new Error(`Ack timeout for ${full.type} (id=${full.id})`));
            }, this.ackTimeoutMs);
            this.pendingAcks.set(full.id, { resolve, reject, timer });
            try {
                ws.send(payload);
            }
            catch (err) {
                clearTimeout(timer);
                this.pendingAcks.delete(full.id);
                reject(err instanceof Error ? err : new Error(String(err)));
            }
        });
    }
    async close() {
        this.shuttingDown = true;
        if (this.reconnectTimer) {
            clearTimeout(this.reconnectTimer);
            this.reconnectTimer = null;
        }
        this.failAllPendingAcks(new Error("CenterClient closing"));
        if (this.ws) {
            try {
                this.ws.close(1000, "client shutdown");
            }
            catch {
                // Best effort.
            }
            this.ws = null;
        }
    }
    emitError(err) {
        const wrapped = err instanceof Error ? err : new Error(String(err));
        this.logger.error(`CenterClient error: ${wrapped.message}`);
        this.emit("error", wrapped);
    }
    httpUrl(path) {
        const base = this.centerUrl
            .replace(/^wss:\/\//, "https://")
            .replace(/^ws:\/\//, "http://");
        return base + path;
    }
    wsUrl(path, query) {
        const base = this.centerUrl
            .replace(/^https:\/\//, "wss://")
            .replace(/^http:\/\//, "ws://");
        const qs = new URLSearchParams(query).toString();
        return `${base}${path}${qs ? "?" + qs : ""}`;
    }
}
//# sourceMappingURL=center-client.js.map