// Thin CDP JSON-RPC client over the existing `ws` runtime dep (ADR-0017 D1, D4). NOT a
// general-purpose CDP library: a hard-coded permit list gates `send` so a server bug cannot send
// `Runtime.evaluate` or any cookie-reading method. Pending requests are correlated by an
// incrementing integer `id`; the in-process id allocator is private so a CDP response cannot spoof
// the id of a request it didn't trigger.
import { WebSocket } from "ws";
import { BrowserToolError } from "./errors.js";
// ADR-0017 D4 — exact permit list. New methods require an ADR amendment, not a code-only patch.
export const PERMITTED_CDP_METHODS = new Set([
    "Target.createTarget",
    "Target.attachToTarget",
    "Target.closeTarget",
    "Page.enable",
    "Page.navigate",
    "Page.stopLoading",
    "Page.captureScreenshot",
    "DOM.getDocument",
    "DOM.getOuterHTML",
    "Browser.getVersion",
]);
const DEFAULT_CDP_TIMEOUT_MS = 10_000;
function isCdpFrame(value) {
    if (typeof value !== "object" || value === null)
        return false;
    const rec = value;
    return typeof rec.id === "number" || typeof rec.method === "string";
}
function parseCdpText(text) {
    let parsed;
    try {
        parsed = JSON.parse(text);
    }
    catch {
        return undefined;
    }
    return isCdpFrame(parsed) ? parsed : undefined;
}
function rawDataToText(data) {
    if (typeof data === "string")
        return data;
    if (Buffer.isBuffer(data))
        return data.toString("utf8");
    if (Array.isArray(data))
        return Buffer.concat(data).toString("utf8");
    return Buffer.from(data).toString("utf8");
}
export class CdpClient {
    socket;
    timeoutMs;
    pending = new Map();
    listeners = new Set();
    closeListeners = new Set();
    nextId = 1;
    connectPromise;
    closed = false;
    closeReason;
    constructor(url, options = {}) {
        this.timeoutMs = options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS;
        const factory = options.socketFactory ?? ((target) => new WebSocket(target));
        this.socket = factory(url);
        this.socket.on("message", (data, isBinary) => {
            this.handleMessage(data, isBinary);
        });
        this.socket.on("close", () => {
            this.handleSocketClosed("chrome-disconnected");
        });
        this.socket.on("error", () => {
            this.handleSocketClosed("chrome-disconnected");
        });
    }
    connect() {
        if (this.connectPromise !== undefined)
            return this.connectPromise;
        this.connectPromise = new Promise((resolve, reject) => {
            if (this.socket.readyState === this.socket.OPEN) {
                resolve();
                return;
            }
            const timer = setTimeout(() => {
                reject(new BrowserToolError("CDP_TIMEOUT", "CDP connection did not open in time."));
            }, this.timeoutMs);
            this.socket.once("open", () => {
                clearTimeout(timer);
                resolve();
            });
            this.socket.once("error", (err) => {
                clearTimeout(timer);
                reject(new BrowserToolError("CHROME_UNREACHABLE", `CDP connection failed: ${err.message}`));
            });
        });
        return this.connectPromise;
    }
    onEvent(listener) {
        this.listeners.add(listener);
        return () => {
            this.listeners.delete(listener);
        };
    }
    onClose(listener) {
        this.closeListeners.add(listener);
        return () => {
            this.closeListeners.delete(listener);
        };
    }
    async send(method, params = {}, sessionId) {
        if (!PERMITTED_CDP_METHODS.has(method)) {
            throw new BrowserToolError("CDP_METHOD_FORBIDDEN", "The requested CDP method is not permitted.");
        }
        if (this.closed) {
            throw new BrowserToolError("TARGET_CLOSED", "CDP connection is closed.");
        }
        await this.connect();
        const id = this.nextId;
        this.nextId += 1;
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new BrowserToolError("CDP_TIMEOUT", "CDP response did not arrive in time."));
            }, this.timeoutMs);
            this.pending.set(id, {
                resolve: (value) => {
                    resolve(value);
                },
                reject,
                timer,
            });
            const frame = sessionId === undefined ? { id, method, params } : { id, method, params, sessionId };
            try {
                this.socket.send(JSON.stringify(frame));
            }
            catch (err) {
                this.pending.delete(id);
                clearTimeout(timer);
                const message = err instanceof Error ? err.message : "send failed";
                reject(new BrowserToolError("CHROME_UNREACHABLE", `CDP send failed: ${message}`));
            }
        });
    }
    close() {
        if (this.closed)
            return;
        this.handleSocketClosed("explicit");
        try {
            this.socket.close();
        }
        catch {
            // The underlying socket may already be torn down; treat as idempotent.
        }
    }
    isClosed() {
        return this.closed;
    }
    closeCause() {
        return this.closeReason;
    }
    handleMessage(data, isBinary) {
        if (isBinary)
            return;
        const parsed = parseCdpText(rawDataToText(data));
        if (parsed === undefined)
            return;
        if ("id" in parsed && typeof parsed.id === "number") {
            this.resolvePending(parsed);
            return;
        }
        if ("method" in parsed && typeof parsed.method === "string") {
            this.dispatchEvent(parsed);
        }
    }
    dispatchEvent(frame) {
        const listenerEvent = {
            method: frame.method,
            params: frame.params ?? {},
            ...(typeof frame.sessionId === "string" ? { sessionId: frame.sessionId } : {}),
        };
        for (const listener of [...this.listeners]) {
            try {
                listener(listenerEvent);
            }
            catch {
                // A listener throwing must not stop event dispatch to other subscribers.
            }
        }
    }
    resolvePending(frame) {
        const pending = this.pending.get(frame.id);
        if (pending === undefined)
            return;
        this.pending.delete(frame.id);
        clearTimeout(pending.timer);
        if (frame.error !== undefined) {
            pending.reject(new BrowserToolError("CDP_METHOD_FORBIDDEN", 
            // Static message — the raw Chrome error may contain paths or session IDs.
            "Chrome rejected a CDP method."));
            return;
        }
        pending.resolve(frame.result ?? {});
    }
    handleSocketClosed(reason) {
        if (this.closed)
            return;
        this.closed = true;
        this.closeReason = reason;
        for (const [, pending] of this.pending) {
            clearTimeout(pending.timer);
            pending.reject(new BrowserToolError("TARGET_CLOSED", "CDP connection closed."));
        }
        this.pending.clear();
        for (const listener of [...this.closeListeners]) {
            try {
                listener(reason);
            }
            catch {
                // A close listener throwing must not stop cleanup notifications.
            }
        }
    }
}
