"""Integration tests for LLM clients using respx mocks."""

from __future__ import annotations

import json

import httpx
import pytest
import respx

from llm_rotator._types import LLMResponse, StreamChunk
from llm_rotator.clients.anthropic import AnthropicClient
from llm_rotator.clients.gemini import GeminiClient
from llm_rotator.clients.openai import OpenAIClient
from llm_rotator.exceptions import KeyDeadError, ModelRateLimitError, ServerError

# ============================================================
# OpenAI Client
# ============================================================


OPENAI_CHAT_RESPONSE = {
    "id": "chatcmpl-123",
    "object": "chat.completion",
    "model": "gpt-4o",
    "choices": [
        {
            "index": 0,
            "message": {"role": "assistant", "content": "Hello!"},
            "finish_reason": "stop",
        }
    ],
    "usage": {
        "prompt_tokens": 10,
        "completion_tokens": 5,
        "total_tokens": 15,
    },
}


class TestOpenAIClientGenerate:
    @respx.mock
    async def test_successful_response(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "hi"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
        )

        assert isinstance(result, LLMResponse)
        assert result.content == "Hello!"
        assert result.model == "gpt-4o"
        assert result.usage.total_tokens == 15
        assert result.usage.prompt_tokens == 10
        assert result.usage.completion_tokens == 5

    @respx.mock
    async def test_custom_base_url(self):
        respx.post("https://openrouter.ai/api/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "hi"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://openrouter.ai/api/v1",
        )
        assert result.content == "Hello!"

    @respx.mock
    async def test_sends_correct_headers_and_body(self):
        route = respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="gpt-4o",
            api_key="sk-my-key",
            base_url="https://api.openai.com/v1",
        )

        request = route.calls.last.request
        assert request.headers["authorization"] == "Bearer sk-my-key"
        assert request.headers["content-type"] == "application/json"
        body = json.loads(request.content)
        assert body["model"] == "gpt-4o"
        assert body["messages"] == [{"role": "user", "content": "test"}]

    @respx.mock
    async def test_401_raises_key_dead(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(401, json={"error": {"message": "invalid"}})
        )
        client = OpenAIClient()
        with pytest.raises(KeyDeadError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-bad",
                base_url="https://api.openai.com/v1",
            )
        assert exc_info.value.status_code == 401

    @respx.mock
    async def test_402_raises_key_dead(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(402, json={"error": {"message": "payment"}})
        )
        client = OpenAIClient()
        with pytest.raises(KeyDeadError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-bad",
                base_url="https://api.openai.com/v1",
            )
        assert exc_info.value.status_code == 402

    @respx.mock
    async def test_429_raises_rate_limit(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(
                429,
                json={"error": {"message": "rate limit"}},
                headers={"retry-after": "30"},
            )
        )
        client = OpenAIClient()
        with pytest.raises(ModelRateLimitError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-test",
                base_url="https://api.openai.com/v1",
            )
        assert exc_info.value.retry_after == 30
        assert exc_info.value.model == "gpt-4o"

    @respx.mock
    async def test_429_without_retry_after(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(429, json={"error": {"message": "rate limit"}})
        )
        client = OpenAIClient()
        with pytest.raises(ModelRateLimitError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-test",
                base_url="https://api.openai.com/v1",
            )
        assert exc_info.value.retry_after is None

    @respx.mock
    async def test_500_raises_server_error(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(500, json={"error": {"message": "internal"}})
        )
        client = OpenAIClient()
        with pytest.raises(ServerError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-test",
                base_url="https://api.openai.com/v1",
            )
        assert exc_info.value.status_code == 500


class TestOpenAIClientStream:
    @respx.mock
    async def test_stream_basic(self):
        sse_data = (
            'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'
            'data: {"choices":[{"delta":{"content":" world"}}]}\n\n'
            'data: {"choices":[{"delta":{}}],'
            '"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n'
            "data: [DONE]\n\n"
        )
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(
                200,
                content=sse_data,
                headers={"content-type": "text/event-stream"},
            )
        )
        client = OpenAIClient()
        chunks: list[StreamChunk] = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "hi"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
        ):
            chunks.append(chunk)

        assert len(chunks) >= 2
        assert chunks[0].delta == "Hello"
        assert chunks[1].delta == " world"
        # Last content chunk or done chunk should have usage
        final = chunks[-1]
        assert final.done is True

    @respx.mock
    async def test_stream_429_raises(self):
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(429, json={"error": {"message": "rate limit"}})
        )
        client = OpenAIClient()
        with pytest.raises(ModelRateLimitError):
            async for _ in client.stream(
                messages=[{"role": "user", "content": "hi"}],
                model="gpt-4o",
                api_key="sk-test",
                base_url="https://api.openai.com/v1",
            ):
                pass


