"""Тесты LLM tool-loop оркестратора чата (#957, Step 2 + §19 redaction-граница).

Сеть НЕ дёргается: всегда внедряем FAKE LLMProvider через complete(provider=...).
Покрываем:
  • tool-call → execute slicer → compose: llm_used=True, grounded_in.sections верны.
  • каждый LLMResult.ok=False reason (disabled/timeout/rate_limited/redaction_refused/
    call_cap/no_api_key/provider_error) → детерминированный Step-1 fallback, llm_used=False.
  • §19 redaction-граница: исходящий SafePayload.fields = ТОЛЬКО агрегаты секций,
    НИКОГДА не сырой analyze-blob / insight-текст; is_confidential=True → fallback,
    ZERO provider-вызовов.
  • call-cap (модель всё просит tool'ы) → fallback; loop завершается.
  • числа в section_data приходят ИЗ отчёта (fake echoes section_data; числа совпадают
    с отчётом, чат-код ничего не выдумывает).
"""

from __future__ import annotations

import json
from typing import Any

import pytest

from app.core.config import settings
from app.services.chat.orchestrator import ChatResult, orchestrate_chat
from app.services.llm.provider import (
    LLMProvider,
    LLMProviderError,
    LLMRateLimitedError,
    LLMTimeoutError,
    ProviderResponse,
    ToolCall,
)

_CAD = "66:41:0204016:10"
_RUN_ID = 42
_NBSP = " "  # неразрывный пробел — разделитель тысяч (зеркало intents._NBSP)


def _report() -> dict[str, Any]:
    """Отчёт (форма SiteFinderReport.as_dict()) с узнаваемыми числами."""
    return {
        "schema_version": "1.0",
        "advisory": True,
        "exec_summary": {
            "headline": "Комфорт-класс, 2-3-комнатные",
            "verdict": "Участок подходит под комфорт-класс.",
            "key_numbers": {"цена_руб_м2": 250000},
            "overall_confidence": "medium",
        },
        "future_market": {
            "forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
            "future_supply": {"pressure": 0.4},
            "summary": "Ожидается дефицит на горизонте 12 мес.",
        },
        "product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]},
        "scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
        "confidence": {"level": "medium", "rationale": "Прогноз спроса — прокси."},
        "scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
    }


# ── Fake-провайдеры (внешние; внедряются через provider=...) ──────────────────────


class _ScriptedProvider(LLMProvider):
    """Внешний fake: отдаёт заранее заданную последовательность ProviderResponse.

    Каждый вызов complete() возвращает следующий элемент скрипта и фиксирует
    messages/tools (для проверки исходящей нагрузки = §19-граница).
    """

    def __init__(self, script: list[ProviderResponse]) -> None:
        self._script = script
        self.calls = 0
        self.captured_messages: list[list[dict[str, Any]]] = []
        self.captured_tools: list[list[dict[str, Any]] | None] = []

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

    @property
    def model(self) -> str:
        return "gpt-4o-mini"

    def complete(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]] | None = None,
        max_output_tokens: int,
    ) -> ProviderResponse:
        self.captured_messages.append(messages)
        self.captured_tools.append(tools)
        resp = self._script[min(self.calls, len(self._script) - 1)]
        self.calls += 1
        return resp


class _EchoProvider(LLMProvider):
    """Внешний fake: на 1-м вызове просит указанные tool'ы, на 2-м ЭХОм возвращает
    полученный user-контент (section_data) как прозу. Доказывает, что числа в ответе
    приходят ИЗ отчёта (через section_data), а не выдумываются чат-кодом.
    """

    def __init__(self, tool_names: list[str]) -> None:
        self._tool_names = tool_names
        self.calls = 0
        self.captured_messages: list[list[dict[str, Any]]] = []

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

    @property
    def model(self) -> str:
        return "gpt-4o-mini"

    def complete(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]] | None = None,
        max_output_tokens: int,
    ) -> ProviderResponse:
        self.captured_messages.append(messages)
        self.calls += 1
        if self.calls == 1:
            calls = [
                ToolCall(id=f"c{i}", name=name, arguments="{}")
                for i, name in enumerate(self._tool_names)
            ]
            return ProviderResponse(content=None, tool_calls=calls, model="gpt-4o-mini")
        # 2-й вызов: эхо user-сообщения (последнее) — там лежит section_data.
        user_msg = messages[-1]["content"]
        return ProviderResponse(content=f"ИТОГ: {user_msg}", model="gpt-4o-mini")


class _RaisingProvider(LLMProvider):
    """Внешний fake, всегда бросающий заданное исключение (guardrail-reasons)."""

    def __init__(self, exc: Exception) -> None:
        self._exc = exc
        self.calls = 0

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

    @property
    def model(self) -> str:
        return "gpt-4o-mini"

    def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
        self.calls += 1
        raise self._exc


