import { EventEmitter } from 'events';
import { WebSocket } from 'ws';
const DEFAULT_TIMEOUT_MS = 10000;
/** `fn.toString()` for CDP — strip ESM `export` so the body is valid inside an IIFE. */
export function serializeFunctionForCdp(fn) {
    return fn.toString().replace(/^export\s+(async\s+)?function/, '$1function');
}
/**
 * Single CDP WebSocket — all `send`/`evaluate` calls are serialized.
 * `runExclusive` pauses DOM poll (see DOMExtractor) while user commands run.
 */
export class CdpClient extends EventEmitter {
    ws = null;
    nextId = 1;
    pending = new Map();
    _connected = false;
    opChain = Promise.resolve();
    commandExclusiveDepth = 0;
    withLock(fn) {
        const run = this.opChain.then(fn, fn);
        this.opChain = run.then(() => undefined, () => undefined);
        return run;
    }
    async connect(wsUrl) {
        return new Promise((resolve, reject) => {
            this.ws = new WebSocket(wsUrl);
            this.ws.on('open', () => {
                this._connected = true;
                resolve();
            });
            this.ws.on('error', (err) => {
                if (!this._connected)
                    reject(err);
            });
            this.ws.on('message', (data) => this.handleMessage(data.toString()));
            this.ws.on('close', () => {
                const was = this._connected;
                this._connected = false;
                this.rejectAll('WebSocket closed');
                if (was)
                    this.emit('disconnected');
            });
        });
    }
    disconnect() {
        this._connected = false;
        this.rejectAll('disconnect');
        this.ws?.close();
        this.ws = null;
        this.opChain = Promise.resolve();
        this.commandExclusiveDepth = 0;
    }
    isConnected() {
        return this._connected;
    }
    /** True while a user command holds the CDP lane (DOM poll should skip). */
    isCommandExclusive() {
        return this.commandExclusiveDepth > 0;
    }
    /** Run fn as one atomic CDP session — inner `send` calls bypass the queue. */
    async runExclusive(fn) {
        return this.withLock(async () => {
            this.commandExclusiveDepth++;
            try {
                return await fn();
            }
            finally {
                this.commandExclusiveDepth--;
            }
        });
    }
    enqueue(fn) {
        return this.withLock(fn);
    }
    async send(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
        if (this.commandExclusiveDepth > 0) {
            return this.sendOnce(method, params, timeoutMs);
        }
        return this.enqueue(() => this.sendOnce(method, params, timeoutMs));
    }
    async sendOnce(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
        if (!this.ws || !this._connected)
            throw new Error('CDP not connected');
        const id = this.nextId++;
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new Error(`CDP timeout: ${method}`));
            }, timeoutMs);
            this.pending.set(id, { resolve, reject, timer });
            this.ws.send(JSON.stringify({ id, method, params }));
        });
    }
    /**
     * Evaluate a self-contained function in the page.
     * Injects __name shim — regex strip of __name() breaks nested helpers (see TROUBLESHOOTING).
     */
    async callFunction(fn, ...args) {
        let timeoutMs = DEFAULT_TIMEOUT_MS;
        const tail = args[args.length - 1];
        if (typeof tail === 'number' && Number.isFinite(tail)) {
            timeoutMs = tail;
            args = args.slice(0, -1);
        }
        const argStr = args.map((a) => JSON.stringify(a)).join(', ');
        const shim = 'var __name=function(fn,_n){return fn;};';
        return this.evaluate(`${shim}(${serializeFunctionForCdp(fn)})(${argStr})`, timeoutMs);
    }
    /**
     * Like `callFunction`, but injects dependency function sources into the same scope
     * (e.g. `readActiveChatDom` for `focusSidebarAgentDom`).
     */
    async callFunctionWithDeps(fn, deps, ...args) {
        let timeoutMs = DEFAULT_TIMEOUT_MS;
        const tail = args[args.length - 1];
        if (typeof tail === 'number' && Number.isFinite(tail)) {
            timeoutMs = tail;
            args = args.slice(0, -1);
        }
        const argStr = args.map((a) => JSON.stringify(a)).join(', ');
        const depSrc = deps.map((d) => serializeFunctionForCdp(d)).join('\n');
        const fnSrc = serializeFunctionForCdp(fn);
        const shim = 'var __name=function(fn,_n){return fn;};';
        return this.evaluate(`${shim}(function(){ ${depSrc}; return (${fnSrc})(${argStr}); })()`, timeoutMs);
    }
    async evaluate(expression, timeoutMs = DEFAULT_TIMEOUT_MS) {
        const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs);
        const ex = result.exceptionDetails;
        if (ex) {
            throw new Error(ex.exception?.description ?? ex.text ?? 'evaluate failed');
        }
        return result.result?.value;
    }
    async typeText(text) {
        await this.send('Input.insertText', { text });
    }
    async pressKey(key, code, keyCode, modifiers = 0) {
        const base = {
            key,
            code,
            windowsVirtualKeyCode: keyCode,
            nativeVirtualKeyCode: keyCode,
            modifiers,
        };
        await this.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base });
        await this.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
    }
    /** Attach local image files to Cursor composer via hidden file input. */
    async attachComposerImages(filePaths) {
        if (filePaths.length === 0)
            return;
        await this.send('DOM.enable', {});
        const doc = (await this.send('DOM.getDocument', { depth: 0 }));
        const rootId = doc.root?.nodeId;
        if (!rootId)
            throw new Error('DOM root not found');
        const selectors = [
            '[class*="agent-panel"] input[type="file"][accept*="image"]',
            '[class*="composer-panel"] input[type="file"][accept*="image"]',
            '.composer-bar input[type="file"]',
            '[class*="composer-bar"] input[type="file"]',
            'input[type="file"][accept*="image"]',
        ];
        let nodeId = 0;
        for (const selector of selectors) {
            const hit = (await this.send('DOM.querySelector', {
                nodeId: rootId,
                selector,
            }));
            if (hit.nodeId) {
                nodeId = hit.nodeId;
                break;
            }
        }
        if (!nodeId)
            throw new Error('Composer file input not found');
        await this.send('DOM.setFileInputFiles', { nodeId, files: filePaths });
    }
    async click(selectorPath) {
        await this.evaluate(`
      (() => {
        const el = document.querySelector(${JSON.stringify(selectorPath)});
        if (!el) throw new Error('Element not found');
        el.scrollIntoView({ block: 'center', behavior: 'instant' });
        el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
        el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
        el.click();
      })()
    `);
    }
    handleMessage(raw) {
        const msg = JSON.parse(raw);
        if (msg.id == null)
            return;
        const p = this.pending.get(msg.id);
        if (!p)
            return;
        clearTimeout(p.timer);
        this.pending.delete(msg.id);
        if (msg.error)
            p.reject(new Error(msg.error.message));
        else
            p.resolve(msg.result ?? {});
    }
    rejectAll(reason) {
        for (const [, p] of this.pending) {
            clearTimeout(p.timer);
            p.reject(new Error(reason));
        }
        this.pending.clear();
    }
}
function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
}
