"""Tests for llm_config module."""

from unittest.mock import MagicMock, patch


from local_deep_research.config.llm_config import (
    VALID_PROVIDERS,
    is_openai_available,
    is_anthropic_available,
    is_openai_endpoint_available,
    is_ollama_available,
    is_lmstudio_available,
    is_llamacpp_available,
    is_google_available,
    is_openrouter_available,
    get_available_providers,
    get_selected_llm_provider,
    wrap_llm_without_think_tags,
    get_llm,
)


class TestValidProviders:
    """Tests for VALID_PROVIDERS constant."""

    def test_contains_expected_providers(self):
        """Should contain all expected providers."""
        expected = [
            "ollama",
            "openai",
            "anthropic",
            "google",
            "openrouter",
            "openai_endpoint",
            "lmstudio",
            "llamacpp",
            "none",
        ]
        for provider in expected:
            assert provider in VALID_PROVIDERS

    def test_is_list(self):
        """Should be a list."""
        assert isinstance(VALID_PROVIDERS, list)


class TestIsOpenaiAvailable:
    """Tests for is_openai_available function (delegates to OpenAIProvider)."""

    def test_returns_true_when_api_key_set(self):
        """Should return True when API key is configured."""
        with patch(
            "local_deep_research.llm.providers.implementations.openai.OpenAIProvider.is_available",
            return_value=True,
        ):
            assert is_openai_available() is True

    def test_returns_false_when_no_api_key(self):
        """Should return False when no API key."""
        with patch(
            "local_deep_research.llm.providers.implementations.openai.OpenAIProvider.is_available",
            return_value=False,
        ):
            assert is_openai_available() is False

    def test_returns_false_on_exception(self):
        """Should return False on exception."""
        with patch(
            "local_deep_research.llm.providers.implementations.openai.OpenAIProvider.is_available",
            side_effect=Exception("error"),
        ):
            assert is_openai_available() is False


class TestIsAnthropicAvailable:
    """Tests for is_anthropic_available function (delegates to AnthropicProvider)."""

    def test_returns_true_when_api_key_set(self):
        """Should return True when API key is configured."""
        with patch(
            "local_deep_research.llm.providers.implementations.anthropic.AnthropicProvider.is_available",
            return_value=True,
        ):
            assert is_anthropic_available() is True

    def test_returns_false_when_no_api_key(self):
        """Should return False when no API key."""
        with patch(
            "local_deep_research.llm.providers.implementations.anthropic.AnthropicProvider.is_available",
            return_value=False,
        ):
            assert is_anthropic_available() is False


class TestIsOpenaiEndpointAvailable:
    """Tests for is_openai_endpoint_available function (delegates to CustomOpenAIEndpointProvider)."""

    def test_returns_true_when_api_key_set(self):
        """Should return True when API key is configured."""
        with patch(
            "local_deep_research.llm.providers.implementations.custom_openai_endpoint.CustomOpenAIEndpointProvider.is_available",
            return_value=True,
        ):
            assert is_openai_endpoint_available() is True

    def test_returns_false_when_no_api_key(self):
        """Should return False when no API key."""
        with patch(
            "local_deep_research.llm.providers.implementations.custom_openai_endpoint.CustomOpenAIEndpointProvider.is_available",
            return_value=False,
        ):
            assert is_openai_endpoint_available() is False


class TestIsOllamaAvailable:
    """Tests for is_ollama_available function (delegates to OllamaProvider)."""

    def test_returns_true_when_ollama_responds(self):
        """Should return True when OllamaProvider reports available."""
        with patch(
            "local_deep_research.llm.providers.implementations.ollama.OllamaProvider.is_available",
            return_value=True,
        ):
            assert is_ollama_available() is True

    def test_returns_false_when_ollama_not_running(self):
        """Should return False when OllamaProvider reports unavailable."""
        with patch(
            "local_deep_research.llm.providers.implementations.ollama.OllamaProvider.is_available",
            return_value=False,
        ):
            assert is_ollama_available() is False

    def test_returns_false_on_exception(self):
        """Should return False on exception."""
        with patch(
            "local_deep_research.llm.providers.implementations.ollama.OllamaProvider.is_available",
            side_effect=Exception("error"),
        ):
            assert is_ollama_available() is False


