"""
Unit tests for ProviderManager — provider init, fallback cascade,
model router setup, provider selection, PHI guard, provider cache,
provider_key, timeout unification, and connectivity validation.
"""

import logging
import os
from unittest.mock import MagicMock, patch

import pytest

from familiar.core.provider_manager import ProviderManager
from familiar.core.providers import DEFAULT_HTTP_TIMEOUT, invalidate_provider_cache


@pytest.fixture
def mock_agent():
    agent = MagicMock()
    agent.config.llm.default_provider = "anthropic"
    agent.config.llm.ollama_base_url = "http://localhost:11434"
    agent.config.llm.ollama_model = "llama3.2"
    agent.provider = MagicMock()
    agent.provider.name = "anthropic"
    agent.provider.provider_key = "anthropic"
    agent.provider.model_name = "claude-sonnet-4-6"
    agent.compliance_mode = None
    return agent


@pytest.fixture
def pm(mock_agent):
    return ProviderManager(mock_agent)


class TestInitProvider:
    """Provider initialization with fallback cascade."""

    def test_anthropic_with_key_and_package(self, pm, mock_agent):
        with (
            patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}),
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
        ):
            mock_gp.return_value = MagicMock()
            pm.init_provider()
            mock_gp.assert_called_with("anthropic", mock_agent.config)

    def test_fallback_to_openai_when_anthropic_unavailable(self, pm, mock_agent):
        """If anthropic package not installed, fall back to OpenAI."""
        with (
            patch.dict(
                os.environ,
                {"ANTHROPIC_API_KEY": "", "OPENAI_API_KEY": "sk-test"},
                clear=False,
            ),
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
            patch.dict("sys.modules", {"anthropic": None}),
        ):
            mock_gp.return_value = MagicMock()
            pm.init_provider()
            mock_gp.assert_called_with("openai", mock_agent.config)

    def test_fallback_to_ollama_when_no_cloud(self, pm, mock_agent):
        """No cloud keys → Ollama."""
        with (
            patch.dict(
                os.environ,
                {"ANTHROPIC_API_KEY": "", "OPENAI_API_KEY": ""},
                clear=False,
            ),
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
        ):
            mock_gp.return_value = MagicMock()
            pm.init_provider()
            mock_gp.assert_called_with("ollama", mock_agent.config)

    def test_openai_preferred_falls_back_to_anthropic(self, pm, mock_agent):
        """If OpenAI is preferred but unavailable, falls to Anthropic."""
        mock_agent.config.llm.default_provider = "openai"
        with (
            patch.dict(
                os.environ,
                {"ANTHROPIC_API_KEY": "sk-a", "OPENAI_API_KEY": ""},
                clear=False,
            ),
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
        ):
            mock_gp.return_value = MagicMock()
            pm.init_provider()
            mock_gp.assert_called_with("anthropic", mock_agent.config)

    def test_warns_when_key_set_but_package_missing(self, pm, mock_agent, caplog):
        """Warns user if API key is set but SDK not installed."""
        import logging

        with (
            patch.dict(
                os.environ,
                {"ANTHROPIC_API_KEY": "sk-test", "OPENAI_API_KEY": ""},
                clear=False,
            ),
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
            patch.dict("sys.modules", {"anthropic": None}),
            caplog.at_level(logging.WARNING),
        ):
            try:
                import anthropic  # noqa: F401

                has_pkg = True
            except ImportError:
                has_pkg = False

            mock_gp.return_value = MagicMock()
            pm.init_provider()
            if not has_pkg:
                assert "ANTHROPIC_API_KEY is set but" in caplog.text


