"""Tests for browser_hybrid package."""

from __future__ import annotations

import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from browser_hybrid import AXNode, Browser, BrowserInfo, Tab
from browser_hybrid.browser import escape_js_string
from browser_hybrid.connection import ConnectionState
from browser_hybrid.errors import BrowserError, CDPError, ConnectionError, TimeoutError


class TestTab:
    """Tests for Tab dataclass."""

    def test_from_dict(self):
        """Test Tab creation from dict."""
        data = {
            "id": "abc123",
            "url": "https://example.com",
            "title": "Example",
            "type": "page",
            "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/abc123",
        }
        tab = Tab.from_dict(data)
        assert tab.id == "abc123"
        assert tab.url == "https://example.com"
        assert tab.title == "Example"
        assert tab.type == "page"

    def test_from_dict_defaults(self):
        """Test Tab creation with defaults."""
        data = {"id": "x"}
        tab = Tab.from_dict(data)
        assert tab.id == "x"
        assert tab.url == ""
        assert tab.title == ""
        assert tab.type == "page"


class TestAXNode:
    """Tests for AXNode dataclass."""

    def test_find_by_role(self):
        """Test finding nodes by role."""
        root = AXNode(
            ref="1",
            role="RootWebArea",
            name="Page",
            children=[
                AXNode(ref="2", role="link", name="Click me"),
                AXNode(ref="3", role="button", name="Submit"),
                AXNode(
                    ref="4",
                    role="link",
                    name="Another link",
                    children=[AXNode(ref="5", role="link", name="Nested link")],
                ),
            ],
        )

        links = root.find_by_role("link")
        assert len(links) == 3
        assert links[0].ref == "2"
        assert links[1].ref == "4"

        buttons = root.find_by_role("button")
        assert len(buttons) == 1
        assert buttons[0].ref == "3"

    def test_find_by_name(self):
        """Test finding nodes by name."""
        root = AXNode(
            ref="1",
            role="RootWebArea",
            name="Page",
            children=[
                AXNode(ref="2", role="link", name="Click Me"),
                AXNode(ref="3", role="button", name="Submit Form"),
            ],
        )

        matches = root.find_by_name("Click")
        assert len(matches) == 1
        assert matches[0].ref == "2"

        matches = root.find_by_name("submit")
        assert len(matches) == 1
        assert matches[0].ref == "3"

        # Empty search should return []
        assert root.find_by_name("") == []
        assert root.find_by_name(None) == []

    def test_find(self):
        """Test finding node by ref."""
        root = AXNode(
            ref="1",
            role="RootWebArea",
            name="Page",
            children=[
                AXNode(ref="2", role="link", name="Link"),
                AXNode(
                    ref="3",
                    role="group",
                    name="Group",
                    children=[AXNode(ref="4", role="button", name="Button")],
                ),
            ],
        )

        node = root.find("4")
        assert node is not None
        assert node.role == "button"

        node = root.find("999")
        assert node is None

    def test_to_tree_str(self):
        """Test tree string formatting."""
        root = AXNode(
            ref="1",
            role="RootWebArea",
            name="Example",
            url="https://example.com",
            children=[
                AXNode(ref="2", role="heading", name="Welcome", level=1),
                AXNode(
                    ref="3", role="link", name="Learn more", url="https://iana.org/domains/example"
                ),
            ],
        )

        tree_str = root.to_tree_str()
        assert "RootWebArea" in tree_str
        assert '"Example"' in tree_str
        assert "[ref=1]" in tree_str
        assert "/url: https://example.com" in tree_str
        assert "heading" in tree_str
        assert "[level=1]" in tree_str

    def test_to_tree_str_non_link_url(self):
        """Test URL display for non-link elements."""
        img = AXNode(ref="img1", role="image", name="Ad", url="https://ads.com/img.png")
        tree_str = img.to_tree_str()
        assert "image" in tree_str
        assert '"Ad"' in tree_str
        assert "/url: https://ads.com/img.png" in tree_str


class TestAXNodeInteractive:
    """Tests for finding interactive elements."""

    def test_find_interactive(self):
        """Test finding all interactive elements."""
        root = AXNode(
            ref="1",
            role="RootWebArea",
            name="Page",
            children=[
                AXNode(ref="2", role="link", name="Link 1"),
                AXNode(ref="3", role="button", name="Button 1"),
                AXNode(ref="4", role="textbox", name="Name"),
                AXNode(ref="5", role="paragraph", name="Just text"),
            ],
        )

        interactive = root.find_interactive()
        assert len(interactive) == 3
        roles = {n.role for n in interactive}
        assert "link" in roles
        assert "button" in roles
        assert "textbox" in roles
        assert "paragraph" not in roles


class TestBrowserInfo:
    """Tests for BrowserInfo dataclass."""

    def test_from_dict(self):
        """Test BrowserInfo creation from dict."""
        data = {
            "Browser": "Chrome/120.0.0.0",
            "Protocol-Version": "1.3",
            "User-Agent": "Mozilla/5.0...",
            "V8-Version": "12.0.0",
            "WebKit-Version": "537.36",
            "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/xxx",
        }
        info = BrowserInfo.from_dict(data)
        assert info.browser == "Chrome/120.0.0.0"
        assert info.protocol_version == "1.3"


