import { io } from "socket.io-client";
import { getErrorMessage } from "../../hub/utils.js";
const HANDSHAKE_STAGE_TIMEOUT_MS = 20_000;
function toUint8Array(value) {
    if (value instanceof Uint8Array) {
        return value;
    }
    return new Uint8Array(value);
}
export class SocketPeerClient {
    options;
    socket;
    manualDisconnect = false;
    /** Deduplicate concurrent REST image fetches per `imageId` until the response returns. */
    inFlightImageGetIds = new Set();
    /** Ids to fetch again after the current in-flight REST request returns. */
    deferredImageGetIds = new Set();
    failedImageGetIds = new Set();
    pendingCrdtSyncMessages = [];
    scheduledCrdtSyncDrain;
    initialCrdtSynced = false;
    initialCrdtSyncWaiters = new Set();
    constructor(options) {
        this.options = options;
    }
    async connect() {
        if (this.socket?.connected) {
            return;
        }
        this.options.uiState.setConnectionStatus({
            state: "connecting",
            message: `Connecting to hub ${this.options.hubUrl}...`,
        });
        const peerVersion = this.options.hello.version ?? "unknown";
        this.logHandshake(`peer v${peerVersion} (protocol v${this.options.hello.protocolVersion}) connecting to hub ${this.options.hubUrl}`);
        this.manualDisconnect = false;
        const handshakeStageTimeoutMs = this.options.handshakeStageTimeoutMs ?? HANDSHAKE_STAGE_TIMEOUT_MS;
        const socket = io(this.options.hubUrl, {
            transports: ["websocket", "polling"],
            tryAllTransports: true,
            timeout: handshakeStageTimeoutMs + 1000,
            reconnection: this.options.reconnection ?? true,
            reconnectionDelay: this.options.reconnectDelayMs ?? 5000,
            reconnectionDelayMax: this.options.reconnectDelayMs ?? 5000,
        });
        this.socket = socket;
        await new Promise((resolve, reject) => {
            let timeoutId = setTimeout(() => {
                if (settled) {
                    return;
                }
                socket.disconnect();
                this.logHandshake(`timed out after ${handshakeStageTimeoutMs}ms waiting for hub welcome`);
                this.options.uiState.setConnectionStatus({
                    state: "error",
                    message: `Timed out connecting to hub ${this.options.hubUrl}`,
                });
                settled = true;
                reject(new Error(`Timed out connecting to hub ${this.options.hubUrl}`));
            }, handshakeStageTimeoutMs);
            let resolved = false;
            let settled = false;
            let helloAckComplete = false;
            let welcomeComplete = false;
            const fail = (message, error = new Error(message)) => {
                if (settled) {
                    return;
                }
                settled = true;
                clearTimeout(timeoutId);
                socket.disconnect();
                this.options.uiState.setConnectionStatus({ state: "error", message });
                reject(error);
            };
            const maybeResolve = () => {
                if (settled || resolved || !helloAckComplete || !welcomeComplete) {
                    return;
                }
                resolved = true;
                settled = true;
                clearTimeout(timeoutId);
                this.options.uiState.setConnectionStatus({
                    state: "connecting",
                    message: "Connected to hub. Uploading peer config...",
                });
                this.logHandshake("hub welcome received; connection/auth stage complete");
                resolve();
            };
            this.installEventHandlers(socket);
            socket.once("hub:welcome", () => {
                welcomeComplete = true;
                this.logHandshake("hub:welcome received");
                maybeResolve();
            });
            socket.on("connect", () => {
                // Fires for each successful transport connection, including auto-reconnect after a drop.
                this.clearImageGetTransportState();
                this.logHandshake("socket connected; sending peer:hello");
                setTimeout(() => {
                    if (settled || !socket.connected) {
                        return;
                    }
                    clearTimeout(timeoutId);
                    this.logHandshake(`waiting up to ${handshakeStageTimeoutMs}ms for peer:hello ack`);
                    const helloTimeoutId = setTimeout(() => {
                        const message = `Timed out waiting for peer:hello ack from hub ${this.options.hubUrl}`;
                        this.logHandshake(`peer:hello ack timed out after ${handshakeStageTimeoutMs}ms`);
                        fail(message, new Error(message));
                    }, handshakeStageTimeoutMs);
                    socket.emit("peer:hello", this.options.hello, (ack) => {
                        clearTimeout(helloTimeoutId);
                        if (!ack) {
                            const message = "Hub did not return a peer:hello acknowledgement.";
                            this.logHandshake(message);
                            fail(message);
                            return;
                        }
                        if (!ack.ok) {
                            this.logHandshake(`peer:hello rejected: ${ack.error}`);
                            fail(ack.error, new Error(ack.error));
                            return;
                        }
                        helloAckComplete = true;
                        this.logHandshake("peer:hello ack received");
                        if (!welcomeComplete) {
                            timeoutId = setTimeout(() => {
                                if (settled) {
                                    return;
                                }
                                socket.disconnect();
                                this.logHandshake(`timed out after ${handshakeStageTimeoutMs}ms waiting for hub welcome`);
                                this.options.uiState.setConnectionStatus({
                                    state: "error",
                                    message: `Timed out connecting to hub ${this.options.hubUrl}`,
                                });
                                settled = true;
                                reject(new Error(`Timed out connecting to hub ${this.options.hubUrl}`));
                            }, handshakeStageTimeoutMs);
                        }
                        maybeResolve();
                    });
                }, 0);
            });
            socket.on("connect_error", (error) => {
                clearTimeout(timeoutId);
                if (resolved) {
                    this.logHandshake(`socket reconnecting after connection error: ${error.message}`);
                    this.options.uiState.setConnectionStatus({
                        state: "reconnecting",
                        message: `Connection lost (${error.message}). Socket.IO reconnecting.`,
                    });
                    return;
                }
                socket.disconnect();
                this.logHandshake(`socket connection error: ${error.message}`);
                this.options.uiState.setConnectionStatus({ state: "error", message: error.message });
                reject(error);
            });
        });
    }
    async uploadConfig(config) {
        const socket = this.requireSocket();
        this.options.uiState.setConnectionStatus({
            state: "connecting",
            message: "Uploading peer config...",
        });
        this.logHandshake("uploading peer config");
        await new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                this.logHandshake(`timed out after ${HANDSHAKE_STAGE_TIMEOUT_MS}ms uploading peer config`);
                reject(new Error(`Timed out uploading peer config to hub ${this.options.hubUrl}`));
            }, HANDSHAKE_STAGE_TIMEOUT_MS);
            socket.emit("peer:config", config, (ack) => {
                clearTimeout(timeoutId);
                if (!ack.ok) {
                    this.logHandshake(`peer:config rejected: ${ack.error}`);
                    this.options.uiState.setConnectionStatus({ state: "error", message: ack.error });
                    reject(new Error(ack.error));
                    return;
                }
                this.options.uiState.setConnectionStatus({
                    state: "connecting",
                    message: "Peer config uploaded. Waiting for session sync...",
                });
                this.logHandshake("peer:config ack received; waiting for initial session sync");
                resolve();
            });
        });
    }
    async waitForInitialSync() {
        if (this.initialCrdtSynced) {
            return;
        }
        await new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                this.initialCrdtSyncWaiters.delete(done);
                this.logHandshake(`timed out after ${HANDSHAKE_STAGE_TIMEOUT_MS}ms waiting for initial session sync`);
                reject(new Error(`Timed out waiting for session sync from hub ${this.options.hubUrl}`));
            }, HANDSHAKE_STAGE_TIMEOUT_MS);
            const done = () => {
                clearTimeout(timeoutId);
                this.logHandshake("initial session sync received; peer handshake complete");
                resolve();
            };
            this.initialCrdtSyncWaiters.add(done);
        });
    }
    async disconnect() {
        this.manualDisconnect = true;
        const socket = this.socket;
        socket?.removeAllListeners();
        socket?.io.removeAllListeners();
        socket?.disconnect();
        socket?.close();
        this.socket = undefined;
        this.initialCrdtSynced = false;
        this.clearPendingCrdtSyncMessages();
        this.clearImageGetTransportState();
        this.options.uiState.setConnectionStatus({
            state: "disconnected",
            message: "Disconnected from hub.",
        });
    }
    retryConnectionNow() {
        if (!this.socket || this.socket.connected) {
            return;
        }
        this.options.uiState.setConnectionStatus({
            state: "reconnecting",
            message: "Retrying connection to hub now...",
        });
        this.socket.connect();
    }
    async queueWrite(text, images, streamingBehavior) {
        await this.emitAcked("session:queue_write", {
            text,
            sentAt: new Date().toISOString(),
            images,
            streamingBehavior,
        });
    }
    async queueFlush() {
        await this.emitAcked("session:queue_flush", {});
    }
    async sendSourceMessage(payload) {
        await this.emitAcked("source:message", payload);
    }
    async abort() {
        this.options.uiState.setCancelling(true);
        try {
            const wasRunning = await new Promise((resolve, reject) => {
                const socket = this.requireSocket();
                socket.emit("session:abort", {}, (ack) => {
                    if (ack.ok) {
                        resolve(ack.wasRunning);
                        return;
                    }
                    reject(new Error(ack.error));
                });
            });
            if (!wasRunning) {
                this.options.uiState.setCancelling(false);
            }
            return wasRunning;
        }
        catch (error) {
            this.options.uiState.setCancelling(false);
            throw error;
        }
    }
    async setModel(modelResourceId) {
        await this.emitAcked("session:set_model", { modelResourceId });
    }
    async setThinkingLevel(level) {
        await this.emitAcked("session:set_thinking_level", { level });
    }
    async invokeCommand(commandName, args) {
        await this.emitAcked("session:invoke_command", { commandName, args });
    }
    async getSessionSources() {
        const socket = this.requireSocket();
        return new Promise((resolve, reject) => {
            socket.emit("session:get_sources", {}, (ack) => {
                if (ack.ok) {
                    resolve(ack.sources);
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    async pauseSource(resourceId) {
        return this.emitMutateSource("session:pause_source", { resourceId });
    }
    async restartSource(resourceId) {
        return this.emitMutateSource("session:restart_source", { resourceId });
    }
    async removeSource(resourceId) {
        return this.emitMutateSource("session:remove_source", { resourceId });
    }
    async getMcpServers() {
        const socket = this.requireSocket();
        return new Promise((resolve, reject) => {
            socket.emit("session:get_mcp_servers", {}, (ack) => {
                if (ack.ok) {
                    if (ack.configError !== undefined) {
                        resolve({ servers: ack.servers, configError: ack.configError });
                    }
                    else {
                        resolve({ servers: ack.servers });
                    }
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    async getSkills() {
        const socket = this.requireSocket();
        return new Promise((resolve, reject) => {
            socket.emit("session:get_skills", {}, (ack) => {
                if (ack.ok) {
                    resolve(ack);
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    async pauseMcpServer(resourceId) {
        return this.emitMutateMcpServer("session:pause_mcp_server", { resourceId });
    }
    async restartMcpServer(resourceId) {
        return this.emitMutateMcpServer("session:restart_mcp_server", { resourceId });
    }
    async removeMcpServer(resourceId) {
        return this.emitMutateMcpServer("session:remove_mcp_server", { resourceId });
    }
    installEventHandlers(socket) {
        socket.on("hub:welcome", (payload) => {
            if (payload.protocolVersion !== this.options.hello.protocolVersion) {
                this.options.uiState.setConnectionStatus({
                    state: "error",
                    message: `Protocol version mismatch: peer=${this.options.hello.protocolVersion}, hub=${payload.protocolVersion}`,
                });
                socket.disconnect();
                return;
            }
            this.clearPendingCrdtSyncMessages();
            this.options.appState.applyWelcome(payload);
            this.options.uiState.setCrdtResyncing(false);
        });
        socket.on("session:crdt_sync", (payload) => {
            const message = toUint8Array(payload.message);
            this.enqueueCrdtSyncMessage(socket, message, payload.format);
        });
        socket.on("guest:agent_message", async (payload) => {
            await this.options.onGuestAgentMessage?.(payload);
        });
        socket.on("disconnect", (reason) => {
            this.clearPendingCrdtSyncMessages();
            this.clearImageGetTransportState();
            if (reason === "io client disconnect" || this.manualDisconnect) {
                this.options.uiState.setConnectionStatus({
                    state: "disconnected",
                    message: "Disconnected from hub.",
                });
                return;
            }
            this.options.uiState.setConnectionStatus({
                state: "reconnecting",
                message: `Connection lost (${reason}). Socket.IO reconnecting.`,
            });
        });
        socket.io.on("reconnect_attempt", (attempt) => {
            if (this.manualDisconnect) {
                return;
            }
            this.options.uiState.setConnectionStatus({
                state: "reconnecting",
                message: `Socket.IO reconnecting (attempt ${attempt})...`,
            });
        });
        socket.io.on("reconnect_error", (error) => {
            if (this.manualDisconnect) {
                return;
            }
            this.options.uiState.setConnectionStatus({
                state: "reconnecting",
                message: `Socket.IO reconnecting after error: ${error.message}`,
            });
        });
        socket.io.on("reconnect_failed", () => {
            if (this.manualDisconnect) {
                return;
            }
            this.options.uiState.setConnectionStatus({
                state: "error",
                message: "Socket.IO reconnect failed.",
            });
        });
        socket.on("tool:call_request", async (payload) => {
            try {
                if (this.options.onToolCallRequest) {
                    await this.options.onToolCallRequest(payload, socket);
                    return;
                }
                socket.emit("tool:call_error", {
                    toolCallId: payload.toolCallId,
                    message: `Peer tool execution is not implemented for tool "${payload.toolName}" yet.`,
                });
            }
            catch (error) {
                socket.emit("tool:call_error", {
                    toolCallId: payload.toolCallId,
                    message: getErrorMessage(error),
                });
            }
        });
    }
    enqueueCrdtSyncMessage(socket, message, format) {
        this.pendingCrdtSyncMessages.push({ message, format });
        this.scheduleCrdtSyncDrain(socket);
    }
    scheduleCrdtSyncDrain(socket) {
        if (this.scheduledCrdtSyncDrain) {
            return;
        }
        const drain = () => {
            this.scheduledCrdtSyncDrain = undefined;
            this.drainAllCrdtSyncMessages(socket);
        };
        const handle = setImmediate(drain);
        handle.unref?.();
        this.scheduledCrdtSyncDrain = { handle };
    }
    drainAllCrdtSyncMessages(socket) {
        if (this.socket !== socket || !socket.connected) {
            this.clearPendingCrdtSyncMessages();
            return;
        }
        let lastMissingImageIds = [];
        while (this.pendingCrdtSyncMessages.length > 0) {
            const item = this.pendingCrdtSyncMessages.shift();
            try {
                const result = this.options.appState.applyCrdtSyncMessage(item.message, item.format);
                lastMissingImageIds = result.missingImageIds;
            }
            catch {
                this.clearPendingCrdtSyncMessages();
                this.options.appState.resetCrdtSyncState();
                this.options.uiState.setCrdtResyncing(true);
                socket.emit("session:crdt_resync_request");
                return;
            }
        }
        this.options.uiState.setCrdtResyncing(false);
        if (this.options.appState.getSnapshot().selectedAgent?.status.isRunning === false) {
            this.options.uiState.setCancelling(false);
        }
        if (this.options.uiState.getSnapshot().connectionState !== "connected") {
            this.options.uiState.setConnectionStatus({
                state: "connected",
                message: "Connected to hub and synchronized the latest session state.",
            });
        }
        this.requestMissingImages(socket, lastMissingImageIds);
        this.resolveInitialCrdtSyncWaiters();
    }
    resolveInitialCrdtSyncWaiters() {
        this.initialCrdtSynced = true;
        for (const resolve of this.initialCrdtSyncWaiters) {
            resolve();
        }
        this.initialCrdtSyncWaiters.clear();
    }
    logHandshake(message) {
        this.options.onHandshakeLog?.(message);
    }
    clearPendingCrdtSyncMessages() {
        this.pendingCrdtSyncMessages.length = 0;
        if (this.scheduledCrdtSyncDrain) {
            clearImmediate(this.scheduledCrdtSyncDrain.handle);
            this.scheduledCrdtSyncDrain = undefined;
        }
    }
    /** In-flight and deferred image resource keys only; does not clear `PeerAppState` image cache. */
    clearImageGetTransportState() {
        this.inFlightImageGetIds.clear();
        this.deferredImageGetIds.clear();
        this.failedImageGetIds.clear();
    }
    requestMissingImages(socket, imageIds) {
        if (imageIds.length === 0) {
            return;
        }
        const cache = this.options.appState.getImageCache();
        const seen = new Set();
        for (const imageId of imageIds) {
            if (seen.has(imageId)) {
                continue;
            }
            seen.add(imageId);
            if (cache.get(imageId)) {
                continue;
            }
            if (this.failedImageGetIds.has(imageId)) {
                continue;
            }
            if (this.inFlightImageGetIds.has(imageId)) {
                this.deferredImageGetIds.add(imageId);
                continue;
            }
            this.inFlightImageGetIds.add(imageId);
            void this.fetchImageResource(imageId)
                .then((image) => {
                if (this.socket !== socket) {
                    return;
                }
                this.failedImageGetIds.delete(imageId);
                this.options.appState.applyImagePayload(image);
            })
                .catch(() => {
                if (this.socket === socket) {
                    this.failedImageGetIds.add(imageId);
                }
            })
                .finally(() => {
                if (this.socket !== socket) {
                    this.inFlightImageGetIds.delete(imageId);
                    return;
                }
                this.inFlightImageGetIds.delete(imageId);
                const retryIds = Array.from(this.deferredImageGetIds);
                this.deferredImageGetIds.clear();
                if (retryIds.length > 0) {
                    this.requestMissingImages(socket, retryIds);
                }
            });
        }
    }
    async fetchImageResource(imageId) {
        const url = new URL(`/resources/images/${encodeURIComponent(imageId)}`, this.options.hubUrl);
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`Image resource ${imageId} returned HTTP ${response.status}.`);
        }
        const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
        const data = Buffer.from(await response.arrayBuffer()).toString("base64");
        return { imageId, mimeType, data };
    }
    async emitAcked(eventName, payload) {
        const socket = this.requireSocket();
        await new Promise((resolve, reject) => {
            const emit = socket.emit.bind(socket);
            emit(eventName, payload, (ack) => {
                if (ack.ok) {
                    resolve();
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    async emitMutateSource(eventName, payload) {
        const socket = this.requireSocket();
        return new Promise((resolve, reject) => {
            const emit = socket.emit.bind(socket);
            emit(eventName, payload, (ack) => {
                if (ack.ok) {
                    resolve(ack.sources);
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    async emitMutateMcpServer(eventName, payload) {
        const socket = this.requireSocket();
        return new Promise((resolve, reject) => {
            const emit = socket.emit.bind(socket);
            emit(eventName, payload, (ack) => {
                if (ack.ok) {
                    resolve(ack.servers);
                    return;
                }
                reject(new Error(ack.error));
            });
        });
    }
    requireSocket() {
        if (!this.socket) {
            throw new Error("Peer client is not connected.");
        }
        return this.socket;
    }
}
//# sourceMappingURL=socket-client.js.map