class TestModelRouterSetup:
    """Model router initialization with multiple providers."""

    def test_router_skipped_with_single_provider(self, pm, mock_agent):
        """Router requires 2+ providers — skip if only 1."""
        mock_agent.config.llm.ollama_base_url = "http://localhost:99999"  # unreachable
        with patch.dict(
            os.environ,
            {
                "ANTHROPIC_API_KEY": "sk-a",
                "OPENAI_API_KEY": "",
                "GEMINI_API_KEY": "",
                "GOOGLE_API_KEY": "",
            },
            clear=False,
        ):
            pm.init_model_router()
            assert pm.model_router is None

    def test_router_initialized_with_two_providers(self, pm, mock_agent):
        """Router activates when 2+ providers are available."""
        with patch.dict(
            os.environ,
            {
                "ANTHROPIC_API_KEY": "sk-a",
                "OPENAI_API_KEY": "sk-o",
                "GEMINI_API_KEY": "",
                "GOOGLE_API_KEY": "",
            },
            clear=False,
        ):
            pm.init_model_router()
            # Router is initialized if ModelRouter can be imported and 2+ providers exist
            # (may be None if ModelRouter import fails in test env, which is acceptable)
            # Just verify no crash
            assert True


class TestSelectProvider:
    """Provider selection per request."""

    def test_default_returns_agent_provider(self, pm, mock_agent):
        """No router → returns default provider."""
        pm.model_router = None
        provider, name = pm.select_provider("hello", MagicMock())
        assert provider is mock_agent.provider

    def test_auto_routing_calls_router(self, pm, mock_agent):
        """When router is set, auto routing is attempted."""
        mock_router = MagicMock()
        mock_router.route.return_value = ("claude-sonnet", "anthropic")
        pm.model_router = mock_router

        with patch("familiar.core.provider_manager.get_provider") as mock_gp:
            mock_gp.return_value = MagicMock()
            provider, name = pm.select_provider("complex question", MagicMock())
            mock_router.route.assert_called_once_with("complex question")

    def test_router_failure_falls_back_to_default(self, pm, mock_agent):
        """If router raises, falls back to default provider."""
        mock_router = MagicMock()
        mock_router.route.side_effect = RuntimeError("router broken")
        pm.model_router = mock_router

        provider, name = pm.select_provider("hello", MagicMock())
        assert provider is mock_agent.provider

    def test_manual_routing_mode(self, pm, mock_agent):
        """Manual routes take precedence over auto."""
        mock_router = MagicMock()
        analyzer = MagicMock()
        qc = MagicMock()
        qc.query_type.value = "general"
        analyzer.analyze.return_value = qc
        mock_router.analyzer = analyzer
        mock_router.has_model.return_value = True
        mock_router.get_model.return_value = MagicMock(provider="openai")
        pm.model_router = mock_router
        pm._routing_mode = "manual"
        pm._manual_routes = {"general": "gpt-4o"}

        with patch("familiar.core.provider_manager.get_provider") as mock_gp:
            routed = MagicMock()
            mock_gp.return_value = routed
            provider, name = pm.select_provider("what time is it", MagicMock())
            assert provider is routed
            assert name == "gpt-4o"
            # Auto routing should NOT be called
            mock_router.route.assert_not_called()