class TestIsLmstudioAvailable:
    """Tests for is_lmstudio_available function (delegates to LMStudioProvider)."""

    def test_returns_true_when_lmstudio_responds(self):
        """Should return True when LMStudioProvider reports available."""
        with patch(
            "local_deep_research.llm.providers.implementations.lmstudio.LMStudioProvider.is_available",
            return_value=True,
        ):
            assert is_lmstudio_available() is True

    def test_returns_false_when_not_running(self):
        """Should return False when LMStudioProvider reports unavailable."""
        with patch(
            "local_deep_research.llm.providers.implementations.lmstudio.LMStudioProvider.is_available",
            return_value=False,
        ):
            assert is_lmstudio_available() is False


class TestIsLlamacppAvailable:
    """Tests for is_llamacpp_available function (delegates to LlamaCppProvider)."""

    def test_returns_true_when_llama_server_responds(self):
        """Should return True when LlamaCppProvider reports available."""
        with patch(
            "local_deep_research.llm.providers.implementations.llamacpp.LlamaCppProvider.is_available",
            return_value=True,
        ):
            assert is_llamacpp_available() is True

    def test_returns_false_when_not_running(self):
        """Should return False when LlamaCppProvider reports unavailable."""
        with patch(
            "local_deep_research.llm.providers.implementations.llamacpp.LlamaCppProvider.is_available",
            return_value=False,
        ):
            assert is_llamacpp_available() is False


class TestIsGoogleAvailable:
    """Tests for is_google_available function."""

    def test_delegates_to_provider(self):
        """Should delegate to GoogleProvider.is_available."""
        with patch(
            "local_deep_research.config.llm_config.is_google_available"
        ) as mock:
            mock.return_value = True
            # Can't easily test delegation without importing the actual provider
            # Just verify the function exists and can be called
            _result = is_google_available()  # noqa: F841
            # Result depends on actual provider availability


class TestIsOpenrouterAvailable:
    """Tests for is_openrouter_available function."""

    def test_delegates_to_provider(self):
        """Should delegate to OpenRouterProvider.is_available."""
        # Similar to is_google_available
        result = is_openrouter_available()
        assert isinstance(result, bool)


class TestGetAvailableProviders:
    """Tests for get_available_providers function."""

    def test_returns_dict(self):
        """Should return a dictionary."""
        # Clear cache first
        get_available_providers.cache_clear()
        with patch(
            "local_deep_research.config.llm_config.is_ollama_available",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.is_openai_available",
                return_value=False,
            ):
                with patch(
                    "local_deep_research.config.llm_config.is_anthropic_available",
                    return_value=False,
                ):
                    with patch(
                        "local_deep_research.config.llm_config.is_google_available",
                        return_value=False,
                    ):
                        with patch(
                            "local_deep_research.config.llm_config.is_openrouter_available",
                            return_value=False,
                        ):
                            with patch(
                                "local_deep_research.config.llm_config.is_openai_endpoint_available",
                                return_value=False,
                            ):
                                with patch(
                                    "local_deep_research.config.llm_config.is_lmstudio_available",
                                    return_value=False,
                                ):
                                    with patch(
                                        "local_deep_research.config.llm_config.is_llamacpp_available",
                                        return_value=False,
                                    ):
                                        result = get_available_providers()
                                        assert isinstance(result, dict)
                                        # Should have "none" when no providers available
                                        assert "none" in result

    def test_includes_ollama_when_available(self):
        """Should include ollama when available."""
        get_available_providers.cache_clear()
        with patch(
            "local_deep_research.config.llm_config.is_ollama_available",
            return_value=True,
        ):
            with patch(
                "local_deep_research.config.llm_config.is_openai_available",
                return_value=False,
            ):
                with patch(
                    "local_deep_research.config.llm_config.is_anthropic_available",
                    return_value=False,
                ):
                    with patch(
                        "local_deep_research.config.llm_config.is_google_available",
                        return_value=False,
                    ):
                        with patch(
                            "local_deep_research.config.llm_config.is_openrouter_available",
                            return_value=False,
                        ):
                            with patch(
                                "local_deep_research.config.llm_config.is_openai_endpoint_available",
                                return_value=False,
                            ):
                                with patch(
                                    "local_deep_research.config.llm_config.is_lmstudio_available",
                                    return_value=False,
                                ):
                                    with patch(
                                        "local_deep_research.config.llm_config.is_llamacpp_available",
                                        return_value=False,
                                    ):
                                        result = get_available_providers()
                                        assert "ollama" in result


