"""LLM tool-loop + RU-проза-композиция чата по §22-форсайту (#957, Step 2).

Поверх ДЕТЕРМИНИРОВАННОГО Step 1 (intents.render_answer) — ОПЦИОНАЛЬНЫЙ LLM-слой.
Архитектурный закон проекта: форсайт-движок детерминирован, LLM ничего не «ломает» —
при ЛЮБОМ сбое/выключенности возвращается детерминированный ответ Step 1.

ПОТОК (ручной tool-call loop — ``complete`` это pass-through, НЕ агент):
  1. ``complete(chat_system, payload(question, {}), tools=<5 секционных спек>, call_index=0)``.
  2. Если модель попросила tool'ы → выполняем секционные срезы против УЖЕ-ЗАГРУЖЕННОГО
     report_dict (tools.execute_tool — НИКАКОЙ БД/recompute/engine-math), копим
     section_data, пересобираем payload и зовём ``complete(..., call_index+1)``.
  3. Повтор пока модель не вернёт финальную прозу (нет tool_calls) ЛИБО пока call_index
     не упрётся в settings.llm_max_calls_per_request → детерминированный fallback.

GROUNDING (§16): числа живут в section_data (= срезы отчёта); LLM лишь оборачивает их
в RU-прозу. system-prompt запрещает выдумывать числа (см. prompts.chat_system).
grounded_in.sections — РЕАЛЬНО запрошенные моделью секции (честный provenance).

FALLBACK: любой ``LLMResult.ok is False`` (disabled/timeout/rate_limited/redaction_refused/
call_cap/no_api_key/provider_error) → Step-1 ``render_answer`` с llm_used=False +
fallback_reason. Пустой финальный ответ модели → тоже детерминированный fallback
(empty_response): пустую прозу клиенту не отдаём.
"""

from __future__ import annotations

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

from sqlalchemy.orm import Session

from app.core.config import settings
from app.schemas.chat import ChatTurn
from app.services.chat import intents, tools
from app.services.chat.safe_payload import build_chat_payload
from app.services.llm import LLMResult, complete, render
from app.services.llm.provider import LLMProvider, ToolCall

logger = logging.getLogger(__name__)

# Имя системного промпта чата (versioned-шаблон в llm/prompts.py).
_CHAT_SYSTEM_PROMPT = "chat_system"


@dataclass(frozen=True, slots=True)
class ChatResult:
    """Итог оркестрации одного хода чата (LLM-проза ИЛИ детерминированный fallback).

    Attributes:
        answer: финальный RU-текст (LLM-проза при llm_used; иначе шаблонный Step-1).
        sections: секции отчёта, легшие в основу (provenance grounded_in.sections):
            реально запрошенные tool'ами при llm_used; render_answer-секции при fallback.
        llm_used: True только при успешной LLM-композиции.
        fallback_reason: машиночитаемая причина деградации (None при llm_used).
    """

    answer: str
    sections: list[str] = field(default_factory=list)
    llm_used: bool = False
    fallback_reason: str | None = None


def _deterministic(report_dict: dict[str, Any], message: str, reason: str) -> ChatResult:
    """Детерминированный Step-1 ответ (FALLBACK). Числа ВЕРБАТИМ из отчёта, без LLM."""
    intent = intents.route_intent(message, None)
    answer, sections = intents.render_answer(intent, report_dict)
    return ChatResult(
        answer=answer,
        sections=sections,
        llm_used=False,
        fallback_reason=reason,
    )


def _run_tool_calls(
    tool_calls: list[ToolCall],
    report_dict: dict[str, Any],
    section_data: dict[str, Any],
    called_sections: list[str],
) -> None:
    """Выполнить запрошенные секционные tool'ы против report_dict, дополнив аккумуляторы.

    PURE-срезы (tools.execute_tool): НИКАКОЙ БД/recompute/engine-math. Имя tool'а →
    под-dict в section_data (для следующего payload); реально затронутые секции отчёта →
    called_sections (provenance, без дублей). Неизвестное имя tool'а игнорируем безопасно
    (execute_tool вернёт маркер «недоступно», sections_for_tool вернёт ()).
    """
    for tc in tool_calls:
        # Аргументы tool'ов наши спеки не определяют (секция фиксирована) — tc.arguments
        # игнорируем намеренно: модель не управляет вычислениями, только выбирает секцию.
        section_data[tc.name] = tools.execute_tool(tc.name, report_dict)
        for sect in tools.sections_for_tool(tc.name):
            if sect not in called_sections:
                called_sections.append(sect)


