"""Tests for llmhosts.router.fallback -- circuit breaker + fallback chain."""

from __future__ import annotations

import time
from typing import Any
from unittest.mock import AsyncMock

import pytest
from pydantic import BaseModel

from llmhosts.router.fallback import (
    PRIORITY_CLOUD_CHEAPEST,
    PRIORITY_LOCAL_OLLAMA,
    PRIORITY_LOCAL_OTHER,
    PRIORITY_OPENROUTER,
    CircuitBreaker,
    FallbackChain,
    FallbackFailure,
    FallbackResult,
    FallbackTarget,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


class FakeBackendInfo(BaseModel):
    """Minimal stand-in for BackendInfo used by build_chain."""

    url: str = "http://localhost:11434"
    models: list[str] = ["llama3.2:8b"]
    is_healthy: bool = True
    is_local: bool = True


class FakeUnifiedRequest(BaseModel):
    model: str = "llama3.2:8b"
    messages: list[dict[str, str]] = [{"role": "user", "content": "hi"}]


class FakeUnifiedResponse(BaseModel):
    content: str = "hello"
    model: str = "llama3.2:8b"


# ---------------------------------------------------------------------------
# FallbackTarget / FallbackResult / FallbackFailure model tests
# ---------------------------------------------------------------------------


class TestFallbackModels:
    def test_fallback_target_defaults(self) -> None:
        t = FallbackTarget(backend_type="ollama", url="http://localhost:11434", model="llama3.2:8b", priority=10)
        assert t.timeout_seconds == 3.0

    def test_fallback_failure(self) -> None:
        f = FallbackFailure(backend_type="openai", error="timeout", latency_ms=3200.5)
        assert f.backend_type == "openai"
        assert f.error == "timeout"

    def test_fallback_result(self) -> None:
        r = FallbackResult(
            attempts=2,
            targets_tried=["ollama", "openai"],
            final_target="openai",
            total_latency_ms=5000.0,
        )
        assert r.attempts == 2
        assert r.failures == []

    def test_fallback_result_with_failures(self) -> None:
        failure = FallbackFailure(backend_type="ollama", error="conn refused", latency_ms=50.0)
        r = FallbackResult(
            attempts=2,
            targets_tried=["ollama", "openai"],
            final_target="openai",
            total_latency_ms=5000.0,
            failures=[failure],
        )
        assert len(r.failures) == 1


# ---------------------------------------------------------------------------
# CircuitBreaker tests
# ---------------------------------------------------------------------------


class TestCircuitBreaker:
    def test_initial_state_is_closed(self) -> None:
        cb = CircuitBreaker()
        assert cb.state == "closed"
        assert cb.is_closed()
        assert not cb.is_open()
        assert cb.allow_request()

    def test_stays_closed_below_threshold(self) -> None:
        cb = CircuitBreaker(failure_threshold=3)
        cb.record_failure()
        cb.record_failure()
        assert cb.state == "closed"
        assert cb.allow_request()

    def test_opens_at_threshold(self) -> None:
        cb = CircuitBreaker(failure_threshold=3)
        cb.record_failure()
        cb.record_failure()
        cb.record_failure()
        assert cb.state == "open"
        assert cb.is_open()
        assert not cb.allow_request()

    def test_success_resets_failures(self) -> None:
        cb = CircuitBreaker(failure_threshold=3)
        cb.record_failure()
        cb.record_failure()
        cb.record_success()
        assert cb.state == "closed"
        # Need 3 more failures now
        cb.record_failure()
        cb.record_failure()
        assert cb.state == "closed"

    def test_half_open_after_cooldown(self) -> None:
        cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=0.05)
        cb.record_failure()
        assert cb.state == "open"
        time.sleep(0.06)
        assert cb.state == "half_open"
        assert cb.allow_request()

    def test_half_open_to_closed_on_success(self) -> None:
        cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=0.05)
        cb.record_failure()
        time.sleep(0.06)
        assert cb.state == "half_open"
        cb.record_success()
        assert cb.state == "closed"

    def test_half_open_to_open_on_failure(self) -> None:
        cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=0.05)
        cb.record_failure()
        time.sleep(0.06)
        assert cb.state == "half_open"
        cb.record_failure()
        assert cb.state == "open"

    def test_reset(self) -> None:
        cb = CircuitBreaker(failure_threshold=1)
        cb.record_failure()
        assert cb.state == "open"
        cb.reset()
        assert cb.state == "closed"
        assert cb.allow_request()

    def test_repr(self) -> None:
        cb = CircuitBreaker(failure_threshold=5, cooldown_seconds=60.0)
        r = repr(cb)
        assert "CircuitBreaker" in r
        assert "closed" in r
        assert "threshold=5" in r


