"""Tests for LLM provider factory.

Tests verify create_chat_model() from llm/providers/factory.py,
including happy paths for supported providers, case insensitivity,
and error handling for unknown providers and missing API keys.
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import pytest

from unfamiliar.config import APIKeyError, LLMConfig, UnfamiliarConfig
from unfamiliar.llm.exceptions import ProviderError
from unfamiliar.llm.providers.factory import (
    create_chat_model,
    get_provider_config,
    get_retry_delay,
)


class TestValidAnthropicProvider:
    """Tests for valid anthropic provider configuration."""

    def test_returns_model_from_init_chat_model(self):
        """Valid anthropic provider returns the model from init_chat_model."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model

    def test_init_chat_model_called_with_correct_parameters(self):
        """init_chat_model receives anthropic-specific parameters."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            create_chat_model(config)

        mock_init.assert_called_once_with(
            model="claude-sonnet-4-5-20250929",
            model_provider="anthropic",
            api_key="sk-test-key",
            temperature=0.2,
            max_retries=2,
        )

    def test_api_key_passed_to_init_chat_model(self):
        """The resolved API key is passed as api_key= kwarg to init_chat_model."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")
        expected_key = "sk-ant-test-key-12345"

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value=expected_key,
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["api_key"] == expected_key


class TestValidGlmProvider:
    """Tests for valid glm provider configuration."""

    def test_returns_model_from_init_chat_model(self):
        """Valid glm provider returns the model from init_chat_model."""
        config = UnfamiliarConfig(provider="glm")
        mock_model = MagicMock(name="ChatOpenAI")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="glm-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model

    def test_init_chat_model_called_with_openai_provider(self):
        """GLM uses model_provider='openai' with base_url."""
        config = UnfamiliarConfig(provider="glm")
        mock_model = MagicMock(name="ChatOpenAI")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="glm-test-key",
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["model_provider"] == "openai"
        assert call_kwargs["base_url"] == "https://api.z.ai/api/coding/paas/v4"
        assert call_kwargs["model"] == "glm-5"

    def test_glm_api_key_passed_to_init_chat_model(self):
        """The resolved API key is passed to init_chat_model for GLM."""
        config = UnfamiliarConfig(provider="glm")
        mock_model = MagicMock(name="ChatOpenAI")
        expected_key = "zhipu-test-key-12345"

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value=expected_key,
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["api_key"] == expected_key


