"""Tests for CloudRouterClient — circuit breaker, LRU cache, and HTTP handling."""

from __future__ import annotations

import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.router.cloud_client import CloudRouterClient

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


def _make_client(api_base: str = "https://llmhosts.com") -> CloudRouterClient:
    return CloudRouterClient(api_base_url=api_base, api_key="lh_sk_test")


def _sample_backends() -> list[str]:
    return ["llama3.1:8b", "qwen3:14b", "llama3.3:70b"]


def _route_response(backend: str = "llama3.1:8b") -> dict[str, Any]:
    return {
        "backend": backend,
        "confidence": 0.85,
        "tier_used": 0,
        "reasoning": "rule-based: first available",
    }


# ---------------------------------------------------------------------------
# Cache key stability
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_cache_key_stable() -> None:
    """Same inputs must always produce the same cache key."""
    client = _make_client()
    backends = ["llama3.1:8b", "qwen3:14b"]
    key1 = client._cache_key(backends, "rtx4060_8gb", 1024, "personal")
    key2 = client._cache_key(backends, "rtx4060_8gb", 1024, "personal")
    assert key1 == key2


@pytest.mark.unit
def test_cache_key_backend_order_invariant() -> None:
    """Backend list order must not affect the cache key (uses sorted())."""
    client = _make_client()
    backends_a = ["llama3.1:8b", "qwen3:14b"]
    backends_b = ["qwen3:14b", "llama3.1:8b"]
    key_a = client._cache_key(backends_a, "rtx4060_8gb", 512, "external")
    key_b = client._cache_key(backends_b, "rtx4060_8gb", 512, "external")
    assert key_a == key_b


@pytest.mark.unit
def test_cache_key_context_bucketing() -> None:
    """Context lengths in the same 512-token bucket must share a cache key.

    512-token bucket boundaries: [0,512), [512,1024), [1024,1536), ...
    1000 → bucket 512 (1000//512 * 512 = 512)
    1023 → bucket 512 (1023//512 * 512 = 512)
    Both should produce the same cache key.
    """
    client = _make_client()
    backends = ["llama3.1:8b"]
    # 1000 and 1023 are both in the [512, 1024) bucket → same key
    key_1000 = client._cache_key(backends, "rtx4060_8gb", 1000, "external")
    key_1023 = client._cache_key(backends, "rtx4060_8gb", 1023, "external")
    assert key_1000 == key_1023


@pytest.mark.unit
def test_cache_key_context_different_buckets() -> None:
    """Context lengths in different buckets must produce different cache keys."""
    client = _make_client()
    backends = ["llama3.1:8b"]
    # 500 → bucket 0; 513 → bucket 512
    key_500 = client._cache_key(backends, "rtx4060_8gb", 500, "external")
    key_513 = client._cache_key(backends, "rtx4060_8gb", 513, "external")
    assert key_500 != key_513


@pytest.mark.unit
def test_cache_key_changes_with_privacy_tier() -> None:
    """Different privacy tiers must produce different cache keys."""
    client = _make_client()
    backends = ["llama3.1:8b"]
    key_personal = client._cache_key(backends, "rtx4060_8gb", 512, "personal")
    key_external = client._cache_key(backends, "rtx4060_8gb", 512, "external")
    assert key_personal != key_external


# ---------------------------------------------------------------------------
# Cache read/write behaviour
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_route_returns_cached_result() -> None:
    """After a successful cloud call the second call must return from cache."""
    client = _make_client()
    response_data = _route_response("qwen3:14b")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_client):
        result1 = await client.route(
            available_backends=["llama3.1:8b", "qwen3:14b"],
            context_length_tokens=512,
        )
        result2 = await client.route(
            available_backends=["llama3.1:8b", "qwen3:14b"],
            context_length_tokens=512,
        )

    # First call: cloud API hit once, second from cache
    assert mock_client.post.call_count == 1
    assert result1 is not None
    assert result1["backend"] == "qwen3:14b"
    # Cached result has "(cached)" prefix in reasoning
    assert result2 is not None
    assert "(cached)" in result2["reasoning"]
    assert result2["backend"] == "qwen3:14b"


@pytest.mark.unit
async def test_cache_size_reported_correctly() -> None:
    """cache_size() must reflect the number of stored entries."""
    client = _make_client()
    assert client.cache_size() == 0

    response_data = _route_response()
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_client):
        await client.route(available_backends=["llama3.1:8b"], context_length_tokens=100)

    assert client.cache_size() == 1


# ---------------------------------------------------------------------------
# Circuit breaker
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_circuit_breaker_opens_after_threshold() -> None:
    """After CIRCUIT_BREAKER_THRESHOLD failures, is_circuit_open() must be True."""
    client = _make_client()

    mock_response = MagicMock()
    mock_response.status_code = 503

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_http):
        for _ in range(client.CIRCUIT_BREAKER_THRESHOLD):
            result = await client.route(available_backends=["llama3.1:8b"])
            assert result is None

    assert client.is_circuit_open()


