"""Tests for llmhosts.tunnel.relay -- Relay tunnel provider."""

from __future__ import annotations

import asyncio
import contextlib
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch

import pytest

from llmhosts.tunnel.models import TunnelProvider
from llmhosts.tunnel.relay import DEFAULT_RELAY_URL, RelayTunnel

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_mock_client(*, connected: bool = True, bytes_relayed: int = 0, uptime: float = 0.0) -> MagicMock:
    """Create a mock RelayClient with configurable properties."""
    client = MagicMock()
    client.connect = AsyncMock()
    client.disconnect = AsyncMock()
    type(client).is_connected = PropertyMock(return_value=connected)
    type(client).bytes_relayed = PropertyMock(return_value=bytes_relayed)
    type(client).uptime_seconds = PropertyMock(return_value=uptime)
    client.relay_url = "wss://llmhosts.com/api/relay/connect"
    client.local_port = 4000
    return client


# ---------------------------------------------------------------------------
# RelayTunnel — Rust not available
# ---------------------------------------------------------------------------


class TestRelayTunnelNoRust:
    @pytest.mark.asyncio
    async def test_start_without_rust(self) -> None:
        with patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", False):
            rt = RelayTunnel()
            status = await rt.start(port=4000, device_token="tok_test")
            assert status.active is False
            assert status.provider == TunnelProvider.RELAY
            assert "not available" in (status.error or "").lower()

    @pytest.mark.asyncio
    async def test_start_without_device_token(self) -> None:
        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", MagicMock),
        ):
            rt = RelayTunnel()
            status = await rt.start(port=4000, device_token=None)
            assert status.active is False
            assert "token" in (status.error or "").lower()


# ---------------------------------------------------------------------------
# RelayTunnel — start / connect
# ---------------------------------------------------------------------------


class TestRelayTunnelStart:
    @pytest.mark.asyncio
    async def test_start_success(self) -> None:
        mock_client = _make_mock_client(connected=True)
        mock_cls = MagicMock(return_value=mock_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
        ):
            rt = RelayTunnel()
            status = await rt.start(port=11400, relay_url="wss://relay.test", device_token="tok_abc")

            assert status.active is True
            assert status.provider == TunnelProvider.RELAY
            assert status.url == "wss://relay.test"
            assert status.local_port == 11400
            assert status.started_at is not None
            mock_cls.assert_called_once_with("wss://relay.test", "tok_abc", 11400, server_pubkey=None)
            mock_client.connect.assert_awaited_once()
            # Reconnect task should be running in background
            assert rt._reconnect_task is not None
            assert not rt._reconnect_task.done()
            await rt.stop()  # clean up background task

    @pytest.mark.asyncio
    async def test_start_default_url(self) -> None:
        mock_client = _make_mock_client(connected=True)
        mock_cls = MagicMock(return_value=mock_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
        ):
            rt = RelayTunnel()
            status = await rt.start(port=4000, device_token="tok_abc")

            assert status.active is True
            assert status.url == DEFAULT_RELAY_URL
            mock_cls.assert_called_once_with(DEFAULT_RELAY_URL, "tok_abc", 4000, server_pubkey=None)
            await rt.stop()  # clean up background task

    @pytest.mark.asyncio
    async def test_start_connect_fails(self) -> None:
        mock_client = _make_mock_client()
        mock_client.connect = AsyncMock(side_effect=RuntimeError("WebSocket connect failed"))
        mock_cls = MagicMock(return_value=mock_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
        ):
            rt = RelayTunnel()
            status = await rt.start(port=4000, device_token="tok_abc")

            assert status.active is False
            assert "connect failed" in (status.error or "").lower()

    @pytest.mark.asyncio
    async def test_start_connected_false(self) -> None:
        mock_client = _make_mock_client(connected=False)
        mock_cls = MagicMock(return_value=mock_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
        ):
            rt = RelayTunnel()
            status = await rt.start(port=4000, device_token="tok_abc")

            assert status.active is False
            assert "disconnected" in (status.error or "").lower()


# ---------------------------------------------------------------------------
# RelayTunnel — stop
# ---------------------------------------------------------------------------


