"""LLM orchestration client (#960) — enabled-check → redact → guardrails → fallback.

Архитектурный принцип (жёсткий): forecasting-движок полностью детерминирован; LLM —
ОПЦИОНАЛЬНЫЙ слой, у которого ВСЕГДА есть детерминированный fallback. Поэтому
``complete`` НИКОГДА не падает наружу из-за LLM: любая проблема (выключено / таймаут /
сеть / ошибка провайдера / отказ redaction / превышение call-cap) превращается в
``LLMResult(ok=False, fallback_used=True)`` — вызывающий подставляет свой
детерминированный результат.

Гарантия «прод не позвонит в OpenAI случайно»: если ``settings.llm_enabled`` is False
ИЛИ ключ не задан — ``complete`` возвращает fallback СРАЗУ, не создавая провайдера и не
делая сетевых вызовов (guard #2). Это первая строка функции.

Sync-first: ядро синхронное (``httpx.Client``) — совпадает с синхронным
forecasting-движком и Celery-консьюмером (#956). Async-консьюмер (#957 chat) мостит
через ``anyio.to_thread.run_sync`` / FastAPI ``run_in_threadpool`` (НЕ делаем async
Celery task — backend.md).
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from typing import Any

from app.core.config import settings

from .provider import (
    LLMProvider,
    LLMProviderError,
    LLMRateLimitedError,
    LLMTimeoutError,
    OpenAIProvider,
    ProviderResponse,
    ToolCall,
)
from .redaction import RedactionRefusedError, SafePayload, scrub_safe_payload

logger = logging.getLogger(__name__)


class LLMUnavailableError(Exception):
    """LLM-слой недоступен (выключен / нет ключа / сбой). Для вызывающих, которые
    предпочитают исключение вместо проверки ``LLMResult.ok``. ``complete`` по умолчанию
    НЕ бросает — возвращает fallback-результат; raise делает только ``complete_or_raise``.
    """


@dataclass(frozen=True, slots=True)
class LLMResult:
    """Результат вызова. ``ok=False`` → вызывающий ОБЯЗАН использовать детерм. fallback.

    Attributes:
        ok: True только при успешном ответе провайдера.
        content: Текст ответа модели (None при tool-only ответе или fallback).
        tool_calls: Распарсенные tool/function calls (pass-through).
        fallback_used: True если результат — сигнал к детерминированному fallback.
        reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
            redaction_refused/provider_error/call_cap/no_api_key). None при ok.
        finish_reason: причина завершения от провайдера (stop/length/content_filter/
            tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
            ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
            или пустой content) и не является полноценным результатом.
        prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
        model: модель, ответившая на запрос ("" при fallback).
    """

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

    @classmethod
    def fallback(cls, reason: str) -> LLMResult:
        return cls(ok=False, fallback_used=True, reason=reason)

    @classmethod
    def from_provider(cls, resp: ProviderResponse) -> LLMResult:
        return cls(
            ok=True,
            content=resp.content,
            tool_calls=list(resp.tool_calls),
            finish_reason=resp.finish_reason,
            prompt_tokens=resp.prompt_tokens,
            completion_tokens=resp.completion_tokens,
            model=resp.model,
        )


# Грубая оценка стоимости (USD / 1K токенов) для лог-хука tokens→$. Только для
# логирования/наблюдаемости — НЕ enforcement. Значения для gpt-4o-mini (ориентир,
# уточняется консьюмером при смене модели/тарифа).
_COST_PER_1K_PROMPT_USD = 0.00015
_COST_PER_1K_COMPLETION_USD = 0.00060

# #1209: общий потолок backoff'а между retry-попытками (секунды). Применяется
# к ОБЕИМ веткам — exponential backoff И серверному Retry-After. Provider'ы
# шлют Retry-After до 86400с при quota-exhaustion (видели у OpenAI / CDN-503);
# без капа time.sleep блокирует anyio-threadpool на часы → пул из ~40 потоков
# исчерпывается → стопор приложения (sync Depends(get_db) тоже сидят в этом
# пуле). 30с — компромисс между «дать провайдеру отдышаться» и «не убивать
# приложение». Реальный raw_wait логируется для наблюдаемости.
_MAX_BACKOFF_S: float = 30.0


def _estimate_cost_usd(prompt_tokens: int, completion_tokens: int) -> float:
    return (
        prompt_tokens / 1000.0 * _COST_PER_1K_PROMPT_USD
        + completion_tokens / 1000.0 * _COST_PER_1K_COMPLETION_USD
    )


def _build_default_provider() -> LLMProvider | None:
    """Собрать провайдера из settings. None если ключ не задан (→ fallback у вызывающего).

    Сейчас единственный провайдер — OpenAI (внешний). Будущий RU-hosted выбирается
    здесь же по настройке (напр. settings.llm_provider) без изменений в complete.
    """
    if not settings.openai_api_key:
        return None
    return OpenAIProvider(
        api_key=settings.openai_api_key,
        model=settings.llm_model,
        base_url=settings.llm_base_url,
        timeout_s=settings.llm_timeout_s,
    )


def _messages_from_payload(system_prompt: str, payload: SafePayload) -> list[dict[str, Any]]:
    """Собрать chat-messages из системного промпта и (уже проскрабленной) нагрузки.

    fields сериализуются в компактный «ключ: значение» блок и приклеиваются к user-тексту —
    модель получает структурированный контекст, а вызывающий не пишет prompt-склейку сам.
    """
    user_parts: list[str] = []
    if payload.text:
        user_parts.append(payload.text)
    if payload.fields:
        rendered = "\n".join(f"{k}: {v}" for k, v in payload.fields.items())
        user_parts.append(rendered)
    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": "\n\n".join(user_parts)},
    ]


def complete(
    *,
    system_prompt: str,
    payload: SafePayload,
    tools: list[dict[str, Any]] | None = None,
    provider: LLMProvider | None = None,
    call_index: int = 0,
) -> LLMResult:
    """Главная точка входа. Прогоняет нагрузку через все защиты и возвращает LLMResult.

    Контракт безопасности (порядок строгий):
      1. enabled-check: ``llm_enabled`` False / нет ключа → fallback БЕЗ сети.
      2. call-cap: ``call_index >= llm_max_calls_per_request`` → fallback (анти-цикл).
      3. redaction (mandatory на external-пути): hard-block confidential + regex-scrub.
      4. provider call с таймаутом + ретраями (429/5xx, backoff, cap), circuit-breaker-lite.
      5. любой сбой → fallback (система не падает из-за LLM).

    Args:
        system_prompt: системная инструкция (без данных — данные в ``payload``).
        payload: ЯВНО собранная вызывающим ``SafePayload`` (allowlist-first контракт).
        tools: опц. OpenAI tool/function-calling спецификации (clean pass-through).
        provider: внедрить провайдера (тесты/будущий RU-hosted). None → из settings.
        call_index: порядковый номер вызова в рамках одной логической операции
            (для call-cap). Консьюмер инкрементит сам.

    Returns:
        ``LLMResult`` — при ``ok=False`` вызывающий ОБЯЗАН подставить детерм. fallback.
    """
    # ── Guard #1/#2: выключено или нет ключа → НИКАКОЙ сети ──────────────────
    if not settings.llm_enabled:
        logger.debug("llm: disabled (llm_enabled=False) → deterministic fallback")
        return LLMResult.fallback("disabled")

    prov = provider or _build_default_provider()
    if prov is None:
        logger.warning("llm: enabled but OPENAI_API_KEY not set → deterministic fallback")
        return LLMResult.fallback("no_api_key")

    # ── Per-request call cap (анти-цикл) ────────────────────────────────────
    if call_index >= settings.llm_max_calls_per_request:
        logger.warning(
            "llm: call cap reached (call_index=%d >= %d) → fallback",
            call_index,
            settings.llm_max_calls_per_request,
        )
        return LLMResult.fallback("call_cap")

    # ── Redaction (mandatory на external-пути) ──────────────────────────────
    try:
        safe = scrub_safe_payload(payload, is_external=prov.is_external)
    except RedactionRefusedError:
        # confidential нагрузка — НЕ отправляем наружу, деградируем в fallback.
        return LLMResult.fallback("redaction_refused")

    messages = _messages_from_payload(system_prompt, safe)

    # ── Provider call с ретраями (circuit-breaker-lite) ─────────────────────
    return _call_with_retries(prov, messages, tools)


def _call_with_retries(
    provider: LLMProvider,
    messages: list[dict[str, Any]],
    tools: list[dict[str, Any]] | None,
) -> LLMResult:
    """Вызвать провайдера с bounded-ретраями на 429/5xx и backoff. Сбой → fallback."""
    max_retries = settings.llm_max_retries
    last_reason = "provider_error"
    for attempt in range(max_retries + 1):
        try:
            resp = provider.complete(
                messages,
                tools=tools,
                max_output_tokens=settings.llm_max_output_tokens,
            )
        except LLMRateLimitedError as e:
            last_reason = "rate_limited"
            if attempt < max_retries:
                # #1209: cap И серверное Retry-After (раньше min(...,30) применялся
                # только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих
                # веток, защищает anyio-threadpool от blocking на часы.
                raw_wait = float(
                    e.retry_after if e.retry_after is not None else 2**attempt
                )
                wait = min(raw_wait, _MAX_BACKOFF_S)
                logger.warning(
                    "llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)",
                    e.status_code,
                    attempt + 1,
                    max_retries + 1,
                    wait,
                    raw_wait,
                )
                time.sleep(wait)
                continue
            # Circuit-breaker-lite: исчерпали ретраи — не «молотим», деградируем.
            logger.warning("llm: rate-limited after %d attempts → fallback", max_retries + 1)
            return LLMResult.fallback("rate_limited")
        except LLMProviderError as e:
            # Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
            # «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
            reason = "timeout" if isinstance(e, LLMTimeoutError) else "provider_error"
            logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
            return LLMResult.fallback(reason)

        # Успех — лог-хук оценки стоимости (tokens→$), без содержимого ответа.
        cost = _estimate_cost_usd(resp.prompt_tokens, resp.completion_tokens)
        logger.info(
            "llm: ok model=%s prompt_tokens=%d completion_tokens=%d est_cost_usd=%.5f",
            resp.model,
            resp.prompt_tokens,
            resp.completion_tokens,
            cost,
        )
        if settings.llm_daily_cost_cap_usd is not None:
            # Сейчас только наблюдаемость: логируем приближение к потолку. Жёсткий
            # enforcement (агрегация по дню) добавит консьюмер при необходимости.
            logger.debug(
                "llm: per-call est_cost_usd=%.5f (daily cap configured=%.2f)",
                cost,
                settings.llm_daily_cost_cap_usd,
            )
        return LLMResult.from_provider(resp)

    # Недостижимо (цикл всегда return'ит), но mypy/читатель спокойнее с явным fallback.
    return LLMResult.fallback(last_reason)


def complete_or_raise(
    *,
    system_prompt: str,
    payload: SafePayload,
    tools: list[dict[str, Any]] | None = None,
    provider: LLMProvider | None = None,
    call_index: int = 0,
) -> LLMResult:
    """Как ``complete``, но при недоступности LLM бросает ``LLMUnavailableError``.

    Для вызывающих, которым удобнее try/except, чем проверка ``LLMResult.ok``. Семантика
    fallback та же — просто упакована в исключение (вызывающий ловит и подставляет детерм.).
    """
    result = complete(
        system_prompt=system_prompt,
        payload=payload,
        tools=tools,
        provider=provider,
        call_index=call_index,
    )
    if not result.ok:
        raise LLMUnavailableError(result.reason or "llm_unavailable")
    return result


__all__ = ["LLMResult", "LLMUnavailableError", "complete", "complete_or_raise"]
