"""Тесты для custom POI CRUD + scoring integration (#254).

Покрывает:
1. POST /api/v1/custom-pois → 201, correct response, X-Session-Id в response header
2. GET  /api/v1/custom-pois → list для user (изоляция по user_id)
3. PATCH /api/v1/custom-pois/{id} → updated, 404 для чужой
4. DELETE /api/v1/custom-pois/{id} → 204, 404 для чужой
5. Validation: weight outside [-5,5] → 422; lon/lat outside range → 422
6. GET /api/v1/custom-pois?parcel_cad=... → filter works
7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score
8. No X-Session-Id → auto-generated UUID в response header
9. Scoring absent when no X-Session-Id header
10. Service-level: db.commit() вызывается в create/update/delete (#261 regression guard)

Стратегия mock: сервисные функции патчим через unittest.mock.patch,
DB — через dependency_overrides (аналогично test_analyze_inline_weights.py).
Service commit tests (#261): MagicMock db + assert db.commit.assert_called_once().
"""

from __future__ import annotations

import uuid
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient

from app.main import app
from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate

# ── Константы ─────────────────────────────────────────────────────────────────

_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
_SESSION = "test-session-abc123"
_TS = datetime(2026, 5, 17, 10, 0, 0, tzinfo=UTC)


# ── Helpers ────────────────────────────────────────────────────────────────────


def _make_poi_out(
    poi_id: int = 1,
    user_id: str = _SESSION,
    name: str = "Парк Маяковского",
    weight: float = 2.0,
    lon: float = 60.605,
    lat: float = 56.838,
    parcel_cad: str | None = None,
) -> CustomPoiOut:
    return CustomPoiOut(
        id=poi_id,
        user_id=user_id,
        parcel_cad=parcel_cad,
        name=name,
        category="park",
        weight=weight,
        lon=lon,
        lat=lat,
        notes=None,
        created_at=_TS,
        updated_at=_TS,
    )


def _make_mapping(data: dict[str, Any]) -> MagicMock:
    m = MagicMock()
    m.__getitem__ = lambda self, k: data[k]
    m.get = lambda k, default=None: data.get(k, default)
    return m


# ── Tests: CRUD ────────────────────────────────────────────────────────────────


def test_create_poi_returns_201() -> None:
    """POST /custom-pois → 201 + корректный тело + X-Session-Id в header."""
    expected = _make_poi_out()
    with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected) as mock_create:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/custom-pois",
            json={
                "name": "Парк Маяковского",
                "category": "park",
                "weight": 2.0,
                "lon": 60.605,
                "lat": 56.838,
            },
            headers={"X-Session-Id": _SESSION},
        )
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["name"] == "Парк Маяковского"
    assert body["weight"] == pytest.approx(2.0)
    assert body["id"] == 1
    assert resp.headers.get("x-session-id") == _SESSION
    mock_create.assert_called_once()


def test_create_poi_auto_generates_session_id() -> None:
    """POST без X-Session-Id → 201 + авто-UUID в X-Session-Id header."""
    expected = _make_poi_out(user_id="some-uuid")
    with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected):
        client = TestClient(app)
        resp = client.post(
            "/api/v1/custom-pois",
            json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 56.0},
        )
    assert resp.status_code == 201, resp.text
    sid = resp.headers.get("x-session-id")
    assert sid is not None, "Ожидали X-Session-Id в response headers"
    # Должен быть валидным UUID
    try:
        uuid.UUID(sid)
    except ValueError:
        pytest.fail(f"X-Session-Id '{sid}' не является валидным UUID")


def test_create_poi_weight_out_of_range_returns_422() -> None:
    """weight > 5 или < -5 → 422 от Pydantic (FastAPI body validation)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/custom-pois",
        json={"name": "Test", "weight": 10.0, "lon": 60.0, "lat": 56.0},
        headers={"X-Session-Id": _SESSION},
    )
    assert resp.status_code == 422, resp.text

    resp2 = client.post(
        "/api/v1/custom-pois",
        json={"name": "Test", "weight": -6.0, "lon": 60.0, "lat": 56.0},
        headers={"X-Session-Id": _SESSION},
    )
    assert resp2.status_code == 422, resp2.text


def test_create_poi_lon_out_of_range_returns_422() -> None:
    """lon > 180 → 422."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/custom-pois",
        json={"name": "Test", "weight": 1.0, "lon": 200.0, "lat": 56.0},
        headers={"X-Session-Id": _SESSION},
    )
    assert resp.status_code == 422, resp.text