def orchestrate_chat(
    db: Session | None,
    cad_num: str,
    message: str,
    history: list[ChatTurn] | None,
    report_dict: dict[str, Any],
    run_id: int,
    *,
    provider: LLMProvider | None = None,
) -> ChatResult:
    """Прогнать один ход чата через LLM tool-loop; при любом сбое — детерм. Step-1 fallback.

    Args:
        db: сессия БД (НЕ используется для пере-расчёта — отчёт уже загружен; принимается
            для единообразия сигнатуры и возможного будущего read-only обогащения. None
            допустим — оркестратор в БД не ходит).
        cad_num: кадастровый номер (для логов/контекста).
        message: вопрос пользователя (RU).
        history: история диалога (Step 2: пока в payload НЕ форвардится — узкий аллоулист
            §19; расширение — отдельным решением).
        report_dict: УЖЕ-ЗАГРУЖЕННЫЙ ``SiteFinderReport.as_dict()`` (8 секций). Источник
            ВСЕХ чисел — срезы этого dict'а; движковая математика НЕ повторяется.
        run_id: id рана-источника (для логов; grounded_in.run_id ставит эндпоинт).
        provider: внедрить LLMProvider (тесты/RU-hosted). None → собирается из settings
            внутри ``complete`` (в проде).

    Returns:
        ``ChatResult`` — LLM-проза (llm_used=True) или детерминированный Step-1 fallback.
    """
    del db, history  # см. docstring: отчёт уже загружен; history пока не форвардим.
    system_prompt = render(_CHAT_SYSTEM_PROMPT)

    # Аккумуляторы между итерациями loop'а.
    section_data: dict[str, Any] = {}  # имя tool'а → срез секции (идёт в payload)
    called_sections: list[str] = []  # реально затронутые секции отчёта (provenance)
    specs = tools.tool_specs()

    # call_index растёт с каждой итерацией; call-cap дублирует guard внутри complete,
    # но держим явный предел и здесь, чтобы loop гарантированно завершался.
    call_index = 0
    max_calls = settings.llm_max_calls_per_request
    while call_index < max_calls:
        payload = build_chat_payload(message, section_data)
        result: LLMResult = complete(
            system_prompt=system_prompt,
            payload=payload,
            tools=specs,
            provider=provider,
            call_index=call_index,
        )

        if not result.ok:
            # disabled / timeout / rate_limited / redaction_refused / call_cap /
            # no_api_key / provider_error → детерминированный Step-1 ответ.
            logger.info(
                "chat: LLM unavailable (reason=%s) for cad=%s run=%s → deterministic fallback",
                result.reason,
                cad_num,
                run_id,
            )
            return _deterministic(report_dict, message, result.reason or "llm_unavailable")

        if result.tool_calls:
            # Модель запросила секции — выполняем срезы и идём на следующий виток с
            # накопленным контекстом (числа из отчёта, не из LLM).
            _run_tool_calls(result.tool_calls, report_dict, section_data, called_sections)
            call_index += 1
            continue

        # Нет tool_calls → финальная проза.
        # Проверяем finish_reason: 'length' / 'content_filter' означают обрезанный
        # или отфильтрованный ответ — неполная проза клиенту не отдаётся (LLMResult
        # docstring §64: «консьюмер ОБЯЗАН проверять даже при ok=True»).
        if result.finish_reason not in (None, "stop", "end_turn", "tool_calls"):
            logger.warning(
                "chat: non-stop finish_reason=%r for cad=%s run=%s → deterministic fallback",
                result.finish_reason,
                cad_num,
                run_id,
            )
            return _deterministic(report_dict, message, f"finish_reason:{result.finish_reason}")

        answer = (result.content or "").strip()
        if not answer:
            # Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.
            logger.info(
                "chat: empty LLM content for cad=%s run=%s → deterministic fallback",
                cad_num,
                run_id,
            )
            return _deterministic(report_dict, message, "empty_response")

        logger.info(
            "chat: LLM-composed answer for cad=%s run=%s (sections=%s, calls=%d)",
            cad_num,
            run_id,
            called_sections,
            call_index,
        )
        return ChatResult(
            answer=answer,
            sections=called_sections,
            llm_used=True,
            fallback_reason=None,
        )

    # Loop исчерпал бюджет вызовов (модель всё просит tool'ы) → детерминированный fallback.
    logger.info(
        "chat: tool-loop hit call cap (%d) for cad=%s run=%s → deterministic fallback",
        max_calls,
        cad_num,
        run_id,
    )
    return _deterministic(report_dict, message, "call_cap")


__all__ = ["ChatResult", "orchestrate_chat"]
