"""Tests for connection module."""

from __future__ import annotations

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

import pytest

from browser_hybrid.connection import Connection, ConnectionPool, ConnectionState, OperationQueue
from browser_hybrid.errors import CDPError, ConnectionError, TimeoutError


class AsyncMockWebSocket:
    """Mock WebSocket that implements async protocol."""

    def __init__(self):
        self.closed = False
        self._messages = []
        self._response_queue = asyncio.Queue()
        self._iter_count = 0
        self._max_iter = 999

    def __await__(self):
        return self

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        self.closed = True

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self._iter_count >= self._max_iter:
            raise StopAsyncIteration
        self._iter_count += 1
        return await self._response_queue.get()

    async def send(self, data):
        self._messages.append(data)

    async def close(self):
        self.closed = True

    def queue_response(self, msg_id, result):
        """Queue a CDP response."""
        self._response_queue.put_nowait(json.dumps({"id": msg_id, "result": result}))

    def queue_error(self, msg_id, error):
        """Queue a CDP error."""
        self._response_queue.put_nowait(json.dumps({"id": msg_id, "error": error}))


def make_async_mock_ws():
    """Create an async mock for websockets.connect."""
    mock_ws = AsyncMockWebSocket()

    async def mock_connect(*args, **kwargs):
        return mock_ws

    return mock_connect, mock_ws