# ---------------------------------------------------------------------------
# FallbackChain tests
# ---------------------------------------------------------------------------


class TestFallbackChain:
    def test_build_chain_priority_order(self) -> None:
        chain = FallbackChain()
        backends: dict[str, Any] = {
            "openrouter": FakeBackendInfo(url="https://openrouter.ai", models=["llama3.2:8b"], is_local=False),
            "ollama": FakeBackendInfo(url="http://localhost:11434", models=["llama3.2:8b"], is_local=True),
            "openai": FakeBackendInfo(url="https://api.openai.com", models=["gpt-4o"], is_local=False),
        }
        targets = chain.build_chain(backends, "llama3.2:8b")
        # ollama first (priority 10), then cloud (50), then openrouter (100)
        assert targets[0].backend_type == "ollama"
        assert targets[0].priority == PRIORITY_LOCAL_OLLAMA
        # openai should be before openrouter
        backend_order = [t.backend_type for t in targets]
        assert backend_order.index("openai") < backend_order.index("openrouter")

    def test_build_chain_skips_unhealthy(self) -> None:
        chain = FallbackChain()
        backends: dict[str, Any] = {
            "ollama": FakeBackendInfo(is_healthy=False),
            "openai": FakeBackendInfo(url="https://api.openai.com", models=["gpt-4o"], is_local=False),
        }
        targets = chain.build_chain(backends, "gpt-4o")
        assert len(targets) == 1
        assert targets[0].backend_type == "openai"

    def test_build_chain_circuit_broken_deferred(self) -> None:
        chain = FallbackChain()
        # Open the circuit for ollama
        for _ in range(3):
            chain.record_failure("ollama")

        backends: dict[str, Any] = {
            "ollama": FakeBackendInfo(url="http://localhost:11434", models=["llama3.2:8b"], is_local=True),
            "openai": FakeBackendInfo(url="https://api.openai.com", models=["gpt-4o"], is_local=False),
        }
        targets = chain.build_chain(backends, "llama3.2:8b")
        # openai should come first since ollama circuit is open
        assert targets[0].backend_type == "openai"
        # ollama deferred to end
        assert targets[-1].backend_type == "ollama"

    def test_build_chain_local_other_priority(self) -> None:
        chain = FallbackChain()
        backends: dict[str, Any] = {
            "vllm": FakeBackendInfo(url="http://localhost:8000", models=["llama3.2:8b"], is_local=True),
        }
        targets = chain.build_chain(backends, "llama3.2:8b")
        assert targets[0].priority == PRIORITY_LOCAL_OTHER

    def test_build_chain_timeout_local_vs_cloud(self) -> None:
        chain = FallbackChain()
        backends: dict[str, Any] = {
            "ollama": FakeBackendInfo(url="http://localhost:11434", models=["llama3.2:8b"], is_local=True),
            "openai": FakeBackendInfo(url="https://api.openai.com", models=["gpt-4o"], is_local=False),
        }
        targets = chain.build_chain(backends, "llama3.2:8b")
        local = next(t for t in targets if t.backend_type == "ollama")
        cloud = next(t for t in targets if t.backend_type == "openai")
        assert local.timeout_seconds == 3.0
        assert cloud.timeout_seconds == 10.0

    def test_failure_count_tracking(self) -> None:
        chain = FallbackChain()
        assert chain.get_failure_count("ollama") == 0
        chain.record_failure("ollama")
        assert chain.get_failure_count("ollama") == 1
        chain.record_failure("ollama")
        assert chain.get_failure_count("ollama") == 2

    def test_success_resets_failure_count(self) -> None:
        chain = FallbackChain()
        chain.record_failure("ollama")
        chain.record_failure("ollama")
        chain.record_success("ollama")
        assert chain.get_failure_count("ollama") == 0

    @pytest.mark.asyncio
    async def test_execute_success_first_try(self) -> None:
        chain = FallbackChain()
        request = FakeUnifiedRequest()
        targets = [
            FallbackTarget(backend_type="ollama", url="http://localhost:11434", model="llama3.2:8b", priority=10),
        ]
        mock_dispatch = AsyncMock(return_value=FakeUnifiedResponse())

        _response, result = await chain.execute_with_fallback(request, targets, mock_dispatch)
        assert result.attempts == 1
        assert result.final_target == "ollama"
        assert len(result.failures) == 0
        mock_dispatch.assert_called_once()

    @pytest.mark.asyncio
    async def test_execute_fallback_on_failure(self) -> None:
        chain = FallbackChain()
        request = FakeUnifiedRequest()
        targets = [
            FallbackTarget(backend_type="ollama", url="http://localhost:11434", model="llama3.2:8b", priority=10),
            FallbackTarget(backend_type="openai", url="https://api.openai.com", model="gpt-4o", priority=50),
        ]
        mock_dispatch = AsyncMock(side_effect=[RuntimeError("connection refused"), FakeUnifiedResponse()])

        _response, result = await chain.execute_with_fallback(request, targets, mock_dispatch)
        assert result.attempts == 2
        assert result.final_target == "openai"
        assert len(result.failures) == 1
        assert result.failures[0].backend_type == "ollama"

    @pytest.mark.asyncio
    async def test_execute_all_fail_raises(self) -> None:
        chain = FallbackChain()
        request = FakeUnifiedRequest()
        targets = [
            FallbackTarget(backend_type="ollama", url="http://localhost:11434", model="llama3.2:8b", priority=10),
        ]
        mock_dispatch = AsyncMock(side_effect=RuntimeError("fail"))

        with pytest.raises(RuntimeError, match="All fallback targets exhausted"):
            await chain.execute_with_fallback(request, targets, mock_dispatch)

    @pytest.mark.asyncio
    async def test_execute_skips_open_circuit(self) -> None:
        chain = FallbackChain()
        # Open ollama's circuit
        for _ in range(3):
            chain.record_failure("ollama")

        request = FakeUnifiedRequest()
        targets = [
            FallbackTarget(backend_type="ollama", url="http://localhost:11434", model="llama3.2:8b", priority=10),
            FallbackTarget(backend_type="openai", url="https://api.openai.com", model="gpt-4o", priority=50),
        ]
        mock_dispatch = AsyncMock(return_value=FakeUnifiedResponse())

        _response, result = await chain.execute_with_fallback(request, targets, mock_dispatch)
        assert result.final_target == "openai"
        assert "ollama" not in result.targets_tried

    @pytest.mark.asyncio
    async def test_execute_empty_chain_raises(self) -> None:
        chain = FallbackChain()
        request = FakeUnifiedRequest()
        with pytest.raises(RuntimeError, match="All fallback targets exhausted"):
            await chain.execute_with_fallback(request, [], AsyncMock())

    def test_resolve_model_exact_match(self) -> None:
        info = FakeBackendInfo(models=["llama3.2:8b", "mistral:7b"])
        result = FallbackChain._resolve_model_for_backend(info, "llama3.2:8b")
        assert result == "llama3.2:8b"

    def test_resolve_model_base_name_match(self) -> None:
        info = FakeBackendInfo(models=["llama3.2:8b"])
        result = FallbackChain._resolve_model_for_backend(info, "llama3.2")
        assert result == "llama3.2:8b"

    def test_resolve_model_no_match_passthrough(self) -> None:
        info = FakeBackendInfo(models=["mistral:7b"])
        result = FallbackChain._resolve_model_for_backend(info, "llama3.2:8b")
        assert result == "llama3.2:8b"


# ---------------------------------------------------------------------------
# Priority constants
# ---------------------------------------------------------------------------


class TestPriorityConstants:
    def test_priority_order(self) -> None:
        assert PRIORITY_LOCAL_OLLAMA < PRIORITY_LOCAL_OTHER < PRIORITY_CLOUD_CHEAPEST < PRIORITY_OPENROUTER
