/**
 * Shared Chrome DevTools Protocol primitives for the browser plugin.
 *
 * The brand's VNC Chromium is launched by platform/scripts/vnc.sh with
 * `--remote-debugging-port=${CDP_PORT}`, so CDP listens on 127.0.0.1:<port>.
 * Everything here attaches to that running browser — it never launches its own.
 *
 * Two consumers share these primitives:
 *   - cdp-render.ts  → stateless one-shot render (create target, read DOM, close).
 *   - cdp-actions.ts → interactive automation driven through a persistent
 *                      BrowserSession that keeps one target + websocket alive
 *                      across tool calls.
 *
 * `fetch` and `WebSocket` are injected (CdpDeps) so unit tests drive every
 * branch without a real browser; production passes the Node 22 globals.
 */
export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
export const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
/** Create a blank target via the CDP HTTP surface. Maps every transport
 *  failure to a structured outcome rather than throwing. */
export async function createTarget(deps, timeoutMs) {
    const endpoint = `http://${deps.cdpHost}:${deps.cdpPort}/json/new`;
    let res;
    try {
        res = await deps.fetchImpl(endpoint, { method: "PUT", signal: AbortSignal.timeout(timeoutMs) });
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("ENOTFOUND")) {
            return { outcome: "cdp-unreachable", detail: msg };
        }
        return { outcome: "error", detail: msg };
    }
    if (!res.ok) {
        const body = await res.text().catch(() => "<unreadable>");
        return { outcome: "error", detail: `CDP /json/new returned ${res.status}: ${body.slice(0, 200)}` };
    }
    let parsed;
    try {
        parsed = (await res.json());
    }
    catch (err) {
        return { outcome: "error", detail: `CDP /json/new response not JSON: ${err instanceof Error ? err.message : String(err)}` };
    }
    if (!parsed.id || !parsed.webSocketDebuggerUrl) {
        return { outcome: "error", detail: `CDP /json/new missing id/webSocketDebuggerUrl: ${JSON.stringify(parsed).slice(0, 200)}` };
    }
    return { target: { id: parsed.id, webSocketDebuggerUrl: parsed.webSocketDebuggerUrl } };
}
/** Best-effort target close. Failure is logged, never surfaced as an outcome. */
export async function closeTarget(deps, targetId) {
    const endpoint = `http://${deps.cdpHost}:${deps.cdpPort}/json/close/${encodeURIComponent(targetId)}`;
    try {
        await deps.fetchImpl(endpoint, { method: "PUT", signal: AbortSignal.timeout(5_000) });
    }
    catch (err) {
        deps.log(`[browser] target-close-failed id=${targetId} err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
    }
}
/** List the open page targets via the CDP HTTP surface. */
export async function listTargets(deps, timeoutMs) {
    const endpoint = `http://${deps.cdpHost}:${deps.cdpPort}/json/list`;
    let res;
    try {
        res = await deps.fetchImpl(endpoint, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("ENOTFOUND")) {
            return { outcome: "cdp-unreachable", detail: msg };
        }
        return { outcome: "error", detail: msg };
    }
    if (!res.ok) {
        const body = await res.text().catch(() => "<unreadable>");
        return { outcome: "error", detail: `CDP /json/list returned ${res.status}: ${body.slice(0, 200)}` };
    }
    let parsed;
    try {
        parsed = await res.json();
    }
    catch (err) {
        return { outcome: "error", detail: `CDP /json/list response not JSON: ${err instanceof Error ? err.message : String(err)}` };
    }
    if (!Array.isArray(parsed)) {
        return { outcome: "error", detail: `CDP /json/list not an array: ${JSON.stringify(parsed).slice(0, 200)}` };
    }
    const targets = parsed
        .filter((t) => typeof t.id === "string" && typeof t.webSocketDebuggerUrl === "string")
        .map((t) => ({
        id: t.id,
        webSocketDebuggerUrl: t.webSocketDebuggerUrl,
        url: typeof t.url === "string" ? t.url : undefined,
        title: typeof t.title === "string" ? t.title : undefined,
        type: typeof t.type === "string" ? t.type : undefined,
    }));
    return { targets };
}
/**
 * Drives one CDP WebSocket session: correlates command ids to results, resolves
 * one-shot events (waitForEvent), and fans persistent events out to subscribers
 * (subscribe) — the latter is what console buffering and dialog handling need.
 *
 *   ws message ──▶ has id? ──yes──▶ resolve/reject pending command
 *                    │no
 *                    ▼
 *               has method? ──▶ one-shot waiter (consume) + every subscriber
 */
export class CdpSession {
    ws;
    nextId = 1;
    pending = new Map();
    eventWaiters = new Map();
    subscribers = new Map();
    closed = false;
    constructor(ws) {
        this.ws = ws;
        ws.addEventListener("message", (ev) => this.onMessage(ev.data));
        ws.addEventListener("close", () => {
            this.closed = true;
            for (const { reject } of this.pending.values())
                reject(new Error("websocket closed"));
            this.pending.clear();
        });
    }
    isClosed() {
        return this.closed;
    }
    onMessage(data) {
        if (typeof data !== "string")
            return;
        let msg;
        try {
            msg = JSON.parse(data);
        }
        catch {
            return;
        }
        if (typeof msg.id === "number") {
            const waiter = this.pending.get(msg.id);
            if (!waiter)
                return;
            this.pending.delete(msg.id);
            if (msg.error)
                waiter.reject(new Error(msg.error.message ?? "CDP command error"));
            else
                waiter.resolve(msg.result);
            return;
        }
        if (msg.method) {
            const oneShot = this.eventWaiters.get(msg.method);
            if (oneShot) {
                this.eventWaiters.delete(msg.method);
                oneShot(msg.params);
            }
            const subs = this.subscribers.get(msg.method);
            if (subs)
                for (const handler of subs)
                    handler(msg.params);
        }
    }
    command(method, params, timeoutMs) {
        if (this.closed)
            return Promise.reject(new Error("websocket closed"));
        const id = this.nextId++;
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`));
            }, timeoutMs);
            this.pending.set(id, {
                resolve: (v) => { clearTimeout(timer); resolve(v); },
                reject: (e) => { clearTimeout(timer); reject(e); },
            });
            this.ws.send(JSON.stringify({ id, method, params }));
        });
    }
    waitForEvent(method, timeoutMs) {
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.eventWaiters.delete(method);
                reject(new Error(`event ${method} not received within ${timeoutMs}ms`));
            }, timeoutMs);
            this.eventWaiters.set(method, () => { clearTimeout(timer); resolve(); });
        });
    }
    /** Persistent subscription to a CDP event. Returns an unsubscribe function. */
    subscribe(method, handler) {
        let set = this.subscribers.get(method);
        if (!set) {
            set = new Set();
            this.subscribers.set(method, set);
        }
        set.add(handler);
        return () => {
            const s = this.subscribers.get(method);
            if (s)
                s.delete(handler);
        };
    }
}
export function openSocket(WebSocketImpl, url, timeoutMs) {
    return new Promise((resolve) => {
        let settled = false;
        let ws;
        try {
            ws = new WebSocketImpl(url);
        }
        catch (err) {
            resolve({ error: err instanceof Error ? err.message : String(err) });
            return;
        }
        const timer = setTimeout(() => {
            if (settled)
                return;
            settled = true;
            try {
                ws.close();
            }
            catch { /* ignore */ }
            resolve({ error: `websocket open timed out after ${timeoutMs}ms` });
        }, timeoutMs);
        ws.addEventListener("open", () => {
            if (settled)
                return;
            settled = true;
            clearTimeout(timer);
            resolve({ ws });
        });
        ws.addEventListener("error", (ev) => {
            if (settled)
                return;
            settled = true;
            clearTimeout(timer);
            const detail = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "websocket error";
            resolve({ error: detail });
        });
    });
}
const CONSOLE_RING_CAP = 200;
/**
 * A single live page driven across many tool calls.
 *
 * Lifecycle:
 *   attach()  → create target, open ws, Page.enable + Runtime.enable, wire the
 *               console ring buffer and the dialog auto-responder.
 *   ensure()  → return the live session, or null if the page died (ws closed:
 *               operator closed the tab, page crashed, browser restarted).
 *   close()   → close the target and drop all state.
 *
 * The action layer treats a null ensure() as "session-lost" and tells the agent
 * to navigate again — only navigate() re-attaches, so a dead page is never
 * silently replaced by a blank one mid-action.
 */
export class BrowserSession {
    deps;
    target = null;
    ws = null;
    session = null;
    consoleRing = [];
    dialogPolicy = null;
    lastDialogUnhandled = false;
    constructor(deps) {
        this.deps = deps;
    }
    /** The live session if the page is still alive, else null. */
    ensure() {
        if (this.session && !this.session.isClosed())
            return this.session;
        // Page died — drop references so the next attach starts clean.
        this.session = null;
        this.ws = null;
        this.target = null;
        return this.session;
    }
    /** Drop our handle on the current page (close the websocket) without closing
     *  the target itself — used when switching the active page to another tab so
     *  the previous tab stays open. */
    detachWs() {
        const ws = this.ws;
        this.ws = null;
        this.session = null;
        this.target = null;
        if (ws) {
            try {
                ws.close();
            }
            catch { /* ignore */ }
        }
    }
    currentTargetId() {
        return this.target?.id ?? null;
    }
    setDialogPolicy(policy) {
        this.dialogPolicy = policy;
    }
    /** Drain the console ring buffer (returns and clears). */
    drainConsole() {
        const out = this.consoleRing.slice();
        this.consoleRing.length = 0;
        return out;
    }
    /** True (and resets) if a dialog fired with no policy armed. */
    takeUnhandledDialogFlag() {
        const v = this.lastDialogUnhandled;
        this.lastDialogUnhandled = false;
        return v;
    }
    /**
     * Attach a fresh target and wire console + dialog handling.
     * Pass an existing target (from the tabs tool) to adopt it instead of creating one.
     */
    async attach(commandTimeoutMs, existing) {
        if (!this.deps.WebSocketImpl) {
            return { outcome: "websocket-unavailable", detail: "global WebSocket is not available on this runtime (Node < 22.4)" };
        }
        if (!this.deps.cdpPort || Number.isNaN(this.deps.cdpPort)) {
            return { outcome: "cdp-unreachable", detail: `CDP_PORT not set or invalid (got ${JSON.stringify(this.deps.cdpPort)})` };
        }
        // Release our handle on any current page, but leave its tab open — opening
        // or switching to another tab must not destroy the one we were on.
        this.detachWs();
        let target = existing;
        if (!target) {
            const created = await createTarget(this.deps, commandTimeoutMs);
            if ("outcome" in created)
                return { outcome: created.outcome, detail: created.detail };
            target = created.target;
        }
        const opened = await openSocket(this.deps.WebSocketImpl, target.webSocketDebuggerUrl, commandTimeoutMs);
        if ("error" in opened) {
            if (!existing)
                await closeTarget(this.deps, target.id);
            return { outcome: "cdp-unreachable", detail: `websocket connect failed: ${opened.error}` };
        }
        const session = new CdpSession(opened.ws);
        try {
            await session.command("Page.enable", {}, commandTimeoutMs);
            await session.command("Runtime.enable", {}, commandTimeoutMs);
        }
        catch (err) {
            try {
                opened.ws.close();
            }
            catch { /* ignore */ }
            if (!existing)
                await closeTarget(this.deps, target.id);
            return { outcome: "error", detail: `enable failed: ${err instanceof Error ? err.message : String(err)}` };
        }
        // Console + uncaught-error buffering, from attach onward.
        session.subscribe("Runtime.consoleAPICalled", (params) => this.recordConsole(params));
        session.subscribe("Runtime.exceptionThrown", (params) => this.recordException(params));
        // Dialog auto-responder: apply the armed policy, or dismiss + flag.
        session.subscribe("Page.javascriptDialogOpening", () => {
            const policy = this.dialogPolicy ?? { accept: false };
            if (!this.dialogPolicy)
                this.lastDialogUnhandled = true;
            session.command("Page.handleJavaScriptDialog", policy.promptText !== undefined
                ? { accept: policy.accept, promptText: policy.promptText }
                : { accept: policy.accept }, commandTimeoutMs).catch((err) => this.deps.log(`[browser] dialog-handle-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`));
        });
        this.target = target;
        this.ws = opened.ws;
        this.session = session;
        return { session };
    }
    async close(commandTimeoutMs) {
        const ws = this.ws;
        const target = this.target;
        this.ws = null;
        this.session = null;
        this.target = null;
        if (ws) {
            try {
                ws.close();
            }
            catch { /* ignore */ }
        }
        if (target)
            await closeTarget(this.deps, target.id);
        void commandTimeoutMs;
    }
    recordConsole(params) {
        const p = params;
        const text = (p.args ?? [])
            .map((a) => (a.value !== undefined ? String(a.value) : a.description ?? ""))
            .join(" ");
        this.push(`[${p.type ?? "log"}] ${text}`);
    }
    recordException(params) {
        const p = params;
        const text = p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? "uncaught exception";
        this.push(`[error] ${text}`);
    }
    push(line) {
        this.consoleRing.push(line);
        if (this.consoleRing.length > CONSOLE_RING_CAP)
            this.consoleRing.shift();
    }
}
//# sourceMappingURL=cdp-session.js.map