"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).

`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).

Надёжность: retry с экспоненциальным backoff на
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
"""

import logging
import time
from typing import Any

import httpx

logger = logging.getLogger(__name__)

_INITIAL_BACKOFF_S = 0.5
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""

_RETRYABLE_STATUS_CODES = frozenset({503})
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""


class LlmUnavailableError(Exception):
    """LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""


class OpenAICompatClient:
    """Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).

    `transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
    передаётся (используется реальный сетевой транспорт `httpx`).
    """

    def __init__(
        self,
        base_url: str,
        model: str,
        *,
        temperature: float = 0.2,
        max_tokens: int = 1024,
        timeout_s: float = 600.0,
        max_attempts: int = 3,
        breaker_threshold: int = 5,
        breaker_cooldown_s: float = 60.0,
        transport: httpx.BaseTransport | None = None,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._temperature = temperature
        self._max_tokens = max_tokens
        self._max_attempts = max_attempts
        self._breaker_threshold = breaker_threshold
        self._breaker_cooldown_s = breaker_cooldown_s
        self._client = httpx.Client(timeout=timeout_s, transport=transport)

        self._consecutive_failures = 0
        self._breaker_open_until = 0.0

    def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
        """Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.

        `max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
        раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
        `max_tokens`, заданный в конструкторе клиента.

        Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
        отказа ещё не истекло) либо все попытки retry исчерпаны.
        """
        now = time.monotonic()
        if now < self._breaker_open_until:
            raise LlmUnavailableError(
                "LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
            )

        url = f"{self._base_url}/chat/completions"
        payload: dict[str, Any] = {
            "model": self._model,
            "messages": [{"role": "user", "content": prompt}],
            "temperature": self._temperature,
            "max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
        }

        last_error: Exception | None = None
        for attempt in range(self._max_attempts):
            try:
                response = self._client.post(url, json=payload)
            except httpx.TransportError as exc:
                last_error = exc
                logger.warning(
                    "Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
                )
            else:
                if response.status_code == 200:
                    self._consecutive_failures = 0
                    try:
                        data = response.json()
                        content: str = data["choices"][0]["message"]["content"]
                    except (ValueError, KeyError, IndexError, TypeError) as exc:
                        last_error = exc
                        logger.warning("Некорректный ответ LLM-сервера: %s", exc)
                    else:
                        return content
                elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
                    last_error = RuntimeError(
                        f"LLM-сервер вернул retryable статус {response.status_code}"
                    )
                    logger.warning(
                        "Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
                    )
                else:
                    # Не retryable статус (например, 4xx) — не тратим оставшиеся
                    # попытки, но фиксируем сбой для circuit breaker.
                    self._register_failure()
                    raise LlmUnavailableError(
                        f"LLM-сервер вернул статус {response.status_code}: {response.text}"
                    ) from None

            if attempt < self._max_attempts - 1:
                time.sleep(_INITIAL_BACKOFF_S * (2**attempt))

        self._register_failure()
        raise LlmUnavailableError(
            f"LLM-сервер недоступен после {self._max_attempts} попыток"
        ) from last_error

    def _register_failure(self) -> None:
        """Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
        self._consecutive_failures += 1
        if self._consecutive_failures >= self._breaker_threshold:
            self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
            logger.warning(
                "Circuit breaker открыт на %.0fс после %d подряд неудач",
                self._breaker_cooldown_s,
                self._consecutive_failures,
            )

    def close(self) -> None:
        """Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).

        Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
        """
        self._client.close()

    def __enter__(self) -> "OpenAICompatClient":
        return self

    def __exit__(self, *exc_info: object) -> None:
        self.close()
