"""
Tests for providers, models, and pricing configuration.
Verifies consistency across llm_client.py, price.py, and connector/routes.py.
"""
import pytest
from types import SimpleNamespace
from unittest.mock import patch, AsyncMock, MagicMock

from eval_lib.llm_client import (
    Provider, LLMDescriptor, _get_client, _HELPERS,
    _calculate_cost, LLMConfigurationError, chat_complete,
)
from eval_lib.price import model_pricing
from eval_lib.connector.routes import PROVIDERS


# ---------------------------------------------------------------------------
# 1. Provider enum completeness
# ---------------------------------------------------------------------------

class TestProviderEnum:
    EXPECTED_PROVIDERS = [
        "openai", "azure", "google", "ollama", "anthropic",
        "deepseek", "qwen", "zhipu", "mistral", "groq", "grok", "custom",
    ]

    def test_all_providers_exist(self):
        actual = [p.value for p in Provider]
        for expected in self.EXPECTED_PROVIDERS:
            assert expected in actual, f"Provider '{expected}' missing from enum"

    def test_no_unexpected_providers(self):
        actual = set(p.value for p in Provider)
        expected = set(self.EXPECTED_PROVIDERS)
        unexpected = actual - expected
        assert not unexpected, f"Unexpected providers in enum: {unexpected}"


# ---------------------------------------------------------------------------
# 2. _HELPERS mapping covers all non-CUSTOM providers
# ---------------------------------------------------------------------------

class TestHelpersMapping:
    def test_all_providers_have_helpers(self):
        for provider in Provider:
            if provider == Provider.CUSTOM:
                continue
            assert provider in _HELPERS, (
                f"Provider {provider.value} has no entry in _HELPERS"
            )

    def test_helpers_are_callable(self):
        for provider, helper in _HELPERS.items():
            assert callable(helper), (
                f"_HELPERS[{provider.value}] is not callable"
            )


# ---------------------------------------------------------------------------
# 3. PROVIDERS dict in routes.py — consistency with Provider enum
# ---------------------------------------------------------------------------

class TestRouteProviders:
    def test_all_route_providers_in_enum(self):
        """Every provider in routes.py PROVIDERS must exist in Provider enum."""
        for pid in PROVIDERS:
            assert pid in [p.value for p in Provider], (
                f"Routes provider '{pid}' not in Provider enum"
            )

    def test_providers_have_required_fields(self):
        for pid, pinfo in PROVIDERS.items():
            assert "name" in pinfo, f"Provider '{pid}' missing 'name'"
            assert "env_var" in pinfo, f"Provider '{pid}' missing 'env_var'"
            assert "models" in pinfo, f"Provider '{pid}' missing 'models'"
            assert isinstance(pinfo["models"], list), (
                f"Provider '{pid}' models is not a list"
            )

    def test_provider_names_not_empty(self):
        for pid, pinfo in PROVIDERS.items():
            assert pinfo["name"], f"Provider '{pid}' has empty name"

    def test_env_vars_not_empty(self):
        for pid, pinfo in PROVIDERS.items():
            assert pinfo["env_var"], f"Provider '{pid}' has empty env_var"


# ---------------------------------------------------------------------------
# 4. Model pricing — structure validation
# ---------------------------------------------------------------------------

class TestModelPricing:
    def test_pricing_not_empty(self):
        assert len(model_pricing) > 0

    def test_all_models_have_input_output(self):
        for model, prices in model_pricing.items():
            assert "input" in prices, f"Model '{model}' missing 'input' price"
            assert "output" in prices, f"Model '{model}' missing 'output' price"

    def test_prices_are_non_negative(self):
        for model, prices in model_pricing.items():
            assert prices["input"] >= 0, (
                f"Model '{model}' has negative input price: {prices['input']}"
            )
            assert prices["output"] >= 0, (
                f"Model '{model}' has negative output price: {prices['output']}"
            )

    def test_prices_are_numeric(self):
        for model, prices in model_pricing.items():
            assert isinstance(prices["input"], (int, float)), (
                f"Model '{model}' input price is not numeric"
            )
            assert isinstance(prices["output"], (int, float)), (
                f"Model '{model}' output price is not numeric"
            )


# ---------------------------------------------------------------------------
# 5. Models listed in PROVIDERS should have pricing (where applicable)
# ---------------------------------------------------------------------------

