"""Тесты эндпоинта чата `POST /api/v1/chat/ask` (#957, Step 1, ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM).

Покрывает:
  • Каждый intent отдаёт корректный шаблонный RU-ответ для фикстуры-отчёта.
  • Нет рана → report_status='pending' + RU-подсказка «запустите анализ» (READ-ONLY).
  • unknown-вопрос → меню тем (fallback_reason='intent_unknown').
  • llm_used всегда False; grounded_in несёт run_id/schema_version/sections.
  • 401 без X-Authenticated-User (mini-RBAC-app, как test_rbac.py — реальный app в
    тестах бьётся мимо Caddy с settings.testing=True, поэтому RBAC проверяем на копии
    middleware).

Стратегия: DB — через app.dependency_overrides[get_db] (MagicMock-сессия); сам отчёт
контролируем патчем latest_run_for (никакой прод-БД).
"""

from __future__ import annotations

import re
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.testclient import TestClient

from app.api.v1 import chat as chat_router
from app.core import auth as auth_mod
from app.core.config import settings
from app.core.db import get_db
from app.main import app
from app.services.llm.provider import LLMProvider, ProviderResponse, ToolCall

_CAD = "66:41:0204016:10"
# Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py).
_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",
        },
        "market_now": {"summary": "Рынок активен."},
        "future_market": {
            "forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
            "future_supply": {"pressure": 0.4},
            "summary": "Ожидается дефицит на горизонте 12 мес.",
        },
        "product_tz": {
            "obj_class": "комфорт",
            "mix": [{"fmt": "2k"}],
            "reasons": [{"why": "дефицит формата"}],
            "summary": "Рекомендуется комфорт-класс.",
        },
        "scenarios": {
            "by_scenario": {"conservative": {}, "base": {}, "aggressive": {}},
            "summary": "Разброс умеренный.",
        },
        "scoring": {
            "special_indices": {"indices": {"cannibalization": {"value": 0.31, "label": None}}},
        },
        "confidence": {
            "level": "medium",
            "rationale": "Прогноз спроса — прокси.",
            "factors": {"deals": "ok"},
        },
        "meta": {"cad_num": _CAD, "schema_version": "1.0"},
    }


def _make_run(report: dict[str, Any], run_id: int = 42) -> MagicMock:
    """Замокать Row из latest_run_for (.result — отчёт, .id — run_id)."""
    run = MagicMock()
    run.result = report
    run.id = run_id
    return run


def _client_with_db() -> TestClient:
    """TestClient с override get_db на MagicMock-сессию (реальная БД не трогается)."""
    app.dependency_overrides[get_db] = lambda: MagicMock()
    return TestClient(app)


# ── Happy-path: intents возвращают шаблонный ответ ───────────────────────────────


@pytest.mark.parametrize(
    ("message", "expected_substrings", "expected_sections"),
    [
        ("Дай резюме по участку", ["Комфорт-класс", f"250{_NBSP}000"], ["exec_summary"]),
        ("Что здесь строить?", ["комфорт"], ["exec_summary", "product_tz"]),
        ("Почему такой прогноз?", ["горизонт", "прокси"], ["future_market"]),
        ("Какие риски?", ["каннибализация портфеля", "0,31"], ["scoring"]),
        ("Покажи сценарии", ["консервативный", "базовый"], ["scenarios"]),
    ],
)
def test_ask_intent_returns_templated_answer(
    message: str,
    expected_substrings: list[str],
    expected_sections: list[str],
) -> None:
    """Каждый intent → корректный RU-ответ; числа из отчёта; grounded_in заполнен."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": message},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "ready"
    assert body["llm_used"] is False
    assert body["advisory"] is True
    for sub in expected_substrings:
        assert sub in body["answer"], f"{sub!r} not in answer: {body['answer']}"
    assert body["grounded_in"]["run_id"] == 42
    assert body["grounded_in"]["schema_version"] == "1.0"
    for sect in expected_sections:
        assert sect in body["grounded_in"]["sections"]


def test_ask_explicit_intent_is_respected() -> None:
    """Явный intent уважается поверх текста (риск-текст, но intent=summary)."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "какие риски?", "intent": "summary"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["grounded_in"]["sections"] == ["exec_summary"]