class TestGetSelectedLlmProvider:
    """Tests for get_selected_llm_provider function."""

    def test_returns_provider_from_settings(self):
        """Should return provider from settings."""
        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value="anthropic",
        ):
            result = get_selected_llm_provider()
            assert result == "anthropic"

    def test_returns_lowercase(self):
        """Should return lowercase provider."""
        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value="OPENAI",
        ):
            result = get_selected_llm_provider()
            assert result == "openai"

    def test_defaults_to_ollama(self):
        """Should default to ollama."""
        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value="ollama",
        ) as mock:
            get_selected_llm_provider()
            # Check default is ollama
            mock.assert_called_with(
                "llm.provider", "ollama", settings_snapshot=None
            )


class TestWrapLlmWithoutThinkTags:
    """Tests for wrap_llm_without_think_tags function."""

    def test_returns_wrapper_instance(self):
        """Should return a wrapper instance."""
        mock_llm = MagicMock()
        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            result = wrap_llm_without_think_tags(mock_llm)
            assert hasattr(result, "invoke")
            assert hasattr(result, "base_llm")

    def test_wrapper_invoke_calls_base_llm(self):
        """Should call base LLM on invoke."""
        mock_llm = MagicMock()
        mock_response = MagicMock()
        mock_response.content = "test response"
        mock_llm.invoke.return_value = mock_response

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            wrapper = wrap_llm_without_think_tags(mock_llm)
            wrapper.invoke("test prompt")
            mock_llm.invoke.assert_called_with("test prompt")

    def test_wrapper_removes_think_tags(self):
        """Should remove think tags from response."""
        mock_llm = MagicMock()
        mock_response = MagicMock()
        mock_response.content = "<think>internal</think>visible"
        mock_llm.invoke.return_value = mock_response

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.remove_think_tags",
                return_value="visible",
            ) as mock_remove:
                wrapper = wrap_llm_without_think_tags(mock_llm)
                wrapper.invoke("test")
                mock_remove.assert_called_with("<think>internal</think>visible")

    def test_wrapper_delegates_attributes(self):
        """Should delegate attribute access to base LLM."""
        mock_llm = MagicMock()
        mock_llm.model_name = "gpt-4"

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            wrapper = wrap_llm_without_think_tags(mock_llm)
            assert wrapper.model_name == "gpt-4"

    def test_applies_rate_limiting_when_enabled(self):
        """Should apply rate limiting when enabled in settings."""
        mock_llm = MagicMock()
        mock_wrapped = MagicMock()

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=True,
        ):
            # Patch at source location since it's imported inside the function
            with patch(
                "local_deep_research.web_search_engines.rate_limiting.llm.create_rate_limited_llm_wrapper",
                return_value=mock_wrapped,
            ) as mock_create:
                wrap_llm_without_think_tags(mock_llm, provider="openai")
                mock_create.assert_called_with(mock_llm, "openai")