def test_create_poi_lat_out_of_range_returns_422() -> None:
    """lat > 90 → 422."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/custom-pois",
        json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 100.0},
        headers={"X-Session-Id": _SESSION},
    )
    assert resp.status_code == 422, resp.text


def test_list_pois_returns_user_items() -> None:
    """GET /custom-pois → список POI пользователя."""
    pois = [_make_poi_out(1), _make_poi_out(2, name="Школа №5", weight=1.5)]
    with patch("app.api.v1.custom_pois.list_custom_pois", return_value=pois):
        client = TestClient(app)
        resp = client.get("/api/v1/custom-pois", headers={"X-Session-Id": _SESSION})
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert len(body) == 2
    assert body[0]["id"] == 1
    assert body[1]["name"] == "Школа №5"


def test_list_pois_filter_by_parcel_cad() -> None:
    """GET /custom-pois?parcel_cad=... → вызывает list_custom_pois с parcel_cad."""
    with patch("app.api.v1.custom_pois.list_custom_pois", return_value=[]) as mock_list:
        client = TestClient(app)
        resp = client.get(
            f"/api/v1/custom-pois?parcel_cad={_CAD}",
            headers={"X-Session-Id": _SESSION},
        )
    assert resp.status_code == 200, resp.text
    # Проверяем что parcel_cad передан в сервис
    call_kwargs = mock_list.call_args
    assert call_kwargs[1].get("parcel_cad") == _CAD or (
        len(call_kwargs[0]) >= 3 and call_kwargs[0][2] == _CAD
    )


def test_patch_poi_returns_updated() -> None:
    """PATCH /custom-pois/{id} → 200 с обновлёнными данными."""
    updated = _make_poi_out(weight=3.0, name="Обновлённый парк")
    with patch("app.api.v1.custom_pois.update_custom_poi", return_value=updated):
        client = TestClient(app)
        resp = client.patch(
            "/api/v1/custom-pois/1",
            json={"weight": 3.0, "name": "Обновлённый парк"},
            headers={"X-Session-Id": _SESSION},
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["weight"] == pytest.approx(3.0)
    assert body["name"] == "Обновлённый парк"


def test_patch_poi_not_found_returns_404() -> None:
    """PATCH для несуществующей/чужой POI → 404."""
    with patch("app.api.v1.custom_pois.update_custom_poi", return_value=None):
        client = TestClient(app)
        resp = client.patch(
            "/api/v1/custom-pois/999",
            json={"weight": 1.0},
            headers={"X-Session-Id": _SESSION},
        )
    assert resp.status_code == 404, resp.text


def test_delete_poi_returns_204() -> None:
    """DELETE /custom-pois/{id} → 204 при успешном удалении."""
    with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=True):
        client = TestClient(app)
        resp = client.delete("/api/v1/custom-pois/1", headers={"X-Session-Id": _SESSION})
    assert resp.status_code == 204, resp.text


def test_delete_poi_not_found_returns_404() -> None:
    """DELETE для несуществующей POI → 404."""
    with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=False):
        client = TestClient(app)
        resp = client.delete("/api/v1/custom-pois/999", headers={"X-Session-Id": _SESSION})
    assert resp.status_code == 404, resp.text


# ── Tests: Scoring integration ─────────────────────────────────────────────────

# Вспомогательный mock DB для analyze_parcel (аналогично test_analyze_inline_weights.py).
# Порядок db.execute calls включает #254 custom POI via get_overlaps_for_scoring,
# который вызывается уже после POI loop — ВНУТРИ analyze_parcel через импортированную
# функцию. Мы патчим её через patch() — DB mock остаётся прежним.


def _make_mapping_analyze(data: dict[str, Any]) -> MagicMock:
    m = MagicMock()
    m.__getitem__ = lambda self, k: data[k]
    m.get = lambda k, default=None: data.get(k, default)
    return m


def _make_db_for_analyze() -> MagicMock:
    """Mock DB Session для analyze_parcel (без custom POI вызовов — они пропатчены)."""
    db = MagicMock()

    geom_row = _make_mapping_analyze(
        {"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
    )
    wkt_row = _make_mapping_analyze({"wkt": _WKT})
    district_row = _make_mapping_analyze(
        {
            "district_name": "Октябрьский",
            "ekb_reference_median": 120000,
            "quarter_median_12m": None,
            "quarter_deals_count": 0,
            "dist_to_center": 1500.0,
        }
    )
    # Центроид УМЫШЛЕННО далеко (>15км) от центра ЕКБ (56.838011, 60.597474) —
    # тогда center_bonus == 0.0 (parcels.py:~1594 порог <15км) и итоговый score
    # изолирует ТОЛЬКО вклад custom-POI. ~29км на север (Δlat≈+0.26°).
    centroid_row = _make_mapping_analyze({"lat": 57.10, "lon": 60.597})

    call_idx = [0]
    responses: list[Any] = [
        ("first", geom_row),  # 0: geom UNION ALL
        ("first", wkt_row),  # 1: WKT
        ("first", district_row),  # 2: district
        ("all", []),  # 3: POI rows
        ("all", []),  # 4: competitor rows
        ("scalar", 0),  # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
        ("all", []),  # 6: pipeline rows
        ("first", centroid_row),  # 7: centroid
        ("all", []),  # 8: noise rows
        ("all", []),  # 9: hydrology rows
        ("all", []),  # 10: utilities rows
        ("first", None),  # 11: parcel_meta
        ("first", None),  # 12: market trend
        ("first", None),  # 13: zoning (begin_nested)
        ("all", []),  # 14: success recommendation (begin_nested)
        ("first", None),  # 15: market price (begin_nested)
        ("all", []),  # 16: recent permits (begin_nested)
        ("scalar", 0),  # 17: geotech_risk
        # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
        # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
        # возвращает ОДНУ строку с этими тремя ключами.
        ("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
    ]

    def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
        idx = call_idx[0]
        call_idx[0] += 1
        if idx >= len(responses):
            r = MagicMock()
            r.mappings.return_value.first.return_value = None
            r.mappings.return_value.all.return_value = []
            r.scalar.return_value = 0
            return r
        kind, data = responses[idx]
        r = MagicMock()
        r.mappings.return_value.first.return_value = data
        r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
        r.scalar.return_value = data if kind == "scalar" else 0
        return r

    db.execute.side_effect = _execute_side_effect

    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=ctx)
    ctx.__exit__ = MagicMock(return_value=False)
    db.begin_nested.return_value = ctx

    return db


def _override_db(db: MagicMock):
    def _get_db_override():
        yield db

    return _get_db_override


_ANALYZE_PATCHES = [
    patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
    patch("app.api.v1.parcels.get_weather_cached", return_value=None),
    patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
    patch(
        "app.api.v1.parcels.get_quarter_dump_data",
        return_value={
            "nspd_zoning": None,
            "nspd_zouit_overlaps": [],
            "nspd_engineering_nearby": [],
            "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
        },
    ),
    patch("app.api.v1.parcels.compute_velocity", return_value=None),
    patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
]


def _start_analyze_patches() -> None:
    for p in _ANALYZE_PATCHES:
        p.start()


def _stop_analyze_patches() -> None:
    for p in _ANALYZE_PATCHES:
        p.stop()


def test_analyze_custom_poi_increases_score() -> None:
    """Custom POI с weight=+2 в 500м → score повышается, custom_poi_score_items непустой."""
    from app.core.db import get_db

    # Симулируем custom POI в 500м
    _custom_overlap = {
        "id": 42,
        "name": "Мой парк",
        "category": "park",
        "weight": 2.0,
        "lon": 60.607,
        "lat": 56.840,
        "distance_m": 500.0,
    }
    # decay = 1 - 500/1000 = 0.5; contribution = 2.0 * 0.5 = 1.0

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_analyze_patches()

    try:
        with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]):
            client = TestClient(app)
            resp = client.post(
                f"/api/v1/parcels/{_CAD}/analyze",
                headers={"X-Session-Id": _SESSION},
            )
        assert resp.status_code == 200, resp.text
        body = resp.json()

        # custom_poi_score_items должен содержать нашу точку
        assert "custom_poi_score_items" in body
        items = body["custom_poi_score_items"]
        assert len(items) == 1
        assert items[0]["label"] == "Мой парк"
        assert items[0]["weight"] == pytest.approx(2.0)
        assert items[0]["distance_m"] == 500
        # contribution = 2.0 * 0.5 = 1.0
        assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01)

        # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
        # == вклад custom-POI (1.0). Так тест изолирует custom-contribution.
        assert body["score"] == pytest.approx(1.0, abs=0.1)
    finally:
        app.dependency_overrides.clear()
        _stop_analyze_patches()


def test_analyze_without_session_id_no_custom_poi() -> None:
    """POST /analyze без X-Session-Id → custom_poi_score_items пустой."""
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_analyze_patches()

    try:
        with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[]) as mock_overlaps:
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        # Без session_id custom_poi_score_items пустой (не вызываем get_overlaps)
        assert body["custom_poi_score_items"] == []
        mock_overlaps.assert_not_called()
    finally:
        app.dependency_overrides.clear()
        _stop_analyze_patches()


def test_analyze_custom_poi_negative_weight_decreases_score() -> None:
    """Custom POI с weight=-3 в 500м → score уменьшается."""
    from app.core.db import get_db

    _custom_overlap = {
        "id": 7,
        "name": "Промзона",
        "category": "industrial",
        "weight": -3.0,
        "lon": 60.607,
        "lat": 56.840,
        "distance_m": 500.0,
    }
    # contribution = -3.0 * 0.5 = -1.5

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_analyze_patches()

    try:
        with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]):
            client = TestClient(app)
            resp = client.post(
                f"/api/v1/parcels/{_CAD}/analyze",
                headers={"X-Session-Id": _SESSION},
            )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        items = body["custom_poi_score_items"]
        assert len(items) == 1
        assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01)
        # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
        # == вклад custom-POI (-1.5). score_final НЕ клампится снизу (parcels.py:2410).
        assert body["score"] == pytest.approx(-1.5, abs=0.1)
    finally:
        app.dependency_overrides.clear()
        _stop_analyze_patches()


# ── Service commit regression tests (#261) ────────────────────────────────────
#
# Проверяем что service-функции вызывают db.commit() после каждой мутации.
# Если db.commit() убрать из сервиса — тест упадёт на assert_called_once().
#
# Паттерн "два сеанса": write_db отдельный от последующего чтения.
# Эмулирует независимый SELECT после HTTP request close:
# get_db.finally → db.close() без commit → rollback pending tx,
# поэтому commit должен быть явным внутри каждой service-функции.


def _make_row_data(
    poi_id: int = 1,
    user_id: str = _SESSION,
    name: str = "Тест",
    weight: float = 1.5,
    lon: float = 60.605,
    lat: float = 56.838,
) -> dict[str, Any]:
    return {
        "id": poi_id,
        "user_id": user_id,
        "parcel_cad": None,
        "name": name,
        "category": "park",
        "weight": weight,
        "lon": lon,
        "lat": lat,
        "notes": None,
        "created_at": _TS,
        "updated_at": _TS,
    }


def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock:
    """Вернуть MagicMock-сессию где execute().mappings().first() → row_data mapping."""
    db = MagicMock()
    row_mock: MagicMock | None = None
    if row_data is not None:
        row_mock = MagicMock()
        row_mock.__getitem__ = lambda self, k: row_data[k]
        row_mock.get = lambda k, default=None: row_data.get(k, default)

    mapping_mock = MagicMock()
    mapping_mock.first.return_value = row_mock

    exec_mock = MagicMock()
    exec_mock.mappings.return_value = mapping_mock
    exec_mock.first.return_value = row_mock  # для DELETE RETURNING id (не mappings)

    db.execute.return_value = exec_mock
    return db


def test_service_create_custom_poi_commits() -> None:
    """create_custom_poi вызывает db.commit() — regression guard #261.

    Если commit убрать: row существует только в рамках незакоммиченной транзакции,
    при db.close() (get_db.finally) соединение вернётся в пул без commit → rollback.
    """
    from app.services.site_finder.custom_pois import create_custom_poi

    db = _make_db_with_row(_make_row_data())
    payload = CustomPoiCreate(name="Тест", weight=1.5, lon=60.605, lat=56.838)

    result = create_custom_poi(db, _SESSION, payload)

    db.commit.assert_called_once()
    assert result.id == 1
    assert result.user_id == _SESSION
    assert result.weight == pytest.approx(1.5)


def test_service_delete_custom_poi_commits_when_found() -> None:
    """delete_custom_poi вызывает db.commit() когда запись найдена — regression guard #261."""
    from app.services.site_finder.custom_pois import delete_custom_poi

    db = _make_db_with_row(_make_row_data())
    db.execute.return_value.first.return_value = MagicMock()  # RETURNING id truthy

    deleted = delete_custom_poi(db, poi_id=1, user_id=_SESSION)

    db.commit.assert_called_once()
    assert deleted is True


