"""Tests for GPULessRouter -- GPU-less smart routing for cloud-only BYOK users."""

from __future__ import annotations

import pytest

from llmhosts.router.cost import CLOUD_PRICING
from llmhosts.router.gpu_less import CloudModelSelection, GPULessRouter, TaskComplexity

# -------------------------------------------------------------------
# 1. Data model creation
# -------------------------------------------------------------------


@pytest.mark.unit
class TestTaskComplexityModel:
    def test_create_with_defaults(self) -> None:
        tc = TaskComplexity(level="simple", confidence=0.8)
        assert tc.level == "simple"
        assert tc.confidence == 0.8
        assert tc.signals == {}
        assert tc.estimated_output_tokens == 256

    def test_create_with_all_fields(self) -> None:
        tc = TaskComplexity(
            level="complex",
            confidence=0.95,
            signals={"code_heavy": True, "reasoning_hits": 3},
            estimated_output_tokens=1024,
        )
        assert tc.level == "complex"
        assert tc.confidence == 0.95
        assert tc.signals["code_heavy"] is True
        assert tc.estimated_output_tokens == 1024


@pytest.mark.unit
class TestCloudModelSelectionModel:
    def test_create_with_defaults(self) -> None:
        sel = CloudModelSelection(
            provider="openai",
            model="gpt-4o-mini",
            reason="cheapest",
            estimated_cost=0.0001,
        )
        assert sel.provider == "openai"
        assert sel.model == "gpt-4o-mini"
        assert sel.reason == "cheapest"
        assert sel.estimated_cost == 0.0001
        assert sel.alternatives == []

    def test_create_with_alternatives(self) -> None:
        sel = CloudModelSelection(
            provider="anthropic",
            model="claude-3-5-haiku-20241022",
            reason="mid-tier",
            estimated_cost=0.001,
            alternatives=[{"provider": "openai", "model": "gpt-4o-mini", "estimated_cost": 0.0005}],
        )
        assert len(sel.alternatives) == 1
        assert sel.alternatives[0]["provider"] == "openai"


# -------------------------------------------------------------------
# 2. classify_complexity
# -------------------------------------------------------------------


@pytest.mark.unit
class TestClassifyComplexity:
    def test_simple_short_message(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "What is Python?"}]
        result = router.classify_complexity(messages)
        assert result.level == "simple"
        assert result.confidence > 0.0

    def test_simple_translation_marker(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "Translate this to French: Hello world"}]
        result = router.classify_complexity(messages)
        assert result.level == "simple"
        assert result.signals.get("simple_marker_hits", 0) >= 1

    def test_simple_summarize_marker(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "Summarize the following text for me"}]
        result = router.classify_complexity(messages)
        assert result.level == "simple"

    def test_code_heavy_triggers_medium(self) -> None:
        router = GPULessRouter(["openai"])
        code_content = (
            "```python\ndef hello():\n    pass\n```\ndef another_func():\n    pass\nclass MyClass:\n    pass\n"
        )
        messages = [{"role": "user", "content": code_content}]
        result = router.classify_complexity(messages)
        assert result.signals.get("code_marker_hits", 0) >= 3
        assert result.signals.get("code_heavy") is True
        # Code-heavy input should push towards medium
        raw = result.signals["raw_scores"]
        assert raw["medium"] > 0

    def test_reasoning_heavy_triggers_complex(self) -> None:
        router = GPULessRouter(["openai"])
        # Use a longer message to avoid the short-content simple boost overwhelming
        # the reasoning markers.  The content exceeds 200 chars to neutralize Signal 2.
        filler = "Here is the detailed context for this task. " * 6
        messages = [
            {
                "role": "user",
                "content": (
                    f"{filler}"
                    "Please analyze this problem step by step. "
                    "Think carefully about the pros and cons, "
                    "and evaluate each option through multi-step reasoning."
                ),
            }
        ]
        result = router.classify_complexity(messages)
        assert result.signals.get("reasoning_marker_hits", 0) >= 2
        assert result.signals.get("reasoning_heavy") is True
        assert result.level == "complex"

    def test_creative_writing_triggers_complex(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "Write a story about a robot exploring the ocean."}]
        result = router.classify_complexity(messages)
        assert result.signals.get("creative_marker_hits", 0) >= 1
        assert result.signals.get("creative_task") is True

    def test_deep_conversation_triggers_complex(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [
            {"role": "user", "content": "Hi"},
            {"role": "assistant", "content": "Hello!"},
            {"role": "user", "content": "Tell me more"},
            {"role": "assistant", "content": "Sure"},
            {"role": "user", "content": "And then?"},
            {"role": "assistant", "content": "Next..."},
            {"role": "user", "content": "Continue"},
            {"role": "assistant", "content": "More"},
        ]
        result = router.classify_complexity(messages)
        assert result.signals["message_count"] == 8
        assert result.signals.get("deep_conversation") is True

    def test_complex_system_prompt(self) -> None:
        router = GPULessRouter(["openai"])
        long_system = "You are an expert assistant. " * 30  # > 500 chars
        messages = [
            {"role": "system", "content": long_system},
            {"role": "user", "content": "Help me."},
        ]
        result = router.classify_complexity(messages)
        assert result.signals.get("complex_system_prompt") is True

    def test_very_long_content(self) -> None:
        router = GPULessRouter(["openai"])
        long_text = "word " * 1000  # 5000 chars
        messages = [{"role": "user", "content": long_text}]
        result = router.classify_complexity(messages)
        assert result.signals.get("very_long_content") is True

    def test_mixed_signals_code_and_simple(self) -> None:
        """When both simple and code markers are present, scores compete."""
        router = GPULessRouter(["openai"])
        messages = [
            {
                "role": "user",
                "content": "Summarize what this function does:\ndef hello():\n    pass",
            }
        ]
        result = router.classify_complexity(messages)
        # Both markers should fire
        assert result.signals.get("simple_marker_hits", 0) >= 1
        assert result.signals.get("code_marker_hits", 0) >= 1
        # Confidence should be moderate since signals conflict
        assert 0.0 < result.confidence < 1.0

    def test_confidence_is_between_0_and_1(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "Hello"}]
        result = router.classify_complexity(messages)
        assert 0.0 < result.confidence <= 1.0