class TestProviderCaseInsensitive:
    """Tests for case-insensitive provider name handling."""

    def test_anthropic_with_capital_a_resolves(self):
        """'Anthropic' with capital A resolves to anthropic provider."""
        config = UnfamiliarConfig(provider="Anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model
        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["model_provider"] == "anthropic"

    def test_anthropic_uppercase_resolves(self):
        """'ANTHROPIC' all uppercase resolves to anthropic provider."""
        config = UnfamiliarConfig(provider="ANTHROPIC")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model
        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["model_provider"] == "anthropic"

    def test_glm_uppercase_resolves(self):
        """'GLM' all uppercase resolves to glm provider via init_chat_model."""
        config = UnfamiliarConfig(provider="GLM")
        mock_model = MagicMock(name="ChatOpenAI")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="glm-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model
        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["model_provider"] == "openai"

    def test_provider_with_whitespace_stripped(self):
        """Provider name with leading/trailing whitespace is stripped."""
        config = UnfamiliarConfig(provider="  anthropic  ")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            result = create_chat_model(config)

        assert result is mock_model


class TestUnknownProvider:
    """Tests for unknown provider error handling."""

    def test_unknown_provider_raises_provider_error(self):
        """Unknown provider string raises ProviderError."""
        config = UnfamiliarConfig(provider="openai")

        with pytest.raises(ProviderError) as exc_info:
            create_chat_model(config)

        assert "Unknown provider" in str(exc_info.value)

    def test_unknown_provider_includes_provider_name_in_message(self):
        """ProviderError message includes the unknown provider name."""
        config = UnfamiliarConfig(provider="openai")

        with pytest.raises(ProviderError) as exc_info:
            create_chat_model(config)

        assert "'openai'" in str(exc_info.value)

    def test_unknown_provider_lists_supported_providers(self):
        """ProviderError message lists supported providers."""
        config = UnfamiliarConfig(provider="invalid-provider")

        with pytest.raises(ProviderError) as exc_info:
            create_chat_model(config)

        error_message = str(exc_info.value)
        assert "anthropic" in error_message
        assert "glm" in error_message


class TestMissingApiKey:
    """Tests for missing API key error handling."""

    def test_missing_api_key_raises_provider_error(self):
        """resolve_api_key raising APIKeyError causes ProviderError."""
        config = UnfamiliarConfig(provider="anthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                side_effect=APIKeyError("No key found"),
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        assert "No API key" in str(exc_info.value)

    def test_missing_api_key_includes_provider_in_message(self):
        """ProviderError for missing key includes provider name."""
        config = UnfamiliarConfig(provider="glm")

        with (
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                side_effect=APIKeyError("No key found"),
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        assert "'glm'" in str(exc_info.value)

    def test_missing_api_key_suggests_environment_variable(self):
        """ProviderError suggests setting environment variable."""
        config = UnfamiliarConfig(provider="anthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                side_effect=APIKeyError("No key found"),
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        error_message = str(exc_info.value)
        assert "environment variable" in error_message.lower()


class TestInitChatModelImportError:
    """Tests for ImportError handling when provider package is missing."""

    def test_import_error_raises_provider_error(self):
        """init_chat_model raising ImportError causes ProviderError."""
        config = UnfamiliarConfig(provider="anthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                side_effect=ImportError("No module named 'anthropic'"),
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        assert isinstance(exc_info.value, ProviderError)
        assert "Failed to initialize" in str(exc_info.value)

    def test_import_error_includes_original_error_message(self):
        """ProviderError includes the ImportError message."""
        config = UnfamiliarConfig(provider="anthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                side_effect=ImportError("anthropic package not installed"),
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        error_message = str(exc_info.value)
        assert isinstance(exc_info.value, ProviderError)
        assert "package not installed" in error_message

    def test_import_error_preserves_provider_name(self):
        """ProviderError for ImportError includes provider name."""
        config = UnfamiliarConfig(provider="anthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                side_effect=ImportError("package missing"),
            ),
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
            pytest.raises(ProviderError) as exc_info,
        ):
            create_chat_model(config)

        assert "anthropic" in str(exc_info.value)


class TestGetRetryDelay:
    """Tests for get_retry_delay function."""

    def test_anthropic_provider_returns_default_delay(self):
        """Anthropic provider returns 1.0 second base delay."""
        result = get_retry_delay("anthropic")
        assert result == 1.0

    def test_glm_provider_returns_short_delay(self):
        """GLM provider returns 2.0 second base delay for sequential calls."""
        result = get_retry_delay("glm")
        assert result == 2.0

    def test_unknown_provider_returns_default_delay(self):
        """Unknown provider names return 1.0 second default delay."""
        result = get_retry_delay("unknown_provider")
        assert result == 1.0

    def test_anthropic_uppercase_returns_default_delay(self):
        """ANTHROPIC in uppercase returns 1.0 second (case-insensitive)."""
        result = get_retry_delay("ANTHROPIC")
        assert result == 1.0

    def test_glm_uppercase_returns_short_delay(self):
        """GLM in uppercase returns 2.0 seconds (case-insensitive)."""
        result = get_retry_delay("GLM")
        assert result == 2.0

    def test_provider_with_leading_trailing_whitespace_is_stripped(self):
        """Provider name with whitespace is stripped before lookup."""
        result = get_retry_delay(" glm ")
        assert result == 2.0

    def test_empty_string_returns_default_delay(self):
        """Empty string returns 1.0 second default delay."""
        result = get_retry_delay("")
        assert result == 1.0

    def test_whitespace_only_string_returns_default_delay(self):
        """Whitespace-only string returns 1.0 second default delay."""
        result = get_retry_delay("   ")
        assert result == 1.0


class TestGetProviderConfig:
    """Tests for get_provider_config function."""

    def test_returns_anthropic_defaults(self):
        """Anthropic provider returns default config."""
        config = UnfamiliarConfig(provider="anthropic")
        result = get_provider_config("anthropic", config)

        assert result.langchain_provider == "anthropic"
        assert result.default_model == "claude-sonnet-4-5-20250929"
        assert result.api_key_env == "ANTHROPIC_API_KEY"
        assert result.base_url is None
        assert result.retry_base_delay == 1.0

    def test_returns_glm_defaults(self):
        """GLM provider returns default config with base_url."""
        config = UnfamiliarConfig(provider="glm")
        result = get_provider_config("glm", config)

        assert result.langchain_provider == "openai"
        assert result.default_model == "glm-5"
        assert result.api_key_env == "ZHIPUAI_API_KEY"
        assert result.base_url == "https://api.z.ai/api/coding/paas/v4"
        assert result.retry_base_delay == 2.0

    def test_unknown_provider_raises_provider_error(self):
        """Unknown provider raises ProviderError."""
        config = UnfamiliarConfig(provider="unknown")

        with pytest.raises(ProviderError) as exc_info:
            get_provider_config("unknown", config)

        assert "Unknown provider" in str(exc_info.value)

    def test_config_model_overrides_default(self):
        """config.llm.model overrides default model."""
        config = UnfamiliarConfig(
            provider="glm",
            llm=LLMConfig(model="glm-5"),
        )
        result = get_provider_config("glm", config)

        assert result.default_model == "glm-5"

    def test_config_base_url_overrides_default(self):
        """config.llm.base_url overrides default base_url."""
        config = UnfamiliarConfig(
            provider="glm",
            llm=LLMConfig(base_url="https://custom.api.endpoint/"),
        )
        result = get_provider_config("glm", config)

        assert result.base_url == "https://custom.api.endpoint/"

    def test_config_api_key_env_overrides_default(self):
        """config.llm.api_key_env overrides default env var."""
        config = UnfamiliarConfig(
            provider="glm",
            llm=LLMConfig(api_key_env="MY_CUSTOM_GLM_KEY"),
        )
        result = get_provider_config("glm", config)

        assert result.api_key_env == "MY_CUSTOM_GLM_KEY"

    def test_config_max_tokens_not_in_provider_config(self):
        """max_tokens is handled in create_chat_model, not ProviderConfig."""
        config = UnfamiliarConfig(
            provider="anthropic",
            llm=LLMConfig(max_tokens=8192),
        )
        result = get_provider_config("anthropic", config)

        # ProviderConfig doesn't have max_tokens field
        assert not hasattr(result, "max_tokens")


class TestMaxTokensHandling:
    """Tests for max_tokens handling in create_chat_model."""

    def test_max_tokens_omitted_when_not_configured(self):
        """max_tokens is omitted from init_chat_model when not set anywhere."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert "max_tokens" not in call_kwargs

    def test_max_tokens_from_config_llm(self):
        """max_tokens from config.llm.max_tokens is passed to init_chat_model."""
        config = UnfamiliarConfig(
            provider="anthropic",
            llm=LLMConfig(max_tokens=8192),
        )
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["max_tokens"] == 8192

    def test_max_tokens_from_env_var(self):
        """UNFAMILIAR_MAX_TOKENS env var is passed to init_chat_model."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
            patch.dict(os.environ, {"UNFAMILIAR_MAX_TOKENS": "16384"}),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["max_tokens"] == 16384

    def test_max_tokens_param_overrides_config(self):
        """max_tokens param takes precedence over config."""
        config = UnfamiliarConfig(
            provider="anthropic",
            llm=LLMConfig(max_tokens=8192),
        )
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            create_chat_model(config, max_tokens=4096)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["max_tokens"] == 4096

    def test_invalid_env_max_tokens_logs_warning(self):
        """Invalid UNFAMILIAR_MAX_TOKENS logs warning and omits max_tokens."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
            patch(
                "unfamiliar.llm.providers.factory.logger"
            ) as mock_logger,
            patch.dict(os.environ, {"UNFAMILIAR_MAX_TOKENS": "invalid"}),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert "max_tokens" not in call_kwargs
        mock_logger.warning.assert_called()


class TestBaseUrlHandling:
    """Tests for base_url handling in create_chat_model."""

    def test_base_url_passed_for_glm(self):
        """GLM provider passes base_url to init_chat_model."""
        config = UnfamiliarConfig(provider="glm")
        mock_model = MagicMock(name="ChatOpenAI")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="glm-test-key",
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["base_url"] == "https://api.z.ai/api/coding/paas/v4"

    def test_base_url_not_passed_for_anthropic(self):
        """Anthropic provider does not pass base_url to init_chat_model."""
        config = UnfamiliarConfig(provider="anthropic")
        mock_model = MagicMock(name="ChatAnthropic")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="sk-test-key",
            ),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert "base_url" not in call_kwargs


class TestModelOverride:
    """Tests for UNFAMILIAR_MODEL env var override."""

    def test_env_model_overrides_default(self):
        """UNFAMILIAR_MODEL env var overrides default model name."""
        config = UnfamiliarConfig(provider="glm")
        mock_model = MagicMock(name="ChatOpenAI")

        with (
            patch(
                "unfamiliar.llm.providers.factory.init_chat_model",
                return_value=mock_model,
            ) as mock_init,
            patch(
                "unfamiliar.llm.providers.factory.resolve_api_key",
                return_value="glm-test-key",
            ),
            patch.dict(os.environ, {"UNFAMILIAR_MODEL": "glm-4.7-Flash"}),
        ):
            create_chat_model(config)

        call_kwargs = mock_init.call_args.kwargs
        assert call_kwargs["model"] == "glm-4.7-Flash"


class TestCreateChatModelDirectly:
    """Tests for create_chat_model that actually create a model.

    These tests verify the model is created with correct attributes.
    """

    def test_creates_anthropic_model_with_correct_temperature(self):
        """create_chat_model sets temperature to 0.2."""
        config = UnfamiliarConfig(provider="anthropic")

        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}):
            model = create_chat_model(config)

        assert model.temperature == 0.2

    def test_creates_anthropic_model_with_correct_max_retries(self):
        """create_chat_model sets max_retries to 2."""
        config = UnfamiliarConfig(provider="anthropic")

        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}):
            model = create_chat_model(config)

        assert model.max_retries == 2

    def test_creates_model_with_config_max_tokens(self):
        """create_chat_model passes config.llm.max_tokens."""
        config = UnfamiliarConfig(
            provider="anthropic",
            llm=LLMConfig(max_tokens=2048),
        )

        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}):
            model = create_chat_model(config)

        assert model.max_tokens == 2048


class TestDefaultModelIdFormat:
    """Tests validating default model ID format (GAP-003).

    These tests catch malformed model IDs before they break users.
    """

    def test_anthropic_default_model_has_valid_format(self):
        """Anthropic default model ID matches expected pattern.

        The format should be claude-{variant}-{major}-{minor}-{YYYYMMDD}.
        A malformed ID like 'claude-sonnet-4-5-2025-929' (extra hyphen)
        would fail this test.
        """
        from unfamiliar.llm.providers.factory import _BUILTIN_PROVIDERS

        model_id = _BUILTIN_PROVIDERS["anthropic"].default_model

        # Should not contain double hyphen pattern in date portion
        # Bad: 2025-929 (hyphen after year)
        # Good: 20250929 (no hyphen)
        assert "-" not in model_id.split("-")[-1], (
            f"Date portion should not contain hyphens: {model_id}"
        )

        # Last segment should be 8 digits (YYYYMMDD)
        date_portion = model_id.split("-")[-1]
        assert date_portion.isdigit() and len(date_portion) == 8, (
            f"Date portion should be 8 digits (YYYYMMDD): {date_portion}"
        )

    def test_anthropic_default_model_starts_with_claude(self):
        """Anthropic default model ID starts with expected prefix."""
        from unfamiliar.llm.providers.factory import _BUILTIN_PROVIDERS

        model_id = _BUILTIN_PROVIDERS["anthropic"].default_model
        assert model_id.startswith("claude-"), (
            f"Model ID should start with 'claude-': {model_id}"
        )

    def test_glm_default_model_is_valid(self):
        """GLM default model ID is present and non-empty."""
        from unfamiliar.llm.providers.factory import _BUILTIN_PROVIDERS

        model_id = _BUILTIN_PROVIDERS["glm"].default_model
        assert model_id, "GLM default model should not be empty"
        assert len(model_id) >= 3, f"Model ID too short: {model_id}"
