"""
Tests for LLM client implementations.
"""

import pytest
from unittest.mock import Mock, patch
import requests

from api.llm.openai_client import OpenAIClient
from api.llm.anthropic_client import AnthropicClient
from api.llm.google_client import GoogleGeminiClient
from api.llm.base import LLMAPIKeyError, LLMRateLimitError, LLMRequestError, LLMProviderUnavailableError


class TestOpenAIClient:
    """Test OpenAI client."""
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_success(self, mock_post):
        """Test successful completion."""
        # Mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "chatcmpl-123",
            "object": "chat.completion",
            "created": 1677652288,
            "model": "gpt-4",
            "choices": [{
                "index": 0,
                "message": {
                    "role": "assistant",
                    "content": "This is a test response"
                },
                "finish_reason": "stop"
            }],
            "usage": {
                "prompt_tokens": 10,
                "completion_tokens": 5,
                "total_tokens": 15
            }
        }
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        result = client.complete(
            prompt="test prompt",
            model="gpt-4",
            api_key="sk-test-key"
        )
        
        assert result.text == "This is a test response"
        assert result.input_tokens == 10
        assert result.output_tokens == 5
        assert result.model == "gpt-4"
        assert result.finish_reason == "stop"

    @patch('api.llm.openai_client.requests.post')
    def test_complete_codex_uses_responses_api(self, mock_post):
        """Codex models should use the Responses API, not Chat Completions."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "resp_123",
            "model": "gpt-5.1-codex-mini",
            "output": [
                {
                    "type": "message",
                    "role": "assistant",
                    "content": [{"type": "output_text", "text": "This is a test response"}],
                }
            ],
            "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
        }
        mock_post.return_value = mock_response

        client = OpenAIClient()
        result = client.complete(
            prompt="test prompt",
            model="openai/gpt-5.1-codex-mini",
            api_key="sk-test-key",
            messages=[{"role": "user", "content": "Hello"}],
        )

        assert result.text == "This is a test response"
        assert result.input_tokens == 10
        assert result.output_tokens == 5

        # Verify the endpoint and payload shape
        call_args = mock_post.call_args
        assert call_args[0][0].endswith("/responses")
        assert "input" in call_args[1]["json"]
        assert "messages" not in call_args[1]["json"]

    @patch('api.llm.openai_client.requests.post')
    def test_normalize_model_does_not_strip_creator_namespace_ids(self, mock_post):
        """Creator/model ids (e.g. Novita) must not be slugified."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "chatcmpl-123",
            "object": "chat.completion",
            "created": 1677652288,
            "model": "xiaomimimo/mimo-v2-flash",
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": "ok"},
                "finish_reason": "stop"
            }],
            "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
        }
        mock_post.return_value = mock_response

        client = OpenAIClient()
        client.complete(
            prompt="hi",
            model="xiaomimimo/mimo-v2-flash",
            api_key="sk-test-key",
            messages=[{"role": "user", "content": "Hi"}],
        )

        sent_payload = mock_post.call_args[1]["json"]
        assert sent_payload["model"] == "xiaomimimo/mimo-v2-flash"

    @patch('api.llm.openai_client.requests.post')
    def test_normalize_model_preserves_novita_creator_namespace_when_creator_matches_provider(self, mock_post):
        """Novita creator namespaces like deepseek/... must survive normalization."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "chatcmpl-123",
            "object": "chat.completion",
            "created": 1677652288,
            "model": "deepseek/deepseek-ocr",
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": "ok"},
                "finish_reason": "stop"
            }],
            "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
        }
        mock_post.return_value = mock_response

        client = OpenAIClient()
        client.complete(
            prompt="hi",
            model="deepseek/deepseek-ocr",
            api_key="sk-test-key",
            base_url="https://api.novita.ai/openai/v1",
            messages=[{"role": "user", "content": "Hi"}],
        )

        sent_payload = mock_post.call_args[1]["json"]
        assert sent_payload["model"] == "deepseek/deepseek-ocr"

    @patch('api.llm.openai_client.requests.post')
    def test_normalize_model_strips_internal_prefix_for_direct_deepseek(self, mock_post):
        """Direct DeepSeek requests should still strip our internal provider prefix."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "chatcmpl-123",
            "object": "chat.completion",
            "created": 1677652288,
            "model": "deepseek-chat",
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": "ok"},
                "finish_reason": "stop"
            }],
            "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
        }
        mock_post.return_value = mock_response

        client = OpenAIClient()
        client.complete(
            prompt="hi",
            model="deepseek/deepseek-chat",
            api_key="sk-test-key",
            base_url="https://api.deepseek.com",
            messages=[{"role": "user", "content": "Hi"}],
        )

        sent_payload = mock_post.call_args[1]["json"]
        assert sent_payload["model"] == "deepseek-chat"
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_invalid_key(self, mock_post):
        """Test completion with invalid API key."""
        mock_response = Mock()
        mock_response.status_code = 401
        mock_response.json.return_value = {
            "error": {
                "message": "Invalid API key",
                "type": "invalid_request_error"
            }
        }
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        with pytest.raises(LLMAPIKeyError):
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="invalid-key"
            )
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_rate_limit(self, mock_post):
        """Test completion with rate limit."""
        mock_response = Mock()
        mock_response.status_code = 429
        mock_response.json.return_value = {
            "error": {
                "message": "Rate limit exceeded",
                "type": "rate_limit_error"
            }
        }
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        with pytest.raises(LLMRateLimitError):
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="sk-test-key"
            )
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_502_error(self, mock_post):
        """Test completion with 502 Bad Gateway error."""
        mock_response = Mock()
        mock_response.status_code = 502
        mock_response.ok = False
        mock_response.text = "Bad Gateway"
        mock_response.json.side_effect = Exception("No JSON")
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="sk-test-key"
            )
        
        error = exc_info.value
        assert error.provider == "OpenAI"
        assert error.status_code == 502
        assert error.is_retryable is True
        assert "Bad Gateway" in error.to_user_message()
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_503_error(self, mock_post):
        """Test completion with 503 Service Unavailable error."""
        mock_response = Mock()
        mock_response.status_code = 503
        mock_response.ok = False
        mock_response.json.return_value = {
            "error": {
                "message": "The server is currently overloaded"
            }
        }
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="sk-test-key"
            )
        
        error = exc_info.value
        assert error.provider == "OpenAI"
        assert error.status_code == 503
        assert error.provider_message == "The server is currently overloaded"
        assert "Service Unavailable" in error.to_user_message()
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_timeout(self, mock_post):
        """Test completion with timeout error."""
        mock_post.side_effect = requests.exceptions.Timeout("Request timed out")
        
        client = OpenAIClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="sk-test-key"
            )
        
        error = exc_info.value
        assert error.provider == "OpenAI"
        assert error.status_code == 504
        assert error.is_retryable is True