class TestConnection:
    """Tests for Connection class."""

    def test_init(self):
        """Test Connection initialization."""
        conn = Connection("ws://localhost:9222/devtools/page/abc", timeout=5.0)
        assert conn.ws_url == "ws://localhost:9222/devtools/page/abc"
        assert conn.timeout == 5.0
        assert not conn.is_connected

    def test_is_connected_false_initially(self):
        """Test is_connected is False initially."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")
        assert conn.is_connected is False

    def test_is_healthy_when_connected(self):
        """Test is_healthy returns True when connected."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")
        conn._connected = True
        conn._ws = MagicMock()
        assert conn.is_healthy() is True

    def test_is_healthy_when_disconnected(self):
        """Test is_healthy returns False when disconnected."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")
        assert conn.is_healthy() is False

    def test_check_health_raises_on_disconnect(self):
        """Test check_health raises ConnectionError when disconnected."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")
        with pytest.raises(ConnectionError) as exc_info:
            conn.check_health()
        assert "not healthy" in str(exc_info.value).lower()

    @pytest.mark.asyncio
    async def test_state_transitions(self):
        """Test connection state transitions."""
        mock_connect, _ = make_async_mock_ws()
        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://localhost:9222/devtools/page/abc")
            assert conn.state == ConnectionState.DISCONNECTED

            # Connect
            await conn.connect()
            assert conn.state == ConnectionState.CONNECTED

            # Reconnect
            reconnect_task = asyncio.create_task(conn.reconnect())
            # Briefly it should be RECONNECTING or DISCONNECTED then CONNECTING
            # Since disconnect is awaited and sets DISCONNECTED,
            # but reconnect sets RECONNECTING first.
            await reconnect_task
            assert conn.state == ConnectionState.CONNECTED

            # Disconnect
            await conn.disconnect()
            assert conn.state == ConnectionState.DISCONNECTED

    def test_state_change_callback(self):
        """Test that state change callback is triggered."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")
        states = []

        def callback(new_state):
            states.append(new_state)

        conn.on_state_change = callback
        conn._set_state(ConnectionState.CONNECTING)
        conn._set_state(ConnectionState.CONNECTED)

        assert states == [ConnectionState.CONNECTING, ConnectionState.CONNECTED]

    @pytest.mark.asyncio
    async def test_reconnect_success(self):
        """Test successful reconnection."""
        mock_connect, _ = make_async_mock_ws()
        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://url", tab_id="abc")
            await conn.connect()
            assert conn.is_connected

            # Force disconnect
            await conn.disconnect()
            assert not conn.is_connected

            # Reconnect
            success = await conn.reconnect(max_retries=1)
            assert success is True
            assert conn.is_connected
            assert conn.state == ConnectionState.CONNECTED

    @pytest.mark.asyncio
    async def test_reconnect_max_retries(self):
        """Test reconnection fails after max retries."""
        # Mock connect to always fail
        with patch("browser_hybrid.connection.connect", side_effect=Exception("Failed")):
            conn = Connection("ws://url", tab_id="abc")
            # Set base_backoff to 0 to speed up test
            success = await conn.reconnect(max_retries=2, base_backoff=0.01)
            assert success is False
            assert not conn.is_connected
            assert conn.state == ConnectionState.DISCONNECTED

    @pytest.mark.asyncio
    async def test_reconnect_exponential_backoff(self):
        """Test reconnection uses exponential backoff."""
        with patch("browser_hybrid.connection.connect", side_effect=Exception("Failed")):
            with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
                conn = Connection("ws://url", tab_id="abc")
                await conn.reconnect(max_retries=3, base_backoff=1.0)

                # Should sleep 1s, then 2s
                assert mock_sleep.call_count == 2
                assert mock_sleep.call_args_list[0].args[0] == 1.0
                assert mock_sleep.call_args_list[1].args[0] == 2.0

    @pytest.mark.asyncio
    async def test_queue_operation_during_reconnect(self):
        """Test that operations are queued during reconnection."""
        conn = Connection("ws://url", tab_id="abc")
        conn._set_state(ConnectionState.RECONNECTING)

        # Call send (which should queue)
        asyncio.create_task(conn.send("Page.navigate", {"url": "x"}))
        await asyncio.sleep(0.01)  # Let it run

        # Check queue
        ops = conn._queue.get_all()
        assert len(ops) == 1
        assert ops[0].method == "Page.navigate"
        assert not ops[0].future.done()

    @pytest.mark.asyncio
    async def test_replay_after_reconnect(self):
        """Test that queued operations are replayed after successful reconnect."""
        mock_connect, mock_ws = make_async_mock_ws()
        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://url", tab_id="abc")
            # Set to RECONNECTING manually for testing
            conn._set_state(ConnectionState.RECONNECTING)

            # Queue an operation (returns a Future that we await)
            send_task = asyncio.create_task(conn.send("Page.navigate", {"url": "x"}))
            await asyncio.sleep(0.01)  # Let it queue

            # Trigger reconnection
            # We need to mock _resolve_ws_url to not fail
            with patch.object(conn, "_resolve_ws_url", return_value="ws://url"):
                # Mock the response in the mock WebSocket for the replayed command
                # After connect(), conn._ws will be mock_ws
                mock_ws.queue_response(2, {"status": "ok"})  # next_id will be 2

                await conn.reconnect(max_retries=1)

                # Operation should be replayed and task should finish
                result = await send_task
                assert result.get("result") == {"status": "ok"}
                # Check that WebSocket received one message (one replayed)
                assert len(mock_ws._messages) == 1

    def test_next_id_thread_safe(self):
        """Test message ID generation is thread-safe."""
        import threading

        conn = Connection("ws://localhost:9222/devtools/page/abc")
        ids = []

        def get_ids():
            for _ in range(100):
                ids.append(conn._next_id())

        threads = [threading.Thread(target=get_ids) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # All IDs should be unique
        assert len(ids) == 1000
        assert len(set(ids)) == 1000

    @pytest.mark.asyncio
    async def test_send_not_connected_raises(self):
        """Test sending on disconnected connection raises error."""
        conn = Connection("ws://localhost:9222/devtools/page/abc")

        with pytest.raises(ConnectionError) as exc_info:
            await conn.send("Page.navigate", {"url": "https://example.com"})

        assert "not connected" in str(exc_info.value).lower()


class TestConnectionLifecycle:
    """Tests for Connection lifecycle and state management."""

    @pytest.mark.asyncio
    async def test_is_connected_false_after_close(self):
        """Test is_connected returns False after WebSocket closes."""
        mock_connect, mock_ws = make_async_mock_ws()

        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://localhost:9222/devtools/page/abc")
            await conn.connect()
            assert conn.is_connected is True

            # Trigger the loop to exit by setting max iterations to current count
            # This will cause __anext__ to raise StopAsyncIteration
            mock_ws._max_iter = mock_ws._iter_count

            # We need to wait a tiny bit for the loop to run and exit
            # or we can manually trigger a message to poke the queue if it was waiting
            mock_ws._response_queue.put_nowait(None)  # Not used after StopAsyncIteration is raised

            # Alternative: just wait for the task to finish
            if conn._receive_task:
                await asyncio.wait_for(conn._receive_task, timeout=1.0)

            assert conn.is_connected is False
            assert conn._ws is None
            assert conn._connected is False

    @pytest.mark.asyncio
    async def test_is_connected_false_after_exception(self):
        """Test is_connected returns False after an exception in receive loop."""
        mock_connect, mock_ws = make_async_mock_ws()

        # Mock __anext__ to raise an exception
        mock_ws.__anext__ = AsyncMock(side_effect=Exception("WebSocket crash"))

        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://localhost:9222/devtools/page/abc")
            await conn.connect()

            # Wait for the task to finish
            if conn._receive_task:
                try:
                    await asyncio.wait_for(conn._receive_task, timeout=1.0)
                except Exception:
                    pass

            assert conn.is_connected is False
            assert conn._ws is None
            assert conn._connected is False

    @pytest.mark.asyncio
    async def test_cleanup_orphaned_futures(self):
        """Test that pending futures are cancelled when connection closes."""
        mock_connect, mock_ws = make_async_mock_ws()

        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            conn = Connection("ws://localhost:9222/devtools/page/abc", timeout=1.0)
            await conn.connect()

            # Start a command that expects a response
            send_task = asyncio.create_task(conn.send("Page.navigate", {"url": "x"}))

            # Give it a moment to send and add to pending
            while not conn._pending:
                await asyncio.sleep(0.01)

            assert len(conn._pending) == 1
            future = list(conn._pending.values())[0]

            # Close connection (triggers _receive_loop exit and cleanup)
            await conn.disconnect()

            # Future should be cancelled and pending cleared
            assert len(conn._pending) == 0
            assert future.cancelled()

            # The send command itself should raise an error (Timeout or Cancelled)
            # Depending on which happens first, but it should definitely not hang
            with pytest.raises((asyncio.CancelledError, asyncio.TimeoutError)):
                await send_task


class TestConnectionPool:
    """Tests for ConnectionPool class."""

    def test_init(self):
        """Test pool initialization."""
        pool = ConnectionPool(timeout=15.0)
        assert pool.timeout == 15.0
        assert len(pool._connections) == 0

    @pytest.mark.asyncio
    async def test_get_existing_returns_none_if_not_exists(self):
        """Test get_existing returns None for unknown tab."""
        pool = ConnectionPool(timeout=10.0)
        result = await pool.get_existing("unknown")
        assert result is None

    @pytest.mark.asyncio
    async def test_concurrent_get_connection(self):
        """Test that multiple concurrent requests for the same tab don't deadlock."""
        mock_connect, mock_ws = make_async_mock_ws()

        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            pool = ConnectionPool(timeout=1.0)
            tab_id = "test-tab"
            ws_url = "ws://localhost:9222/devtools/page/test"

            # Start multiple concurrent requests
            tasks = [pool.get_connection(tab_id, ws_url) for _ in range(5)]

            # They should all return eventually
            results = await asyncio.gather(*tasks)

            # All should return the same connection object
            assert all(r is results[0] for r in results)
            assert results[0].is_connected is True
            assert len(pool._connections) == 1

    @pytest.mark.asyncio
    async def test_pool_remove(self):
        """Test removing connection from pool."""
        mock_connect, mock_ws = make_async_mock_ws()
        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            pool = ConnectionPool()
            await pool.get_connection("tab1", "ws://url")
            assert len(pool._connections) == 1

            await pool.remove("tab1")
            assert len(pool._connections) == 0
            # Connection.disconnect() sets ws.closed = True via __aexit__
            assert mock_ws.closed

    @pytest.mark.asyncio
    async def test_pool_close_all(self):
        """Test closing all connections."""
        mock_connect, mock_ws = make_async_mock_ws()
        with patch("browser_hybrid.connection.connect", side_effect=mock_connect):
            pool = ConnectionPool()
            await pool.get_connection("t1", "ws://1")
            await pool.get_connection("t2", "ws://2")
            assert len(pool._connections) == 2

            await pool.close_all()
            assert len(pool._connections) == 0