@pytest.fixture
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
    """Включить LLM с fake-ключом (без реальных секретов)."""
    monkeypatch.setattr(settings, "llm_enabled", True)
    monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
    # Не спим на ретраях (rate-limit reason).
    monkeypatch.setattr("app.services.llm.client.time.sleep", lambda s: None)


def _run(provider: LLMProvider, message: str = "Что здесь строить?") -> ChatResult:
    return orchestrate_chat(
        db=None,  # оркестратор отчёт уже получил; БД не трогает (del db)
        cad_num=_CAD,
        message=message,
        history=None,
        report_dict=_report(),
        run_id=_RUN_ID,
        provider=provider,
    )


# ── Happy path: tool-call → slice → compose ──────────────────────────────────────


def test_tool_call_then_compose_sets_llm_used_and_sections(_enabled: None) -> None:
    """Модель просит exec_summary → slice → проза. llm_used=True, sections верны."""
    prov = _ScriptedProvider(
        [
            ProviderResponse(
                content=None,
                tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")],
                model="gpt-4o-mini",
            ),
            ProviderResponse(content="Рекомендуется комфорт-класс.", model="gpt-4o-mini"),
        ]
    )
    res = _run(prov)
    assert res.llm_used is True
    assert res.fallback_reason is None
    assert res.answer == "Рекомендуется комфорт-класс."
    assert res.sections == ["exec_summary"]
    assert prov.calls == 2


def test_multiple_tools_provenance_dedup(_enabled: None) -> None:
    """Несколько tool'ов (вкл. get_risks=2 секции) → provenance объединяет без дублей."""
    prov = _ScriptedProvider(
        [
            ProviderResponse(
                content=None,
                tool_calls=[
                    ToolCall(id="c1", name="get_exec_summary", arguments="{}"),
                    ToolCall(id="c2", name="get_risks", arguments="{}"),
                ],
                model="gpt-4o-mini",
            ),
            ProviderResponse(content="Готовый ответ.", model="gpt-4o-mini"),
        ]
    )
    res = _run(prov)
    assert res.llm_used is True
    # get_exec_summary→exec_summary; get_risks→scoring,confidence. Порядок сохранён.
    assert res.sections == ["exec_summary", "scoring", "confidence"]


def test_first_call_no_tools_returns_prose_immediately(_enabled: None) -> None:
    """Модель сразу вернула прозу (без tool'ов) → llm_used=True, sections пусты."""
    prov = _ScriptedProvider([ProviderResponse(content="Краткий ответ.", model="gpt-4o-mini")])
    res = _run(prov)
    assert res.llm_used is True
    assert res.answer == "Краткий ответ."
    assert res.sections == []
    assert prov.calls == 1


def test_numbers_come_from_report_not_invented(_enabled: None) -> None:
    """Fake ЭХОм возвращает section_data → числа в ответе РОВНО из отчёта (вербатим)."""
    prov = _EchoProvider(["get_exec_summary", "get_forecast"])
    res = _run(prov)
    assert res.llm_used is True
    # 250000 и 0.4 — числа отчёта; они в ответе, потому что пришли через section_data.
    assert "250000" in res.answer
    assert "0.4" in res.answer
    # Секции запрошены реально.
    assert res.sections == ["exec_summary", "future_market"]


# ── §19 redaction-граница: что может/НЕ может попасть в исходящий payload ──────────


def test_outbound_payload_contains_only_section_aggregates(_enabled: None) -> None:
    """ГЛАВНОЕ §19: после tool-call в исходящих fields ТОЛЬКО срезы секций — ни сырого
    analyze-blob, ни insight-текста. Числа секций — да; чужих ключей — нет.
    """
    prov = _ScriptedProvider(
        [
            ProviderResponse(
                content=None,
                tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")],
                model="gpt-4o-mini",
            ),
            ProviderResponse(content="ок", model="gpt-4o-mini"),
        ]
    )
    _run(prov)
    # 2-й исходящий запрос несёт section_data в user-сообщении.
    second_user = prov.captured_messages[1][-1]["content"]
    # Аллоулист: ключ tool'а + содержимое секции присутствуют.
    assert "get_exec_summary" in second_user
    assert "Комфорт-класс" in second_user  # агрегат секции
    # А вот «сырых» источников там быть НЕ должно (их и нет в section_data).
    assert "analyze" not in second_user
    assert "rosreestr" not in second_user
    assert "insight" not in second_user


