"""Tests for POI saturation per capita (#42).

Pure-функции (severity / multiplier / provision_ratio) — без БД.
DB-слой compute_district_saturation — через минимальный mock Session
(тот же приём, что в test_poi_score.py).
"""

from contextlib import contextmanager
from datetime import date

import pytest

from app.services.site_finder.saturation import (
    _MULT_DEFICIT,
    _MULT_NEUTRAL,
    _MULT_SURPLUS,
    classify_severity,
    compute_district_saturation,
    compute_provision_ratio,
    saturation_multiplier,
)

# ── classify_severity ─────────────────────────────────────────────────────────


def test_severity_none_is_none():
    """Нет данных (None) → None, НЕ «острый дефицит»."""
    assert classify_severity(None) is None


def test_severity_buckets():
    assert classify_severity(0.2) == "острый дефицит"
    assert classify_severity(0.49) == "острый дефицит"
    assert classify_severity(0.5) == "дефицит"
    assert classify_severity(0.84) == "дефицит"
    assert classify_severity(0.85) == "норма"
    assert classify_severity(1.0) == "норма"
    assert classify_severity(1.3) == "норма"
    assert classify_severity(1.31) == "профицит"
    assert classify_severity(3.0) == "профицит"


# ── saturation_multiplier (acceptance #42) ─────────────────────────────────────


def test_multiplier_deficit_boosts():
    """Дефицитный район → ×1.2 (incentive строить school-adjacent)."""
    assert saturation_multiplier("school", 0.6) == _MULT_DEFICIT


def test_multiplier_surplus_penalises():
    """Перенасыщенный район → ×0.5."""
    assert saturation_multiplier("school", 2.0) == _MULT_SURPLUS


def test_multiplier_norm_neutral():
    assert saturation_multiplier("school", 1.0) == _MULT_NEUTRAL


def test_multiplier_none_and_unknown_category_neutral():
    """Нет данных или категория без норматива → нейтрально (×1.0)."""
    assert saturation_multiplier("school", None) == _MULT_NEUTRAL
    assert saturation_multiplier("metro_stop", 0.1) == _MULT_NEUTRAL


# ── compute_provision_ratio ─────────────────────────────────────────────────────


def test_provision_unknown_category_none():
    assert (
        compute_provision_ratio(poi_count=5, population=100000, age_share=0.1, category="park")
        is None
    )


def test_provision_zero_population_none():
    assert (
        compute_provision_ratio(poi_count=5, population=0, age_share=0.1, category="school") is None
    )


def test_provision_missing_age_share_none():
    """Школа/детсад без age_share → None (когорту не оценить)."""
    assert (
        compute_provision_ratio(poi_count=5, population=100000, age_share=None, category="school")
        is None
    )


def test_provision_hospital_ignores_age_share():
    """Поликлиника нормируется на ВСЁ население — age_share не нужен."""
    # 100000 жителей, норматив 0.1 учр./1000 = 10 учреждений эталон.
    # 10 объектов × capacity 1.0 / (100000/1000) = 10/100 = 0.1 на 1000 = ровно норматив.
    ratio = compute_provision_ratio(
        poi_count=10, population=100000, age_share=None, category="hospital"
    )
    assert ratio == pytest.approx(1.0)


def test_provision_more_poi_higher_ratio():
    """Больше объектов при той же когорте → выше обеспеченность (монотонность)."""
    few = compute_provision_ratio(
        poi_count=2, population=100000, age_share=0.115, category="school"
    )
    many = compute_provision_ratio(
        poi_count=10, population=100000, age_share=0.115, category="school"
    )
    assert few is not None and many is not None
    assert many > few


def test_provision_smaller_cohort_higher_ratio():
    """#42 суть: та же школа на МЕНЬШЕЕ число детей → выше обеспеченность per capita."""
    big_cohort = compute_provision_ratio(
        poi_count=3, population=300000, age_share=0.115, category="school"
    )
    small_cohort = compute_provision_ratio(
        poi_count=3, population=100000, age_share=0.115, category="school"
    )
    assert big_cohort is not None and small_cohort is not None
    assert small_cohort > big_cohort


# ── compute_district_saturation (mock DB) ────────────────────────────────────


class _MockMappings:
    def __init__(self, row: dict | None) -> None:
        self._row = row

    def first(self) -> dict | None:
        return self._row


class _MockResult:
    def __init__(self, row: dict | None) -> None:
        self._row = row

    def mappings(self) -> "_MockMappings":
        return _MockMappings(self._row)


class _MockDb:
    """Минимальный мок SQLAlchemy Session (как в test_poi_score.py).

    ``begin_nested`` — реальный (не-swallow) context manager, зеркалит SAVEPOINT из
    ``compute_district_saturation`` (#2464 cluster A finding 4): исключение внутри
    ``with`` должно долетать до ``except`` в проде, не глушиться на выходе из CM.
    """

    def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None:
        self._row = row
        self._raise = raise_on_execute

    @contextmanager
    def begin_nested(self):  # type: ignore[no-untyped-def]
        yield

    def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
        if self._raise:
            raise RuntimeError("simulated DB failure")
        return _MockResult(self._row)