class TestProviderFallback:
    """Provider fallback on failure."""

    def test_fallback_returns_alternate_provider(self, pm, mock_agent):
        """Fallback finds a different provider from the same config."""
        primary = MagicMock()
        primary.name = "Anthropic"
        primary.provider_key = "anthropic"

        with patch("familiar.core.provider_manager.get_provider") as mock_gp:
            fallback = MagicMock()
            mock_gp.return_value = fallback
            mock_agent.config.llm.default_provider = "openai"
            result = pm.provider_fallback(primary, RuntimeError("rate limit"))
            assert result is not None

    def test_capacity_tracker_records_rate_limit(self, pm, mock_agent):
        """Capacity tracker records rate limit events."""
        pm.capacity_tracker = MagicMock()
        primary = MagicMock()
        primary.name = "Anthropic"
        primary.provider_key = "anthropic"

        with patch("familiar.core.provider_manager.get_provider"):
            pm.provider_fallback(primary, RuntimeError("rate limit exceeded (429)"))
            pm.capacity_tracker.record_rate_limit.assert_called()

    def test_fallback_uses_provider_key(self, pm, mock_agent):
        """Fallback skips current provider by provider_key, not by name string matching."""
        primary = MagicMock()
        primary.name = "Claude (claude-sonnet-4-6)"
        primary.provider_key = "anthropic"

        with (
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
            patch("familiar.core.provider_manager.get_best_ollama_model", return_value=None),
        ):
            fallback_prov = MagicMock()
            mock_gp.return_value = fallback_prov
            result = pm.provider_fallback(primary, RuntimeError("test"))
            assert result is fallback_prov
            # Should have tried ollama first (it comes before anthropic in fallback_order
            # and anthropic is skipped since provider_key matches)

    def test_circuit_breaker_uses_provider_key(self, pm, mock_agent):
        """Circuit breaker failure recording uses record_failure_for_provider."""
        mock_router = MagicMock()
        pm.model_router = mock_router
        primary = MagicMock()
        primary.name = "Claude (sonnet)"
        primary.provider_key = "anthropic"

        with patch("familiar.core.provider_manager.get_provider") as mock_gp:
            mock_gp.return_value = MagicMock()
            pm.provider_fallback(primary, RuntimeError("test"))
            mock_router.record_failure_for_provider.assert_called_once_with("anthropic")

    def test_phi_chains_exception(self, pm, mock_agent):
        """PHI_NO_LOCAL_FALLBACK chains the underlying exception."""
        mock_agent.compliance_mode = "hipaa"
        # PHI guard uses provider_key to detect cloud providers
        mock_agent.provider.name = "Claude (sonnet)"
        mock_agent.provider.provider_key = "anthropic"
        mock_agent.provider.model_name = "claude-sonnet-4-6"

        with (
            patch("familiar.core.provider_manager.get_provider") as mock_gp,
            patch("familiar.core.compliance.check_phi_content", return_value=True),
        ):
            underlying = ValueError("ollama not found")
            mock_gp.side_effect = underlying
            with pytest.raises(RuntimeError, match="PHI_NO_LOCAL_FALLBACK") as exc_info:
                pm.select_provider("patient SSN 123-45-6789", MagicMock())
            assert exc_info.value.__cause__ is underlying

    def test_model_name_returns_model(self, pm, mock_agent):
        """select_provider returns provider's model_name, not default_provider config."""
        pm.model_router = None
        mock_agent.provider.model_name = "claude-sonnet-4-6"
        _, model_name = pm.select_provider("hello", MagicMock())
        assert model_name == "claude-sonnet-4-6"


class TestProviderKey:
    """provider_key returns correct stable family identifiers."""

    def test_anthropic_provider_key(self):
        from familiar.core.providers import AnthropicProvider

        p = AnthropicProvider.__new__(AnthropicProvider)
        assert p.provider_key == "anthropic"

    def test_openai_provider_key(self):
        from familiar.core.providers import OpenAIProvider

        p = OpenAIProvider.__new__(OpenAIProvider)
        assert p.provider_key == "openai"

    def test_ollama_provider_key(self):
        from familiar.core.providers import OllamaProvider

        p = OllamaProvider.__new__(OllamaProvider)
        assert p.provider_key == "ollama"

    def test_gemini_provider_key(self):
        from familiar.core.providers import GeminiProvider

        p = GeminiProvider.__new__(GeminiProvider)
        assert p.provider_key == "gemini"


