"""Тесты premises_lookup (#96 MVP on-demand per-building помещения/паркинг).

Сетевые вызовы НЕ делаем — мокаем sync NSPDClient.search_by_cad (резолв objdoc)
и async NSPDBulkClient.list_objects_in_building (tab-group counts). Проверяем
happy path + graceful-фоллбэки (здание не найдено / нет objdoc / WAF).
"""

from __future__ import annotations

import contextlib
from typing import Any
from unittest.mock import MagicMock, patch

from app.schemas.nspd_bulk import ObjectsListing
from app.services.scrapers.nspd_client import NspdLiteWafError
from app.services.site_finder.premises_lookup import resolve_cad_for_domrf

MODULE = "app.services.site_finder.premises_lookup"


def _run_returns(listing: ObjectsListing | None) -> Any:
    """side_effect для мока asyncio.run: закрывает переданную корутину и отдаёт listing.

    Без close() реальная корутина _fetch_listing(...) остаётся never-awaited
    (RuntimeWarning), т.к. мок asyncio.run её не исполняет. Закрываем явно.
    """

    def _side_effect(coro: Any) -> ObjectsListing | None:
        coro.close()
        return listing

    return _side_effect


def _search_result(options: dict[str, Any] | None, feature_id: str | None = "999") -> MagicMock:
    """Замокать NSPDSearchResult.first → NSPDFeature-подобный объект."""
    result = MagicMock()
    if options is None and feature_id is None:
        result.first = None
        return result
    feature = MagicMock()
    feature.properties = {"options": options or {}}
    feature.feature_id = feature_id
    result.first = feature
    return result


def test_get_building_premises_happy_path() -> None:
    """search → objdocId → list_objects → ObjectsListing с parking_ratio."""
    from app.services.site_finder.premises_lookup import get_building_premises

    listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
    ):
        mock_client_cls.return_value.search_by_cad.return_value = _search_result(
            {"objdocId": 40995027}
        )
        out = get_building_premises("66:41:0106036:183")

    assert out is listing
    assert out.parking_ratio == 0.5
    mock_run.assert_called_once()


def test_get_building_premises_snake_case_objdoc() -> None:
    """objdoc_id в snake_case тоже резолвится (bulk-shape)."""
    from app.services.site_finder.premises_lookup import get_building_premises

    listing = ObjectsListing(objdoc_id=777, flats_count=10, parking_count=2)

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
    ):
        mock_client_cls.return_value.search_by_cad.return_value = _search_result({"objdoc_id": 777})
        out = get_building_premises("66:41:0106036:183")

    assert out is listing


def test_get_building_premises_fallback_to_feature_id() -> None:
    """Нет objdocId в options → fallback на feature.id (verified live: id=objdoc)."""
    from app.services.site_finder.premises_lookup import get_building_premises

    listing = ObjectsListing(objdoc_id=12345, flats_count=5, parking_count=0)

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
    ):
        mock_client_cls.return_value.search_by_cad.return_value = _search_result(
            {}, feature_id="12345"
        )
        out = get_building_premises("66:41:0106036:183")

    assert out is listing
    # objdoc_id=12345 должен был уйти в _fetch_listing
    mock_run.assert_called_once()


def test_get_building_premises_not_found_returns_none() -> None:
    """Здание не найдено в НСПД (result.first=None) → None, async не дёргается."""
    from app.services.site_finder.premises_lookup import get_building_premises

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run") as mock_run,
    ):
        mock_client_cls.return_value.search_by_cad.return_value = _search_result(
            None, feature_id=None
        )
        out = get_building_premises("66:41:0106036:183")

    assert out is None
    mock_run.assert_not_called()


def test_get_building_premises_no_objdoc_returns_none() -> None:
    """Нет ни objdocId, ни feature.id → None (нельзя дёрнуть tab-group)."""
    from app.services.site_finder.premises_lookup import get_building_premises

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run") as mock_run,
    ):
        mock_client_cls.return_value.search_by_cad.return_value = _search_result(
            {}, feature_id=None
        )
        out = get_building_premises("66:41:0106036:183")

    assert out is None
    mock_run.assert_not_called()


def test_get_building_premises_waf_returns_none() -> None:
    """WAF/сеть на search_by_cad → None (graceful, analyze не падает)."""
    from app.services.site_finder.premises_lookup import get_building_premises

    with (
        patch(f"{MODULE}.NSPDClient") as mock_client_cls,
        patch(f"{MODULE}.asyncio.run") as mock_run,
    ):
        mock_client_cls.return_value.search_by_cad.side_effect = NspdLiteWafError("HTTP 403 WAF")
        out = get_building_premises("66:41:0106036:183")

    assert out is None
    mock_run.assert_not_called()


# ── #2464 cluster A: resolve_cad_for_domrf SAVEPOINT-регрессия ──────────────────
# db.execute внутри resolve_cad_for_domrf раньше глотал сбой БЕЗ SAVEPOINT/rollback —
# на реальном Postgres это отравляет shared session (lazy-эндпоинт может дёргать
# resolve_cad_for_domrf на несколько конкурентов подряд, ОДНА и та же db-сессия):
# следующий db.execute падал "current transaction is aborted". Фикс: db.execute
# обёрнут в db.begin_nested().


class _ResolveCadFakeDB:
    """Фейковая сессия: execute().mappings().first() + begin_nested()."""

    def __init__(self, row: dict[str, Any] | Exception | None) -> None:
        self._row = row
        self.begin_nested_calls = 0

    @contextlib.contextmanager
    def begin_nested(self) -> Any:
        """Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
        self.begin_nested_calls += 1
        yield

    def execute(self, sql: Any, params: dict[str, Any]) -> Any:
        if isinstance(self._row, Exception):
            raise self._row
        row = self._row

        class _Result:
            def mappings(self) -> _Result:
                return self

            def first(self) -> dict[str, Any] | None:
                return row

        return _Result()


def test_resolve_cad_for_domrf_db_failure_returns_none() -> None:
    """DB-сбой → None (документированный fallback), analyze не падает."""
    db = _ResolveCadFakeDB(RuntimeError("connection refused"))
    assert resolve_cad_for_domrf(db, lat=56.8, lon=60.6) is None  # type: ignore[arg-type]


def test_resolve_cad_for_domrf_uses_savepoint_and_session_reusable_after_failure() -> None:
    """begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
    вызов resolve_cad_for_domrf на ТОЙ ЖЕ db-сессии успевает."""
    db = _ResolveCadFakeDB(RuntimeError("boom"))
    out = resolve_cad_for_domrf(db, lat=56.8, lon=60.6)  # type: ignore[arg-type]
    assert out is None
    assert db.begin_nested_calls == 1

    db._row = {"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 12.5}
    out2 = resolve_cad_for_domrf(db, lat=56.8, lon=60.6)  # type: ignore[arg-type]
    assert out2 is not None
    assert out2.cad_num == "66:41:0106036:183"
    assert db.begin_nested_calls == 2