def test_ask_answer_always_has_advisory_caveat() -> None:
    """Каждый ответ заканчивается advisory-оговоркой."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "резюме"},
        )
    assert "советующий характер" in resp.json()["answer"]


def test_ask_history_accepted_but_unused() -> None:
    """history принимается (200), но в Step 1 не влияет на ответ (llm_used=False)."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={
                "cad_num": _CAD,
                "message": "резюме",
                "history": [{"role": "user", "content": "привет"}],
            },
        )
    assert resp.status_code == 200, resp.text
    assert resp.json()["llm_used"] is False


# ── unknown intent → меню ────────────────────────────────────────────────────────


def test_ask_unknown_intent_returns_menu() -> None:
    """Нераспознанный вопрос → меню тем + fallback_reason='intent_unknown'."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "какая погода в Москве?"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "ready"
    assert body["fallback_reason"] == "intent_unknown"
    assert body["grounded_in"]["sections"] == []
    assert "что здесь строить" in body["answer"].lower()


# ── pending: рана нет ────────────────────────────────────────────────────────────


def test_ask_no_run_returns_pending() -> None:
    """Рана нет → report_status='pending' + RU-подсказка «запустите анализ»."""
    client = _client_with_db()
    with patch("app.services.chat.retrieval.latest_run_for", return_value=None):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "pending"
    assert body["grounded_in"] is None
    assert body["llm_used"] is False
    assert body["fallback_reason"] == "report_pending"
    assert "Запустите анализ участка" in body["answer"]


def test_ask_report_read_bridged_through_threadpool(monkeypatch: pytest.MonkeyPatch) -> None:
    """#1245: синхронное чтение §22-отчёта мостится через run_in_threadpool (не в loop).

    `get_report_for_chat` — sync SQLAlchemy (тянет крупный JSONB) и НЕ должен блокировать
    event loop. Оборачиваем `run_in_threadpool` в шпион: проверяем, что чтение отчёта
    реально диспатчится через него (как и синхронный LLM `complete` в _answer_via_llm).
    """
    from app.services.chat import retrieval as retrieval_mod

    dispatched: list[str] = []
    real_run_in_threadpool = chat_router.run_in_threadpool

    async def _spy(func: Any, *args: Any, **kwargs: Any) -> Any:
        dispatched.append(getattr(func, "__name__", repr(func)))
        return await real_run_in_threadpool(func, *args, **kwargs)

    monkeypatch.setattr(chat_router, "run_in_threadpool", _spy)
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "резюме"},
        )
    assert resp.status_code == 200, resp.text
    # Синхронное чтение отчёта прошло через threadpool (имя sync-функции зафиксировано).
    assert retrieval_mod.get_report_for_chat.__name__ in dispatched


def test_ask_db_error_returns_pending_not_500() -> None:
    """Сбой БД на read-only → pending (не 500), клиент может повторить."""
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        side_effect=RuntimeError("db down"),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "pending"
    assert body["fallback_reason"] == "report_unavailable"


# ── Валидация запроса ────────────────────────────────────────────────────────────


def test_ask_missing_message_422() -> None:
    """message обязателен → 422."""
    client = _client_with_db()
    resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD})
    assert resp.status_code == 422


def test_ask_empty_cad_num_422() -> None:
    """Пустой cad_num → 422 (min_length=1)."""
    client = _client_with_db()
    resp = client.post("/api/v1/chat/ask", json={"cad_num": "", "message": "резюме"})
    assert resp.status_code == 422


# ── RBAC: 401 без X-Authenticated-User ───────────────────────────────────────────
# Реальный app в тестах бьётся мимо Caddy с settings.testing=True → rbac_guard
# пропускает. RBAC проверяем на КОПИИ middleware (как tests/test_rbac.py), смонтировав
# тот же chat-роутер под /api/v1/chat.

_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})


def _build_rbac_test_app() -> FastAPI:
    """FastAPI с копией rbac_guard из app/main.py + смонтированным chat-роутером."""
    rbac_app = FastAPI()

    @rbac_app.middleware("http")
    async def rbac_guard(  # type: ignore[unused-ignore]
        request: Request,
        call_next: Callable[[Request], Awaitable[Response]],
    ) -> Response:
        path = request.url.path
        if path in _PUBLIC_PATHS:
            return await call_next(request)
        username = request.headers.get("X-Authenticated-User")
        if not username:
            return JSONResponse(
                status_code=401,
                content={"detail": "no authenticated user (Caddy basic_auth required)"},
            )
        try:
            role = auth_mod.get_role(username)
        except KeyError:
            return JSONResponse(
                status_code=403,
                content={"detail": "user not in roles config"},
            )
        if _ADMIN_API_RE.match(path) and role != "admin":
            return JSONResponse(status_code=403, content={"detail": "admin only"})
        return await call_next(request)

    rbac_app.include_router(chat_router.router, prefix="/api/v1/chat", tags=["chat"])
    rbac_app.dependency_overrides[get_db] = lambda: MagicMock()
    return rbac_app


def test_ask_no_auth_header_returns_401() -> None:
    """Без X-Authenticated-User → 401 (rbac_guard, чат смонтирован под /api/v1)."""
    client = TestClient(_build_rbac_test_app())
    resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"})
    assert resp.status_code == 401
    assert "no authenticated user" in resp.json()["detail"]


def test_ask_unknown_user_returns_403() -> None:
    """Юзер не в roles.yaml → 403 (rbac_guard)."""
    client = TestClient(_build_rbac_test_app())
    resp = client.post(
        "/api/v1/chat/ask",
        json={"cad_num": _CAD, "message": "резюме"},
        headers={"X-Authenticated-User": "nobody-unknown"},
    )
    assert resp.status_code == 403


def test_ask_known_user_passes_rbac() -> None:
    """Известный юзер (admin) проходит rbac_guard → доходит до хендлера (200)."""
    client = TestClient(_build_rbac_test_app())
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "резюме"},
            headers={"X-Authenticated-User": "admin"},
        )
    assert resp.status_code == 200, resp.text


# ── Step 2: ветка llm_enabled (LLM tool-loop через эндпоинт) ─────────────────────
# Сеть НЕ дёргается: подменяем _build_default_provider на fake (provider=None путь в
# complete), чтобы проверить именно бридж эндпоинта (run_in_threadpool → orchestrate).


class _FakeProvider(LLMProvider):
    """Внешний fake провайдера: 1-й вызов — tool-call, 2-й — финальная проза."""

    def __init__(self) -> None:
        self.calls = 0

    @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.calls += 1
        if self.calls == 1:
            return ProviderResponse(
                content=None,
                tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")],
                model="gpt-4o-mini",
            )
        return ProviderResponse(content="LLM-проза по участку.", model="gpt-4o-mini")


def test_ask_llm_enabled_uses_orchestrator(monkeypatch: pytest.MonkeyPatch) -> None:
    """llm_enabled=True → tool-loop: llm_used=True, ответ LLM, grounded_in.sections верны."""
    monkeypatch.setattr(settings, "llm_enabled", True)
    monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
    # ОДИН инстанс на запрос: его stateful .calls ведёт tool-call → проза между
    # витками loop'а (новый инстанс на каждый виток сбросил бы счётчик).
    fake = _FakeProvider()
    monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake)
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "что здесь строить?"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "ready"
    assert body["llm_used"] is True
    assert body["answer"] == "LLM-проза по участку."
    assert body["grounded_in"]["sections"] == ["exec_summary"]
    assert body["grounded_in"]["run_id"] == 42


def test_ask_llm_enabled_degrades_to_deterministic_on_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """llm_enabled=True, но провайдера нет (ключ None) → детерм. fallback, llm_used=False."""
    monkeypatch.setattr(settings, "llm_enabled", True)
    monkeypatch.setattr(settings, "openai_api_key", None)
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "дай резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["llm_used"] is False
    assert body["fallback_reason"] == "no_api_key"
    # Детерминированный Step-1 ответ с числом отчёта (вербатим).
    assert f"250{_NBSP}000" in body["answer"]


# ── parcel_context: паспорт участка вливается в report_dict и доступен tool'у ─────


class _ParcelInfoProvider(LLMProvider):
    """Fake: 1-й вызов — tool-call get_parcel_info, 2-й — финальная проза (grounding)."""

    def __init__(self) -> None:
        self.calls = 0
        self.captured_fields: 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.calls += 1
        if self.calls == 1:
            return ProviderResponse(
                content=None,
                tool_calls=[ToolCall(id="p1", name="get_parcel_info", arguments="{}")],
                model="gpt-4o-mini",
            )
        # Второй ход: срез паспорта участка уже в user-сообщении (fields) — фиксируем факт,
        # что зона ПЗЗ реально долетела до модели.
        self.captured_fields = {"seen": "\n".join(m.get("content") or "" for m in messages)}
        return ProviderResponse(content="Зона ПЗЗ участка: Ж-4.", model="gpt-4o-mini")


def test_ask_merges_parcel_context_and_tool_can_read_it(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """§22-отчёт есть → эндпоинт дотягивает parcel_context; get_parcel_info его отдаёт LLM."""
    monkeypatch.setattr(settings, "llm_enabled", True)
    monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
    fake = _ParcelInfoProvider()
    monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake)
    parcel_ctx = {"zone_code": "Ж-4", "zone_name": "Зона многоэтажной застройки"}
    client = _client_with_db()
    with (
        patch(
            "app.services.chat.retrieval.latest_run_for",
            return_value=_make_run(_report()),
        ),
        patch(
            "app.api.v1.chat.get_parcel_context_for_chat",
            return_value=parcel_ctx,
        ) as mock_ctx,
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "какая зона ПЗЗ?"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["llm_used"] is True
    assert body["answer"] == "Зона ПЗЗ участка: Ж-4."
    # provenance содержит parcel_context (tool get_parcel_info реально отработал).
    assert body["grounded_in"]["sections"] == ["parcel_context"]
    # Паспорт участка дотягивался ровно для этого cad_num.
    mock_ctx.assert_called_once()
    assert mock_ctx.call_args.args[1] == _CAD
    # Зона ПЗЗ реально попала в контекст, отправленный модели.
    assert "Ж-4" in fake.captured_fields["seen"]


def test_ask_no_parcel_context_falls_back_to_report_only(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Analyze-рана нет (parcel_context=None) → чат работает как раньше (только форсайт)."""
    monkeypatch.setattr(settings, "llm_enabled", False)
    client = _client_with_db()
    with (
        patch(
            "app.services.chat.retrieval.latest_run_for",
            return_value=_make_run(_report()),
        ),
        patch("app.api.v1.chat.get_parcel_context_for_chat", return_value=None),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "дай резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "ready"
    assert f"250{_NBSP}000" in body["answer"]


def test_ask_parcel_context_error_does_not_break_chat(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Сбой чтения паспорта участка → чат не падает, отдаёт отчёт (обогащение опционально)."""
    monkeypatch.setattr(settings, "llm_enabled", False)
    client = _client_with_db()
    with (
        patch(
            "app.services.chat.retrieval.latest_run_for",
            return_value=_make_run(_report()),
        ),
        patch(
            "app.api.v1.chat.get_parcel_context_for_chat",
            side_effect=RuntimeError("analyze read down"),
        ),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "дай резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["report_status"] == "ready"
    assert f"250{_NBSP}000" in body["answer"]


def test_ask_llm_disabled_no_provider_constructed(monkeypatch: pytest.MonkeyPatch) -> None:
    """llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire)."""
    monkeypatch.setattr(settings, "llm_enabled", False)

    def _tripwire() -> None:
        raise AssertionError("provider must NOT be built when llm_enabled=False")

    monkeypatch.setattr("app.services.llm.client._build_default_provider", _tripwire)
    client = _client_with_db()
    with patch(
        "app.services.chat.retrieval.latest_run_for",
        return_value=_make_run(_report()),
    ):
        resp = client.post(
            "/api/v1/chat/ask",
            json={"cad_num": _CAD, "message": "дай резюме"},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["llm_used"] is False
    assert f"250{_NBSP}000" in body["answer"]
