"""CDP Client - Chrome DevTools Protocol client for WebView control."""

import asyncio
import json
import base64
from typing import Any, Callable, Optional, Dict, List

import structlog
import websockets

logger = structlog.get_logger(__name__)


class CDPClient:
    """Client for Chrome DevTools Protocol."""

    def __init__(self, ws_url: str):
        """Initialize CDP client.

        Args:
            ws_url: WebSocket URL for CDP connection.
        """
        self.ws_url = ws_url
        self._ws: Optional[Any] = None
        self._connected = False
        self._message_id = 0
        self._pending_requests: Dict[int, asyncio.Future] = {}
        self._event_listeners: Dict[str, List[Callable]] = {}
        self._listen_task: Optional[asyncio.Task] = None

    async def connect(self) -> None:
        """Connect to the WebView via CDP."""
        logger.info("cdp.connect", ws_url=self.ws_url)
        self._ws = await websockets.connect(self.ws_url)
        self._connected = True
        self._listen_task = asyncio.create_task(self._listen())

    async def disconnect(self) -> None:
        """Disconnect from the WebView."""
        logger.info("cdp.disconnect")
        self._connected = False
        if self._listen_task:
            self._listen_task.cancel()
            try:
                await self._listen_task
            except asyncio.CancelledError:
                pass
            self._listen_task = None

        if self._ws:
            await self._ws.close()
            self._ws = None

    async def _listen(self) -> None:
        """Listen for messages from the WebSocket."""
        if not self._ws:
            return
        try:
            async for message in self._ws:
                data = json.loads(message)

                # Check if it's a response to a command
                if "id" in data:
                    msg_id = data["id"]
                    if msg_id in self._pending_requests:
                        future = self._pending_requests.pop(msg_id)
                        if not future.done():
                            if "error" in data:
                                future.set_exception(Exception(data["error"]))
                            else:
                                future.set_result(data.get("result", {}))

                # Check if it's an event
                elif "method" in data:
                    event = data["method"]
                    params = data.get("params", {})
                    logger.debug("cdp.event_received", event_name=event)
                    if event in self._event_listeners:
                        for callback in self._event_listeners[event]:
                            try:
                                if asyncio.iscoroutinefunction(callback):
                                    asyncio.create_task(callback(params))
                                else:
                                    callback(params)
                            except Exception as e:
                                logger.error(
                                    "cdp.event_callback_error", event_name=event, error=str(e)
                                )
        except websockets.exceptions.ConnectionClosed:
            logger.info("cdp.connection_closed")
            self._connected = False
        except Exception as e:
            logger.error("cdp.listen_error", error=str(e))
            self._connected = False

    async def send(self, command: str, params: Optional[dict] = None) -> dict:
        """Send a CDP command.

        Args:
            command: CDP command name.
            params: Command parameters.

        Returns:
            Command response.
        """
        if not self._connected or not self._ws:
            raise RuntimeError("Not connected to WebView")

        self._message_id += 1
        msg_id = self._message_id

        payload = {"id": msg_id, "method": command, "params": params or {}}

        future = asyncio.get_running_loop().create_future()
        self._pending_requests[msg_id] = future

        logger.debug("cdp.send", command=command, msg_id=msg_id)
        await self._ws.send(json.dumps(payload))

        return dict(await future)

    def on(self, event: str, callback: Callable) -> None:
        """Register event listener.

        Args:
            event: Event name.
            callback: Callback function.
        """
        if event not in self._event_listeners:
            self._event_listeners[event] = []
        self._event_listeners[event].append(callback)
        logger.debug("cdp.event_registered", event_name=event)

    # --- Domain Enablers ---

    async def enable_dom(self) -> dict:
        """Enable DOM domain."""
        return await self.send("DOM.enable")

    async def enable_page(self) -> dict:
        """Enable Page domain."""
        return await self.send("Page.enable")

    async def enable_runtime(self) -> dict:
        """Enable Runtime domain."""
        return await self.send("Runtime.enable")

    async def enable_log(self) -> dict:
        """Enable Log domain."""
        return await self.send("Log.enable")

    # --- Convenience Methods ---

    async def get_document(self) -> dict:
        """Get the document root.

        Returns:
            Document root node.
        """
        await self.enable_dom()
        return await self.send("DOM.getDocument")

    async def resolve_node(self, node_id: int) -> dict:
        """Resolve a DOM node to a Runtime remote object.

        Args:
            node_id: DOM node ID.

        Returns:
            Remote object.
        """
        return await self.send("DOM.resolveNode", {"nodeId": node_id})

    async def get_page_tree(self) -> dict:
        """Get the page tree.

        Returns:
            Page tree information.
        """
        await self.enable_page()
        return await self.send("Page.getFrameTree")

    async def click(self, selector: str) -> None:
        """Click an element.

        Args:
            selector: CSS selector for the element.
        """
        logger.info("cdp.click", selector=selector)
        script = f"""
            const el = document.querySelector('{selector}');
            if (el) {{
                el.click();
            }} else {{
                throw new Error('Element not found: {selector}');
            }}
        """
        result = await self.evaluate(script)
        if result.get("exceptionDetails"):
            raise RuntimeError(f"Click failed: {result['exceptionDetails']}")

    async def type(self, selector: str, text: str) -> None:
        """Type text into an element.

        Args:
            selector: CSS selector for the element.
            text: Text to type.
        """
        logger.info("cdp.type", selector=selector, text=text)
        # Using JSON to safely escape the text
        escaped_text = json.dumps(text)
        script = f"""
            const el = document.querySelector('{selector}');
            if (el) {{
                el.value = {escaped_text};
                el.dispatchEvent(new Event('input', {{ bubbles: true }}));
                el.dispatchEvent(new Event('change', {{ bubbles: true }}));
            }} else {{
                throw new Error('Element not found: {selector}');
            }}
        """
        result = await self.evaluate(script)
        if result.get("exceptionDetails"):
            raise RuntimeError(f"Type failed: {result['exceptionDetails']}")

    async def scroll(self, x: int, y: int) -> None:
        """Scroll the page.

        Args:
            x: Horizontal scroll position.
            y: Vertical scroll position.
        """
        logger.info("cdp.scroll", x=x, y=y)
        await self.evaluate(f"window.scrollTo({x}, {y})")

    async def screenshot(self) -> bytes:
        """Take a screenshot.

        Returns:
            Screenshot as PNG bytes.
        """
        await self.enable_page()
        result = await self.send("Page.captureScreenshot", {"format": "png"})
        if "data" in result:
            return base64.b64decode(result["data"])
        return b""

    async def screenshot_element(self, selector: str) -> bytes:
        """Take a screenshot of a specific element.

        Args:
            selector: CSS selector for the element.

        Returns:
            Screenshot as PNG bytes.
        """
        await self.enable_dom()
        await self.enable_page()

        doc = await self.get_document()
        root_node_id = doc.get("root", {}).get("nodeId")
        if not root_node_id:
            raise RuntimeError("Could not get document root")

        node = await self.send("DOM.querySelector", {"nodeId": root_node_id, "selector": selector})
        node_id = node.get("nodeId")
        if not node_id:
            raise RuntimeError(f"Element not found: {selector}")

        box_model = await self.send("DOM.getBoxModel", {"nodeId": node_id})
        model = box_model.get("model", {})

        if not model:
            raise RuntimeError("Could not get element box model")

        width = model["width"]
        height = model["height"]
        x = model["content"][0]
        y = model["content"][1]

        clip = {"x": x, "y": y, "width": width, "height": height, "scale": 1}

        result = await self.send("Page.captureScreenshot", {"format": "png", "clip": clip})
        if "data" in result:
            return base64.b64decode(result["data"])
        return b""

    async def on_console_message(self, callback: Callable) -> None:
        """Register a callback for console messages.

        Args:
            callback: Function to call with console message data.
        """
        await self.enable_runtime()
        self.on("Runtime.consoleAPICalled", callback)

    async def evaluate(self, script: str) -> Any:
        """Evaluate JavaScript.

        Args:
            script: JavaScript code to execute.

        Returns:
            Evaluation result.
        """
        await self.enable_runtime()
        return await self.send(
            "Runtime.evaluate", {"expression": script, "returnByValue": True, "awaitPromise": True}
        )