def test_service_delete_custom_poi_no_commit_when_not_found() -> None:
    """delete_custom_poi НЕ вызывает db.commit() если запись не найдена."""
    from app.services.site_finder.custom_pois import delete_custom_poi

    db = _make_db_with_row(None)
    db.execute.return_value.first.return_value = None  # RETURNING id пусто

    deleted = delete_custom_poi(db, poi_id=999, user_id=_SESSION)

    db.commit.assert_not_called()
    assert deleted is False


def test_service_update_custom_poi_commits_when_fields_given() -> None:
    """update_custom_poi вызывает db.commit() при наличии полей для обновления — regression #261."""
    from app.services.site_finder.custom_pois import update_custom_poi

    db = _make_db_with_row(_make_row_data(weight=2.0))
    payload = CustomPoiUpdate(weight=2.0)

    result = update_custom_poi(db, poi_id=1, user_id=_SESSION, payload=payload)

    db.commit.assert_called_once()
    assert result is not None


def test_service_update_custom_poi_no_commit_when_payload_empty() -> None:
    """update_custom_poi НЕ вызывает db.commit() если payload не содержит изменяемых полей."""
    from app.services.site_finder.custom_pois import update_custom_poi

    db = _make_db_with_row(_make_row_data())
    # Все поля None → sets = ["updated_at = NOW()"], len == 1 → UPDATE не выполняется
    payload = CustomPoiUpdate()

    update_custom_poi(db, poi_id=1, user_id=_SESSION, payload=payload)

    db.commit.assert_not_called()


