"""Tests for FRClient.

All websockets calls are mocked so these tests run without websockets installed.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path
from types import ModuleType
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ── path setup ──────────────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parents[4]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
    sys.path.insert(0, str(SRC))

# ── inject a fake websockets before importing the client ────────────────────
# websockets may not be installed in the dev environment; we stub the entire
# module so FRClient can be imported and tested without the real dependency.


def _make_fake_websockets() -> ModuleType:
    """Build a minimal websockets stub sufficient for FRClient tests."""
    fake = ModuleType("websockets")

    class _FakeConnect:
        """Placeholder; replaced per-test via patch."""

    fake.connect = _FakeConnect
    return fake


if "websockets" not in sys.modules:
    sys.modules["websockets"] = _make_fake_websockets()

from services.fr_client import FRClient, FRConfig  # noqa: E402  (after sys.modules patch)

# ── constants ────────────────────────────────────────────────────────────────

_FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 64  # minimal JPEG-like bytes
_DEFAULT_ENDPOINT = "ws://kluster.klass.dev:42067/"


# ── helpers ───────────────────────────────────────────────────────────────────


def _make_ws_ctx(recv_payload: str) -> MagicMock:
    """Build a mock async context manager that looks like a websockets connection.

    Args:
        recv_payload: The string returned by ``ws.recv()``.

    Returns:
        A MagicMock usable as ``async with websockets.connect(...) as ws:``.
    """
    ws = MagicMock()
    ws.send = AsyncMock()
    ws.recv = AsyncMock(return_value=recv_payload)

    ctx = MagicMock()
    ctx.__aenter__ = AsyncMock(return_value=ws)
    ctx.__aexit__ = AsyncMock(return_value=False)
    return ctx


# ── tests ────────────────────────────────────────────────────────────────────


class TestFRConfig:
    """FRConfig default values and field behaviour."""

    def test_default_endpoint_uses_env_or_kluster(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.delenv("FR_ENDPOINT", raising=False)
        config = FRConfig()
        assert config.endpoint == _DEFAULT_ENDPOINT

    def test_endpoint_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("FR_ENDPOINT", "ws://myhost:42067/")
        config = FRConfig()
        assert config.endpoint == "ws://myhost:42067/"

    def test_default_timeout(self) -> None:
        config = FRConfig()
        assert config.timeout_s == 10

    def test_custom_values(self) -> None:
        config = FRConfig(endpoint="ws://custom:1234/", timeout_s=30)
        assert config.endpoint == "ws://custom:1234/"
        assert config.timeout_s == 30


class TestFRClientIdentify:
    """FRClient.identify() behaviour."""

    @pytest.mark.asyncio
    async def test_identify_success_returns_detections(self) -> None:
        """Valid JSON response → identify() returns detection list."""
        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        payload = json.dumps(
            {
                "timestamp": 1234567890.0,
                "detections": [
                    {"identity": "alice", "confidence": 0.92, "bbox": [10, 20, 100, 200]},
                    {"identity": None, "confidence": 0.45, "bbox": [300, 50, 400, 180]},
                ],
            }
        )
        ctx = _make_ws_ctx(payload)

        import websockets

        with patch.object(websockets, "connect", return_value=ctx):
            result = await client.identify(_FAKE_JPEG)

        assert len(result) == 2
        assert result[0]["identity"] == "alice"
        assert result[0]["confidence"] == pytest.approx(0.92)
        assert result[0]["bbox"] == [10, 20, 100, 200]
        assert result[1]["identity"] is None

    @pytest.mark.asyncio
    async def test_identify_sends_binary_frame(self) -> None:
        """identify() must send the raw bytes to the WebSocket."""
        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        payload = json.dumps({"detections": []})
        ctx = _make_ws_ctx(payload)
        ws = ctx.__aenter__.return_value

        import websockets

        with patch.object(websockets, "connect", return_value=ctx):
            await client.identify(_FAKE_JPEG)

        ws.send.assert_awaited_once_with(_FAKE_JPEG)

    @pytest.mark.asyncio
    async def test_identify_empty_detections_response(self) -> None:
        """Response with empty detections list → returns empty list."""
        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        payload = json.dumps({"timestamp": 0.0, "detections": []})
        ctx = _make_ws_ctx(payload)

        import websockets

        with patch.object(websockets, "connect", return_value=ctx):
            result = await client.identify(_FAKE_JPEG)

        assert result == []

    @pytest.mark.asyncio
    async def test_identify_connection_error_returns_empty(self) -> None:
        """Connection failure → identify() returns [] (no raise)."""
        config = FRConfig(endpoint="ws://unreachable:42067/")
        client = FRClient(config)

        import websockets

        with patch.object(
            websockets, "connect", side_effect=ConnectionRefusedError("refused")
        ):
            result = await client.identify(_FAKE_JPEG)

        assert result == []

    @pytest.mark.asyncio
    async def test_identify_timeout_returns_empty(self) -> None:
        """asyncio.TimeoutError on recv → identify() returns []."""
        import asyncio

        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        ws = MagicMock()
        ws.send = AsyncMock()
        ws.recv = AsyncMock(side_effect=asyncio.TimeoutError())

        ctx = MagicMock()
        ctx.__aenter__ = AsyncMock(return_value=ws)
        ctx.__aexit__ = AsyncMock(return_value=False)

        import websockets

        with patch.object(websockets, "connect", return_value=ctx):
            result = await client.identify(_FAKE_JPEG)

        assert result == []

    @pytest.mark.asyncio
    async def test_identify_malformed_json_returns_empty(self) -> None:
        """Non-JSON response → identify() returns []."""
        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        ctx = _make_ws_ctx("this is not json {{{")

        import websockets

        with patch.object(websockets, "connect", return_value=ctx):
            result = await client.identify(_FAKE_JPEG)

        assert result == []

    @pytest.mark.asyncio
    async def test_identify_empty_bytes_returns_empty_without_connecting(
        self,
    ) -> None:
        """Empty bytes are rejected before opening a connection."""
        config = FRConfig(endpoint="ws://fake:42067/")
        client = FRClient(config)

        import websockets

        with patch.object(websockets, "connect") as mock_connect:
            result = await client.identify(b"")

        assert result == []
        mock_connect.assert_not_called()

    @pytest.mark.asyncio
    async def test_identify_uses_config_endpoint(self) -> None:
        """connect() is called with the configured endpoint."""
        config = FRConfig(endpoint="ws://custom-host:9999/")
        client = FRClient(config)

        payload = json.dumps({"detections": []})
        ctx = _make_ws_ctx(payload)

        import websockets

        with patch.object(websockets, "connect", return_value=ctx) as mock_connect:
            await client.identify(_FAKE_JPEG)

        mock_connect.assert_called_once()
        call_args = mock_connect.call_args
        assert call_args.args[0] == "ws://custom-host:9999/"
