import { randomBytes } from "node:crypto";
import http from "node:http";
import { WebSocket, WebSocketServer } from "ws";
const RELAY_PROTOCOL_VERSION = 2;
const RELAY_DEBUG = process.env.DIPCLAW_RELAY_DEBUG === "1";
export async function startExtensionRelay() {
    const token = randomBytes(24).toString("base64url");
    const id = randomBytes(12).toString("hex");
    const server = http.createServer((_, res) => {
        res.statusCode = 404;
        res.end("not found");
    });
    const relay = new ExtensionRelayServer(server, id, token);
    await relay.listen();
    return relay;
}
class ExtensionRelayServer {
    server;
    id;
    token;
    protocolVersion = RELAY_PROTOCOL_VERSION;
    wss = new WebSocketServer({ noServer: true });
    extensionSocket = null;
    cdpSocket = null;
    nextExtensionCommandId = 1;
    nextCdpSessionId = 1;
    pendingExtensionCommands = new Map();
    tabs = new Map();
    targetToTab = new Map();
    sessionToTab = new Map();
    tabToSession = new Map();
    frameTreeReadySessions = new Set();
    bufferedSessionEvents = new Map();
    initialized = false;
    autoAttach = false;
    discoverTargets = false;
    readyError = null;
    readyResolve = null;
    readyReject = null;
    readyTimer = null;
    constructor(server, id, token) {
        this.server = server;
        this.id = id;
        this.token = token;
        this.server.on("upgrade", (req, socket, head) => {
            const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
            if (path !== `/extension/${this.id}` && path !== `/cdp/${this.id}`) {
                socket.destroy();
                return;
            }
            this.wss.handleUpgrade(req, socket, head, (ws) => {
                if (path === `/extension/${this.id}`) {
                    this.acceptExtensionSocket(ws, req);
                }
                else {
                    this.acceptCdpSocket(ws);
                }
            });
        });
    }
    async listen() {
        await new Promise((resolve, reject) => {
            this.server.once("error", reject);
            this.server.listen(0, "127.0.0.1", () => {
                this.server.off("error", reject);
                resolve();
            });
        });
    }
    extensionEndpoint() {
        return `ws://127.0.0.1:${this.port()}/extension/${this.id}?token=${encodeURIComponent(this.token)}`;
    }
    cdpEndpoint() {
        return `ws://127.0.0.1:${this.port()}/cdp/${this.id}`;
    }
    waitForExtensionReady(timeoutMs) {
        if (this.readyError)
            return Promise.reject(this.readyError);
        if (this.initialized)
            return Promise.resolve();
        return new Promise((resolve, reject) => {
            this.readyResolve = () => {
                if (this.readyTimer) {
                    clearTimeout(this.readyTimer);
                    this.readyTimer = null;
                }
                resolve();
            };
            this.readyReject = (error) => {
                if (this.readyTimer) {
                    clearTimeout(this.readyTimer);
                    this.readyTimer = null;
                }
                reject(error);
            };
            this.readyTimer = setTimeout(() => {
                if (!this.initialized) {
                    this.readyReject?.(new Error(`OmniFlow relay did not initialize within ${timeoutMs}ms`));
                }
            }, timeoutMs).unref();
        });
    }
    async close() {
        for (const pending of this.pendingExtensionCommands.values()) {
            pending.reject(new Error("relay closed"));
        }
        this.pendingExtensionCommands.clear();
        this.extensionSocket?.terminate();
        this.cdpSocket?.terminate();
        for (const client of this.wss.clients) {
            client.terminate();
        }
        this.extensionSocket = null;
        this.cdpSocket = null;
        await new Promise((resolve) => this.wss.close(() => resolve()));
        await new Promise((resolve) => this.server.close(() => resolve()));
    }
    port() {
        const addr = this.server.address();
        if (!addr || typeof addr === "string") {
            throw new Error("relay server is not listening");
        }
        return addr.port;
    }
    acceptExtensionSocket(ws, req) {
        const url = new URL(req.url ?? "/", "http://127.0.0.1");
        if (url.searchParams.get("token") !== this.token || this.extensionSocket) {
            ws.close(1008, "unauthorized");
            return;
        }
        this.extensionSocket = ws;
        ws.on("message", (data) => this.onExtensionMessage(String(data)));
        ws.on("close", () => {
            if (this.extensionSocket === ws)
                this.extensionSocket = null;
            for (const pending of this.pendingExtensionCommands.values()) {
                pending.reject(new Error("extension disconnected"));
            }
            this.pendingExtensionCommands.clear();
        });
    }
    acceptCdpSocket(ws) {
        if (this.cdpSocket) {
            ws.close(1008, "cdp already connected");
            return;
        }
        this.cdpSocket = ws;
        ws.on("message", (data) => this.onCdpMessage(ws, String(data)));
        ws.on("close", () => {
            if (this.cdpSocket === ws)
                this.cdpSocket = null;
        });
    }
    onExtensionMessage(raw) {
        let message;
        try {
            message = JSON.parse(raw);
        }
        catch {
            return;
        }
        debugRelay("extension <=", message.method ?? `#${message.id}`);
        if (message.id !== undefined) {
            const pending = this.pendingExtensionCommands.get(message.id);
            if (pending) {
                this.pendingExtensionCommands.delete(message.id);
                if (message.error) {
                    pending.reject(new Error(errorMessage(message.error)));
                }
                else {
                    pending.resolve(message.result ?? {});
                }
            }
            return;
        }
        if (!message.method)
            return;
        this.onExtensionEvent(message.method, message.params ?? []);
    }
    onExtensionEvent(method, params) {
        switch (method) {
            case "extension.initialized": {
                const state = params[0];
                this.applyDebuggerTargets(state?.targets ?? []);
                if (state?.readyState?.status === "error") {
                    this.readyError = new Error(`OmniFlow extension failed to initialize: ${state.readyState.error ?? "unknown error"}`);
                    this.readyReject?.(this.readyError);
                    this.readyResolve = null;
                    this.readyReject = null;
                    return;
                }
                this.initialized = true;
                this.readyResolve?.();
                this.readyResolve = null;
                this.readyReject = null;
                if (this.discoverTargets) {
                    for (const tab of this.tabs.values())
                        this.emitTargetCreated(tab);
                }
                if (this.autoAttach) {
                    for (const tab of this.tabs.values()) {
                        void this.attachTab(tab.id, true).catch(() => { });
                    }
                }
                return;
            }
            case "chrome.tabs.onCreated": {
                const tab = params[0];
                if (typeof tab?.id === "number") {
                    this.tabs.set(tab.id, tab);
                    if (this.initialized && this.discoverTargets)
                        this.emitTargetCreated(tab);
                    if (this.initialized && this.autoAttach) {
                        void this.attachTab(tab.id, true).catch(() => { });
                    }
                }
                return;
            }
            case "chrome.tabs.onUpdated": {
                const tabId = params[0];
                const changeInfo = params[1];
                const tab = params[2];
                if (typeof tabId === "number") {
                    const next = { ...(this.tabs.get(tabId) ?? { id: tabId }), ...(tab ?? {}), ...(changeInfo ?? {}) };
                    this.tabs.set(tabId, next);
                    this.emit("Target.targetInfoChanged", { targetInfo: this.targetInfo(next) });
                }
                return;
            }
            case "chrome.tabs.onRemoved": {
                const tabId = params[0];
                if (typeof tabId === "number") {
                    const targetId = this.targetIdForTab(tabId);
                    const sessionId = this.tabToSession.get(tabId);
                    this.tabs.delete(tabId);
                    this.tabToSession.delete(tabId);
                    this.targetToTab.delete(targetId);
                    if (sessionId)
                        this.sessionToTab.delete(sessionId);
                    if (sessionId) {
                        this.frameTreeReadySessions.delete(sessionId);
                        this.bufferedSessionEvents.delete(sessionId);
                        this.emit("Target.detachedFromTarget", {
                            sessionId,
                            targetId,
                        });
                    }
                    this.emit("Target.targetDestroyed", { targetId });
                }
                return;
            }
            case "chrome.debugger.onEvent": {
                const source = params[0];
                const cdpMethod = params[1];
                const cdpParams = params[2] ?? {};
                if (typeof source?.tabId !== "number" || !cdpMethod)
                    return;
                const sessionId = this.tabToSession.get(source.tabId);
                if (!sessionId)
                    return;
                if (cdpMethod === "Runtime.executionContextCreated" &&
                    !this.frameTreeReadySessions.has(sessionId)) {
                    const buffered = this.bufferedSessionEvents.get(sessionId) ?? [];
                    buffered.push({ method: cdpMethod, params: cdpParams });
                    this.bufferedSessionEvents.set(sessionId, buffered);
                    return;
                }
                this.emit(cdpMethod, cdpParams, sessionId);
                return;
            }
            case "chrome.debugger.onDetach": {
                const source = params[0];
                if (typeof source?.tabId !== "number")
                    return;
                const sessionId = this.tabToSession.get(source.tabId);
                if (sessionId) {
                    this.emit("Target.detachedFromTarget", {
                        sessionId,
                        targetId: this.targetIdForTab(source.tabId),
                    });
                    this.sessionToTab.delete(sessionId);
                    this.tabToSession.delete(source.tabId);
                    this.frameTreeReadySessions.delete(sessionId);
                    this.bufferedSessionEvents.delete(sessionId);
                }
                return;
            }
        }
    }
    async onCdpMessage(ws, raw) {
        let message;
        try {
            message = JSON.parse(raw);
        }
        catch {
            return;
        }
        if (message.id === undefined || !message.method)
            return;
        debugRelay("cdp <=", message.method, message.sessionId ?? "");
        try {
            const result = await this.handleCdpCommand(message);
            debugRelay("cdp =>", message.method, "ok");
            send(ws, { id: message.id, result, ...(message.sessionId ? { sessionId: message.sessionId } : {}) });
            if (message.sessionId && message.method === "Page.getFrameTree") {
                this.frameTreeReadySessions.add(message.sessionId);
                this.flushBufferedSessionEvents(message.sessionId);
            }
        }
        catch (error) {
            debugRelay("cdp =>", message.method, error instanceof Error ? error.message : String(error));
            send(ws, {
                id: message.id,
                error: {
                    code: -32000,
                    message: error instanceof Error ? error.message : String(error),
                },
                ...(message.sessionId ? { sessionId: message.sessionId } : {}),
            });
        }
    }
    async handleCdpCommand(message) {
        if (message.sessionId) {
            const tabId = this.sessionToTab.get(message.sessionId);
            if (tabId === undefined) {
                throw new Error(`Unknown CDP session: ${message.sessionId}`);
            }
            const result = await this.sendExtensionCommand("chrome.debugger.sendCommand", [
                { tabId },
                message.method,
                message.params ?? {},
            ]);
            if (message.method === "Page.getFrameTree") {
                debugRelay("Page.getFrameTree result", JSON.stringify(result));
            }
            return result;
        }
        switch (message.method) {
            case "Browser.getVersion":
                return {
                    protocolVersion: "1.3",
                    product: "Chrome/extension-relay",
                    revision: "0",
                    userAgent: "Chrome extension relay",
                    jsVersion: "0",
                };
            case "Target.getTargets":
                return { targetInfos: [...this.tabs.values()].map((tab) => this.targetInfo(tab)) };
            case "Target.getTargetInfo": {
                const targetId = message.params?.targetId;
                const tabId = typeof targetId === "string"
                    ? this.tabIdFromTargetId(targetId)
                    : this.tabs.values().next().value?.id;
                if (typeof tabId !== "number") {
                    throw new Error("Target.getTargetInfo requires an available tab");
                }
                return {
                    targetInfo: this.targetInfo(this.tabs.get(tabId) ?? { id: tabId }),
                };
            }
            case "Target.setDiscoverTargets":
                this.discoverTargets = message.params?.discover !== false;
                if (this.discoverTargets) {
                    for (const tab of this.tabs.values())
                        this.emitTargetCreated(tab);
                }
                return {};
            case "Target.setAutoAttach":
                this.autoAttach = message.params?.autoAttach !== false;
                if (this.autoAttach) {
                    for (const tab of this.tabs.values()) {
                        await this.attachTab(tab.id, true);
                    }
                }
                return {};
            case "Target.activateTarget": {
                const targetId = stringParam(message, "targetId");
                const tabId = this.tabIdFromTargetId(targetId);
                await this.sendExtensionCommand("chrome.tabs.update", [tabId, { active: true }]);
                return {};
            }
            case "Target.attachToTarget": {
                const targetId = stringParam(message, "targetId");
                const tabId = this.tabIdFromTargetId(targetId);
                const sessionId = await this.attachTab(tabId, false);
                return { sessionId };
            }
            case "Target.detachFromTarget": {
                const sessionId = stringParam(message, "sessionId");
                const tabId = this.sessionToTab.get(sessionId);
                if (tabId !== undefined) {
                    await this.sendExtensionCommand("chrome.debugger.detach", [{ tabId }]).catch(() => { });
                    this.sessionToTab.delete(sessionId);
                    this.tabToSession.delete(tabId);
                    this.frameTreeReadySessions.delete(sessionId);
                    this.bufferedSessionEvents.delete(sessionId);
                }
                return {};
            }
            case "Target.createTarget": {
                const url = typeof message.params?.url === "string" ? message.params.url : "about:blank";
                const tab = (await this.sendExtensionCommand("chrome.tabs.create", [{ url }]));
                if (typeof tab.id === "number")
                    this.tabs.set(tab.id, tab);
                await this.refreshDebuggerTargets().catch(() => { });
                return { targetId: this.targetIdForTab(tab.id) };
            }
            case "Target.closeTarget": {
                const targetId = stringParam(message, "targetId");
                const tabId = this.tabIdFromTargetId(targetId);
                await this.sendExtensionCommand("chrome.tabs.remove", [tabId]);
                return { success: true };
            }
            case "Browser.setDownloadBehavior":
            case "Browser.setPermission":
            case "Browser.grantPermissions":
            case "Browser.resetPermissions":
            case "Browser.cancelDownload":
            case "Browser.close":
            case "Browser.getWindowForTarget":
            case "Browser.setWindowBounds":
            case "Target.setRemoteLocations":
            case "Target.getBrowserContexts":
                if (message.method === "Target.getBrowserContexts") {
                    return { browserContextIds: [] };
                }
                if (message.method === "Browser.getWindowForTarget") {
                    return { windowId: 1, bounds: { windowState: "normal" } };
                }
                return {};
        }
        throw new Error(`Unsupported browser-level CDP method: ${message.method}`);
    }
    async attachTab(tabId, emitEvent) {
        const existing = this.tabToSession.get(tabId);
        if (existing)
            return existing;
        await this.sendExtensionCommand("chrome.debugger.attach", [{ tabId }, "1.3"]);
        await this.refreshDebuggerTargets().catch(() => { });
        await this.refreshTabTargetFromFrameTree(tabId).catch(() => { });
        const sessionId = `dipclaw-tab-${tabId}-${this.nextCdpSessionId++}`;
        this.sessionToTab.set(sessionId, tabId);
        this.tabToSession.set(tabId, sessionId);
        if (emitEvent) {
            const tab = this.tabs.get(tabId) ?? { id: tabId };
            this.emit("Target.attachedToTarget", {
                sessionId,
                targetInfo: this.targetInfo(tab),
                waitingForDebugger: false,
            });
        }
        return sessionId;
    }
    sendExtensionCommand(method, params) {
        if (!this.extensionSocket || this.extensionSocket.readyState !== WebSocket.OPEN) {
            return Promise.reject(new Error("extension relay socket is not connected"));
        }
        const id = this.nextExtensionCommandId++;
        const payload = JSON.stringify({ id, method, params });
        debugRelay("extension =>", method);
        return new Promise((resolve, reject) => {
            this.pendingExtensionCommands.set(id, { resolve, reject });
            this.extensionSocket.send(payload, (error) => {
                if (error) {
                    this.pendingExtensionCommands.delete(id);
                    reject(error);
                }
            });
        });
    }
    targetInfo(tab) {
        return {
            targetId: this.targetIdForTab(tab.id),
            type: "page",
            title: tab.title ?? "",
            url: tab.url ?? "about:blank",
            attached: this.tabToSession.has(tab.id),
            canAccessOpener: false,
            browserContextId: "default",
        };
    }
    emitTargetCreated(tab) {
        this.emit("Target.targetCreated", { targetInfo: this.targetInfo(tab) });
    }
    flushBufferedSessionEvents(sessionId) {
        const events = this.bufferedSessionEvents.get(sessionId);
        if (!events)
            return;
        this.bufferedSessionEvents.delete(sessionId);
        for (const event of events) {
            this.emit(event.method, event.params, sessionId);
        }
    }
    async refreshDebuggerTargets() {
        const targets = (await this.sendExtensionCommand("chrome.debugger.getTargets", []));
        this.applyDebuggerTargets(targets);
    }
    async refreshTabTargetFromFrameTree(tabId) {
        const result = (await this.sendExtensionCommand("chrome.debugger.sendCommand", [
            { tabId },
            "Page.getFrameTree",
            {},
        ]));
        const frameId = result.frameTree?.frame?.id;
        if (!frameId)
            return;
        const existing = this.tabs.get(tabId) ?? { id: tabId };
        this.tabs.set(tabId, { ...existing, cdpTargetId: frameId });
        this.targetToTab.set(frameId, tabId);
    }
    applyDebuggerTargets(targets) {
        for (const target of targets) {
            if (typeof target.tabId !== "number" || typeof target.id !== "string") {
                continue;
            }
            const existing = this.tabs.get(target.tabId) ?? { id: target.tabId };
            const next = {
                ...existing,
                ...(target.url ? { url: target.url } : {}),
                ...(target.title ? { title: target.title } : {}),
                cdpTargetId: target.id,
            };
            this.tabs.set(target.tabId, next);
            this.targetToTab.set(target.id, target.tabId);
        }
    }
    targetIdForTab(tabId) {
        if (tabId === undefined)
            throw new Error("Chrome tab is missing id");
        return this.tabs.get(tabId)?.cdpTargetId ?? syntheticTargetIdForTab(tabId);
    }
    tabIdFromTargetId(targetId) {
        const mapped = this.targetToTab.get(targetId);
        if (mapped !== undefined)
            return mapped;
        return tabIdFromSyntheticTargetId(targetId);
    }
    emit(method, params, sessionId) {
        if (!this.cdpSocket || this.cdpSocket.readyState !== WebSocket.OPEN)
            return;
        debugRelay("cdp event =>", method, sessionId ?? "");
        if (method === "Target.attachedToTarget") {
            debugRelay("attached payload", JSON.stringify(params));
        }
        send(this.cdpSocket, { method, params, ...(sessionId ? { sessionId } : {}) });
    }
}
function send(ws, payload) {
    if (ws.readyState === WebSocket.OPEN)
        ws.send(JSON.stringify(payload));
}
function syntheticTargetIdForTab(tabId) {
    if (tabId === undefined)
        throw new Error("Chrome tab is missing id");
    return `tab-${tabId}`;
}
function tabIdFromSyntheticTargetId(targetId) {
    if (!targetId.startsWith("tab-"))
        throw new Error(`Unsupported target id: ${targetId}`);
    const n = Number(targetId.slice(4));
    if (!Number.isInteger(n))
        throw new Error(`Invalid target id: ${targetId}`);
    return n;
}
function stringParam(message, name) {
    const value = message.params?.[name];
    if (typeof value !== "string")
        throw new Error(`${message.method} requires ${name}`);
    return value;
}
function errorMessage(error) {
    return typeof error === "string" ? error : error.message ?? "extension command failed";
}
function debugRelay(...args) {
    if (RELAY_DEBUG) {
        console.error("[extension-relay]", ...args);
    }
}
//# sourceMappingURL=extension-relay.js.map