"""LLM provider abstraction (#960) — внешний OpenAI + шов под RU-hosted.

``LLMProvider`` — абстрактная база, чтобы будущий RU-hosted провайдер
(GigaChat/YandexGPT — данные остаются в РФ) можно было добавить, НЕ трогая
консьюмеров и клиент. Ключевой шов — свойство ``is_external``:
- ``OpenAIProvider.is_external == True`` → клиент ОБЯЗАН прогнать нагрузку через
  redaction перед вызовом (см. client.complete).
- будущий ``GigaChatProvider.is_external == False`` → redaction можно ослабить.

HTTP — raw ``httpx`` (без openai-SDK: новая зависимость = pyproject + uv.lock и
abort деплоя на lock-drift; httpx уже backend-mandated клиент). Поддержан
tool/function-calling pass-through: ``tools`` в запросе, ``tool_calls`` в ответе.
"""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any

import httpx

logger = logging.getLogger(__name__)


class LLMProviderError(Exception):
    """Базовая ошибка провайдера (сеть/HTTP/парсинг). Ловится клиентом → fallback."""


class LLMTimeoutError(LLMProviderError):
    """Провайдер не ответил в пределах таймаута."""


class LLMRateLimitedError(LLMProviderError):
    """429/5xx от провайдера (после исчерпания ретраев на уровне провайдера)."""

    def __init__(self, message: str, *, status_code: int, retry_after: float | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.retry_after = retry_after


@dataclass(frozen=True, slots=True)
class ToolCall:
    """Распарсенный tool/function call из ответа модели (pass-through, без exec)."""

    id: str
    name: str
    arguments: str  # сырой JSON-строкой как отдаёт OpenAI; парсит вызывающий


@dataclass(frozen=True, slots=True)
class ProviderResponse:
    """Сырой ответ провайдера (до обёртки в LLMResult клиентом)."""

    content: str | None
    tool_calls: list[ToolCall] = field(default_factory=list)
    finish_reason: str | None = None
    prompt_tokens: int = 0
    completion_tokens: int = 0
    model: str = ""


class LLMProvider(ABC):
    """Абстрактный провайдер. Реализации: OpenAIProvider (external) + future RU-hosted."""

    @property
    @abstractmethod
    def is_external(self) -> bool:
        """True если провайдер выгружает данные за пределы РФ (=> redaction mandatory)."""

    @property
    @abstractmethod
    def model(self) -> str:
        """Идентификатор модели по умолчанию (для логов/оценки стоимости)."""

    @abstractmethod
    def complete(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]] | None = None,
        max_output_tokens: int,
    ) -> ProviderResponse:
        """Один синхронный вызов chat-completions. Без ретраев/таймаута-логики выше —
        это делает client.complete (guardrails). Поднимает ``LLMProviderError`` на сбое.
        """


# ── OpenAI (external) ─────────────────────────────────────────────────────────


def _coerce_token_count(value: Any) -> int:
    """usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).

    OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
    может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
    мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
    «complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
    ронять ответ: деградируем до 0.
    """
    try:
        return int(value or 0)
    except (ValueError, TypeError):
        return 0


def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
    """Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
    choices = data.get("choices") or []
    if not choices:
        raise LLMProviderError("OpenAI response has no choices")
    choice = choices[0] or {}
    message = choice.get("message") or {}
    raw_tool_calls = message.get("tool_calls") or []
    tool_calls: list[ToolCall] = []
    for tc in raw_tool_calls:
        fn = (tc or {}).get("function") or {}
        tool_calls.append(
            ToolCall(
                id=str(tc.get("id", "")),
                name=str(fn.get("name", "")),
                arguments=str(fn.get("arguments", "")),
            )
        )
    usage = data.get("usage") or {}
    return ProviderResponse(
        content=message.get("content"),
        tool_calls=tool_calls,
        finish_reason=choice.get("finish_reason"),
        prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
        completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
        model=str(data.get("model") or fallback_model),
    )


class OpenAIProvider(LLMProvider):
    """OpenAI Chat Completions через raw httpx. ВНЕШНИЙ → redaction обязателен.

    Ключ передаётся в конструктор вызывающим (client читает его из settings); сам
    провайдер ключ нигде не логирует. Stateless относительно данных; один instance
    на вызов достаточно дёшев (httpx.Client создаётся на запрос — простота поверх
    пула; консьюмеры низкочастотные).
    """

    def __init__(
        self,
        *,
        api_key: str,
        model: str,
        base_url: str = "https://api.openai.com/v1",
        timeout_s: float = 30.0,
    ) -> None:
        self._api_key = api_key
        self._model = model
        self._base_url = base_url.rstrip("/")
        self._timeout_s = timeout_s

    @property
    def is_external(self) -> bool:
        return True

    @property
    def model(self) -> str:
        return self._model

    def _post(self, body: dict[str, Any]) -> dict[str, Any]:
        """POST {base_url}/chat/completions. Вынесен отдельно для monkeypatch в тестах.

        Ключ кладётся в заголовок Authorization и НИКОГДА не логируется. Поднимает
        типизированные LLM*-ошибки — без ретраев (их делает client.complete).
        """
        url = f"{self._base_url}/chat/completions"
        headers = {
            "Authorization": f"Bearer {self._api_key}",
            "Content-Type": "application/json",
        }
        try:
            with httpx.Client(timeout=self._timeout_s) as client:
                resp = client.post(url, headers=headers, json=body)
        except httpx.TimeoutException as e:
            raise LLMTimeoutError(f"OpenAI request timed out after {self._timeout_s}s") from e
        except httpx.HTTPError as e:
            raise LLMProviderError(f"OpenAI network error: {e}") from e

        if resp.status_code == 429 or resp.status_code >= 500:
            retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
            raise LLMRateLimitedError(
                f"OpenAI HTTP {resp.status_code}",
                status_code=resp.status_code,
                retry_after=retry_after,
            )
        if resp.status_code != 200:
            # 4xx (кроме 429): тело может содержать описание ошибки, но НЕ наши данные
            # эха целиком — берём короткий префикс. Ключ в теле ответа не возвращается.
            raise LLMProviderError(f"OpenAI HTTP {resp.status_code}: {resp.text[:200]}")
        try:
            data: dict[str, Any] = resp.json()
        except ValueError as e:
            raise LLMProviderError(f"OpenAI returned non-JSON body: {e}") from e
        return data

    def complete(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]] | None = None,
        max_output_tokens: int,
    ) -> ProviderResponse:
        body: dict[str, Any] = {
            "model": self._model,
            "messages": messages,
            "max_tokens": max_output_tokens,
        }
        if tools:
            # Clean pass-through tool-calling: прокидываем tools как есть, без agent-loop.
            body["tools"] = tools
            body["tool_choice"] = "auto"
        data = self._post(body)
        return _parse_openai_response(data, fallback_model=self._model)


def _parse_retry_after(value: str | None) -> float | None:
    """Распарсить заголовок Retry-After (только числовая форма в секундах)."""
    if value and value.isdigit():
        return float(value)
    return None


__all__ = [
    "LLMProvider",
    "LLMProviderError",
    "LLMRateLimitedError",
    "LLMTimeoutError",
    "OpenAIProvider",
    "ProviderResponse",
    "ToolCall",
]
