"""Тесты permits_nearby (#105 Phase 3 — точный радиус-запрос РНС/РВЭ по geom, ГИСОГД-66).

Живую БД не трогаем — MagicMock-сессия (зеркало test_supply_layers / test_market_metrics):
`db.execute().mappings().all()` отдаёт заранее заданные строки. Проверяем:
  • честный ноль на пустом радиусе (не ошибка, не None),
  • разделение rs_count/rv_count по doc_group,
  • округление distance_m + nearest_distance_m,
  • ISO-сериализацию date_doc,
  • ЧЕСТНЫЕ счётчики по полной выборке БЕЗ тихого капа (SQL без LIMIT), items капается
    отдельно до 30 → items_truncated,
  • отсутствие :name::type в SQL, порядок ORDER BY distance_m ASC,
  • non-fatal семантику (сбой db.execute не глотается молча — пробрасывается вызывающему).
"""

from __future__ import annotations

import datetime as dt
import re
from typing import Any
from unittest.mock import MagicMock

import pytest

from app.services.site_finder.permits_nearby import (
    _PERMITS_NEARBY_SQL,
    get_permits_nearby,
)

_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"


def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
    """Сессия с одним execute → .mappings().all() == rows."""
    db = MagicMock()
    result = MagicMock()
    result.mappings.return_value.all.return_value = rows
    db.execute.return_value = result
    return db


def _row(
    doc_group: str,
    distance_m: float,
    *,
    doc_name: str = "Разрешение",
    doc_num: str = "66-01",
    date_doc: dt.date | None = None,
    approved_organization: str = "Администрация ЕКБ",
    date_reg: dt.date | None = None,
) -> dict[str, Any]:
    return {
        "doc_group": doc_group,
        "doc_name": doc_name,
        "doc_num": doc_num,
        "date_doc": date_doc,
        "date_reg": date_reg,
        "approved_organization": approved_organization,
        "distance_m": distance_m,
    }


def test_empty_radius_is_honest_zero_not_none() -> None:
    """0 строк в радиусе → честный ноль (total_count=0, items=[]), НЕ None, НЕ ошибка."""
    out = get_permits_nearby(_mock_db([]), _WKT)
    assert out is not None
    assert out["total_count"] == 0
    assert out["rs_count"] == 0
    assert out["rv_count"] == 0
    assert out["nearest_distance_m"] is None
    assert out["items"] == []
    assert out["items_truncated"] is False
    assert out["radius_m"] == 500
    assert out["source"] == "gisogd66"


def test_rs_rv_counts_split_by_doc_group() -> None:
    """rs_count считает 'RS', rv_count — 'RV'; прочие группы не попадают ни в один."""
    rows = [
        _row("RS", 100.0),
        _row("RS", 150.0),
        _row("RV", 200.0),
        _row("OTHER", 250.0),
    ]
    out = get_permits_nearby(_mock_db(rows), _WKT)
    assert out["total_count"] == 4
    assert out["rs_count"] == 2
    assert out["rv_count"] == 1
    assert len(out["items"]) == 4
    assert out["items_truncated"] is False


def test_distance_rounding_and_nearest() -> None:
    """distance_m округляется до 1 знака; nearest_distance_m = ближайший (rows отсортированы)."""
    rows = [_row("RS", 123.456), _row("RV", 456.789)]
    out = get_permits_nearby(_mock_db(rows), _WKT)
    assert out["items"][0]["distance_m"] == 123.5
    assert out["items"][1]["distance_m"] == 456.8
    assert out["nearest_distance_m"] == 123.5


def test_date_doc_serialized_to_iso() -> None:
    """date_doc → ISO-строка; None → None (не падает)."""
    rows = [
        _row("RS", 100.0, date_doc=dt.date(2025, 3, 14)),
        _row("RV", 200.0, date_doc=None),
    ]
    out = get_permits_nearby(_mock_db(rows), _WKT)
    assert out["items"][0]["date_doc"] == "2025-03-14"
    assert out["items"][1]["date_doc"] is None