def _chkalovsky_row(n_school: int = 12) -> dict:
    """Чкаловский — крупнейший район ЕКБ (286277 чел.), типичный дефицит школ."""
    return {
        "population": 286277,
        "area_km2": 389.81,
        "age_share_preschool": 0.08,
        "age_share_school": 0.115,
        "age_share_elderly": 0.16,
        "age_cohorts_estimated": True,
        "source": "Росстат 2025-01-01",
        "as_of_date": date(2025, 1, 1),
        "n_school": n_school,
        "n_kindergarten": 20,
        "n_hospital": 5,
    }


def test_saturation_no_demographics_returns_none():
    """Район без строки демографии (population NULL) → None."""
    assert compute_district_saturation(_MockDb(None), "Неизвестный") is None


def test_saturation_db_error_returns_none():
    """Ошибка БД не роняет analyze — graceful None."""
    db = _MockDb(_chkalovsky_row(), raise_on_execute=True)
    assert compute_district_saturation(db, "Чкаловский") is None


def test_saturation_db_error_leaves_session_usable_for_next_query():
    """#2464 cluster A finding 4: сбой query здесь не должен отравлять session.

    Симулируем реальный сценарий: db.execute кидает ОДИН раз (внутри
    compute_district_saturation), затем на ТОЙ ЖЕ session успешно отрабатывает
    следующий (не связанный) запрос — как это было бы с persist_analysis_run
    сразу после saturation-блока в parcels.py analyze_parcel. Без SAVEPOINT
    (begin_nested) второй execute на реальном Postgres упал бы с "current
    transaction is aborted, commands ignored until end of transaction block".
    """

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

        @contextmanager
        def begin_nested(self):  # type: ignore[no-untyped-def]
            yield

        def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
            self.calls += 1
            if self.calls == 1:
                raise RuntimeError("simulated DB failure")
            return _MockResult({"marker": "next-query-succeeded"})

    db = _FlakyDb()
    assert compute_district_saturation(db, "Чкаловский") is None
    # Сессия осталась usable: следующий (не связанный) db.execute отрабатывает.
    result = db.execute("SELECT 1")
    assert result.mappings().first() == {"marker": "next-query-succeeded"}
    assert db.calls == 2


def test_saturation_shape_and_flags():
    db = _MockDb(_chkalovsky_row())
    out = compute_district_saturation(db, "Чкаловский")
    assert out is not None
    assert out["district"] == "Чкаловский"
    assert out["population"] == 286277
    assert out["as_of_date"] == "2025-01-01"
    assert out["cohorts_estimated"] is True
    assert set(out["categories"]) == {"school", "kindergarten", "hospital"}
    school = out["categories"]["school"]
    # Школьная когорта — оценка (региональная доля), помечена флагом.
    assert school["cohort_estimated"] is True
    assert school["cohort_population"] == int(286277 * 0.115)
    assert school["poi_count"] == 12
    assert school["norm"]["capacity_per_1000"] == 92.0


def test_saturation_hospital_cohort_is_factual():
    """Поликлиника нормируется на всё население → cohort НЕ оценка (факт)."""
    out = compute_district_saturation(_MockDb(_chkalovsky_row()), "Чкаловский")
    assert out is not None
    hospital = out["categories"]["hospital"]
    assert hospital["cohort_estimated"] is False
    assert hospital["cohort_population"] == 286277


def test_saturation_deficit_district_school_gets_boost():
    """#42 acceptance: дефицитный по школам район → school POI score_multiplier=1.2.

    Чкаловский, 12 школ, школьники ≈ 32922 чел. (286277×0.115).
    Эталон-места = 32922/1000 × 92 ≈ 3029. Факт = 12 × 600 = 7200…
    при таком capacity это профицит. Сужаем когорту: мало школ на много детей →
    проверяем boundary через малое число объектов.
    """
    # 1 школа на крупный район → острый дефицит → ×1.2.
    out = compute_district_saturation(_MockDb(_chkalovsky_row(n_school=1)), "Чкаловский")
    assert out is not None
    school = out["categories"]["school"]
    assert school["severity"] in ("дефицит", "острый дефицит")
    assert school["score_multiplier"] == _MULT_DEFICIT


def test_saturation_ratio_matches_pure_helper():
    """DB-слой использует ту же чистую формулу, что и compute_provision_ratio."""
    row = _chkalovsky_row(n_school=4)
    out = compute_district_saturation(_MockDb(row), "Чкаловский")
    assert out is not None
    expected = compute_provision_ratio(
        poi_count=4, population=286277, age_share=0.115, category="school"
    )
    assert expected is not None
    assert out["categories"]["school"]["provision_ratio"] == round(expected, 3)
