"""Extended tests for llm_config provider paths (LlamaCpp, Ollama, OpenAI)."""

from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.config.llm_config import get_llm


def _settings_dict(overrides=None):
    """Base settings dict shared across tests."""
    base = {
        "llm.model": "test-model",
        "llm.temperature": 0.5,
        "llm.provider": "ollama",
        "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,
    }
    if overrides:
        base.update(overrides)
    return base


def _mock_get_setting(settings):
    """Return a side_effect for get_setting_from_snapshot."""
    return lambda key, default=None, **kw: settings.get(key, default)


# ── LlamaCpp provider ──────────────────────────────────────────────


class TestLlamaCppProvider:
    """Tests for the LlamaCpp provider path in get_llm."""

    def test_no_path_raises(self):
        """Missing model path raises ValueError."""
        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": None,
            }
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
        ):
            with pytest.raises(ValueError, match="model path not configured"):
                get_llm(provider="llamacpp")

    def test_invalid_extension_raises(self, tmp_path):
        """Invalid file extension raises ValueError."""
        bad_file = tmp_path / "model.txt"
        bad_file.write_text("not a model")

        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": str(bad_file),
            }
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_model_path"
            ) as mock_validate,
        ):
            # PathValidator.validate_model_path returns the path as-is
            mock_validate.return_value = bad_file
            with pytest.raises(
                ValueError, match="Invalid model file extension"
            ):
                get_llm(provider="llamacpp")

    def test_creates_with_gpu_params(self, tmp_path):
        """LlamaCpp is created with GPU params from settings."""
        model_file = tmp_path / "model.gguf"
        model_file.write_text("fake model data")

        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": str(model_file),
                "llm.llamacpp_n_gpu_layers": 8,
                "llm.llamacpp_n_batch": 256,
                "llm.llamacpp_f16_kv": False,
            }
        )

        mock_llamacpp_cls = MagicMock()
        mock_llamacpp_instance = MagicMock()
        mock_llamacpp_cls.return_value = mock_llamacpp_instance

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_model_path"
            ) as mock_pv_validate,
            patch(
                "local_deep_research.config.llm_config.wrap_llm_without_think_tags",
                return_value=mock_llamacpp_instance,
            ),
            patch.dict(
                "sys.modules",
                {
                    "langchain_community.llms": MagicMock(
                        LlamaCpp=mock_llamacpp_cls
                    )
                },
            ),
        ):
            mock_pv_validate.return_value = model_file

            result = get_llm(provider="llamacpp")

            mock_llamacpp_cls.assert_called_once()
            call_kwargs = mock_llamacpp_cls.call_args[1]
            assert call_kwargs["n_gpu_layers"] == 8
            assert call_kwargs["n_batch"] == 256
            assert call_kwargs["f16_kv"] is False
            assert result is mock_llamacpp_instance

    def test_path_validation_called(self, tmp_path):
        """PathValidator.validate_model_path is called for security."""
        model_file = tmp_path / "model.gguf"
        model_file.write_text("fake model")

        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": str(model_file),
            }
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_model_path"
            ) as mock_pv_validate,
            patch(
                "local_deep_research.config.llm_config.wrap_llm_without_think_tags",
                return_value=MagicMock(),
            ),
            patch.dict(
                "sys.modules",
                {"langchain_community.llms": MagicMock()},
            ),
        ):
            mock_pv_validate.return_value = model_file
            get_llm(provider="llamacpp")
            mock_pv_validate.assert_called_once_with(str(model_file))

    def test_directory_path_lists_gguf_files(self, tmp_path):
        """Directory with .gguf files produces helpful error listing them."""
        (tmp_path / "alpha.gguf").write_text("fake")
        (tmp_path / "beta.gguf").write_text("fake")

        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": str(tmp_path),
            }
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_model_path",
                side_effect=ValueError(f"Model path is not a file: {tmp_path}"),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_safe_path",
                return_value=tmp_path,
            ),
            patch(
                "local_deep_research.config.paths.get_models_directory",
                return_value=tmp_path,
            ),
        ):
            with pytest.raises(
                ValueError, match="Found .gguf files:"
            ) as exc_info:
                get_llm(provider="llamacpp")
            msg = str(exc_info.value)
            assert "alpha.gguf" in msg
            assert "beta.gguf" in msg

    def test_directory_path_no_gguf_files(self, tmp_path):
        """Empty directory produces helpful error about no .gguf files."""
        settings = _settings_dict(
            {
                "llm.provider": "llamacpp",
                "llm.llamacpp_model_path": str(tmp_path),
            }
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_model_path",
                side_effect=ValueError(f"Model path is not a file: {tmp_path}"),
            ),
            patch(
                "local_deep_research.security.path_validator.PathValidator.validate_safe_path",
                return_value=tmp_path,
            ),
            patch(
                "local_deep_research.config.paths.get_models_directory",
                return_value=tmp_path,
            ),
        ):
            with pytest.raises(ValueError, match="No .gguf files found"):
                get_llm(provider="llamacpp")