def test_item_shape_fields() -> None:
    """Каждый item несёт ровно контрактные ключи (не протекает date_reg и пр.)."""
    rows = [_row("RS", 42.0, doc_name="РНС №5", approved_organization="Мингос")]
    out = get_permits_nearby(_mock_db(rows), _WKT)
    item = out["items"][0]
    assert set(item.keys()) == {
        "doc_group",
        "doc_name",
        "doc_num",
        "date_doc",
        "approved_organization",
        "distance_m",
    }
    assert item["doc_name"] == "РНС №5"
    assert item["approved_organization"] == "Мингос"


def test_radius_m_passed_through_to_params_and_result() -> None:
    """Кастомный radius_m прокидывается в bind-params И в выходной agg."""
    db = _mock_db([])
    out = get_permits_nearby(db, _WKT, radius_m=1000)
    assert out["radius_m"] == 1000
    _args, _kwargs = db.execute.call_args
    params = _args[1]
    assert params["radius_m"] == 1000
    assert params["wkt"] == _WKT


def test_sql_no_limit_counts_over_full_selection() -> None:
    """SQL БЕЗ LIMIT (честные счётчики по всей выборке) + ORDER BY / ST_DWithin.

    Кап 30 делается в Python на items, НЕ в SQL — иначе total_count/rs_count/rv_count
    занижались бы (тихий кап, запрещён проектной конвенцией).
    """
    sql = str(_PERMITS_NEARBY_SQL).lower()
    assert "gisogd_permits" in sql
    assert "st_dwithin" in sql
    assert "order by distance_m asc" in sql
    assert "limit" not in sql, "LIMIT в SQL капнул бы счётчики — кап только на items в Python"
    # items_limit больше не bind-параметр (капаем rows[:30] в Python).
    db = _mock_db([])
    get_permits_nearby(db, _WKT)
    _args, _kwargs = db.execute.call_args
    assert "items_limit" not in _args[1]


def test_items_capped_but_counts_honest_over_full_selection() -> None:
    """35 строк в выборке → items len=30 (кап), но total_count=35 честный + truncated=True.

    Ядро фикса «без тихих капов»: список отдаётся усечённым (progressive-disclosure на
    фронте по items_truncated), а счётчики отражают РЕАЛЬНОЕ число разрешений в радиусе.
    """
    # 20×RS ближайшие, затем 15×RV дальше — итого 35, отсортированы по distance ASC.
    rows = [_row("RS", float(d)) for d in range(10, 30)]  # 20 строк, 10..29 м
    rows += [_row("RV", float(d)) for d in range(30, 45)]  # 15 строк, 30..44 м
    assert len(rows) == 35
    out = get_permits_nearby(_mock_db(rows), _WKT)
    assert out["total_count"] == 35
    assert out["rs_count"] == 20
    assert out["rv_count"] == 15
    assert len(out["items"]) == 30
    assert out["items_truncated"] is True
    # nearest — по ближайшей строке (сохраняется даже при капе списка).
    assert out["nearest_distance_m"] == 10.0
    # items — именно первые 30 ближайших (последние 5 дальних RV не попали).
    assert all(it["distance_m"] <= 39.0 for it in out["items"])


def test_sql_no_double_colon_cast() -> None:
    """psycopg v3 / backend.md: никаких :name::type (::geography приклеен к ')', OK)."""
    sql = str(_PERMITS_NEARBY_SQL)
    assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}"


def test_db_error_propagates_not_swallowed() -> None:
    """Сбой db.execute пробрасывается (non-fatal обёртка живёт в parcels.py, не тут)."""
    db = MagicMock()
    db.execute.side_effect = RuntimeError("db down")
    with pytest.raises(RuntimeError, match="db down"):
        get_permits_nearby(db, _WKT)