class TestRelayTunnelStop:
    @pytest.mark.asyncio
    async def test_stop_connected(self) -> None:
        mock_client = _make_mock_client()

        rt = RelayTunnel()
        rt._client = mock_client
        rt._active = True
        rt._url = "wss://relay.test"

        await rt.stop()

        assert rt._active is False
        assert rt._client is None
        assert rt._url is None
        mock_client.disconnect.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_stop_not_started(self) -> None:
        rt = RelayTunnel()
        await rt.stop()  # should not raise
        assert rt._active is False

    @pytest.mark.asyncio
    async def test_stop_disconnect_error(self) -> None:
        mock_client = _make_mock_client()
        mock_client.disconnect = AsyncMock(side_effect=RuntimeError("ws error"))

        rt = RelayTunnel()
        rt._client = mock_client
        rt._active = True

        await rt.stop()  # should not raise
        assert rt._active is False
        assert rt._client is None


# ---------------------------------------------------------------------------
# RelayTunnel — status
# ---------------------------------------------------------------------------


class TestRelayTunnelStatus:
    @pytest.mark.asyncio
    async def test_status_active(self) -> None:
        mock_client = _make_mock_client(connected=True)

        rt = RelayTunnel()
        rt._client = mock_client
        rt._active = True
        rt._url = "wss://relay.test"
        rt._port = 4000

        status = await rt.status()
        assert status.active is True
        assert status.provider == TunnelProvider.RELAY
        assert status.url == "wss://relay.test"

    @pytest.mark.asyncio
    async def test_status_connection_lost(self) -> None:
        mock_client = _make_mock_client(connected=False)

        rt = RelayTunnel()
        rt._client = mock_client
        rt._active = True

        status = await rt.status()
        assert status.active is False
        assert "lost" in (status.error or "").lower()

    @pytest.mark.asyncio
    async def test_status_not_started(self) -> None:
        rt = RelayTunnel()
        status = await rt.status()
        assert status.active is False
        assert status.provider == TunnelProvider.RELAY
        assert status.error is None

    @pytest.mark.asyncio
    async def test_status_client_property_error(self) -> None:
        mock_client = MagicMock()
        type(mock_client).is_connected = PropertyMock(side_effect=RuntimeError("ffi error"))

        rt = RelayTunnel()
        rt._client = mock_client
        rt._active = True

        status = await rt.status()
        assert status.active is False
        assert "lost" in (status.error or "").lower()


# ---------------------------------------------------------------------------
# RelayTunnel — get_url
# ---------------------------------------------------------------------------


class TestRelayTunnelGetUrl:
    @pytest.mark.asyncio
    async def test_get_url_active(self) -> None:
        rt = RelayTunnel()
        rt._url = "wss://relay.test"
        url = await rt.get_url()
        assert url == "wss://relay.test"

    @pytest.mark.asyncio
    async def test_get_url_not_started(self) -> None:
        rt = RelayTunnel()
        url = await rt.get_url()
        assert url is None


# ---------------------------------------------------------------------------
# RelayTunnel — Rust client properties
# ---------------------------------------------------------------------------


class TestRelayTunnelProperties:
    def test_bytes_relayed_with_client(self) -> None:
        mock_client = _make_mock_client(bytes_relayed=12345)
        rt = RelayTunnel()
        rt._client = mock_client
        assert rt.bytes_relayed == 12345

    def test_bytes_relayed_no_client(self) -> None:
        rt = RelayTunnel()
        assert rt.bytes_relayed == 0

    def test_uptime_seconds_with_client(self) -> None:
        mock_client = _make_mock_client(uptime=42.5)
        rt = RelayTunnel()
        rt._client = mock_client
        assert rt.uptime_seconds == 42.5

    def test_uptime_seconds_no_client(self) -> None:
        rt = RelayTunnel()
        assert rt.uptime_seconds == 0.0

    def test_bytes_relayed_client_error(self) -> None:
        mock_client = MagicMock()
        type(mock_client).bytes_relayed = PropertyMock(side_effect=RuntimeError("ffi"))
        rt = RelayTunnel()
        rt._client = mock_client
        assert rt.bytes_relayed == 0

    def test_uptime_seconds_client_error(self) -> None:
        mock_client = MagicMock()
        type(mock_client).uptime_seconds = PropertyMock(side_effect=RuntimeError("ffi"))
        rt = RelayTunnel()
        rt._client = mock_client
        assert rt.uptime_seconds == 0.0

    def test_reconnect_count_initial(self) -> None:
        rt = RelayTunnel()
        assert rt.reconnect_count == 0