# ── Ollama provider ────────────────────────────────────────────────


class TestOllamaProvider:
    """Tests for the Ollama provider path in get_llm."""

    def _ollama_settings(self, overrides=None):
        s = _settings_dict(
            {
                "llm.provider": "ollama",
                "llm.ollama.url": "http://localhost:11434",
                "llm.ollama.enable_thinking": True,
            }
        )
        if overrides:
            s.update(overrides)
        return s

    def test_model_not_found_raises_error(self):
        """Model not in Ollama model list raises ValueError."""
        settings = self._ollama_settings()

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"models": [{"name": "other-model"}]}

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.config.llm_config.is_ollama_available",
                return_value=True,
            ),
            patch(
                "local_deep_research.config.llm_config.safe_get",
                return_value=mock_response,
            ),
        ):
            with pytest.raises(ValueError, match="test-model"):
                get_llm(provider="ollama", model_name="test-model")

    def test_model_found_creates_client(self):
        """Model found in list creates ChatOllama."""
        settings = self._ollama_settings()

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"models": [{"name": "test-model"}]}

        mock_ollama_cls = MagicMock()
        mock_ollama_instance = MagicMock()
        mock_ollama_cls.return_value = mock_ollama_instance

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.config.llm_config.is_ollama_available",
                return_value=True,
            ),
            patch(
                "local_deep_research.config.llm_config.safe_get",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.config.llm_config.ChatOllama",
                mock_ollama_cls,
            ),
            patch(
                "local_deep_research.config.llm_config.wrap_llm_without_think_tags",
                return_value=mock_ollama_instance,
            ),
        ):
            result = get_llm(provider="ollama", model_name="test-model")
            mock_ollama_cls.assert_called_once()
            assert result is mock_ollama_instance

    def test_api_tags_exception_continues(self):
        """Exception checking model list doesn't crash — continues to create ChatOllama."""
        settings = self._ollama_settings()

        mock_ollama_cls = MagicMock()
        mock_ollama_instance = MagicMock()
        mock_ollama_cls.return_value = mock_ollama_instance

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch(
                "local_deep_research.config.llm_config.is_ollama_available",
                return_value=True,
            ),
            patch(
                "local_deep_research.config.llm_config.safe_get",
                side_effect=ConnectionError("refused"),
            ),
            patch(
                "local_deep_research.config.llm_config.ChatOllama",
                mock_ollama_cls,
            ),
            patch(
                "local_deep_research.config.llm_config.wrap_llm_without_think_tags",
                return_value=mock_ollama_instance,
            ),
        ):
            result = get_llm(provider="ollama", model_name="test-model")
            # Should still try to create ChatOllama despite API error
            mock_ollama_cls.assert_called_once()
            assert result is mock_ollama_instance


# ── OpenAI provider optional params ────────────────────────────────


class TestOpenAIOptionalParams:
    """Tests for OpenAI provider optional parameters."""

    def _openai_settings(self, overrides=None):
        s = _settings_dict(
            {
                "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,
            }
        )
        if overrides:
            s.update(overrides)
        return s

    def test_with_api_base(self):
        """api_base is passed through as openai_api_base."""
        from langchain_openai import ChatOpenAI

        settings = self._openai_settings(
            {"llm.openai.api_base": "https://custom.endpoint/v1"}
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch.object(
                ChatOpenAI, "__init__", return_value=None
            ) as mock_init,
        ):
            get_llm(provider="openai")
            call_kwargs = mock_init.call_args.kwargs
            assert (
                call_kwargs["openai_api_base"] == "https://custom.endpoint/v1"
            )

    def test_with_organization(self):
        """organization is passed through as openai_organization."""
        from langchain_openai import ChatOpenAI

        settings = self._openai_settings(
            {"llm.openai.organization": "org-abc123"}
        )

        with (
            patch(
                "local_deep_research.config.llm_config.is_llm_registered",
                return_value=False,
            ),
            patch(
                "local_deep_research.config.llm_config.get_setting_from_snapshot",
                side_effect=_mock_get_setting(settings),
            ),
            patch.object(
                ChatOpenAI, "__init__", return_value=None
            ) as mock_init,
        ):
            get_llm(provider="openai")
            call_kwargs = mock_init.call_args.kwargs
            assert call_kwargs["openai_organization"] == "org-abc123"