# ============================================================
# Gemini Client
# ============================================================


GEMINI_RESPONSE = {
    "candidates": [
        {
            "content": {
                "parts": [{"text": "Hello from Gemini!"}],
                "role": "model",
            },
            "finishReason": "STOP",
        }
    ],
    "usageMetadata": {
        "promptTokenCount": 8,
        "candidatesTokenCount": 4,
        "totalTokenCount": 12,
    },
}


class TestGeminiClientGenerate:
    @respx.mock
    async def test_successful_response(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        respx.post(url).mock(return_value=httpx.Response(200, json=GEMINI_RESPONSE))
        client = GeminiClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "hi"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
        )

        assert isinstance(result, LLMResponse)
        assert result.content == "Hello from Gemini!"
        assert result.usage.prompt_tokens == 8
        assert result.usage.completion_tokens == 4
        assert result.usage.total_tokens == 12

    @respx.mock
    async def test_sends_correct_headers(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        route = respx.post(url).mock(return_value=httpx.Response(200, json=GEMINI_RESPONSE))
        client = GeminiClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="gemini-2.0-flash",
            api_key="AIza-my-key",
        )

        request = route.calls.last.request
        assert request.headers["x-goog-api-key"] == "AIza-my-key"

    @respx.mock
    async def test_message_translation(self):
        """system → systemInstruction, assistant → model role."""
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        route = respx.post(url).mock(return_value=httpx.Response(200, json=GEMINI_RESPONSE))
        client = GeminiClient()
        await client.generate(
            messages=[
                {"role": "system", "content": "You are helpful"},
                {"role": "user", "content": "hi"},
                {"role": "assistant", "content": "hello"},
                {"role": "user", "content": "how are you?"},
            ],
            model="gemini-2.0-flash",
            api_key="AIza-test",
        )

        body = json.loads(route.calls.last.request.content)
        # System message → systemInstruction
        assert body["systemInstruction"]["parts"] == [{"text": "You are helpful"}]
        # Contents should have user and model roles
        contents = body["contents"]
        assert contents[0]["role"] == "user"
        assert contents[1]["role"] == "model"  # assistant → model
        assert contents[2]["role"] == "user"

    @respx.mock
    async def test_401_raises_key_dead(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        respx.post(url).mock(
            return_value=httpx.Response(401, json={"error": {"message": "bad key"}})
        )
        client = GeminiClient()
        with pytest.raises(KeyDeadError):
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gemini-2.0-flash",
                api_key="AIza-bad",
            )

    @respx.mock
    async def test_429_raises_rate_limit(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        respx.post(url).mock(
            return_value=httpx.Response(
                429,
                json={"error": {"message": "quota"}},
                headers={"retry-after": "60"},
            )
        )
        client = GeminiClient()
        with pytest.raises(ModelRateLimitError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gemini-2.0-flash",
                api_key="AIza-test",
            )
        assert exc_info.value.retry_after == 60

    @respx.mock
    async def test_500_raises_server_error(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        respx.post(url).mock(return_value=httpx.Response(500, json={"error": {"message": "err"}}))
        client = GeminiClient()
        with pytest.raises(ServerError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="gemini-2.0-flash",
                api_key="AIza-test",
            )
        assert exc_info.value.status_code == 500


class TestGeminiClientStream:
    @respx.mock
    async def test_stream_basic(self):
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:streamGenerateContent"
        )
        chunk1 = {
            "candidates": [{"content": {"parts": [{"text": "Hi"}], "role": "model"}}],
        }
        chunk2 = {
            "candidates": [{"content": {"parts": [{"text": " there"}], "role": "model"}}],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 3,
                "totalTokenCount": 8,
            },
        }
        sse_data = f"data: {json.dumps(chunk1)}\n\ndata: {json.dumps(chunk2)}\n\n"
        respx.post(url).mock(
            return_value=httpx.Response(
                200,
                content=sse_data,
                headers={"content-type": "text/event-stream"},
            )
        )
        client = GeminiClient()
        chunks: list[StreamChunk] = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "hi"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
        ):
            chunks.append(chunk)

        assert len(chunks) == 2
        assert chunks[0].delta == "Hi"
        assert chunks[1].delta == " there"
        assert chunks[1].usage is not None
        assert chunks[1].usage.total_tokens == 8