class TestErrors:
    """Tests for error classes."""

    def test_browser_error_context(self):
        """Test BrowserError with context."""
        err = BrowserError("Something failed", context={"url": "https://example.com", "code": 500})
        assert "Something failed" in str(err)
        assert "url=https://example.com" in str(err)
        assert "code=500" in str(err)

    def test_connection_error(self):
        """Test ConnectionError with host/port."""
        err = ConnectionError("Chrome not reachable", host="localhost", port=9222)
        assert "Chrome not reachable" in str(err)
        assert "host=localhost" in str(err)
        assert "port=9222" in str(err)

    def test_timeout_error(self):
        """Test TimeoutError with timeout/operation."""
        err = TimeoutError("Navigation timed out", timeout=30.0, operation="navigate")
        assert "Navigation timed out" in str(err)
        assert "timeout=30.0" in str(err)
        assert "operation=navigate" in str(err)

    def test_cdp_error(self):
        """Test CDPError with code/method."""
        err = CDPError("Invalid node", code=-32000, method="DOM.resolveNode")
        assert "Invalid node" in str(err)
        assert "code=-32000" in str(err)
        assert "method=DOM.resolveNode" in str(err)


class TestJsEscaping:
    """Tests for JavaScript string escaping."""

    def test_basic_escape(self):
        """Test escaping of various characters."""
        assert escape_js_string("hello") == "hello"
        assert escape_js_string("don't") == "don\\'t"
        assert escape_js_string('say "hello"') == 'say \\"hello\\"'
        assert escape_js_string("back`tick") == "back\\`tick"
        # Input: slash\path (1 backslash) → Output: slash\\path (2 backslashes)
        input_backslash = "slash\\path"
        output_backslash = escape_js_string(input_backslash)
        # Verify backslash is doubled
        assert output_backslash == input_backslash.replace("\\", "\\\\")
        # Input: line\nbreak (newline char) → Output: line\nbreak (backslash-n literal)
        assert escape_js_string("line\nbreak").startswith("line\\")
        assert "\\n" in escape_js_string("line\nbreak")
        # Input: return\r (carriage return) → Output: return\r (backslash-r literal)
        assert "\\r" in escape_js_string("return\r")

    def test_vulnerability_payload(self):
        """Test the specific payload mentioned in the issue."""
        payload = "'); alert('xss'); ("
        escaped = escape_js_string(payload)
        # It should be safely escaped so it can't break out of single quotes
        assert r"\'" in escaped
        assert escaped == r"\'); alert(\'xss\'); ("

    def test_fill_form_dispatches_events(self):
        """Test fill_form template includes input and change events."""
        from browser_hybrid.browser import _FILL_FORM_JS

        js_code = _FILL_FORM_JS.replace("{label}", "Email").replace("{value}", "test")

        assert "input.dispatchEvent(new Event('input', {bubbles: true}))" in js_code
        assert "input.dispatchEvent(new Event('change', {bubbles: true}))" in js_code


@pytest.mark.real_connection_check
class TestBrowserHttpMethods:
    """Tests for Browser HTTP API methods."""

    @patch("browser_hybrid.browser.urllib.request.urlopen")
    def test_connection_check(self, mock_urlopen):
        """Test connection check on init."""
        mock_urlopen.return_value.__enter__ = MagicMock()
        mock_urlopen.return_value.__exit__ = MagicMock()
        mock_urlopen.return_value.read.return_value = json.dumps({"Browser": "Chrome/1.0"}).encode()

        browser = Browser()
        assert browser.host == "localhost"
        assert browser.port == 9222

    @patch("browser_hybrid.browser.urllib.request.urlopen")
    def test_connection_failure(self, mock_urlopen):
        """Test connection failure raises ConnectionError."""
        import urllib.error

        mock_urlopen.side_effect = urllib.error.URLError("Connection refused")

        with pytest.raises(ConnectionError):
            Browser()

    @patch("browser_hybrid.browser.urllib.request.urlopen")
    def test_list_tabs(self, mock_urlopen):
        """Test listing tabs."""
        # First call: /json/version for connection check
        version_response = MagicMock()
        version_response.read.return_value = json.dumps({"Browser": "Chrome/1.0"}).encode()
        version_response.__enter__ = MagicMock(return_value=version_response)
        version_response.__exit__ = MagicMock(return_value=False)

        # Second call: /json/list for list_tabs
        list_response = MagicMock()
        list_response.read.return_value = json.dumps(
            [
                {"id": "tab1", "url": "https://example.com", "title": "Example", "type": "page"},
                {"id": "tab2", "url": "https://google.com", "title": "Google", "type": "page"},
                {"id": "sw1", "url": "sw:", "title": "Service Worker", "type": "service_worker"},
            ]
        ).encode()
        list_response.__enter__ = MagicMock(return_value=list_response)
        list_response.__exit__ = MagicMock(return_value=False)

        mock_urlopen.side_effect = [version_response, list_response]

        browser = Browser()
        tabs = browser.list_tabs()

        assert len(tabs) == 2  # Only page type
        assert all(t.type == "page" for t in tabs)