# -------------------------------------------------------------------
# 3. select_model
# -------------------------------------------------------------------


@pytest.mark.unit
class TestSelectModel:
    def test_selects_model_for_simple_task(self) -> None:
        router = GPULessRouter(["openai"])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        selection = router.select_model(complexity)
        assert selection.provider == "openai"
        assert selection.model in ("gpt-4o-mini", "gpt-3.5-turbo")

    def test_selects_model_for_medium_task(self) -> None:
        router = GPULessRouter(["openai"])
        complexity = TaskComplexity(level="medium", confidence=0.8)
        selection = router.select_model(complexity)
        assert selection.provider == "openai"
        assert selection.model == "gpt-4o"

    def test_selects_model_for_complex_task(self) -> None:
        router = GPULessRouter(["anthropic"])
        complexity = TaskComplexity(level="complex", confidence=0.9)
        selection = router.select_model(complexity)
        assert selection.provider == "anthropic"
        assert selection.model == "claude-3-opus-20240229"

    def test_selects_with_provider_override(self) -> None:
        router = GPULessRouter(["openai"])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        selection = router.select_model(complexity, providers=["anthropic"])
        assert selection.provider == "anthropic"

    def test_fallback_to_lower_tier_when_no_match(self) -> None:
        """If complex tier has no matching providers, fall back to medium/simple."""
        router = GPULessRouter(["mistral"])
        complexity = TaskComplexity(level="complex", confidence=0.9)
        # Complex tier has openai+anthropic only; mistral is in medium/simple
        selection = router.select_model(complexity)
        assert selection.provider == "mistral"

    def test_raises_when_no_providers(self) -> None:
        router = GPULessRouter([])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        with pytest.raises(ValueError, match="No cloud providers available"):
            router.select_model(complexity)

    def test_raises_when_no_matching_provider_in_any_tier(self) -> None:
        router = GPULessRouter(["fake-provider-xyz"])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        with pytest.raises(ValueError, match="No models available"):
            router.select_model(complexity)

    def test_alternatives_populated(self) -> None:
        router = GPULessRouter(["openai", "anthropic", "google"])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        selection = router.select_model(complexity)
        # Multiple providers in simple tier -> alternatives list should be non-empty
        assert len(selection.alternatives) > 0

    def test_providers_lowercased_in_init(self) -> None:
        router = GPULessRouter(["OpenAI", "  ANTHROPIC  "])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        selection = router.select_model(complexity)
        assert selection.provider in ("openai", "anthropic")

    def test_reason_contains_model_and_level(self) -> None:
        router = GPULessRouter(["openai"])
        complexity = TaskComplexity(level="simple", confidence=0.9)
        selection = router.select_model(complexity)
        assert "simple" in selection.reason
        assert selection.model in selection.reason


# -------------------------------------------------------------------
# 4. estimate_cost
# -------------------------------------------------------------------


@pytest.mark.unit
class TestEstimateCost:
    def test_known_model_returns_positive_cost(self) -> None:
        router = GPULessRouter(["openai"])
        cost = router.estimate_cost("gpt-4o-mini")
        assert cost > 0.0

    def test_unknown_model_returns_zero(self) -> None:
        router = GPULessRouter(["openai"])
        cost = router.estimate_cost("totally-unknown-model-xyz")
        assert cost == 0.0

    def test_cost_with_messages(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "Hello, how are you today?"}]
        cost = router.estimate_cost("gpt-4o-mini", messages=messages)
        assert cost > 0.0

    def test_cost_with_estimated_tokens(self) -> None:
        router = GPULessRouter(["openai"])
        cost_default = router.estimate_cost("gpt-4o-mini")
        cost_large = router.estimate_cost("gpt-4o-mini", estimated_tokens=2000)
        # More output tokens should cost more
        assert cost_large > cost_default

    def test_cost_calculation_accuracy(self) -> None:
        """Verify cost formula: input_tokens * input_price + output_tokens * output_price."""
        router = GPULessRouter(["openai"])
        model = "gpt-4o-mini"
        pricing = CLOUD_PRICING[model]
        # Default: input_tokens=150, output_tokens=256
        expected = 150 * pricing["input"] + 256 * pricing["output"]
        actual = router.estimate_cost(model)
        assert actual == pytest.approx(expected)

    def test_cost_with_messages_uses_token_estimate(self) -> None:
        router = GPULessRouter(["openai"])
        # "Hello" = 5 chars -> ~1 token (max(1, 5//4) = 1)
        messages = [{"role": "user", "content": "Hello"}]
        cost = router.estimate_cost("gpt-4o-mini", messages=messages)
        pricing = CLOUD_PRICING["gpt-4o-mini"]
        expected = 1 * pricing["input"] + 256 * pricing["output"]
        assert cost == pytest.approx(expected)