def test_raw_report_keys_outside_tools_never_leak(_enabled: None) -> None:
    """Секции отчёта, которые модель НЕ запрашивала (market_now), НЕ уходят наружу."""
    report = _report()
    # Подложим «сырой» секрет в секцию, которую tool'ы НЕ режут (нет get_market_now).
    report["market_now"] = {"secret_raw_deals": "+7 912 345 67 89 Иванов Иван Иванович"}

    prov = _ScriptedProvider(
        [
            ProviderResponse(
                content=None,
                tool_calls=[ToolCall(id="c1", name="get_forecast", arguments="{}")],
                model="gpt-4o-mini",
            ),
            ProviderResponse(content="ок", model="gpt-4o-mini"),
        ]
    )
    orchestrate_chat(
        db=None,
        cad_num=_CAD,
        message="прогноз",
        history=None,
        report_dict=report,
        run_id=_RUN_ID,
        provider=prov,
    )
    blob = json.dumps(prov.captured_messages, ensure_ascii=False)
    # market_now не запрашивался → его сырьё нигде в исходящих messages.
    assert "secret_raw_deals" not in blob
    assert "market_now" not in blob


def test_confidential_payload_blocks_with_zero_provider_calls(
    _enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
    """is_confidential=True → RedactionRefusedError в complete → fallback, 0 вызовов
    провайдера. Эмулируем confidential, заставив build_chat_payload помечать нагрузку.
    """
    import app.services.chat.orchestrator as orch
    from app.services.llm import SafePayload

    def _confidential_payload(question: str, section_data: dict[str, Any], **_: Any) -> SafePayload:
        return SafePayload(text=question, fields=dict(section_data), is_confidential=True)

    monkeypatch.setattr(orch, "build_chat_payload", _confidential_payload)
    prov = _ScriptedProvider([ProviderResponse(content="не должно дойти", model="gpt-4o-mini")])
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "redaction_refused"
    # Hard-block ДО провайдера: ни одного вызова.
    assert prov.calls == 0
    # Ответ — детерминированный Step-1 (содержит advisory-оговорку).
    assert "советующий характер" in res.answer


# ── LLMResult.ok=False reasons → детерминированный Step-1 fallback ────────────────


def test_disabled_falls_back_without_provider(monkeypatch: pytest.MonkeyPatch) -> None:
    """llm_enabled=False → reason='disabled', провайдер не вызывается."""
    monkeypatch.setattr(settings, "llm_enabled", False)
    prov = _ScriptedProvider([ProviderResponse(content="x", model="gpt-4o-mini")])
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "disabled"
    assert prov.calls == 0
    assert "советующий характер" in res.answer


def test_timeout_falls_back(_enabled: None) -> None:
    prov = _RaisingProvider(LLMTimeoutError("slow"))
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "timeout"


def test_rate_limited_falls_back(_enabled: None) -> None:
    prov = _RaisingProvider(LLMRateLimitedError("429", status_code=429, retry_after=None))
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "rate_limited"


def test_provider_error_falls_back(_enabled: None) -> None:
    prov = _RaisingProvider(LLMProviderError("boom"))
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "provider_error"


def test_no_api_key_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
    """llm_enabled=True но ключ None и provider=None → reason='no_api_key' fallback."""
    monkeypatch.setattr(settings, "llm_enabled", True)
    monkeypatch.setattr(settings, "openai_api_key", None)
    res = orchestrate_chat(
        db=None,
        cad_num=_CAD,
        message="резюме",
        history=None,
        report_dict=_report(),
        run_id=_RUN_ID,
        provider=None,
    )
    assert res.llm_used is False
    assert res.fallback_reason == "no_api_key"


def test_fallback_answer_is_grounded_step1(_enabled: None) -> None:
    """Fallback-ответ — реальный Step-1 рендер: для summary несёт число отчёта."""
    prov = _RaisingProvider(LLMTimeoutError("slow"))
    res = _run(prov, message="дай резюме по участку")
    assert res.llm_used is False
    # Step-1 summary тянет exec_summary с числом 250000 (формат NBSP-тысяч).
    assert res.sections == ["exec_summary"]
    assert f"250{_NBSP}000" in res.answer


# ── Call-cap → fallback; loop завершается ────────────────────────────────────────


def test_call_cap_terminates_loop_and_falls_back(
    _enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Модель ВСЕГДА просит tool'ы → loop упирается в call-cap → детерм. fallback."""
    monkeypatch.setattr(settings, "llm_max_calls_per_request", 3)
    # Каждый ответ — снова tool_call (никогда не финальная проза).
    always_tool = ProviderResponse(
        content=None,
        tool_calls=[ToolCall(id="c", name="get_exec_summary", arguments="{}")],
        model="gpt-4o-mini",
    )
    prov = _ScriptedProvider([always_tool])
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "call_cap"
    # Loop завершился: не более max_calls обращений к провайдеру.
    assert prov.calls <= 3


def test_empty_final_content_falls_back(_enabled: None) -> None:
    """Модель вернула пустую прозу → не отдаём пустоту, детерм. fallback."""
    prov = _ScriptedProvider([ProviderResponse(content="   ", model="gpt-4o-mini")])
    res = _run(prov)
    assert res.llm_used is False
    assert res.fallback_reason == "empty_response"
