"""Unit tests for LLM handler."""

from typing import Any
from unittest.mock import MagicMock, patch

import pytest


# Inject litellm as a mock on import
@pytest.fixture
def mock_litellm_import():
    """Fixture that mocks litellm (autouse=True removed to fix Issue #11)."""
    with patch.dict("sys.modules", {"litellm": MagicMock()}):
        yield


from yagra.handlers.llm_handler import (  # noqa: E402
    LLMHandlerCallError,
    LLMHandlerConfigError,
    create_llm_handler,
)


class TestCreateLLMHandler:
    """Tests for the create_llm_handler function."""

    def test_create_llm_handler_returns_callable(self, mock_litellm_import: None) -> None:
        """Confirms that the factory function returns a callable."""
        handler = create_llm_handler()
        assert callable(handler)

    def test_create_llm_handler_without_litellm_raises_import_error(self) -> None:
        """Confirms that ImportError is raised when litellm is not installed."""
        # Patch the litellm global variable to None to simulate uninstalled state
        with patch("yagra.handlers.llm_handler.litellm", None):
            with pytest.raises(ImportError, match="litellm is not installed"):
                from yagra.handlers.llm_handler import create_llm_handler as create_fn

                create_fn()


class TestLLMHandler:
    """Tests for the behavior of the LLM handler."""

    def test_handler_basic_call(self, mock_litellm_import: None) -> None:
        """Confirms that a successful LLM call works correctly."""
        handler = create_llm_handler(retry=1, timeout=10)

        # Mock litellm.completion
        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Hello, world!"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "こんにちは"}
            params = {
                "prompt": {"system": "You are a helpful assistant", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                "output_key": "response",
            }

            result = handler(state, params)

            assert result == {"response": "Hello, world!"}
            mock_litellm.completion.assert_called_once()

    def test_handler_prompt_interpolation(self, mock_litellm_import: None) -> None:
        """Confirms that {variable} format variable substitution works correctly."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Test response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"name": "Alice", "age": 30}
            params = {
                "prompt": {
                    "system": "Assistant",
                    "user": "My name is {name} and I am {age} years old",
                },
                "model": {"provider": "openai", "name": "gpt-4"},
                "output_key": "result",
            }

            result = handler(state, params)

            # Verify litellm.completion was called with the correct messages
            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == "My name is Alice and I am 30 years old"
            assert result == {"result": "Test response"}

    @pytest.mark.skip(
        reason="Issue #11: Conflict between pytest fixture and exception handling."
        "Implementation works correctly and is covered by integration tests."
    )
    def test_handler_missing_prompt_raises_error(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when the prompt parameter is missing.

        Note: This test is skipped due to Issue #11.
        The exception is raised correctly, but verification with pytest.raises
        and try/except fails due to a conflict with the fixture. The actual
        exception is verified in integration tests.
        """
        handler = create_llm_handler()

        state: dict[str, Any] = {}
        params = {
            "model": {"provider": "openai", "name": "gpt-4"},
        }

        with pytest.raises(LLMHandlerConfigError, match="'prompt' must be a dict"):
            handler(state, params)

    @pytest.mark.skip(reason="Issue #11: Conflict between fixture and exception handling")
    def test_handler_missing_model_raises_error(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when the model parameter is missing."""
        handler = create_llm_handler()

        state: dict[str, Any] = {}
        params = {
            "prompt": {"system": "Test", "user": "Test"},
        }

        with pytest.raises(LLMHandlerConfigError, match="'model' must be a dict"):
            handler(state, params)

    @pytest.mark.skip(reason="Issue #11: Conflict between fixture and exception handling")
    def test_handler_missing_model_provider_raises_error(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when model.provider is missing."""
        handler = create_llm_handler()

        state: dict[str, Any] = {}
        params = {
            "prompt": {"system": "Test", "user": "Test"},
            "model": {"name": "gpt-4"},
        }

        with pytest.raises(
            LLMHandlerConfigError, match="'model' must have 'provider' and 'name' keys"
        ):
            handler(state, params)

    def test_handler_retry_on_failure(self, mock_litellm_import: None) -> None:
        """Confirms that retry is executed on failure."""
        handler = create_llm_handler(retry=3, timeout=10)

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            # First 2 attempts fail, 3rd succeeds
            mock_response = MagicMock()
            mock_response.choices = [MagicMock(message=MagicMock(content="Success"))]

            mock_litellm.completion.side_effect = [
                Exception("Rate limit"),
                Exception("Timeout"),
                mock_response,
            ]

            with patch("yagra.handlers.llm_handler.time.sleep"):  # skip sleep
                state = {"query": "test"}
                params = {
                    "prompt": {"system": "Test", "user": "{query}"},
                    "model": {"provider": "openai", "name": "gpt-4"},
                    "output_key": "output",
                }

                result = handler(state, params)

                assert result == {"output": "Success"}
                assert mock_litellm.completion.call_count == 3

    @pytest.mark.skip(reason="Issue #11: Conflict between fixture and exception handling")
    def test_handler_fails_after_max_retry(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when the maximum retry count is reached."""
        handler = create_llm_handler(retry=2, timeout=10)

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.side_effect = Exception("Persistent error")

            with patch("yagra.handlers.llm_handler.time.sleep"):
                state = {"query": "test"}
                params = {
                    "prompt": {"system": "Test", "user": "{query}"},
                    "model": {"provider": "openai", "name": "gpt-4"},
                    "output_key": "output",
                }

                with pytest.raises(LLMHandlerCallError, match="LLM call failed after 2 attempts"):
                    handler(state, params)

    def test_handler_with_model_kwargs(self, mock_litellm_import: None) -> None:
        """Confirms that model.kwargs are correctly passed to litellm."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {
                    "provider": "openai",
                    "name": "gpt-4",
                    "kwargs": {"temperature": 0.5, "max_tokens": 100},
                },
            }

            handler(state, params)

            call_args = mock_litellm.completion.call_args
            assert call_args.kwargs["temperature"] == 0.5
            assert call_args.kwargs["max_tokens"] == 100

    def test_handler_default_output_key(self, mock_litellm_import: None) -> None:
        """Confirms that 'output' is used as the default when output_key is omitted."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Default output"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                # output_key omitted
            }

            result = handler(state, params)

            assert "output" in result
            assert result["output"] == "Default output"

    @pytest.mark.skip(reason="Issue #11: Conflict between fixture and exception handling")
    def test_handler_empty_response_raises_error(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when the LLM returns an empty response."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = []

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerCallError, match="LLM returned empty response"):
                handler(state, params)

    @pytest.mark.skip(reason="Issue #11: Conflict between fixture and exception handling")
    def test_handler_none_content_raises_error(self, mock_litellm_import: None) -> None:
        """Confirms that an error is raised when the LLM returns None content."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content=None))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerCallError, match="LLM returned None content"):
                handler(state, params)


class TestLLMHandlerAutoDetect:
    """Tests for automatic input_keys detection."""

    def test_auto_detect_single_variable(self, mock_litellm_import: None) -> None:
        """Confirms that {query} in the prompt is automatically fetched from state."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Auto detect response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "Hello"}
            params = {
                "prompt": {"system": "Assistant", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                "output_key": "response",
                # input_keys not specified → auto-detection
            }

            result = handler(state, params)

            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == "Hello"
            assert result == {"response": "Auto detect response"}

    def test_auto_detect_multiple_variables(self, mock_litellm_import: None) -> None:
        """Confirms that both {name} and {age} in the prompt are auto-fetched from state."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Multi var response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"name": "Alice", "age": "30"}
            params = {
                "prompt": {"system": "Assistant", "user": "Name: {name}, Age: {age}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                # input_keys not specified → auto-detection
            }

            handler(state, params)

            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == "Name: Alice, Age: 30"

    def test_explicit_input_keys_takes_priority(self, mock_litellm_import: None) -> None:
        """Confirms that explicitly specified input_keys take priority (backward compatibility)."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Explicit keys response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "Hello"}
            params = {
                "prompt": {"system": "Assistant", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                "input_keys": ["query"],  # explicitly specified
            }

            result = handler(state, params)

            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == "Hello"
            assert result == {"output": "Explicit keys response"}

    def test_explicit_empty_input_keys_disables_interpolation(
        self, mock_litellm_import: None
    ) -> None:
        """Confirms that explicitly specifying input_keys: [] disables interpolation (backward compatibility)."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="No interpolation"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state = {"query": "Hello"}
            params = {
                "prompt": {"system": "Assistant", "user": "No variables here"},
                "model": {"provider": "openai", "name": "gpt-4"},
                "input_keys": [],  # explicitly empty list
            }

            result = handler(state, params)

            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == "No variables here"
            assert result == {"output": "No interpolation"}

    def test_auto_detect_missing_key_uses_empty_string(self, mock_litellm_import: None) -> None:
        """Confirms that an empty string is used when an auto-detected key is not in state."""
        handler = create_llm_handler(retry=1, timeout=10)

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="Empty key response"))]

        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            mock_litellm.completion.return_value = mock_response

            state: dict[str, str] = {}  # query does not exist
            params = {
                "prompt": {"system": "Assistant", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                # input_keys not specified → auto-detection
            }

            result = handler(state, params)

            call_args = mock_litellm.completion.call_args
            messages = call_args.kwargs["messages"]
            assert messages[1]["content"] == ""  # replaced with empty string
            assert result == {"output": "Empty key response"}


class TestLLMHandlerValidationErrors:
    """Tests for parameter validation errors (verified in patch context)."""

    def test_handler_prompt_not_dict_raises_config_error(self, mock_litellm_import: None) -> None:
        """Confirms that LLMHandlerConfigError is raised when prompt is not a dict."""
        with patch("yagra.handlers.llm_handler.litellm") as _mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            state: dict[str, Any] = {}
            params = {
                "prompt": "not a dict",
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerConfigError, match="'prompt' must be a dict"):
                handler(state, params)

    def test_handler_model_not_dict_raises_config_error(self, mock_litellm_import: None) -> None:
        """Confirms that LLMHandlerConfigError is raised when model is not a dict."""
        with patch("yagra.handlers.llm_handler.litellm") as _mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            state: dict[str, Any] = {}
            params = {
                "prompt": {"system": "Test", "user": "Test"},
                "model": "gpt-4",
            }

            with pytest.raises(LLMHandlerConfigError, match="'model' must be a dict"):
                handler(state, params)

    def test_handler_model_missing_provider_raises_config_error(
        self, mock_litellm_import: None
    ) -> None:
        """Confirms that LLMHandlerConfigError is raised when provider is missing from model."""
        with patch("yagra.handlers.llm_handler.litellm") as _mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            state: dict[str, Any] = {}
            params = {
                "prompt": {"system": "Test", "user": "Test"},
                "model": {"name": "gpt-4"},  # no provider
            }

            with pytest.raises(
                LLMHandlerConfigError,
                match="'model' must have 'provider' and 'name' keys",
            ):
                handler(state, params)

    def test_handler_model_missing_name_raises_config_error(
        self, mock_litellm_import: None
    ) -> None:
        """Confirms that LLMHandlerConfigError is raised when name is missing from model."""
        with patch("yagra.handlers.llm_handler.litellm") as _mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            state: dict[str, Any] = {}
            params = {
                "prompt": {"system": "Test", "user": "Test"},
                "model": {"provider": "openai"},  # no name
            }

            with pytest.raises(
                LLMHandlerConfigError,
                match="'model' must have 'provider' and 'name' keys",
            ):
                handler(state, params)

    def test_handler_prompt_interpolation_key_error_raises_config_error(
        self, mock_litellm_import: None
    ) -> None:
        """Confirms that LLMHandlerConfigError is raised when input_keys references a variable not in the user template.

        Details:

        With input_keys=["a"] and user="{b}",
        input_values = {"a": state.get("a", "")} is computed, and
        "{b}".format(a="") raises KeyError: 'b'.
        """
        with patch("yagra.handlers.llm_handler.litellm") as _mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            state: dict[str, Any] = {"a": "value_a"}
            params = {
                "prompt": {"system": "Test", "user": "Hello {b}"},
                "model": {"provider": "openai", "name": "gpt-4"},
                "input_keys": [
                    "a"
                ],  # KeyError occurs because "b" is not included in keys used for format
            }

            with pytest.raises(
                LLMHandlerConfigError,
                match="Missing key in state for prompt interpolation",
            ):
                handler(state, params)


class TestLLMHandlerCallErrors:
    """Tests for LLM call errors (verified in patch context)."""

    def test_handler_empty_choices_raises_call_error(self, mock_litellm_import: None) -> None:
        """Confirms that LLMHandlerCallError is raised when LLM returns empty choices."""
        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            mock_response = MagicMock()
            mock_response.choices = []
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerCallError, match="LLM returned empty response"):
                handler(state, params)

    def test_handler_none_content_raises_call_error(self, mock_litellm_import: None) -> None:
        """Confirms that LLMHandlerCallError is raised when LLM returns None content."""
        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            handler = create_llm_handler(retry=1, timeout=10)

            mock_response = MagicMock()
            mock_response.choices = [MagicMock(message=MagicMock(content=None))]
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerCallError, match="LLM returned None content"):
                handler(state, params)

    def test_handler_call_error_not_retried(self, mock_litellm_import: None) -> None:
        """Confirms that LLMHandlerCallError is raised immediately without retry.

        Even with retry=3, confirms that completion is only called once
        for empty choices (the ``raise`` branch at line 187).
        """
        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            handler = create_llm_handler(retry=3, timeout=10)

            mock_response = MagicMock()
            mock_response.choices = []  # triggers LLMHandlerCallError
            mock_litellm.completion.return_value = mock_response

            state = {"query": "test"}
            params = {
                "prompt": {"system": "Test", "user": "{query}"},
                "model": {"provider": "openai", "name": "gpt-4"},
            }

            with pytest.raises(LLMHandlerCallError):
                handler(state, params)

            # Confirms called only once without retry
            assert mock_litellm.completion.call_count == 1

    def test_handler_api_error_retried_and_finally_fails(self, mock_litellm_import: None) -> None:
        """Confirms that API call failures are retried and finally raise LLMHandlerCallError."""
        with patch("yagra.handlers.llm_handler.litellm") as mock_litellm:
            with patch("yagra.handlers.llm_handler.time.sleep"):
                handler = create_llm_handler(retry=2, timeout=10)

                mock_litellm.completion.side_effect = RuntimeError("api error")

                state = {"query": "test"}
                params = {
                    "prompt": {"system": "Test", "user": "{query}"},
                    "model": {"provider": "openai", "name": "gpt-4"},
                    "output_key": "output",
                }

                with pytest.raises(LLMHandlerCallError, match="LLM call failed after 2 attempts"):
                    handler(state, params)

                # retry=2 so completion is called twice
                assert mock_litellm.completion.call_count == 2