def test_service_update_custom_poi_no_commit_when_not_found() -> None:
    """update_custom_poi НЕ вызывает db.commit() если POI не найдена."""
    from app.services.site_finder.custom_pois import update_custom_poi

    db = _make_db_with_row(None)  # get_custom_poi вернёт None (POI не найдена)
    payload = CustomPoiUpdate(weight=3.0)

    result = update_custom_poi(db, poi_id=999, user_id=_SESSION, payload=payload)

    db.commit.assert_not_called()
    assert result is None


# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
# "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.


def _overlap_row(data: dict[str, Any]) -> MagicMock:
    m = MagicMock()
    m.__getitem__ = lambda self, k: data[k]
    return m


def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
    """Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
    from app.services.site_finder.custom_pois import get_overlaps_for_scoring

    row = _overlap_row(
        {
            "id": 42,
            "name": "Мой парк",
            "category": "park",
            "weight": 2.0,
            "lon": 60.607,
            "lat": 56.840,
            "distance_m": 500.0,
        }
    )
    db = MagicMock()
    db.execute.return_value.mappings.return_value.all.return_value = [row]

    out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")

    assert out == [
        {
            "id": 42,
            "name": "Мой парк",
            "category": "park",
            "weight": 2.0,
            "lon": 60.607,
            "lat": 56.840,
            "distance_m": 500.0,
        }
    ]


def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
    """Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
    from app.services.site_finder.custom_pois import get_overlaps_for_scoring

    db = MagicMock()
    db.execute.return_value.mappings.return_value.all.return_value = []

    get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)

    assert db.begin_nested.call_count == 1


def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
    """Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
    from app.services.site_finder.custom_pois import get_overlaps_for_scoring

    db = MagicMock()
    db.execute.side_effect = RuntimeError("simulated DB failure")

    assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []


def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
    """db.execute сбоит один раз, но db остаётся usable для следующего вызова на
    этой же сессии (имитирует custom-POI overlaps → остальные шаги analyze_parcel
    на одной shared Session)."""
    from app.services.site_finder.custom_pois import get_overlaps_for_scoring

    db = MagicMock()
    db.execute.side_effect = RuntimeError("boom")
    assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []

    db.execute.side_effect = None
    next_result = MagicMock()
    next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
    db.execute.return_value = next_result
    assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