# -------------------------------------------------------------------
# 5. route convenience method
# -------------------------------------------------------------------


@pytest.mark.unit
class TestRoute:
    def test_route_returns_tuple(self) -> None:
        router = GPULessRouter(["openai"])
        messages = [{"role": "user", "content": "What is 2+2?"}]
        result = router.route(messages)
        assert isinstance(result, tuple)
        assert len(result) == 2
        complexity, selection = result
        assert isinstance(complexity, TaskComplexity)
        assert isinstance(selection, CloudModelSelection)

    def test_route_complexity_matches_selection(self) -> None:
        router = GPULessRouter(["openai", "anthropic"])
        messages = [{"role": "user", "content": "Summarize: AI is great."}]
        complexity, selection = router.route(messages)
        # Selection reason should mention the complexity level
        assert complexity.level in selection.reason


# -------------------------------------------------------------------
# 6. _extract_text
# -------------------------------------------------------------------


@pytest.mark.unit
class TestExtractText:
    def test_string_content(self) -> None:
        messages = [{"role": "user", "content": "Hello world"}]
        text = GPULessRouter._extract_text(messages)
        assert text == "Hello world"

    def test_list_content_with_text_blocks(self) -> None:
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "First part"},
                    {"type": "text", "text": "Second part"},
                ],
            }
        ]
        text = GPULessRouter._extract_text(messages)
        assert "First part" in text
        assert "Second part" in text

    def test_list_content_skips_non_text(self) -> None:
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
                    {"type": "text", "text": "Describe this image"},
                ],
            }
        ]
        text = GPULessRouter._extract_text(messages)
        assert "Describe this image" in text
        assert "example.com" not in text

    def test_mixed_string_and_list_messages(self) -> None:
        messages = [
            {"role": "system", "content": "You are a helper."},
            {
                "role": "user",
                "content": [{"type": "text", "text": "What is this?"}],
            },
        ]
        text = GPULessRouter._extract_text(messages)
        assert "You are a helper." in text
        assert "What is this?" in text

    def test_empty_messages(self) -> None:
        text = GPULessRouter._extract_text([])
        assert text == ""

    def test_missing_content_key(self) -> None:
        messages = [{"role": "user"}]
        text = GPULessRouter._extract_text(messages)
        assert text == ""


# -------------------------------------------------------------------
# 7. _estimate_output_tokens
# -------------------------------------------------------------------


@pytest.mark.unit
class TestEstimateOutputTokens:
    def test_base_simple(self) -> None:
        tokens = GPULessRouter._estimate_output_tokens("simple", 100, 0)
        assert tokens == 128

    def test_base_medium(self) -> None:
        tokens = GPULessRouter._estimate_output_tokens("medium", 100, 0)
        assert tokens == 512

    def test_base_complex(self) -> None:
        tokens = GPULessRouter._estimate_output_tokens("complex", 100, 0)
        assert tokens == 1024

    def test_unknown_level_defaults_to_256(self) -> None:
        tokens = GPULessRouter._estimate_output_tokens("unknown", 100, 0)
        assert tokens == 256

    def test_code_boost_multiplier(self) -> None:
        # code_hits >= 3 multiplies by 1.5
        tokens_no_code = GPULessRouter._estimate_output_tokens("medium", 100, 0)
        tokens_code = GPULessRouter._estimate_output_tokens("medium", 100, 5)
        assert tokens_code == int(tokens_no_code * 1.5)

    def test_length_boost_for_very_long_input(self) -> None:
        # input_chars=12000 -> input_tokens_est=3000 -> 3000*0.3=900
        # For simple level: base=128, no code boost, length_boost=900 > 128
        tokens = GPULessRouter._estimate_output_tokens("simple", 12000, 0)
        assert tokens == 900

    def test_cap_at_4096(self) -> None:
        # Extremely long input to trigger cap
        tokens = GPULessRouter._estimate_output_tokens("complex", 100000, 5)
        assert tokens == 4096

    def test_code_boost_not_applied_below_threshold(self) -> None:
        # code_hits=2 should NOT trigger the 1.5x multiplier
        tokens = GPULessRouter._estimate_output_tokens("medium", 100, 2)
        assert tokens == 512  # base medium, no boost