# Integration tests require Chrome running with debug port
# Run with: pytest -m integration


class TestCdpErrorHandling:
    """Tests for CDP error handling in Browser._send_cdp."""

    @pytest.mark.asyncio
    @patch("browser_hybrid.browser.Browser._get_connection")
    async def test_send_cdp_domain_enable_error(self, mock_get_conn):
        """Test that domain.enable errors are correctly raised."""
        from browser_hybrid.errors import CDPError

        mock_conn = AsyncMock()
        mock_get_conn.return_value = mock_conn

        # Mock result for domain.enable failure
        mock_conn.send.return_value = {"error": {"code": -32601, "message": "Method not found"}}

        browser = Browser()
        # Call _send_cdp directly - we can since we are testing internal logic
        with pytest.raises(CDPError) as excinfo:
            await browser._send_cdp(mock_conn, "Page.navigate", domains=["NonExistent"])

        assert "NonExistent.enable" in excinfo.value.method
        assert "Method not found" in str(excinfo.value)


class TestWaiterMethods:
    """Tests for wait_for_load_state and goto adaptive behavior."""

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_wait_for_load_state_fast(self, mock_get_conn, mock_send_cdp):
        """Test wait_for_load_state with fast-resolving page."""
        mock_get_conn.return_value = AsyncMock()
        # Runtime.evaluate returns {"result": {"value": ...}}
        mock_send_cdp.return_value = {"result": {"value": "complete"}}

        browser = Browser()
        res = browser.wait_for_load_state(timeout=0.5)
        assert res is True
        assert mock_send_cdp.call_count == 1

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_wait_for_load_state_slow(self, mock_get_conn, mock_send_cdp):
        """Test wait_for_load_state with slow-resolving page (re-polls)."""
        mock_get_conn.return_value = AsyncMock()
        # Returns loading then complete
        mock_send_cdp.side_effect = [
            {"result": {"value": "loading"}},
            {"result": {"value": "complete"}},
        ]

        browser = Browser()
        res = browser.wait_for_load_state(state="load", timeout=1.0)
        assert res is True
        assert mock_send_cdp.call_count == 2

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_wait_for_load_state_timeout(self, mock_get_conn, mock_send_cdp):
        """Test wait_for_load_state timeout raises error."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {"result": {"value": "loading"}}

        browser = Browser()
        with pytest.raises(TimeoutError) as excinfo:
            browser.wait_for_load_state(timeout=0.1)
        assert "timed out" in str(excinfo.value)


class TestAXTreeParsing:
    """Tests for accessibility tree parsing logic."""

    def test_parse_ax_nodes_out_of_order(self):
        """Test parsing accessibility nodes where child precedes parent."""
        browser = Browser()
        # Child ID "2" appears before Parent ID "1"
        nodes = [
            {"nodeId": "2", "role": {"value": "link"}, "name": {"value": "Child"}, "parentId": "1"},
            {
                "nodeId": "1",
                "role": {"value": "RootWebArea"},
                "name": {"value": "Parent"},
                "childIds": ["2"],
            },
        ]

        root = browser._parse_ax_nodes(nodes)
        assert root.ref == "1"
        assert len(root.children) == 1
        assert root.children[0].ref == "2"
        assert root.children[0].parent == root


class TestBrowserInteractions:
    """Tests for browser interaction methods (click, type, etc)."""

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_click_ref(self, mock_get_conn, mock_send_cdp):
        """Test clicking by accessibility ref."""
        mock_get_conn.return_value = AsyncMock()
        # Mock tree response
        mock_send_cdp.side_effect = [
            {
                "nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]
            },  # Accessibility.getFullAXTree
            {"object": {"type": "object", "objectId": "obj1"}},  # DOM.resolveNode
            {"model": {"content": [0, 0, 10, 0, 10, 10, 0, 10]}},  # DOM.getBoxModel
            {},  # Input.dispatchMouseEvent press
            {},  # Input.dispatchMouseEvent release
        ]

        browser = Browser()
        res = browser.click("axnode@123")
        assert res is True

        # Verify DOM.resolveNode was called with backendNodeId
        resolve_call = [c for c in mock_send_cdp.call_args_list if c.args[1] == "DOM.resolveNode"][
            0
        ]
        assert resolve_call.args[2]["backendNodeId"] == 456

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_type_text_ref(self, mock_get_conn, mock_send_cdp):
        """Test typing by accessibility ref."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.side_effect = [
            {"nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]},
            {"object": {"type": "object", "objectId": "obj1"}},
            {},  # Runtime.callFunctionOn
        ]

        browser = Browser()
        res = browser.type_text("axnode@123", "secret")
        assert res is True

        # Verify callFunctionOn was called with the text
        type_call = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Runtime.callFunctionOn"
        ][0]
        assert "secret" in type_call.args[2]["functionDeclaration"]

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_fill_form_by_name(self, mock_get_conn, mock_send_cdp):
        """Test fill_form using DOM search."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {"result": {"value": True}}

        browser = Browser()
        res = browser.fill_form({"Email": "user@example.com"})
        assert res == {"Email": True}

        # Verify Runtime.evaluate call
        eval_call = [c for c in mock_send_cdp.call_args_list if c.args[1] == "Runtime.evaluate"][0]
        assert "Email" in eval_call.args[2]["expression"]
        assert "user@example.com" in eval_call.args[2]["expression"]

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_click_by_text_first_match(self, mock_get_conn, mock_send_cdp):
        """Test that click_by_text behavior remains as documented (first-match)."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {"result": {"value": True}}

        browser = Browser()
        res = browser.click_by_text("Button")
        assert res is True

        # Verify call to Runtime.evaluate
        assert any(c.args[1] == "Runtime.evaluate" for c in mock_send_cdp.call_args_list)