class TestGetLlm:
    """Tests for get_llm function."""

    def test_uses_custom_registered_llm(self):
        """Should use custom LLM when registered."""
        # Import BaseChatModel for proper spec
        from langchain_core.language_models import BaseChatModel

        mock_llm = MagicMock(spec=BaseChatModel)

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=True,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_llm_from_registry",
                return_value=mock_llm,
            ):
                with patch(
                    "local_deep_research.config.llm_config.wrap_llm_without_think_tags",
                    return_value=mock_llm,
                ):
                    with patch(
                        "local_deep_research.config.llm_config.get_setting_from_snapshot",
                        return_value="custom_provider",
                    ):
                        result = get_llm(provider="custom_provider")
                        assert result is mock_llm

    def test_cleans_model_name(self):
        """Should clean model name of quotes and whitespace."""
        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": ' "gpt-4" ',
                    "llm.temperature": 0.7,
                    "llm.provider": "openai",
                }.get(key, default)

                # This will fail trying to create actual LLM, but we're testing name cleaning
                try:
                    get_llm()
                except Exception:
                    pass  # Expected to fail without actual provider

    def test_normalizes_provider_to_lowercase(self):
        """Should normalize provider to lowercase."""
        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "test-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "OPENAI",  # uppercase
                }.get(key, default)

                try:
                    get_llm()
                except Exception:
                    pass  # Expected - testing normalization not actual LLM creation

    def test_invalid_provider_raises_error(self):
        """Should raise ValueError for invalid provider."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "test-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "invalid_provider",
                }.get(key, default)

                with pytest.raises(ValueError, match="Invalid provider"):
                    get_llm()

    def test_raises_when_model_setting_empty(self):
        """get_llm() must raise ValueError when llm.model is empty string."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "",
                    "llm.temperature": 0.7,
                    "llm.provider": "ollama",
                }.get(key, default)

                with pytest.raises(
                    ValueError, match="LLM model not configured"
                ):
                    get_llm()

    def test_raises_when_model_setting_whitespace_only(self):
        """get_llm() must raise ValueError when llm.model is whitespace."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "   ",
                    "llm.temperature": 0.7,
                    "llm.provider": "ollama",
                }.get(key, default)

                with pytest.raises(
                    ValueError, match="LLM model not configured"
                ):
                    get_llm()

    def test_raises_when_model_setting_missing_returns_empty_default(self):
        """get_llm() must raise when snapshot returns empty default."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                # Snapshot has neither llm.model nor a custom default;
                # function falls back to its own "" default.
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.temperature": 0.7,
                    "llm.provider": "ollama",
                }.get(key, default)

                with pytest.raises(
                    ValueError, match="LLM model not configured"
                ):
                    get_llm()

    def test_anthropic_provider_creates_chat_anthropic(self):
        """Should create ChatAnthropic when provider is anthropic."""
        from langchain_anthropic import ChatAnthropic

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "claude-3-sonnet",
                    "llm.temperature": 0.7,
                    "llm.provider": "anthropic",
                    "llm.anthropic.api_key": "sk-ant-test",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatAnthropic, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="anthropic")
                    mock_init.assert_called_once()
                    # Check it was called with model and api_key
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["model"] == "claude-3-sonnet"
                    assert call_kwargs["anthropic_api_key"] == "sk-ant-test"

    def test_anthropic_without_api_key_raises_error(self):
        """Test that missing Anthropic API key raises ValueError."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "claude-3-sonnet",
                    "llm.temperature": 0.7,
                    "llm.provider": "anthropic",
                    "llm.anthropic.api_key": None,
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with pytest.raises(
                    ValueError, match="Anthropic API key not configured"
                ):
                    get_llm(provider="anthropic")

    def test_openai_provider_creates_chat_openai(self):
        """Should create ChatOpenAI when provider is openai."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "gpt-4",
                    "llm.temperature": 0.7,
                    "llm.provider": "openai",
                    "llm.openai.api_key": "sk-test",
                    "llm.openai.api_base": None,
                    "llm.openai.organization": None,
                    "llm.streaming": None,
                    "llm.max_retries": None,
                    "llm.request_timeout": None,
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="openai")
                    mock_init.assert_called_once()
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["model"] == "gpt-4"
                    assert call_kwargs["api_key"] == "sk-test"

    def test_openai_without_api_key_raises_error(self):
        """Test that missing OpenAI API key raises ValueError."""
        import pytest

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "gpt-4",
                    "llm.temperature": 0.7,
                    "llm.provider": "openai",
                    "llm.openai.api_key": None,
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with pytest.raises(
                    ValueError, match="OpenAI API key not configured"
                ):
                    get_llm(provider="openai")

    def test_openai_endpoint_creates_chat_openai(self):
        """Should create ChatOpenAI with custom endpoint when provider is openai_endpoint."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "custom-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "openai_endpoint",
                    "llm.openai_endpoint.api_key": "custom-key",
                    "llm.openai_endpoint.url": "https://custom.api.com/v1",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="openai_endpoint")
                    mock_init.assert_called_once()
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["model"] == "custom-model"
                    assert call_kwargs["api_key"] == "custom-key"
                    assert "custom.api.com" in call_kwargs.get(
                        "openai_api_base", ""
                    )

    def test_openai_endpoint_without_api_key_uses_placeholder(self):
        """Should succeed without API key for local servers like llama.cpp."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "local-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "openai_endpoint",
                    "llm.openai_endpoint.api_key": None,
                    "llm.openai_endpoint.url": "http://localhost:8080/v1",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    # Should not raise even without an API key
                    get_llm(provider="openai_endpoint")
                    mock_init.assert_called_once()
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["model"] == "local-model"
                    # A placeholder key should be passed, not None
                    assert call_kwargs["api_key"] is not None
                    assert call_kwargs["api_key"] != ""

    def test_lmstudio_creates_chat_openai(self):
        """Should create ChatOpenAI with LM Studio URL when provider is lmstudio."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "local-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "lmstudio",
                    "llm.lmstudio.url": "http://localhost:1234",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": False,
                    "llm.context_window_size": 8192,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="lmstudio")
                    mock_init.assert_called_once()
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["model"] == "local-model"
                    assert "localhost:1234" in call_kwargs.get("base_url", "")

    def test_lmstudio_passes_configured_api_key(self):
        """User-set llm.lmstudio.api_key flows through the direct get_llm path."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "local-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "lmstudio",
                    "llm.lmstudio.url": "http://localhost:1234",
                    "llm.lmstudio.api_key": "my-secret-key",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": False,
                    "llm.context_window_size": 8192,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="lmstudio")
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["api_key"] == "my-secret-key"

    def test_lmstudio_falls_back_to_placeholder_when_no_api_key(self):
        """Empty/missing llm.lmstudio.api_key falls back to the placeholder."""
        from langchain_openai import ChatOpenAI

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "local-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "lmstudio",
                    "llm.lmstudio.url": "http://localhost:1234",
                    "llm.lmstudio.api_key": "",
                    "llm.local_context_window_size": 4096,
                    "llm.context_window_unrestricted": False,
                    "llm.context_window_size": 8192,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                with patch.object(
                    ChatOpenAI, "__init__", return_value=None
                ) as mock_init:
                    get_llm(provider="lmstudio")
                    call_kwargs = mock_init.call_args.kwargs
                    assert call_kwargs["api_key"] == "lm-studio"

    def test_custom_factory_function_is_called(self):
        """Should call factory function for custom registered LLM."""
        from langchain_core.language_models import BaseChatModel

        mock_llm = MagicMock(spec=BaseChatModel)
        mock_factory = MagicMock(return_value=mock_llm)

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=True,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_llm_from_registry",
                return_value=mock_factory,
            ):
                with patch(
                    "local_deep_research.config.llm_config.get_setting_from_snapshot"
                ) as mock_get:
                    mock_get.side_effect = lambda key, default=None, **kwargs: {
                        "llm.model": "custom-model",
                        "llm.temperature": 0.5,
                        "llm.provider": "custom_provider",
                        "rate_limiting.llm_enabled": False,
                    }.get(key, default)

                    get_llm(
                        model_name="custom-model",
                        temperature=0.5,
                        provider="custom_provider",
                    )

                    mock_factory.assert_called_once()
                    call_kwargs = mock_factory.call_args.kwargs
                    assert call_kwargs["model_name"] == "custom-model"
                    assert call_kwargs["temperature"] == 0.5

    def test_custom_factory_with_invalid_signature_raises(self):
        """Should raise TypeError when factory has invalid signature."""
        import pytest

        def bad_factory():
            return MagicMock()

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=True,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_llm_from_registry",
                return_value=bad_factory,
            ):
                with patch(
                    "local_deep_research.config.llm_config.get_setting_from_snapshot"
                ) as mock_get:
                    mock_get.side_effect = lambda key, default=None, **kwargs: {
                        "llm.model": "model",
                        "llm.temperature": 0.7,
                        "llm.provider": "bad_factory",
                        "rate_limiting.llm_enabled": False,
                    }.get(key, default)

                    with pytest.raises(TypeError, match="invalid signature"):
                        get_llm(provider="bad_factory")

    def test_custom_factory_returning_non_basechatmodel_raises(self):
        """Should raise ValueError when factory returns non-BaseChatModel."""
        import pytest

        def bad_factory(
            model_name=None, temperature=None, settings_snapshot=None
        ):
            return "not a model"

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=True,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_llm_from_registry",
                return_value=bad_factory,
            ):
                with patch(
                    "local_deep_research.config.llm_config.get_setting_from_snapshot"
                ) as mock_get:
                    mock_get.side_effect = lambda key, default=None, **kwargs: {
                        "llm.model": "model",
                        "llm.temperature": 0.7,
                        "llm.provider": "bad_factory",
                        "rate_limiting.llm_enabled": False,
                    }.get(key, default)

                    with pytest.raises(
                        ValueError, match="must return a BaseChatModel"
                    ):
                        get_llm(provider="bad_factory")

    def test_context_window_size_for_local_providers(self):
        """Should use local context window size for local providers."""
        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot"
            ) as mock_get:
                calls = []

                def track_calls(key, default=None, **kwargs):
                    calls.append(key)
                    settings = {
                        "llm.model": "test-model",
                        "llm.temperature": 0.7,
                        "llm.provider": "lmstudio",
                        "llm.lmstudio.url": "http://localhost:1234",
                        "llm.local_context_window_size": 2048,  # Custom local size
                        "llm.context_window_size": 128000,
                        "llm.context_window_unrestricted": False,
                        "llm.supports_max_tokens": True,
                        "llm.max_tokens": 4096,
                        "rate_limiting.llm_enabled": False,
                    }
                    return settings.get(key, default)

                mock_get.side_effect = track_calls

                from langchain_openai import ChatOpenAI

                with patch.object(ChatOpenAI, "__init__", return_value=None):
                    get_llm(provider="lmstudio")
                    # Should have checked local_context_window_size
                    assert "llm.local_context_window_size" in calls

    def test_research_context_is_mutable_parameter(self):
        """Should accept research_context parameter."""

        research_context = {"existing": "value"}
        mock_llm = MagicMock()

        with patch(
            "local_deep_research.config.llm_config.is_llm_registered",
            return_value=False,
        ):
            with (
                patch(
                    "local_deep_research.config.llm_config.get_setting_from_snapshot"
                ) as mock_get,
                patch(
                    "local_deep_research.config.llm_config.ChatAnthropic",
                    return_value=mock_llm,
                ),
            ):
                mock_get.side_effect = lambda key, default=None, **kwargs: {
                    "llm.model": "test-model",
                    "llm.temperature": 0.7,
                    "llm.provider": "anthropic",
                    "llm.anthropic.api_key": "test-key",
                    "llm.context_window_unrestricted": True,
                    "llm.supports_max_tokens": True,
                    "llm.max_tokens": 4096,
                    "rate_limiting.llm_enabled": False,
                }.get(key, default)

                result = get_llm(
                    provider="anthropic", research_context=research_context
                )
                assert hasattr(result, "base_llm")
                # Original key should still be there
                assert research_context["existing"] == "value"


class TestWrapperStringResponse:
    """Tests for wrapper handling string responses."""

    def test_wrapper_handles_string_response(self):
        """Should handle string response from LLM."""
        mock_llm = MagicMock()
        mock_llm.invoke.return_value = "<think>thought</think>answer"

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            wrapper = wrap_llm_without_think_tags(mock_llm)
            result = wrapper.invoke("test")
            # Should remove think tags from string
            assert "answer" in result
            assert "<think>" not in result

    def test_wrapper_handles_invoke_exception(self):
        """Should propagate exceptions from LLM invoke."""
        import pytest

        mock_llm = MagicMock()
        mock_llm.invoke.side_effect = RuntimeError("LLM error")

        with patch(
            "local_deep_research.config.llm_config.get_setting_from_snapshot",
            return_value=False,
        ):
            wrapper = wrap_llm_without_think_tags(mock_llm)
            with pytest.raises(RuntimeError, match="LLM error"):
                wrapper.invoke("test")