@pytest.mark.unit
async def test_circuit_breaker_resets_after_cooldown() -> None:
    """After cooldown elapses, is_circuit_open() must return False."""
    client = _make_client()
    # Force circuit open with an impossibly short cooldown
    client.CIRCUIT_BREAKER_COOLDOWN = 0  # type: ignore[assignment]
    client._consecutive_failures = client.CIRCUIT_BREAKER_THRESHOLD
    client._circuit_open_until = time.monotonic() - 1  # already in the past

    # Should now reset
    assert not client.is_circuit_open()
    assert client._consecutive_failures == 0


@pytest.mark.unit
async def test_circuit_breaker_suppresses_calls_while_open() -> None:
    """When the circuit is open, route() must return None without making HTTP calls."""
    client = _make_client()
    # Manually open the circuit
    client._circuit_open_until = time.monotonic() + 999.0

    mock_http = AsyncMock()
    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is None
    mock_http.post.assert_not_called()  # type: ignore[union-attr]


# ---------------------------------------------------------------------------
# HTTP error handling — no raises
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_route_returns_none_on_http_error() -> None:
    """An HTTP 500 response must return None, not raise."""
    client = _make_client()

    mock_response = MagicMock()
    mock_response.status_code = 500

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is None


@pytest.mark.unit
async def test_route_returns_none_on_http_401() -> None:
    """An HTTP 401 response must return None (not raise or re-authenticate)."""
    client = _make_client()

    mock_response = MagicMock()
    mock_response.status_code = 401

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is None


@pytest.mark.unit
async def test_route_returns_none_on_network_error() -> None:
    """A network error (ConnectError) must return None, not raise."""
    client = _make_client()

    try:
        import httpx

        connect_error = httpx.ConnectError("connection refused")
    except ImportError:
        pytest.skip("httpx not installed")

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(side_effect=connect_error)

    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is None


@pytest.mark.unit
async def test_route_returns_none_on_timeout() -> None:
    """A timeout exception must return None, not raise."""
    client = _make_client()

    try:
        import httpx

        timeout_error = httpx.TimeoutException("read timeout")
    except ImportError:
        pytest.skip("httpx not installed")

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(side_effect=timeout_error)

    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is None


# ---------------------------------------------------------------------------
# Empty/None inputs
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_route_returns_none_for_empty_backends() -> None:
    """An empty backend list must return None immediately without an HTTP call."""
    client = _make_client()
    result = await client.route(available_backends=[])
    assert result is None


# ---------------------------------------------------------------------------
# Successful routing response structure
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_route_returns_full_decision_on_success() -> None:
    """A 200 response must return all expected fields."""
    client = _make_client()
    response_data = {
        "backend": "llama3.1:8b",
        "confidence": 0.7,
        "tier_used": 0,
        "reasoning": "rule-based: first available",
    }

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_http):
        result = await client.route(available_backends=["llama3.1:8b"])

    assert result is not None
    assert result["backend"] == "llama3.1:8b"
    assert result["confidence"] == 0.7
    assert result["tier_used"] == 0
    assert "reasoning" in result


@pytest.mark.unit
async def test_route_sends_auth_header_when_api_key_set() -> None:
    """When api_key is set the Authorization header must be sent."""
    client = CloudRouterClient(api_key="lh_sk_secret")
    response_data = _route_response()

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    captured_headers: dict[str, str] = {}

    async def fake_post(url: str, json: Any = None, headers: Any = None) -> Any:
        if headers:
            captured_headers.update(headers)
        return mock_response

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = fake_post

    with patch("httpx.AsyncClient", return_value=mock_http):
        await client.route(available_backends=["llama3.1:8b"])

    assert captured_headers.get("Authorization") == "Bearer lh_sk_secret"


@pytest.mark.unit
async def test_route_no_auth_header_when_no_api_key() -> None:
    """When api_key is None, no Authorization header must be sent."""
    client = CloudRouterClient(api_key=None)
    response_data = _route_response()

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    captured_headers: dict[str, str] = {}

    async def fake_post(url: str, json: Any = None, headers: Any = None) -> Any:
        if headers:
            captured_headers.update(headers)
        return mock_response

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = fake_post

    with patch("httpx.AsyncClient", return_value=mock_http):
        await client.route(available_backends=["llama3.1:8b"])

    assert "Authorization" not in captured_headers


# ---------------------------------------------------------------------------
# Failure counter resets on success
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_failure_counter_resets_on_success() -> None:
    """A successful call after failures must reset the consecutive failure counter."""
    client = _make_client()
    client._consecutive_failures = 5  # partially towards threshold

    response_data = _route_response()
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_data

    mock_http = AsyncMock()
    mock_http.__aenter__ = AsyncMock(return_value=mock_http)
    mock_http.__aexit__ = AsyncMock(return_value=False)
    mock_http.post = AsyncMock(return_value=mock_response)

    with patch("httpx.AsyncClient", return_value=mock_http):
        await client.route(available_backends=["llama3.1:8b"])

    assert client._consecutive_failures == 0