class TestKeyboardAndMouse:
    """Tests for keyboard and mouse actions."""

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_hover(self, mock_get_conn, mock_send_cdp):
        """Test hover over element."""
        mock_get_conn.return_value = AsyncMock()
        # Mock tree response
        mock_send_cdp.side_effect = [
            {
                "nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]
            },  # Accessibility.getFullAXTree
            {"object": {"type": "object", "objectId": "obj1"}},  # DOM.resolveNode
            {},  # Runtime.callFunctionOn
        ]

        browser = Browser()
        res = browser.hover("axnode@123")
        assert res is True

        # Verify Runtime.callFunctionOn was called with hover events
        calls = [c.args[1] for c in mock_send_cdp.call_args_list]
        assert "Runtime.callFunctionOn" in calls

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_click_right_button(self, mock_get_conn, mock_send_cdp):
        """Test right-click (context menu)."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.side_effect = [
            {"nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]},
            {"object": {"type": "object", "objectId": "obj1"}},
            {"model": {"content": [10, 10, 30, 10, 30, 30, 10, 30]}},  # DOM.getBoxModel
            {},  # Input.dispatchMouseEvent press
            {},  # Input.dispatchMouseEvent release
        ]

        browser = Browser()
        res = browser.click("axnode@123", button="right")
        assert res is True

        # Verify Input.dispatchMouseEvent was called with button='right'
        mouse_calls = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.dispatchMouseEvent"
        ]
        assert len(mouse_calls) == 2
        assert mouse_calls[0].args[2]["button"] == "right"

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_click_double(self, mock_get_conn, mock_send_cdp):
        """Test double-click."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.side_effect = [
            {"nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]},
            {"object": {"type": "object", "objectId": "obj1"}},
            {"model": {"content": [10, 10, 30, 10, 30, 30, 10, 30]}},  # DOM.getBoxModel
            {},  # Input.dispatchMouseEvent press
            {},  # Input.dispatchMouseEvent release
        ]

        browser = Browser()
        res = browser.click("axnode@123", double=True)
        assert res is True

        # Verify Input.dispatchMouseEvent was called with clickCount=2
        mouse_calls = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.dispatchMouseEvent"
        ]
        assert len(mouse_calls) == 2
        assert mouse_calls[0].args[2]["clickCount"] == 2
        assert mouse_calls[1].args[2]["clickCount"] == 2
        assert mouse_calls[0].args[2]["x"] == 20
        assert mouse_calls[0].args[2]["y"] == 20

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_press_key_single_char(self, mock_get_conn, mock_send_cdp):
        """Test pressing a single character."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {}

        browser = Browser()
        browser.press_key("a")

        # Verify Input.enable and dispatchKeyEvent calls
        calls = [c.args[1] for c in mock_send_cdp.call_args_list]
        assert "Input.enable" in calls
        assert "Input.dispatchKeyEvent" in calls

        # Verify keyDown and keyUp
        key_calls = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.dispatchKeyEvent"
        ]
        assert len(key_calls) == 2  # keyDown and keyUp
        assert key_calls[0].args[2]["type"] == "keyDown"
        assert key_calls[1].args[2]["type"] == "keyUp"
        assert key_calls[0].args[2]["key"] == "a"

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_press_key_with_modifier(self, mock_get_conn, mock_send_cdp):
        """Test pressing key with Ctrl modifier."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {}

        browser = Browser()
        browser.press_key("c", modifiers=["Control"])

        # Verify modifier flag is set
        key_calls = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.dispatchKeyEvent"
        ]
        # Control modifier = 2
        assert key_calls[0].args[2]["modifiers"] == 2

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_click_double_event(self, mock_get_conn, mock_send_cdp):
        """Double-click should trigger dblclick if coordinates fail."""
        mock_get_conn.return_value = AsyncMock()
        # Mocking box model to fail (return None) to trigger JS fallback
        mock_send_cdp.side_effect = [
            {"nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]},
            {"object": {"type": "object", "objectId": "obj1"}},
            {},  # DOM.getBoxModel returns empty
            {},  # Runtime.callFunctionOn
        ]

        browser = Browser()
        browser.click("axnode@123", double=True)

        # Verify callFunctionOn includes dblclick event (fallback)
        call = [c for c in mock_send_cdp.call_args_list if c.args[1] == "Runtime.callFunctionOn"][0]
        assert "dblclick" in call.args[2]["functionDeclaration"]

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_recording_double_click(self, mock_get_conn, mock_send_cdp):
        """Double-click should be recorded with double=True."""
        import os
        import tempfile

        from browser_hybrid.models import Recording

        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {"nodes": []}

        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.json")
            browser = Browser()
            browser.start_recording(path)
            browser.click("axnode@123", double=True)
            browser.stop_recording()

            recording = Recording.from_file(path)
            click_action = [a for a in recording.actions if a.type == "click"][0]
            assert click_action.params.get("double") is True

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_press_key_special_keys(self, mock_get_conn, mock_send_cdp):
        """Test pressing special keys like Enter, Tab."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.return_value = {}

        browser = Browser()
        browser.press_key("Enter")

        key_calls = [
            c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.dispatchKeyEvent"
        ]
        assert key_calls[0].args[2]["key"] == "Enter"
        assert key_calls[0].args[2]["keyCode"] == 13

    @patch("browser_hybrid.browser.Browser._send_cdp")
    @patch("browser_hybrid.browser.Browser._get_connection")
    def test_type_slowly(self, mock_get_conn, mock_send_cdp):
        """Test type_slowly character-by-character."""
        mock_get_conn.return_value = AsyncMock()
        mock_send_cdp.side_effect = [
            {"nodes": [{"nodeId": "axnode@123", "backendDOMNodeId": 456}]},
            {"object": {"type": "object", "objectId": "obj1"}},
            {},  # Runtime.callFunctionOn for focus
            {},  # Input.enable
        ] + [{}] * 5  # 5 characters

        browser = Browser()
        res = browser.type_slowly("axnode@123", "hello")
        assert res is True

        # Verify Input.insertText called for each character
        insert_calls = [c for c in mock_send_cdp.call_args_list if c.args[1] == "Input.insertText"]
        assert len(insert_calls) == 5  # 5 characters


class TestBrowserReconnection:
    """Tests for automatic reconnection in Browser."""

    @pytest.mark.asyncio
    async def test_auto_reconnect_on_disconnect(self):
        """Test that Browser triggers reconnection when it sees DISCONNECTED state."""
        mock_conn = MagicMock()
        mock_conn.state = ConnectionState.DISCONNECTED
        mock_conn.reconnect = AsyncMock(return_value=True)
        # on_state_change will be set by _get_connection
        mock_conn.on_state_change = None

        browser = Browser(reconnect=True)
        # Use current test loop
        browser._loop = asyncio.get_running_loop()
        with patch.object(browser, "_get_pool") as mock_pool:
            mock_pool.return_value.get_connection = AsyncMock(return_value=mock_conn)

            # Get connection should subscribe
            tab = Tab(id="1", url="u", title="t", type="p", web_socket_debugger_url="w")
            conn = await browser._get_connection(tab)
            assert conn.on_state_change is not None

            # Simulate state change to DISCONNECTED
            # The callback in Browser sets up a task to call _handle_disconnect
            conn.on_state_change(ConnectionState.DISCONNECTED)

            # Wait a bit for the task to run
            await asyncio.sleep(0.01)

            # Verify that reconnect was called with browser config
            mock_conn.reconnect.assert_called_once_with(
                max_retries=3,
                base_backoff=1.0,
            )

    @pytest.mark.asyncio
    async def test_disabled_reconnect_raises_error(self):
        """Test that Browser does not reconnect if disabled."""
        mock_conn = MagicMock()
        mock_conn.state = ConnectionState.DISCONNECTED
        mock_conn.send = AsyncMock(side_effect=ConnectionError("Not connected"))

        browser = Browser(reconnect=False)

        # _send_cdp should not try to reconnect and should let ConnectionError propagate
        with pytest.raises(ConnectionError):
            await browser._send_cdp(mock_conn, "Page.navigate")

        assert mock_conn.reconnect.call_count == 0

    @pytest.mark.asyncio
    async def test_reconnect_callback_triggered(self):
        """Test that reconnect_callback is called with status messages."""
        mock_conn = MagicMock()
        mock_conn.tab_id = "test-tab"
        mock_conn.state = ConnectionState.DISCONNECTED
        mock_conn.reconnect = AsyncMock(return_value=True)

        messages = []

        def callback(msg):
            messages.append(msg)

        browser = Browser(reconnect_callback=callback)
        browser._loop = asyncio.get_running_loop()

        # Trigger reconnection via _handle_disconnect
        await browser._handle_disconnect(mock_conn)

        # Should have "Connection to tab test-tab lost..."
        # and "Automatic reconnection... successful."
        assert len(messages) >= 2
        assert "lost" in messages[0]
        assert "successful" in messages[1]

    @pytest.mark.asyncio
    async def test_interceptors_preserved_after_reconnect(self):
        """Test that interceptors are re-registered after reconnection."""
        mock_conn = MagicMock()
        # Mock reconnect to return True
        mock_conn.reconnect = AsyncMock(return_value=True)
        # Mock send to succeed for Network.enable
        mock_conn.send = AsyncMock(return_value={"result": {}})

        browser = Browser(reconnect=True)
        # Use current test loop to avoid "already running" errors
        browser._loop = asyncio.get_running_loop()

        # Register an interceptor
        callback = MagicMock()
        with patch.object(browser, "_get_connection", AsyncMock(return_value=mock_conn)):
            browser.on_request(callback)
            await asyncio.sleep(0.01)  # Allow initial setup to run

        # Reconnect
        await browser._handle_disconnect(mock_conn)

        # Check call count:
        # 1. Initial on_request calls _setup (which calls Network.enable)
        # 2. _handle_disconnect calls _reregister_interceptors (which calls Network.enable)
        # Total Network.enable calls: 2
        enable_calls = [c for c in mock_conn.send.call_args_list if c.args[0] == "Network.enable"]
        assert len(enable_calls) == 2
        # Check on_event was called twice
        assert mock_conn.on_event.call_count == 2
        assert mock_conn.on_event.call_args_list[1].args[0] == "Network.requestWillBeSent"

    @pytest.mark.asyncio
    async def test_interceptors_cleared_on_close(self):
        """Test that interceptors are cleared when browser is closed."""
        browser = Browser()
        # Use current test loop
        browser._loop = asyncio.get_running_loop()

        browser.on_request(MagicMock())
        assert len(browser._interceptors) == 1

        browser.close()
        # close() should clear _interceptors
        assert len(browser._interceptors) == 0


class TestBrowserIntegration:
    """Integration tests (require Chrome with --remote-debugging-port=9222)."""

    @pytest.mark.integration
    def test_list_tabs_real(self):
        """Test listing real tabs."""
        pytest.skip("Requires Chrome debug mode")

    @pytest.mark.integration
    def test_new_and_close_tab_real(self):
        """Test creating and closing tabs."""
        pytest.skip("Requires Chrome debug mode")

    @pytest.mark.integration
    def test_accessibility_tree_real(self):
        """Test accessibility tree parsing."""
        pytest.skip("Requires Chrome debug mode")


class TestWaitConditions:
    """Tests for wait condition methods."""

    def test_wait_for_url_exact_match(self):
        """Test wait_for_url with exact URL."""
        with patch.object(Browser, "evaluate", return_value="https://example.com/page"):
            browser = Browser()
            result = browser.wait_for_url(url="https://example.com/page")
            assert result is True

    def test_wait_for_url_pattern_match(self):
        """Test wait_for_url with regex pattern."""
        with patch.object(Browser, "evaluate", return_value="https://example.com/page/123"):
            browser = Browser()
            result = browser.wait_for_url(pattern=r"/page/\d+")
            assert result is True

    def test_wait_for_url_timeout(self):
        """Test wait_for_url returns False on timeout."""
        with patch.object(Browser, "evaluate", return_value="https://other.com"):
            browser = Browser()
            result = browser.wait_for_url(url="https://example.com", timeout=0.5)
            assert result is False

    def test_wait_for_url_both_params_raises(self):
        """Test wait_for_url raises when both url and pattern provided."""
        browser = Browser()
        with pytest.raises(ValueError, match="not both"):
            browser.wait_for_url(url="https://example.com", pattern=r"\.com")

    def test_wait_for_url_no_params_raises(self):
        """Test wait_for_url raises when no params provided."""
        browser = Browser()
        with pytest.raises(ValueError, match="must be provided"):
            browser.wait_for_url()

    def test_wait_for_selector_visible(self):
        """Test wait_for_selector_visible returns True when visible."""
        with patch.object(Browser, "evaluate", return_value=True):
            browser = Browser()
            result = browser.wait_for_selector_visible("#my-element")
            assert result is True

    def test_wait_for_selector_visible_timeout(self):
        """Test wait_for_selector_visible returns False on timeout."""
        with patch.object(Browser, "evaluate", return_value=False):
            browser = Browser()
            result = browser.wait_for_selector_visible("#hidden", timeout=0.5)
            assert result is False

    def test_wait_for_selector_hidden(self):
        """Test wait_for_selector_hidden returns True when hidden."""
        with patch.object(Browser, "evaluate", return_value=True):
            browser = Browser()
            result = browser.wait_for_selector_hidden("#loading")
            assert result is True

    def test_wait_for_selector_hidden_timeout(self):
        """Test wait_for_selector_hidden returns False on timeout."""
        with patch.object(Browser, "evaluate", return_value=False):
            browser = Browser()
            result = browser.wait_for_selector_hidden("#visible", timeout=0.5)
            assert result is False

    def test_wait_for_function_truthy(self):
        """Test wait_for_function returns True when function returns truthy."""
        with patch.object(Browser, "evaluate", return_value=True):
            browser = Browser()
            result = browser.wait_for_function("document.readyState === 'complete'")
            assert result is True

    def test_wait_for_function_timeout(self):
        """Test wait_for_function returns False on timeout."""
        with patch.object(Browser, "evaluate", return_value=False):
            browser = Browser()
            result = browser.wait_for_function("false", timeout=0.5)
            assert result is False

    def test_wait_for_function_transient_error(self):
        """Test wait_for_function handles transient errors."""
        call_count = [0]

        def mock_evaluate(expr):
            call_count[0] += 1
            if call_count[0] < 3:
                raise ConnectionError("Simulated transient error")
            return True

        with patch.object(Browser, "evaluate", side_effect=mock_evaluate):
            browser = Browser()
            result = browser.wait_for_function("true", timeout=1.0)
            assert result is True

    def test_wait_for_navigation_delegates_to_load_state(self):
        """Test wait_for_navigation calls wait_for_load_state."""
        with patch.object(Browser, "wait_for_load_state", return_value=True) as mock:
            browser = Browser()
            result = browser.wait_for_navigation(timeout=5.0, wait_until="domcontentloaded")
            assert result is True
            mock.assert_called_once_with(state="domcontentloaded", tab=None, timeout=5.0)


class TestCookies:
    """Tests for cookie management methods."""

    def test_get_cookies_returns_list(self):
        """Test get_cookies returns list of cookies."""
        mock_cookies = [
            {"name": "session", "value": "abc123", "domain": "example.com"},
            {"name": "theme", "value": "dark", "domain": "example.com"},
        ]

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            if method == "Network.enable":
                return {}
            if method == "Network.getCookies":
                return {"cookies": mock_cookies}
            return {}

        with patch.object(Browser, "_get_connection", return_value=AsyncMock()):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                result = browser.get_cookies()
                assert result == mock_cookies

    def test_get_cookies_with_urls(self):
        """Test get_cookies filters by URLs."""
        mock_cookies = [{"name": "test", "value": "val", "domain": "example.com"}]
        call_args = []

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            call_args.append((method, params))
            if method == "Network.enable":
                return {}
            if method == "Network.getCookies":
                return {"cookies": mock_cookies}
            return {}

        with patch.object(Browser, "_get_connection", return_value=AsyncMock()):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                result = browser.get_cookies(urls=["https://example.com"])
                assert result == mock_cookies
                # Verify URLs were passed
                assert call_args[1][1] == {"urls": ["https://example.com"]}

    def test_set_cookies_calls_cdp(self):
        """Test set_cookies calls Network.setCookie for each cookie."""
        cookies = [
            {"name": "session", "value": "abc123", "domain": "example.com"},
            {"name": "theme", "value": "dark", "url": "https://example.com"},
        ]
        call_args = []

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            call_args.append((method, params))
            return {}

        with patch.object(Browser, "_get_connection", return_value=AsyncMock()):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                browser.set_cookies(cookies)

                # Verify Network.enable + 2 setCookie calls
                methods = [args[0] for args in call_args]
                assert "Network.enable" in methods
                assert methods.count("Network.setCookie") == 2

    def test_set_cookies_missing_name_raises(self):
        """Test set_cookies raises ValueError when name is missing."""
        browser = Browser()
        with pytest.raises(ValueError, match="missing required key.*name"):
            browser.set_cookies([{"value": "test", "domain": "example.com"}])

    def test_set_cookies_missing_value_raises(self):
        """Test set_cookies raises ValueError when value is missing."""
        browser = Browser()
        with pytest.raises(ValueError, match="missing required key.*value"):
            browser.set_cookies([{"name": "session", "domain": "example.com"}])

    def test_set_cookies_missing_both_raises(self):
        """Test set_cookies raises ValueError when both required keys missing."""
        browser = Browser()
        with pytest.raises(ValueError, match="missing required key"):
            browser.set_cookies([{"domain": "example.com"}])

    def test_delete_cookies_by_name(self):
        """Test delete_cookies with specific name derives URL from current tab."""
        call_args = []

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            call_args.append((method, params))
            return {}

        # Mock _get_current_tab to return a tab with URL
        mock_tab = Tab(
            id="test-id",
            url="https://example.com",
            title="Test",
            type="page",
            web_socket_debugger_url="ws://test",
        )

        with patch.object(Browser, "_get_connection", return_value=AsyncMock()):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                with patch.object(Browser, "_get_current_tab", return_value=mock_tab):
                    browser = Browser()
                    browser.delete_cookies(name="session")

                    # Should include name AND url (derived from current tab)
                    assert call_args[-1] == (
                        "Network.deleteCookies",
                        {"name": "session", "url": "https://example.com"},
                    )

    def test_delete_cookies_clears_all(self):
        """Test delete_cookies with no criteria clears all cookies."""
        mock_cookies = [
            {"name": "session", "value": "abc", "domain": "example.com"},
            {"name": "theme", "value": "dark", "domain": "example.com"},
        ]
        mock_tab = Tab(
            id="test-id",
            url="https://example.com",
            title="Test",
            type="page",
            web_socket_debugger_url="ws://test",
        )
        call_args = []

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            call_args.append((method, params))
            if method == "Network.enable":
                return {}
            if method == "Network.getCookies":
                return {"cookies": mock_cookies}
            return {}

        with patch.object(Browser, "_get_connection", return_value=AsyncMock()):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                with patch.object(Browser, "_get_current_tab", return_value=mock_tab):
                    browser = Browser()
                    browser.delete_cookies()

                    # Should call deleteCookies for each cookie found
                    delete_calls = [
                        args for args in call_args if args[0] == "Network.deleteCookies"
                    ]
                    assert len(delete_calls) == 2


class TestNetworkInterception:
    """Tests for network interception methods."""

    def test_on_request_registers_callback(self):
        """Test on_request registers callback for Network.requestWillBeSent."""
        mock_conn = AsyncMock()
        mock_conn.on_event = MagicMock()

        async def mock_get_connection(tab=None):
            return mock_conn

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            if method == "Network.enable":
                return {}
            return {}

        with patch.object(Browser, "_get_connection", side_effect=mock_get_connection):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                callback = MagicMock()
                browser.on_request(callback)

                # Verify Network.enable was called
                # and event listener was registered
                assert "Network.requestWillBeSent" in mock_conn.on_event.call_args[0]

    def test_on_response_registers_callback(self):
        """Test on_response registers callback for Network.responseReceived."""
        mock_conn = AsyncMock()
        mock_conn.on_event = MagicMock()

        async def mock_get_connection(tab=None):
            return mock_conn

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            if method == "Network.enable":
                return {}
            return {}

        with patch.object(Browser, "_get_connection", side_effect=mock_get_connection):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                callback = MagicMock()
                browser.on_response(callback)

                assert "Network.responseReceived" in mock_conn.on_event.call_args[0]

    def test_on_request_failed_registers_callback(self):
        """Test on_request_failed registers callback for Network.loadingFailed."""
        mock_conn = AsyncMock()
        mock_conn.on_event = MagicMock()

        async def mock_get_connection(tab=None):
            return mock_conn

        async def mock_send_cdp(conn, method, params=None, **kwargs):
            if method == "Network.enable":
                return {}
            return {}

        with patch.object(Browser, "_get_connection", side_effect=mock_get_connection):
            with patch.object(Browser, "_send_cdp", side_effect=mock_send_cdp):
                browser = Browser()
                callback = MagicMock()
                browser.on_request_failed(callback)

                assert "Network.loadingFailed" in mock_conn.on_event.call_args[0]

    def test_clear_interceptors(self):
        """Test clear_interceptors removes all handlers."""
        mock_conn = AsyncMock()
        mock_conn._event_listeners = {"Network.requestWillBeSent": [MagicMock()]}

        async def mock_get_connection(tab=None):
            return mock_conn

        with patch.object(Browser, "_get_connection", side_effect=mock_get_connection):
            browser = Browser()
            browser._interceptors = {"Network.requestWillBeSent": [MagicMock()]}
            browser.clear_interceptors()

            assert browser._interceptors == {}
            # Verify the listeners dict was cleared
            assert mock_conn._event_listeners == {}

    def test_connection_dispatch_event_calls_listeners(self):
        """Test Connection._dispatch_event calls registered callbacks."""
        from browser_hybrid.connection import Connection

        conn = Connection("ws://localhost:9222")
        callback1 = MagicMock()
        callback2 = MagicMock()

        conn.on_event("Network.requestWillBeSent", callback1)
        conn.on_event("Network.requestWillBeSent", callback2)

        params = {"requestId": "123", "request": {"url": "https://example.com"}}
        conn._dispatch_event("Network.requestWillBeSent", params)

        callback1.assert_called_once_with(params)
        callback2.assert_called_once_with(params)

    def test_connection_off_event_removes_callback(self):
        """Test Connection.off_event removes specific callback."""
        from browser_hybrid.connection import Connection

        conn = Connection("ws://localhost:9222")
        callback = MagicMock()

        conn.on_event("Network.requestWillBeSent", callback)
        assert "Network.requestWillBeSent" in conn._event_listeners
        assert len(conn._event_listeners["Network.requestWillBeSent"]) == 1

        conn.off_event("Network.requestWillBeSent", callback)
        assert len(conn._event_listeners["Network.requestWillBeSent"]) == 0

    def test_connection_off_event_removes_all(self):
        """Test Connection.off_event removes all callbacks when callback=None."""
        from browser_hybrid.connection import Connection

        conn = Connection("ws://localhost:9222")
        conn.on_event("Network.requestWillBeSent", MagicMock())
        conn.on_event("Network.requestWillBeSent", MagicMock())

        assert len(conn._event_listeners["Network.requestWillBeSent"]) == 2

        conn.off_event("Network.requestWillBeSent")
        assert len(conn._event_listeners["Network.requestWillBeSent"]) == 0

    def test_temp_tab_suppresses_cleanup_error(self):
        """Cleanup errors should not propagate from temp_tab."""
        browser = Browser()

        # Mock new_tab to succeed and close_tab to fail
        with (
            patch.object(browser, "new_tab", return_value=MagicMock()),
            patch.object(browser, "close_tab", side_effect=Exception("close failed")),
        ):
            # Should NOT raise "close failed"
            with browser.temp_tab("https://example.com"):
                pass  # Success

            # Should also work when block fails - block error should win
            with pytest.raises(ValueError, match="original"):
                with browser.temp_tab("https://example.com"):
                    raise ValueError("original")

    def test_temp_tab_propagates_block_error(self):
        """Block errors should propagate properly from temp_tab."""
        browser = Browser()
        with (
            patch.object(browser, "new_tab", return_value=MagicMock()),
            patch.object(browser, "close_tab"),
        ):
            with pytest.raises(ValueError, match="block error"):
                with browser.temp_tab("https://example.com"):
                    raise ValueError("block error")