class TestProviderCache:
    """Provider instance caching via get_provider()."""

    @pytest.fixture(autouse=True)
    def _clear_cache(self):
        """Clear provider cache before and after each test."""
        invalidate_provider_cache()
        yield
        invalidate_provider_cache()

    def test_cache_hit_returns_same_instance(self):
        with patch("familiar.core.providers.PROVIDERS") as mock_providers:
            mock_factory = MagicMock()
            mock_instance = MagicMock()
            mock_factory.return_value = mock_instance
            mock_providers.__contains__ = lambda self, k: k == "anthropic"
            mock_providers.__getitem__ = lambda self, k: mock_factory
            mock_providers.keys.return_value = ["anthropic"]

            from familiar.core.providers import _provider_cache, get_provider

            _provider_cache.clear()
            config = MagicMock()
            p1 = get_provider("anthropic", config)
            p2 = get_provider("anthropic", config)
            assert p1 is p2
            assert mock_factory.call_count == 1

    def test_invalidate_specific(self):
        from familiar.core.providers import _provider_cache

        _provider_cache["anthropic"] = MagicMock()
        _provider_cache["openai"] = MagicMock()
        invalidate_provider_cache("anthropic")
        assert "anthropic" not in _provider_cache
        assert "openai" in _provider_cache

    def test_invalidate_all(self):
        from familiar.core.providers import _provider_cache

        _provider_cache["anthropic"] = MagicMock()
        _provider_cache["openai"] = MagicMock()
        invalidate_provider_cache()
        assert len(_provider_cache) == 0

    def test_switch_provider_invalidates_cache(self, pm, mock_agent):
        """switch_provider() invalidates the cache for that provider."""
        with patch("familiar.core.provider_manager.invalidate_provider_cache") as mock_inv:
            with patch("familiar.core.provider_manager.get_provider") as mock_gp:
                mock_gp.return_value = MagicMock(name="Ollama (llama3.2)")
                pm.switch_provider("ollama")
                mock_inv.assert_called_once_with("ollama")


class TestTimeoutUnification:
    """DEFAULT_HTTP_TIMEOUT constant and usage."""

    def test_constant_value(self):
        assert DEFAULT_HTTP_TIMEOUT == 120

    def test_env_override_applies(self):
        """FAMILIAR_HTTP_TIMEOUT env var is respected by providers."""
        with patch.dict(os.environ, {"FAMILIAR_HTTP_TIMEOUT": "200"}):
            val = float(os.environ.get("FAMILIAR_HTTP_TIMEOUT", DEFAULT_HTTP_TIMEOUT))
            assert val == 200.0


class TestFallbackLogging:
    """Fallback logging includes attempted and skipped providers."""

    def test_exhausted_fallback_logs_attempted_and_skipped(self, pm, mock_agent, caplog):
        """When all fallbacks fail, log which were attempted vs skipped."""
        primary = MagicMock()
        primary.name = "Anthropic"
        primary.provider_key = "anthropic"

        # Rate-limit ollama so it's skipped
        pm.capacity_tracker.record_rate_limit("ollama")
        pm.capacity_tracker.record_rate_limit("ollama")

        with (
            patch("familiar.core.provider_manager.get_provider", side_effect=Exception("no")),
            patch("familiar.core.provider_manager.get_best_ollama_model", return_value=None),
            caplog.at_level(logging.WARNING),
        ):
            result = pm.provider_fallback(primary, RuntimeError("test"))
            assert result is None
            assert "All fallback providers exhausted" in caplog.text
            assert "Skipped" in caplog.text


class TestConnectivityValidation:
    """_validate_connectivity warn-only checks."""

    def test_ollama_unreachable_warns(self, pm, caplog):
        """Unreachable Ollama logs a warning but doesn't raise."""
        provider = MagicMock()
        provider.provider_key = "ollama"
        provider.name = "Ollama (llama3.2)"
        provider.base_url = "http://localhost:99999"

        with caplog.at_level(logging.WARNING):
            pm._validate_connectivity(provider)
            assert "Connectivity check" in caplog.text or "failed" in caplog.text

    def test_malformed_api_key_warns(self, pm, caplog):
        """A malformed API key logs a warning."""
        provider = MagicMock()
        provider.provider_key = "anthropic"
        provider.name = "Claude (sonnet)"

        with (
            patch.dict(os.environ, {"ANTHROPIC_API_KEY": "bad-key"}),
            caplog.at_level(logging.WARNING),
        ):
            pm._validate_connectivity(provider)
            assert "does not start with" in caplog.text
