"""
Tests for LLM client abstraction (Shell layer).

Tests CLI passthrough modes with mocked subprocess.
Per R8 (Real Wiring): subprocess.run is the boundary, mocked for tests.
"""

from pathlib import Path
import subprocess
from unittest.mock import patch, MagicMock

import pytest
from returns.result import Failure, Success

from lattice.core.types.config import CompilerConfig
from lattice.shell.llm import llm_complete


class TestCLIPassthrough:
    """Test CLI passthrough mode for llm_complete."""

    def test_cli_claude_mode(self):
        """cli:claude should invoke claude CLI with correct arguments."""
        config = CompilerConfig(model="cli:claude", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/claude"
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout='{"content": [{"type": "text", "text": "Hello from Claude"}]}',
                    stderr="",
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Success)
                assert result.unwrap() == "Hello from Claude"

                # Verify command structure
                call_args = mock_run.call_args[0][0]
                assert call_args[0] == "claude"
                assert "-p" in call_args
                assert "Test prompt" in call_args
                assert "--output-format" in call_args
                assert "json" in call_args

    def test_cli_opencode_mode(self):
        """cli:opencode should invoke opencode CLI with correct arguments."""
        config = CompilerConfig(model="cli:opencode", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/opencode"
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout='{"content": "Hello from OpenCode"}',
                    stderr="",
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Success)
                assert result.unwrap() == "Hello from OpenCode"

                # Verify command structure
                call_args = mock_run.call_args[0][0]
                assert call_args[0] == "opencode"
                assert "run" in call_args
                assert "Test prompt" in call_args
                assert "--format" in call_args
                assert "json" in call_args

    def test_cli_opencode_with_model(self):
        """cli:opencode:model should pass model to opencode CLI."""
        config = CompilerConfig(model="cli:opencode:gpt-4", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/opencode"
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout='{"content": "Response from GPT-4"}',
                    stderr="",
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Success)

                # Verify model is passed
                call_args = mock_run.call_args[0][0]
                assert "--model" in call_args
                assert "gpt-4" in call_args

    def test_cli_not_found(self):
        """Should return Failure when CLI is not found."""
        config = CompilerConfig(model="cli:claude", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            mock_which.return_value = None  # CLI not found

            result = llm_complete(config, "Test prompt")

            assert isinstance(result, Failure)
            assert "claude CLI not found" in result.failure()

    def test_cli_non_zero_exit(self):
        """Should return Failure when CLI exits with non-zero code."""
        config = CompilerConfig(model="cli:opencode", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/opencode"
                mock_run.return_value = MagicMock(
                    returncode=1,
                    stdout="",
                    stderr="Error: something went wrong",
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Failure)
                assert "exited with code 1" in result.failure()

    def test_cli_invalid_json_output(self):
        """Should return Failure when CLI outputs invalid JSON."""
        config = CompilerConfig(model="cli:claude", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/claude"
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout="not valid json",
                    stderr="",
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Failure)
                assert "Failed to parse" in result.failure()

    def test_cli_timeout(self):
        """Should return Failure on timeout."""
        config = CompilerConfig(model="cli:claude", api_key_env="")

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/claude"
                mock_run.side_effect = subprocess.TimeoutExpired(
                    cmd="claude", timeout=300
                )

                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Failure)
                assert "timed out" in result.failure()

    def test_cli_unknown_model(self):
        """Should return Failure for unknown CLI model prefix."""
        config = CompilerConfig(model="cli:unknown", api_key_env="")

        result = llm_complete(config, "Test prompt")

        assert isinstance(result, Failure)
        assert "Unknown CLI model" in result.failure()

    def test_cli_no_api_key_needed(self):
        """CLI mode should not require API key."""
        config = CompilerConfig(model="cli:claude", api_key_env="")  # Empty env

        with patch("lattice.shell.llm.shutil.which") as mock_which:
            with patch("lattice.shell.llm.subprocess.run") as mock_run:
                mock_which.return_value = "/usr/bin/claude"
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout='{"content": [{"type": "text", "text": "OK"}]}',
                    stderr="",
                )

                result = llm_complete(config, "Test")

                # Should succeed even without API key
                assert isinstance(result, Success)

    def test_litellm_mode_unchanged(self):
        """Non-cli: models should still use litellm."""
        # This test uses mock to verify litellm is still called for non-cli models
        # Since litellm may not be installed in test env, we mock the import
        config = CompilerConfig(model="openai/gpt-4", api_key_env="OPENAI_API_KEY")

        with patch.dict("sys.modules", {"litellm": MagicMock()}):
            import sys

            mock_litellm = sys.modules["litellm"]
            mock_response = {"choices": [{"message": {"content": "Hello from GPT-4"}}]}
            mock_litellm.completion.return_value = mock_response

            with patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"}):
                result = llm_complete(config, "Test prompt")

                assert isinstance(result, Success)
                assert result.unwrap() == "Hello from GPT-4"
                # Verify litellm.completion was called
                mock_litellm.completion.assert_called_once()