# ============================================================
# Anthropic Client
# ============================================================


ANTHROPIC_RESPONSE = {
    "id": "msg_01XFDUDYJgAACzvnptvVoYEL",
    "type": "message",
    "role": "assistant",
    "model": "claude-sonnet-4-20250514",
    "content": [{"type": "text", "text": "Hello from Claude!"}],
    "stop_reason": "end_turn",
    "usage": {"input_tokens": 12, "output_tokens": 6},
}


class TestAnthropicClientGenerate:
    @respx.mock
    async def test_successful_response(self):
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "hi"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        )

        assert isinstance(result, LLMResponse)
        assert result.content == "Hello from Claude!"
        assert result.model == "claude-sonnet-4-20250514"
        assert result.provider == "anthropic"
        assert result.usage.prompt_tokens == 12
        assert result.usage.completion_tokens == 6
        assert result.usage.total_tokens == 18

    @respx.mock
    async def test_system_message_separate_field(self):
        """system messages go into 'system' field, not into messages array."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[
                {"role": "system", "content": "You are helpful"},
                {"role": "user", "content": "hi"},
            ],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        )

        body = json.loads(route.calls.last.request.content)
        assert body["system"] == "You are helpful"
        assert len(body["messages"]) == 1
        assert body["messages"][0]["role"] == "user"

    @respx.mock
    async def test_multi_system_messages_concatenated(self):
        """Multiple system messages are concatenated with double newline."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[
                {"role": "system", "content": "Rule 1"},
                {"role": "system", "content": "Rule 2"},
                {"role": "user", "content": "hi"},
            ],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        )

        body = json.loads(route.calls.last.request.content)
        assert body["system"] == "Rule 1\n\nRule 2"

    @respx.mock
    async def test_multi_turn(self):
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[
                {"role": "user", "content": "hi"},
                {"role": "assistant", "content": "hello"},
                {"role": "user", "content": "how are you?"},
            ],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        )

        body = json.loads(route.calls.last.request.content)
        assert len(body["messages"]) == 3
        assert body["messages"][0]["role"] == "user"
        assert body["messages"][1]["role"] == "assistant"
        assert body["messages"][2]["role"] == "user"
        assert "system" not in body

    @respx.mock
    async def test_sends_correct_headers(self):
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-my-key",
        )

        request = route.calls.last.request
        assert request.headers["x-api-key"] == "sk-ant-my-key"
        assert request.headers["anthropic-version"] == "2023-06-01"
        assert request.headers["content-type"] == "application/json"

    @respx.mock
    async def test_max_tokens_default(self):
        """max_tokens defaults to 4096 when not specified."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        )

        body = json.loads(route.calls.last.request.content)
        assert body["max_tokens"] == 4096

    @respx.mock
    async def test_401_raises_key_dead(self):
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                401,
                json={
                    "type": "error",
                    "error": {"type": "authentication_error", "message": "invalid x-api-key"},
                },
            )
        )
        client = AnthropicClient()
        with pytest.raises(KeyDeadError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="claude-sonnet-4-20250514",
                api_key="sk-ant-bad",
            )
        assert exc_info.value.status_code == 401

    @respx.mock
    async def test_429_raises_rate_limit(self):
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                429,
                json={
                    "type": "error",
                    "error": {"type": "rate_limit_error", "message": "rate limited"},
                },
                headers={"retry-after": "45"},
            )
        )
        client = AnthropicClient()
        with pytest.raises(ModelRateLimitError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="claude-sonnet-4-20250514",
                api_key="sk-ant-test",
            )
        assert exc_info.value.retry_after == 45
        assert exc_info.value.model == "claude-sonnet-4-20250514"

    @respx.mock
    async def test_529_overloaded_raises_server_error(self):
        """Anthropic-specific 529 status → ServerError."""
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                529,
                json={
                    "type": "error",
                    "error": {"type": "overloaded_error", "message": "overloaded"},
                },
            )
        )
        client = AnthropicClient()
        with pytest.raises(ServerError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="claude-sonnet-4-20250514",
                api_key="sk-ant-test",
            )
        assert exc_info.value.status_code == 529
        assert exc_info.value.provider == "anthropic"

    @respx.mock
    async def test_500_raises_server_error(self):
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                500, json={"type": "error", "error": {"type": "api_error", "message": "internal"}}
            )
        )
        client = AnthropicClient()
        with pytest.raises(ServerError) as exc_info:
            await client.generate(
                messages=[{"role": "user", "content": "hi"}],
                model="claude-sonnet-4-20250514",
                api_key="sk-ant-test",
            )
        assert exc_info.value.status_code == 500


def _anthropic_sse(*events: dict) -> str:
    """Build SSE data string from a sequence of event dicts."""
    return "".join(f"data: {json.dumps(e)}\n\n" for e in events)


class TestAnthropicClientStream:
    @respx.mock
    async def test_stream_success(self):
        sse_data = _anthropic_sse(
            {
                "type": "message_start",
                "message": {
                    "id": "msg_01",
                    "type": "message",
                    "role": "assistant",
                    "model": "claude-sonnet-4-20250514",
                    "usage": {"input_tokens": 10, "output_tokens": 0},
                },
            },
            {
                "type": "content_block_start",
                "index": 0,
                "content_block": {"type": "text", "text": ""},
            },
            {
                "type": "content_block_delta",
                "index": 0,
                "delta": {"type": "text_delta", "text": "Hello"},
            },
            {
                "type": "content_block_delta",
                "index": 0,
                "delta": {"type": "text_delta", "text": " world"},
            },
            {"type": "content_block_stop", "index": 0},
            {
                "type": "message_delta",
                "delta": {"stop_reason": "end_turn"},
                "usage": {"output_tokens": 5},
            },
            {"type": "message_stop"},
        )
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                200,
                content=sse_data,
                headers={"content-type": "text/event-stream"},
            )
        )
        client = AnthropicClient()
        chunks: list[StreamChunk] = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "hi"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        ):
            chunks.append(chunk)

        assert len(chunks) >= 3
        # Content chunks
        assert chunks[0].delta == "Hello"
        assert chunks[1].delta == " world"
        # Final done chunk
        final = chunks[-1]
        assert final.done is True
        assert final.usage is not None
        assert final.usage.prompt_tokens == 10
        assert final.usage.completion_tokens == 5
        assert final.usage.total_tokens == 15

    @respx.mock
    async def test_stream_message_stop_done(self):
        """message_stop event → done=True."""
        sse_data = _anthropic_sse(
            {
                "type": "message_start",
                "message": {
                    "id": "msg_01",
                    "type": "message",
                    "role": "assistant",
                    "model": "claude-sonnet-4-20250514",
                    "usage": {"input_tokens": 5, "output_tokens": 0},
                },
            },
            {
                "type": "content_block_delta",
                "index": 0,
                "delta": {"type": "text_delta", "text": "Hi"},
            },
            {
                "type": "message_delta",
                "delta": {"stop_reason": "end_turn"},
                "usage": {"output_tokens": 1},
            },
            {"type": "message_stop"},
        )
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                200,
                content=sse_data,
                headers={"content-type": "text/event-stream"},
            )
        )
        client = AnthropicClient()
        chunks: list[StreamChunk] = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "hi"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
        ):
            chunks.append(chunk)

        assert chunks[-1].done is True

    @respx.mock
    async def test_stream_429_raises(self):
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                429,
                json={
                    "type": "error",
                    "error": {"type": "rate_limit_error", "message": "rate limited"},
                },
            )
        )
        client = AnthropicClient()
        with pytest.raises(ModelRateLimitError):
            async for _ in client.stream(
                messages=[{"role": "user", "content": "hi"}],
                model="claude-sonnet-4-20250514",
                api_key="sk-ant-test",
            ):
                pass


# ============================================================
# Tool Calling Tests
# ============================================================

SAMPLE_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    }
]


class TestOpenAIToolCalling:
    @respx.mock
    async def test_tools_passthrough(self):
        """Tools are sent in request body as-is."""
        route = respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
            tools=SAMPLE_TOOLS,
            tool_choice="auto",
        )
        body = json.loads(route.calls.last.request.content)
        assert body["tools"] == SAMPLE_TOOLS
        assert body["tool_choice"] == "auto"

    @respx.mock
    async def test_tool_call_response(self):
        """tool_calls in response are parsed into ToolCall objects."""
        response_data = {
            "id": "chatcmpl-123",
            "model": "gpt-4o",
            "choices": [
                {
                    "index": 0,
                    "message": {
                        "role": "assistant",
                        "content": None,
                        "tool_calls": [
                            {
                                "id": "call_abc",
                                "type": "function",
                                "function": {
                                    "name": "get_weather",
                                    "arguments": '{"city":"London"}',
                                },
                            }
                        ],
                    },
                    "finish_reason": "tool_calls",
                }
            ],
            "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
        }
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=response_data)
        )
        client = OpenAIClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
            tools=SAMPLE_TOOLS,
        )
        assert result.content is None
        assert result.tool_calls is not None
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].id == "call_abc"
        assert result.tool_calls[0].name == "get_weather"
        assert result.tool_calls[0].arguments == '{"city":"London"}'

    @respx.mock
    async def test_json_mode(self):
        """response_format is forwarded to the request."""
        route = respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        await client.generate(
            messages=[{"role": "user", "content": "json"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
            response_format={"type": "json_object"},
        )
        body = json.loads(route.calls.last.request.content)
        assert body["response_format"] == {"type": "json_object"}

    @respx.mock
    async def test_pydantic_schema(self):
        """Pydantic model → JSON Schema in response_format."""
        from pydantic import BaseModel

        class Weather(BaseModel):
            temp: float
            city: str

        route = respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=OPENAI_CHAT_RESPONSE)
        )
        client = OpenAIClient()
        await client.generate(
            messages=[{"role": "user", "content": "weather"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
            response_format=Weather,
        )
        body = json.loads(route.calls.last.request.content)
        assert body["response_format"]["type"] == "json_schema"
        assert body["response_format"]["json_schema"]["name"] == "Weather"
        assert "properties" in body["response_format"]["json_schema"]["schema"]

    @respx.mock
    async def test_tool_call_stream(self):
        """Streaming accumulates tool_calls and emits in done chunk."""
        tc0 = json.dumps({"choices": [{"delta": {"tool_calls": [
            {"index": 0, "id": "call_1", "function": {
                "name": "get_weather", "arguments": ""}}
        ]}}]})
        tc1 = json.dumps({"choices": [{"delta": {"tool_calls": [
            {"index": 0, "function": {"arguments": '{"ci'}}
        ]}}]})
        tc2 = json.dumps({"choices": [{"delta": {"tool_calls": [
            {"index": 0, "function": {"arguments": 'ty":"SF"}'}}
        ]}}]})
        tc_usage = json.dumps({
            "choices": [{"delta": {}}],
            "usage": {
                "prompt_tokens": 5,
                "completion_tokens": 10,
                "total_tokens": 15,
            },
        })
        sse_data = (
            f"data: {tc0}\n\n"
            f"data: {tc1}\n\n"
            f"data: {tc2}\n\n"
            f"data: {tc_usage}\n\n"
            "data: [DONE]\n\n"
        )
        respx.post("https://api.openai.com/v1/chat/completions").mock(
            return_value=httpx.Response(
                200, content=sse_data, headers={"content-type": "text/event-stream"}
            )
        )
        client = OpenAIClient()
        chunks = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "weather?"}],
            model="gpt-4o",
            api_key="sk-test",
            base_url="https://api.openai.com/v1",
            tools=SAMPLE_TOOLS,
        ):
            chunks.append(chunk)

        done_chunk = chunks[-1]
        assert done_chunk.done is True
        assert done_chunk.tool_calls is not None
        assert len(done_chunk.tool_calls) == 1
        assert done_chunk.tool_calls[0].name == "get_weather"
        assert done_chunk.tool_calls[0].arguments == '{"city":"SF"}'


class TestGeminiToolCalling:
    @respx.mock
    async def test_tools_translation(self):
        """OpenAI tools format → Gemini functionDeclarations."""
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        route = respx.post(url).mock(
            return_value=httpx.Response(200, json=GEMINI_RESPONSE)
        )
        client = GeminiClient()
        await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
            tools=SAMPLE_TOOLS,
        )
        body = json.loads(route.calls.last.request.content)
        assert "tools" in body
        decl = body["tools"][0]["functionDeclarations"]
        assert len(decl) == 1
        assert decl[0]["name"] == "get_weather"
        assert decl[0]["description"] == "Get current weather"

    @respx.mock
    async def test_tool_call_response(self):
        """Gemini functionCall → ToolCall."""
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        gemini_tool_response = {
            "candidates": [
                {
                    "content": {
                        "parts": [
                            {
                                "functionCall": {
                                    "name": "get_weather",
                                    "args": {"city": "London"},
                                }
                            }
                        ],
                        "role": "model",
                    }
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 10,
                "candidatesTokenCount": 5,
                "totalTokenCount": 15,
            },
        }
        respx.post(url).mock(
            return_value=httpx.Response(200, json=gemini_tool_response)
        )
        client = GeminiClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
            tools=SAMPLE_TOOLS,
        )
        assert result.tool_calls is not None
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].name == "get_weather"
        assert json.loads(result.tool_calls[0].arguments) == {"city": "London"}

    @respx.mock
    async def test_json_mode(self):
        """response_format → generationConfig.responseMimeType."""
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        route = respx.post(url).mock(
            return_value=httpx.Response(200, json=GEMINI_RESPONSE)
        )
        client = GeminiClient()
        await client.generate(
            messages=[{"role": "user", "content": "json"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
            response_format={"type": "json_object"},
        )
        body = json.loads(route.calls.last.request.content)
        assert body["generationConfig"]["responseMimeType"] == "application/json"

    @respx.mock
    async def test_tool_choice_auto(self):
        """tool_choice='auto' → toolConfig mode AUTO."""
        url = (
            "https://generativelanguage.googleapis.com"
            "/v1beta/models/gemini-2.0-flash:generateContent"
        )
        route = respx.post(url).mock(
            return_value=httpx.Response(200, json=GEMINI_RESPONSE)
        )
        client = GeminiClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="gemini-2.0-flash",
            api_key="AIza-test",
            tools=SAMPLE_TOOLS,
            tool_choice="auto",
        )
        body = json.loads(route.calls.last.request.content)
        assert body["toolConfig"]["functionCallingConfig"]["mode"] == "AUTO"


class TestAnthropicToolCalling:
    @respx.mock
    async def test_tools_translation(self):
        """OpenAI tools → Anthropic tools format."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
            tools=SAMPLE_TOOLS,
        )
        body = json.loads(route.calls.last.request.content)
        assert "tools" in body
        tool = body["tools"][0]
        assert tool["name"] == "get_weather"
        assert tool["description"] == "Get current weather"
        assert "input_schema" in tool
        assert tool["input_schema"]["type"] == "object"

    @respx.mock
    async def test_tool_call_response(self):
        """Anthropic tool_use block → ToolCall."""
        response_data = {
            "id": "msg_01",
            "type": "message",
            "role": "assistant",
            "model": "claude-sonnet-4-20250514",
            "content": [
                {
                    "type": "tool_use",
                    "id": "toolu_abc",
                    "name": "get_weather",
                    "input": {"city": "London"},
                }
            ],
            "stop_reason": "tool_use",
            "usage": {"input_tokens": 10, "output_tokens": 15},
        }
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=response_data)
        )
        client = AnthropicClient()
        result = await client.generate(
            messages=[{"role": "user", "content": "weather?"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
            tools=SAMPLE_TOOLS,
        )
        assert result.tool_calls is not None
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].id == "toolu_abc"
        assert result.tool_calls[0].name == "get_weather"
        assert json.loads(result.tool_calls[0].arguments) == {"city": "London"}

    @respx.mock
    async def test_tool_call_stream(self):
        """Streaming tool_use accumulation."""
        sse_data = _anthropic_sse(
            {
                "type": "message_start",
                "message": {
                    "id": "msg_01",
                    "type": "message",
                    "role": "assistant",
                    "model": "claude-sonnet-4-20250514",
                    "usage": {"input_tokens": 10, "output_tokens": 0},
                },
            },
            {
                "type": "content_block_start",
                "index": 0,
                "content_block": {
                    "type": "tool_use",
                    "id": "toolu_abc",
                    "name": "get_weather",
                },
            },
            {
                "type": "content_block_delta",
                "index": 0,
                "delta": {"type": "input_json_delta", "partial_json": '{"city"'},
            },
            {
                "type": "content_block_delta",
                "index": 0,
                "delta": {"type": "input_json_delta", "partial_json": ':"SF"}'},
            },
            {"type": "content_block_stop", "index": 0},
            {
                "type": "message_delta",
                "delta": {"stop_reason": "tool_use"},
                "usage": {"output_tokens": 10},
            },
            {"type": "message_stop"},
        )
        respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(
                200,
                content=sse_data,
                headers={"content-type": "text/event-stream"},
            )
        )
        client = AnthropicClient()
        chunks = []
        async for chunk in client.stream(
            messages=[{"role": "user", "content": "weather?"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
            tools=SAMPLE_TOOLS,
        ):
            chunks.append(chunk)

        done_chunk = chunks[-1]
        assert done_chunk.done is True
        assert done_chunk.tool_calls is not None
        assert len(done_chunk.tool_calls) == 1
        assert done_chunk.tool_calls[0].name == "get_weather"
        assert json.loads(done_chunk.tool_calls[0].arguments) == {"city": "SF"}

    @respx.mock
    async def test_tool_choice_auto(self):
        """tool_choice='auto' → Anthropic {type: 'auto'}."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[{"role": "user", "content": "test"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
            tools=SAMPLE_TOOLS,
            tool_choice="auto",
        )
        body = json.loads(route.calls.last.request.content)
        assert body["tool_choice"] == {"type": "auto"}

    @respx.mock
    async def test_json_mode_via_system_prompt(self):
        """JSON mode → instruction added to system prompt."""
        route = respx.post("https://api.anthropic.com/v1/messages").mock(
            return_value=httpx.Response(200, json=ANTHROPIC_RESPONSE)
        )
        client = AnthropicClient()
        await client.generate(
            messages=[{"role": "user", "content": "json"}],
            model="claude-sonnet-4-20250514",
            api_key="sk-ant-test",
            response_format={"type": "json_object"},
        )
        body = json.loads(route.calls.last.request.content)
        assert "You must respond with valid JSON only" in body["system"]


# ============================================================
# Rotator Tool Forwarding Tests
# ============================================================


class TestRotatorToolForwarding:
    @respx.mock
    async def test_tools_forwarded_to_client(self):
        """rotator.complete(tools=...) forwards to client."""
        from llm_rotator.config import RotatorConfig
        from llm_rotator.rotator import LLMRotator
        from tests.conftest import FakeLLMClient

        config = RotatorConfig.model_validate({
            "providers": [
                {
                    "name": "openai",
                    "client_type": "openai",
                    "priority": 1,
                    "keys": [{"token": "sk-test", "alias": "k1"}],
                    "models": ["gpt-4o"],
                }
            ]
        })
        fake = FakeLLMClient()
        rotator = LLMRotator(config, clients={"openai": fake})
        await rotator.complete(
            messages=[{"role": "user", "content": "hi"}],
            tools=SAMPLE_TOOLS,
            tool_choice="auto",
        )
        assert fake.calls[-1]["tools"] == SAMPLE_TOOLS
        assert fake.calls[-1]["tool_choice"] == "auto"

    @respx.mock
    async def test_response_format_forwarded(self):
        """rotator.complete(response_format=...) forwards to client."""
        from llm_rotator.config import RotatorConfig
        from llm_rotator.rotator import LLMRotator
        from tests.conftest import FakeLLMClient

        config = RotatorConfig.model_validate({
            "providers": [
                {
                    "name": "openai",
                    "client_type": "openai",
                    "priority": 1,
                    "keys": [{"token": "sk-test", "alias": "k1"}],
                    "models": ["gpt-4o"],
                }
            ]
        })
        fake = FakeLLMClient()
        rotator = LLMRotator(config, clients={"openai": fake})
        await rotator.complete(
            messages=[{"role": "user", "content": "hi"}],
            response_format={"type": "json_object"},
        )
        assert fake.calls[-1]["response_format"] == {"type": "json_object"}