class TestGoogleGeminiClient:
    """Test Google Gemini client."""

    @patch("api.llm.google_client.requests.post")
    def test_429_includes_retry_after(self, mock_post):
        mock_resp = Mock()
        mock_resp.status_code = 429
        mock_resp.ok = False
        mock_resp.headers = {"Retry-After": "12"}
        mock_resp.json.return_value = {"error": {"message": "Quota exceeded for metric 'GenerateContent'"}}  # example shape
        mock_resp.text = ""
        mock_post.return_value = mock_resp

        client = GoogleGeminiClient()
        with pytest.raises(LLMRateLimitError) as exc_info:
            client.complete(
                prompt="hi",
                model="gemini-2.0-flash",
                api_key="fake-key",
                messages=[{"role": "user", "content": "Hi"}],
            )

        msg = str(exc_info.value)
        assert "Google rate limit exceeded" in msg
        assert "retry_after=12" in msg
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_connection_error(self, mock_post):
        """Test completion with connection error."""
        mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
        
        client = OpenAIClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="gpt-4",
                api_key="sk-test-key"
            )
        
        error = exc_info.value
        assert error.provider == "OpenAI"
        assert error.status_code == 503
        assert error.is_retryable is True
    
    @patch('api.llm.openai_client.requests.post')
    def test_complete_with_temperature(self, mock_post):
        """Test completion with temperature parameter."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{
                "message": {"content": "response"},
                "finish_reason": "stop"
            }],
            "usage": {"prompt_tokens": 10, "completion_tokens": 5},
            "model": "gpt-4"
        }
        mock_post.return_value = mock_response
        
        client = OpenAIClient()
        client.complete(
            prompt="test",
            model="gpt-4",
            api_key="sk-test",
            temperature=0.7
        )
        
        # Verify temperature was included in request
        call_args = mock_post.call_args
        assert "temperature" in call_args[1]["json"]
        assert call_args[1]["json"]["temperature"] == 0.7

    @patch("api.llm.openai_client.requests.post")
    def test_complete_retries_without_tools_when_function_calling_unsupported(self, mock_post):
        """If provider rejects function calling, retry once without tool fields."""
        first = Mock()
        first.status_code = 400
        first.ok = False
        first.content = b'{"code":400}'
        first.text = '{"code":400,"reason":"INVALID_REQUEST_BODY","message":"model features function calling not support"}'
        first.json.return_value = {
            "code": 400,
            "reason": "INVALID_REQUEST_BODY",
            "message": "model features function calling not support",
        }

        second = Mock()
        second.status_code = 200
        second.ok = True
        second.content = b'{"id":"chatcmpl-123"}'
        second.json.return_value = {
            "id": "chatcmpl-123",
            "object": "chat.completion",
            "created": 1677652288,
            "model": "deepseek/deepseek-ocr",
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": "fallback ok"},
                "finish_reason": "stop",
            }],
            "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
        }

        mock_post.side_effect = [first, second]

        client = OpenAIClient()
        result = client.complete(
            prompt="hi",
            model="deepseek/deepseek-ocr",
            api_key="sk-test-key",
            tools=[{
                "type": "function",
                "function": {
                    "name": "Read",
                    "description": "read file",
                    "parameters": {"type": "object", "properties": {}},
                },
            }],
            tool_choice="auto",
        )

        assert result.text == "fallback ok"
        assert mock_post.call_count == 2
        first_payload = mock_post.call_args_list[0].kwargs["json"]
        second_payload = mock_post.call_args_list[1].kwargs["json"]
        assert "tools" in first_payload and "tool_choice" in first_payload
        assert "tools" not in second_payload and "tool_choice" not in second_payload

    @patch("api.llm.openai_client.requests.post")
    def test_stream_retries_without_tools_when_function_calling_unsupported(self, mock_post):
        """Streaming calls should also retry without tool fields on unsupported-tool 400."""
        first = Mock()
        first.status_code = 400
        first.ok = False
        first.content = b'{"code":400}'
        first.text = '{"code":400,"reason":"INVALID_REQUEST_BODY","message":"model features function calling not support"}'
        first.json.return_value = {
            "code": 400,
            "reason": "INVALID_REQUEST_BODY",
            "message": "model features function calling not support",
        }

        second = Mock()
        second.status_code = 200
        second.ok = True
        second.iter_lines.return_value = [
            'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
            "data: [DONE]",
        ]

        mock_post.side_effect = [first, second]

        client = OpenAIClient()
        chunks = list(
            client.stream_chat_completions(
                model="deepseek/deepseek-ocr",
                api_key="sk-test-key",
                messages=[{"role": "user", "content": "hello"}],
                extra_payload={
                    "tools": [{
                        "type": "function",
                        "function": {
                            "name": "Read",
                            "description": "read file",
                            "parameters": {"type": "object", "properties": {}},
                        },
                    }],
                    "tool_choice": "auto",
                },
            )
        )

        assert any((delta == "ok") for delta, _, _, _ in chunks)
        assert mock_post.call_count == 2
        first_payload = mock_post.call_args_list[0].kwargs["json"]
        second_payload = mock_post.call_args_list[1].kwargs["json"]
        assert "tools" in first_payload and "tool_choice" in first_payload
        assert "tools" not in second_payload and "tool_choice" not in second_payload


class TestAnthropicClient:
    """Test Anthropic client."""
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_success(self, mock_post):
        """Test successful completion."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "msg-123",
            "type": "message",
            "role": "assistant",
            "content": [{
                "type": "text",
                "text": "This is a Claude response"
            }],
            "model": "claude-3-opus-20240229",
            "stop_reason": "end_turn",
            "stop_sequence": None,
            "usage": {
                "input_tokens": 10,
                "output_tokens": 5
            }
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        result = client.complete(
            prompt="test prompt",
            model="claude-3-opus",
            api_key="sk-ant-test-key",
            max_tokens=100
        )
        
        assert result.text == "This is a Claude response"
        assert result.input_tokens == 10
        assert result.output_tokens == 5
        assert result.model == "claude-3-opus-20240229"
        assert result.finish_reason == "stop"
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_invalid_key(self, mock_post):
        """Test completion with invalid API key."""
        mock_response = Mock()
        mock_response.status_code = 401
        mock_response.json.return_value = {
            "error": {
                "message": "Invalid API key"
            }
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        with pytest.raises(LLMAPIKeyError):
            client.complete(
                prompt="test prompt",
                model="claude-3-opus",
                api_key="invalid-key",
                max_tokens=100
            )

    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_forbidden_maps_to_invalid_key(self, mock_post):
        """403 from Anthropic should map to invalid-key semantics."""
        mock_response = Mock()
        mock_response.status_code = 403
        mock_response.json.return_value = {
            "error": {
                "message": "Forbidden"
            }
        }
        mock_post.return_value = mock_response

        client = AnthropicClient()
        with pytest.raises(LLMAPIKeyError):
            client.complete(
                prompt="test prompt",
                model="claude-3-opus",
                api_key="invalid-or-forbidden-key",
                max_tokens=100
            )

    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_max_tokens_stop_reason_maps_length(self, mock_post):
        """Anthropic max_tokens stop reason should map to OpenAI-compatible length."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "id": "msg-124",
            "type": "message",
            "role": "assistant",
            "content": [{
                "type": "text",
                "text": "Partial response"
            }],
            "model": "claude-3-opus-20240229",
            "stop_reason": "max_tokens",
            "stop_sequence": None,
            "usage": {
                "input_tokens": 10,
                "output_tokens": 5
            }
        }
        mock_post.return_value = mock_response

        client = AnthropicClient()
        result = client.complete(
            prompt="test prompt",
            model="claude-3-opus",
            api_key="sk-ant-test-key",
            max_tokens=100
        )

        assert result.finish_reason == "length"
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_502_error(self, mock_post):
        """Test completion with 502 Bad Gateway error."""
        mock_response = Mock()
        mock_response.status_code = 502
        mock_response.ok = False
        mock_response.text = "Bad Gateway"
        mock_response.json.side_effect = Exception("No JSON")
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="claude-3-opus",
                api_key="sk-ant-test-key",
                max_tokens=100
            )
        
        error = exc_info.value
        assert error.provider == "Anthropic"
        assert error.status_code == 502
        assert error.is_retryable is True
        assert "Bad Gateway" in error.to_user_message()
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_complete_overloaded_529(self, mock_post):
        """Test completion with 529 Overloaded error (Anthropic-specific)."""
        mock_response = Mock()
        mock_response.status_code = 529
        mock_response.ok = False
        mock_response.json.return_value = {
            "error": {
                "message": "API is temporarily overloaded"
            }
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        with pytest.raises(LLMProviderUnavailableError) as exc_info:
            client.complete(
                prompt="test prompt",
                model="claude-3-opus",
                api_key="sk-ant-test-key",
                max_tokens=100
            )
        
        error = exc_info.value
        assert error.provider == "Anthropic"
        assert error.status_code == 529
        assert error.provider_message == "API is temporarily overloaded"
        assert "Overloaded" in error.to_user_message()
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_model_mapping(self, mock_post):
        """Test model name mapping."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "content": [{"type": "text", "text": "response"}],
            "usage": {"input_tokens": 10, "output_tokens": 5},
            "model": "claude-3-opus-20240229"
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        client.complete(
            prompt="test",
            model="claude-3-opus",  # User-friendly name
            api_key="sk-ant-test",
            max_tokens=100
        )
        
        # Verify API model ID was used
        call_args = mock_post.call_args
        assert call_args[1]["json"]["model"] == "claude-3-opus-20240229"
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_claude_4_models(self, mock_post):
        """Test Claude 4 model name mapping."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "content": [{"type": "text", "text": "response"}],
            "usage": {"input_tokens": 10, "output_tokens": 5},
            "model": "claude-opus-4-20250514"
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        
        # Test various Claude 4 naming conventions
        test_models = [
            "claude-opus-4.1",
            "claude-opus-4",
            "claude-4-opus-4.1",
            "claude-4-opus",
            "claude-sonnet-4.5",
            "claude-sonnet-4",
            "claude-haiku-4.5",
        ]
        
        for model_name in test_models:
            client.complete(
                prompt="test",
                model=model_name,
                api_key="sk-ant-test",
                max_tokens=100
            )
            
            # Verify model was mapped (not passed through as-is)
            call_args = mock_post.call_args
            mapped_model = call_args[1]["json"]["model"]
            assert mapped_model != model_name  # Should be mapped to API ID
            assert "claude" in mapped_model.lower()  # Should still be a Claude model
    
    @patch('api.llm.anthropic_client.requests.post')
    def test_default_max_tokens(self, mock_post):
        """Test that max_tokens defaults to 1024 if not provided."""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "content": [{"type": "text", "text": "response"}],
            "usage": {"input_tokens": 10, "output_tokens": 5},
            "model": "claude-3-haiku-20240307"
        }
        mock_post.return_value = mock_response
        
        client = AnthropicClient()
        client.complete(
            prompt="test",
            model="claude-3-haiku",
            api_key="sk-ant-test"
            # max_tokens not provided
        )
        
        # Verify default max_tokens was used
        call_args = mock_post.call_args
        assert call_args[1]["json"]["max_tokens"] == 1024