class TestModelPricingCoverage:
    # Providers where models are user-deployed or local (no fixed pricing)
    SKIP_PRICING_PROVIDERS = {"ollama", "azure"}

    def test_provider_models_have_pricing(self):
        missing = []
        for pid, pinfo in PROVIDERS.items():
            if pid in self.SKIP_PRICING_PROVIDERS:
                continue
            for model in pinfo["models"]:
                if model not in model_pricing:
                    missing.append(f"{pid}:{model}")
        assert not missing, (
            f"Models listed in PROVIDERS but missing from price.py:\n"
            + "\n".join(f"  - {m}" for m in missing)
        )


# ---------------------------------------------------------------------------
# 6. Specific model families present
# ---------------------------------------------------------------------------

class TestExpectedModels:
    def test_gpt5_models(self):
        for m in ["gpt-5", "gpt-5-mini", "gpt-5-nano"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_gpt41_models(self):
        for m in ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_gpt4o_models(self):
        for m in ["gpt-4o", "gpt-4o-mini"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_claude_models(self):
        for m in [
            "claude-opus-4-6-20250619", "claude-opus-4-5-20250415",
            "claude-sonnet-4-6-20250619", "claude-sonnet-4-5-20250415",
            "claude-haiku-4-5-20251001",
        ]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_deepseek_models(self):
        for m in [
            "deepseek-chat", "deepseek-v3.2", "deepseek-v3.2-exp",
            "deepseek-v3.1", "deepseek-v3", "deepseek-reasoner",
            "deepseek-r1", "deepseek-r1-lite",
        ]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_grok_models(self):
        for m in [
            "grok-4.1", "grok-4", "grok-4-heavy",
            "grok-4-fast", "grok-beta", "grok-3", "grok-2",
        ]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_gemini_models(self):
        for m in [
            "gemini-2.5-pro", "gemini-2.5-flash",
            "gemini-2.0-flash", "gemini-2.0-flash-lite",
        ]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_mistral_models(self):
        for m in ["mistral-large-latest", "mistral-small-latest", "codestral-latest"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_qwen_models(self):
        for m in ["qwen-max", "qwen-plus", "qwen-turbo"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_zhipu_models(self):
        for m in ["glm-4-plus", "glm-4-air", "glm-4-flash"]:
            assert m in model_pricing, f"Missing {m} in price.py"

    def test_groq_models(self):
        for m in ["llama-3.3-70b-versatile", "llama-3.1-8b-instant"]:
            assert m in model_pricing, f"Missing {m} in price.py"


# ---------------------------------------------------------------------------
# 7. LLMDescriptor parsing
# ---------------------------------------------------------------------------

class TestLLMDescriptor:
    def test_parse_simple_model(self):
        d = LLMDescriptor.parse("gpt-4o")
        assert d.provider == Provider.OPENAI
        assert d.model == "gpt-4o"

    def test_parse_with_provider(self):
        d = LLMDescriptor.parse("deepseek:deepseek-chat")
        assert d.provider == Provider.DEEPSEEK
        assert d.model == "deepseek-chat"

    def test_parse_grok(self):
        d = LLMDescriptor.parse("grok:grok-4")
        assert d.provider == Provider.GROK
        assert d.model == "grok-4"

    def test_parse_tuple(self):
        d = LLMDescriptor.parse(("mistral", "mistral-large-latest"))
        assert d.provider == Provider.MISTRAL
        assert d.model == "mistral-large-latest"

    def test_parse_all_providers(self):
        """Verify parsing works for every provider value."""
        for provider in Provider:
            if provider == Provider.CUSTOM:
                continue
            spec = f"{provider.value}:test-model"
            d = LLMDescriptor.parse(spec)
            assert d.provider == provider
            assert d.model == "test-model"


# ---------------------------------------------------------------------------
# 8. Cost calculation
# ---------------------------------------------------------------------------

class TestCostCalculation:
    def test_known_model_cost(self):
        llm = LLMDescriptor(Provider.OPENAI, "gpt-4o-mini")
        usage = SimpleNamespace(prompt_tokens=1000, completion_tokens=500)
        cost = _calculate_cost(llm, usage)
        expected = round(1000 * 0.15 / 1_000_000 + 500 * 0.60 / 1_000_000, 6)
        assert cost == expected

    def test_ollama_cost_is_zero(self):
        llm = LLMDescriptor(Provider.OLLAMA, "llama3.3")
        usage = SimpleNamespace(prompt_tokens=1000, completion_tokens=500)
        cost = _calculate_cost(llm, usage)
        assert cost == 0.0

    def test_unknown_model_returns_none(self):
        llm = LLMDescriptor(Provider.OPENAI, "nonexistent-model-xyz")
        usage = SimpleNamespace(prompt_tokens=1000, completion_tokens=500)
        cost = _calculate_cost(llm, usage)
        assert cost is None

    def test_no_usage_returns_none(self):
        llm = LLMDescriptor(Provider.OPENAI, "gpt-4o")
        cost = _calculate_cost(llm, None)
        assert cost is None

    def test_deepseek_cost(self):
        llm = LLMDescriptor(Provider.DEEPSEEK, "deepseek-chat")
        usage = SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=1_000_000)
        cost = _calculate_cost(llm, usage)
        expected = round(0.28 + 0.42, 6)
        assert cost == expected

    def test_grok_cost(self):
        llm = LLMDescriptor(Provider.GROK, "grok-4")
        usage = SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=1_000_000)
        cost = _calculate_cost(llm, usage)
        expected = round(3.00 + 15.00, 6)
        assert cost == expected

    def test_free_model_cost(self):
        llm = LLMDescriptor(Provider.ZHIPU, "glm-4-flash")
        usage = SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=1_000_000)
        cost = _calculate_cost(llm, usage)
        assert cost == 0.0


# ---------------------------------------------------------------------------
# 9. _get_client — env var checks
# ---------------------------------------------------------------------------

class TestGetClientEnvVars:
    """Test that _get_client raises LLMConfigurationError when env vars are missing."""

    PROVIDER_ENV_VARS = {
        Provider.OPENAI: "OPENAI_API_KEY",
        Provider.AZURE: "AZURE_OPENAI_API_KEY",
        Provider.GOOGLE: "GOOGLE_API_KEY",
        Provider.ANTHROPIC: "ANTHROPIC_API_KEY",
        Provider.DEEPSEEK: "DEEPSEEK_API_KEY",
        Provider.QWEN: "DASHSCOPE_API_KEY",
        Provider.ZHIPU: "ZHIPU_API_KEY",
        Provider.MISTRAL: "MISTRAL_API_KEY",
        Provider.GROQ: "GROQ_API_KEY",
        Provider.GROK: "XAI_API_KEY",
    }

    @pytest.fixture(autouse=True)
    def clear_client_cache(self):
        _get_client.cache_clear()
        yield
        _get_client.cache_clear()

    @pytest.mark.parametrize("provider,env_var", list(PROVIDER_ENV_VARS.items()),
                             ids=[p.value for p in PROVIDER_ENV_VARS])
    def test_missing_env_var_raises(self, provider, env_var):
        env = {k: "" for k in [
            "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT",
            "GOOGLE_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
            "DASHSCOPE_API_KEY", "ZHIPU_API_KEY", "MISTRAL_API_KEY",
            "GROQ_API_KEY", "XAI_API_KEY",
        ]}
        with patch.dict("os.environ", env, clear=False):
            for var in env:
                if var in ("PATH", "HOME"):
                    continue
                if var in os.environ:
                    del os.environ[var]
            with pytest.raises(LLMConfigurationError):
                _get_client(provider)

    def test_ollama_no_key_required(self):
        """Ollama should not raise even without OLLAMA_API_KEY."""
        with patch.dict("os.environ", {}, clear=False):
            for var in ["OLLAMA_API_KEY", "OLLAMA_API_BASE_URL"]:
                os.environ.pop(var, None)
            client = _get_client(Provider.OLLAMA)
            assert client is not None


# ---------------------------------------------------------------------------
# 10. _get_client — correct base_url for OpenAI-compatible providers
# ---------------------------------------------------------------------------

class TestGetClientBaseUrls:
    EXPECTED_BASE_URLS = {
        Provider.DEEPSEEK: "https://api.deepseek.com/v1",
        Provider.QWEN: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        Provider.ZHIPU: "https://open.bigmodel.cn/api/paas/v4",
        Provider.MISTRAL: "https://api.mistral.ai/v1",
        Provider.GROQ: "https://api.groq.com/openai/v1",
        Provider.GROK: "https://api.x.ai/v1",
    }

    @pytest.fixture(autouse=True)
    def clear_client_cache(self):
        _get_client.cache_clear()
        yield
        _get_client.cache_clear()

    @pytest.mark.parametrize("provider,expected_url", list(EXPECTED_BASE_URLS.items()),
                             ids=[p.value for p in EXPECTED_BASE_URLS])
    def test_base_url(self, provider, expected_url):
        env = {
            "DEEPSEEK_API_KEY": "test-key",
            "DASHSCOPE_API_KEY": "test-key",
            "ZHIPU_API_KEY": "test-key",
            "MISTRAL_API_KEY": "test-key",
            "GROQ_API_KEY": "test-key",
            "XAI_API_KEY": "test-key",
        }
        with patch.dict("os.environ", env, clear=False):
            client = _get_client(provider)
            assert str(client.base_url).rstrip("/") == expected_url


import os
