"""OpenAIProvider tests (#960) — request/response shape, tool-calling, error mapping.

Сеть НЕ дёргается: для shape-тестов мокаем OpenAIProvider._post; для error-mapping —
httpx.MockTransport (проверяем разбор статусов/таймаута на реальном httpx-слое). Ключ — fake.
"""

from __future__ import annotations

from typing import Any

import httpx
import pytest

from app.services.llm.provider import (
    LLMProviderError,
    LLMRateLimitedError,
    LLMTimeoutError,
    OpenAIProvider,
)

_FAKE_KEY = "test-fake-key-not-real"

# Реальный конструктор сохраняем ДО патча: иначе фабрика, дёргая httpx.Client,
# попадёт в саму себя (provider делает `import httpx`, поэтому патчим module-attr).
_REAL_HTTPX_CLIENT = httpx.Client


def _install_mock_transport(
    monkeypatch: pytest.MonkeyPatch,
    handler: Any,
) -> None:
    """Подменить httpx.Client в provider на клиент с MockTransport (без сети)."""
    transport = httpx.MockTransport(handler)

    def factory(**kw: Any) -> httpx.Client:
        kw.pop("transport", None)
        return _REAL_HTTPX_CLIENT(transport=transport, **kw)

    monkeypatch.setattr("app.services.llm.provider.httpx.Client", factory)


def _provider() -> OpenAIProvider:
    return OpenAIProvider(api_key=_FAKE_KEY, model="gpt-4o-mini")


def _chat_response(content: str = "привет") -> dict[str, Any]:
    return {
        "model": "gpt-4o-mini",
        "choices": [
            {"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}
        ],
        "usage": {"prompt_tokens": 10, "completion_tokens": 5},
    }


def _tool_call_response() -> dict[str, Any]:
    return {
        "model": "gpt-4o-mini",
        "choices": [
            {
                "message": {
                    "role": "assistant",
                    "content": None,
                    "tool_calls": [
                        {
                            "id": "call_1",
                            "type": "function",
                            "function": {
                                "name": "extract_floors",
                                "arguments": '{"floors": 16}',
                            },
                        }
                    ],
                },
                "finish_reason": "tool_calls",
            }
        ],
        "usage": {"prompt_tokens": 20, "completion_tokens": 8},
    }


def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None:
    """complete формирует корректное тело: model, messages, max_tokens."""
    captured: dict[str, Any] = {}

    def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
        captured["body"] = body
        return _chat_response()

    monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
    resp = _provider().complete(
        [{"role": "user", "content": "привет"}], max_output_tokens=256
    )

    assert captured["body"]["model"] == "gpt-4o-mini"
    assert captured["body"]["max_tokens"] == 256
    assert captured["body"]["messages"][0]["content"] == "привет"
    # Без tools — поля tools/tool_choice не добавляются.
    assert "tools" not in captured["body"]
    assert resp.content == "привет"
    assert resp.prompt_tokens == 10
    assert resp.completion_tokens == 5


def test_complete_passes_tools_through(monkeypatch: pytest.MonkeyPatch) -> None:
    """tools прокидываются в тело как есть + tool_choice=auto (clean pass-through)."""
    captured: dict[str, Any] = {}

    def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
        captured["body"] = body
        return _tool_call_response()

    monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
    tools = [
        {
            "type": "function",
            "function": {
                "name": "extract_floors",
                "parameters": {"type": "object", "properties": {"floors": {"type": "integer"}}},
            },
        }
    ]
    resp = _provider().complete(
        [{"role": "user", "content": "сколько этажей"}],
        tools=tools,
        max_output_tokens=256,
    )

    assert captured["body"]["tools"] == tools
    assert captured["body"]["tool_choice"] == "auto"
    # Ответ tool_calls распарсен.
    assert resp.content is None
    assert len(resp.tool_calls) == 1
    assert resp.tool_calls[0].name == "extract_floors"
    assert resp.tool_calls[0].arguments == '{"floors": 16}'
    assert resp.tool_calls[0].id == "call_1"


def test_post_maps_429_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
    """429 → LLMRateLimitedError со status_code и retry_after из заголовка."""

    def fake_handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(429, headers={"Retry-After": "7"}, json={"error": "rate"})

    _install_mock_transport(monkeypatch, fake_handler)
    with pytest.raises(LLMRateLimitedError) as ei:
        _provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
    assert ei.value.status_code == 429
    assert ei.value.retry_after == 7.0


def test_post_maps_500_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
    """5xx → LLMRateLimitedError (ретраебельно на уровне клиента)."""

    def fake_handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(503, json={"error": "unavailable"})

    _install_mock_transport(monkeypatch, fake_handler)
    with pytest.raises(LLMRateLimitedError) as ei:
        _provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
    assert ei.value.status_code == 503


def test_post_maps_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
    """httpx.TimeoutException → LLMTimeoutError."""

    def fake_handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ReadTimeout("slow", request=request)

    _install_mock_transport(monkeypatch, fake_handler)
    with pytest.raises(LLMTimeoutError):
        _provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)


def test_post_maps_4xx_to_provider_error(monkeypatch: pytest.MonkeyPatch) -> None:
    """400 → LLMProviderError (не ретраебельно)."""

    def fake_handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(400, json={"error": "bad request"})

    _install_mock_transport(monkeypatch, fake_handler)
    with pytest.raises(LLMProviderError):
        _provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)


def test_post_sends_bearer_key_in_header(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ключ уходит в Authorization: Bearer — и НЕ попадает в тело запроса."""
    seen: dict[str, Any] = {}

    def fake_handler(request: httpx.Request) -> httpx.Response:
        seen["auth"] = request.headers.get("Authorization")
        seen["body"] = request.content.decode()
        return httpx.Response(200, json=_chat_response())

    _install_mock_transport(monkeypatch, fake_handler)
    _provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
    assert seen["auth"] == f"Bearer {_FAKE_KEY}"
    # Ключ не должен оказаться в JSON-теле.
    assert _FAKE_KEY not in seen["body"]


def test_is_external_true() -> None:
    """OpenAIProvider — внешний (redaction mandatory)."""
    assert _provider().is_external is True