# ---------------------------------------------------------------------------
# RelayTunnel — reconnect loop
# ---------------------------------------------------------------------------


class TestRelayTunnelReconnect:
    @pytest.mark.asyncio
    async def test_stop_cancels_reconnect_task(self) -> None:
        """stop() should cancel the background reconnect task."""
        mock_client = _make_mock_client(connected=True)
        mock_cls = MagicMock(return_value=mock_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
        ):
            rt = RelayTunnel()
            await rt.start(port=4000, device_token="tok_abc")

            task = rt._reconnect_task
            assert task is not None and not task.done()

            await rt.stop()

            assert rt._reconnect_task is None
            assert rt._active is False

    @pytest.mark.asyncio
    async def test_reconnect_loop_exits_on_stop(self) -> None:
        """_reconnect_loop should exit cleanly when _stopping is set."""
        rt = RelayTunnel()
        rt._stopping = True

        # Should return immediately without doing anything
        task = asyncio.create_task(rt._reconnect_loop())
        await asyncio.sleep(0.01)
        await task  # must complete without hanging

    @pytest.mark.asyncio
    async def test_reconnect_loop_reconnects_on_disconnect(self) -> None:
        """_reconnect_loop should create a new client when is_connected is False."""
        # The initial client is pre-seeded as disconnected (simulates a dropped connection).
        # When the reconnect loop calls _RelayClient(), it returns a fresh connected client.
        disconnected_client = _make_mock_client(connected=False)
        reconnected_client = _make_mock_client(connected=True)
        mock_cls = MagicMock(return_value=reconnected_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
            # Speed up the test: 10ms poll, 10ms backoff
            patch("llmhosts.tunnel.relay._HEALTH_POLL_INTERVAL", 0.01),
            patch("llmhosts.tunnel.relay._RECONNECT_BACKOFF", [0.01]),
        ):
            rt = RelayTunnel()
            rt._relay_url = "wss://relay.test"
            rt._device_token = "tok_abc"
            rt._port = 4000

            # Simulate an already-connected-but-lost state
            rt._client = disconnected_client
            rt._active = True
            rt._stopping = False

            # Run one iteration of the loop
            task = asyncio.create_task(rt._reconnect_loop())
            # Give it time to detect disconnect and reconnect
            await asyncio.sleep(0.15)
            rt._stopping = True
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task

            # Should have reconnected
            assert rt._active is True
            assert rt._reconnect_count == 1

    @pytest.mark.asyncio
    async def test_reconnect_loop_handles_connect_error(self) -> None:
        """Reconnect loop should increment attempt counter on connect failure."""
        failing_client = MagicMock()
        failing_client.connect = AsyncMock(side_effect=RuntimeError("server down"))

        mock_cls = MagicMock(return_value=failing_client)

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClient", mock_cls),
            patch("llmhosts.tunnel.relay._HEALTH_POLL_INTERVAL", 0.01),
            patch("llmhosts.tunnel.relay._RECONNECT_BACKOFF", [0.01]),
        ):
            rt = RelayTunnel()
            rt._relay_url = "wss://relay.test"
            rt._device_token = "tok_abc"
            rt._port = 4000
            rt._stopping = False

            # Set up as lost connection (not connected)
            disconnected = _make_mock_client(connected=False)
            rt._client = disconnected
            rt._active = True

            task = asyncio.create_task(rt._reconnect_loop())
            await asyncio.sleep(0.1)
            rt._stopping = True
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task

            # reconnect_count stays 0 since all attempts failed
            assert rt.reconnect_count == 0