class TestErrors:
    """Tests for error classes (imported from errors module)."""

    def test_connection_error_context(self):
        """Test ConnectionError includes context."""
        err = ConnectionError("Chrome not reachable", host="localhost", port=9222)
        assert "localhost" in str(err)
        assert "9222" in str(err)

    def test_timeout_error_context(self):
        """Test TimeoutError includes context."""
        err = TimeoutError("Navigation timed out", timeout=30.0, operation="navigate")
        assert "30.0" in str(err)
        assert "navigate" in str(err)

    def test_cdp_error_context(self):
        """Test CDPError includes context."""
        err = CDPError("Invalid node", code=-32000, method="DOM.resolveNode")
        assert "-32000" in str(err)
        assert "DOM.resolveNode" in str(err)


class TestIntegration:
    """Integration tests that require actual WebSocket connection."""

    @pytest.mark.integration
    async def test_real_connect(self):
        """Test connecting to real Chrome (requires debug mode)."""
        pytest.skip("Requires Chrome debug mode")

    @pytest.mark.integration
    async def test_real_send_command(self):
        """Test sending CDP command to real Chrome."""
        pytest.skip("Requires Chrome debug mode")


class TestConnectionProtocol:
    """Tests for Connection protocol behavior (without real WebSocket)."""

    @pytest.mark.asyncio
    async def test_receive_loop_parses_messages(self):
        """Test receive loop parses JSON messages correctly."""
        # Test that the receive loop logic handles messages correctly
        # by testing the parsing logic directly

        # Message without id (event) - should be ignored
        event_msg = json.dumps({"method": "Page.loadEventFired", "params": {}})
        parsed = json.loads(event_msg)
        assert parsed.get("id") is None

        # Message with id (response)
        response_msg = json.dumps({"id": 1, "result": {"frameId": "main"}})
        parsed = json.loads(response_msg)
        assert parsed.get("id") == 1
        assert "result" in parsed

        # Error response
        error_msg = json.dumps({"id": 2, "error": {"code": -32000, "message": "Invalid node"}})
        parsed = json.loads(error_msg)
        assert parsed.get("id") == 2
        assert "error" in parsed


class TestConnectionPoolBasics:
    """Basic tests for ConnectionPool that don't require WebSocket."""

    def test_pool_repr(self):
        """Test pool string representation."""
        pool = ConnectionPool(timeout=10.0)
        assert "ConnectionPool" in repr(pool)
        assert "active=0" in repr(pool)

    def test_pool_active_count(self):
        """Test active_count property."""
        pool = ConnectionPool(timeout=10.0)
        assert pool.active_count == 0


class TestOperationQueue:
    """Tests for OperationQueue class."""

    def test_queue_and_get_all(self):
        """Test queueing and getting all operations."""
        queue = OperationQueue()
        f1 = queue.queue(1, "Page.navigate", {"url": "1"})
        f2 = queue.queue(2, "Page.reload", {})

        ops = queue.get_all()
        assert len(ops) == 2
        assert ops[0].request_id == 1
        assert ops[1].request_id == 2
        assert not f1.done()
        assert not f2.done()

        # Queue should be empty now
        assert len(queue.get_all()) == 0

    def test_clear(self):
        """Test clearing queue."""
        queue = OperationQueue()
        queue.queue(1, "Page.navigate", {"url": "1"})
        queue.clear()
        assert len(queue.get_all()) == 0
