"""Unit-тесты газовой вертикали ГРС «Газпром трансгаз Екатеринбург» (#2119 Фаза B1).

Pure / mock-based — без реальной сети и БД. Покрывает:
- normalize_grs_name: ё→е, схлопывание пробелов/дефисов, сохранение «ГРС»-префикса.
- parse_grs_table: заголовок-матчинг колонок, rowspan forward-fill имени ГРС на
  строки-выходы, запятые/«н/д»-числа, multi-выход.
- _passes_region_filter: колонка региона → фильтр «свердлов»; нет → всё.
- _upsert_grs_rows: SAVEPOINT + счётчики на fake-DB.
- _query_gas_city_grs: агрегация по grs_name (SUM/AVG/MAX/COUNT), пустая таблица → [].
- _reserve_note: ЕЭСК + reserve_mva IS NULL → пометка; иначе None.
"""

from __future__ import annotations

from contextlib import contextmanager
from datetime import date
from typing import Any

from app.services.site_finder import connection_capacity_lookup as ccl
from app.services.site_finder import gazprom_grs_loader as gg

# ── normalize_grs_name ────────────────────────────────────────────────────────


def test_normalize_grs_yo_to_e() -> None:
    assert gg.normalize_grs_name("ГРС Берёзовский") == "грс березовский"


def test_normalize_grs_keeps_prefix() -> None:
    """Префикс «ГРС»/«АРП» СОХРАНЯЕТСЯ (различает станции)."""
    assert gg.normalize_grs_name("ГРС Кольцово") == "грс кольцово"
    assert gg.normalize_grs_name("АРП Северная") == "арп северная"


def test_normalize_grs_dash_and_spaces_collapse() -> None:
    assert gg.normalize_grs_name("ГРС Северная — 2") == "грс северная-2"
    assert gg.normalize_grs_name("ГРС  Западная") == "грс западная"


def test_normalize_grs_empty() -> None:
    assert gg.normalize_grs_name(None) == ""
    assert gg.normalize_grs_name("") == ""


# ── parse_grs_table: РЕАЛЬНАЯ структура таблицы ГТЕ ───────────────────────────
#
# Структура (снята с прода 2026-07-02):
#   row0: caption (1 ячейка);
#   row1: «по состоянию на DD.MM.YYYY» (asof);
#   row2: РЕАЛЬНЫЕ заголовки (маркеры «Наименование» + «Проектная»);
#   row3: суб-заголовки (Основание/Срок/Параметры) — n=3, скип;
#   row4: строка нумерации «0..7» — n=8, скип (регион ещё не Свердловский);
#   регион-разделители (1 ячейка) → current_region;
#   FULL-строка (n>=10): idx 2=имя … 6=свободная, 7=%, 8+=уведомление;
#   CONTINUATION-строка (n=7, доп. выход): idx 0=имя(с выходом) … 4=свободная, 5=%.

_GTE_HTML = """
<html><body>
<table>
  <tr><td>Информация о наличии (отсутствии) технической возможности…</td></tr>
  <tr><td></td><td>по состоянию на 01.06.2026</td><td></td></tr>
  <tr>
    <th>№ п/п</th><th>Субъект Российской Федерации</th>
    <th>Наименование газораспределительной станции</th>
    <th>Проектная мощность (производительность)</th>
    <th>Загрузка газораспределительной станции</th>
    <th>Суммарный объём газа по действующим ТУ</th>
    <th>Наличие (дефицит) пропускной способности</th>
    <th>Наличие (дефицит) пропускной способности, %</th>
    <th>Наличие уведомления ГРО</th>
  </tr>
  <tr><th>Основание</th><th>Срок мероприятий</th><th>Параметры увеличения</th></tr>
  <tr>
    <td>0</td><td>1</td><td>2</td><td>3</td><td>4</td>
    <td>5</td><td>6</td><td>7</td>
  </tr>

  <tr><td>СВЕРДЛОВСКАЯ ОБЛАСТЬ</td></tr>
  <tr>
    <td>114</td><td>Свердловская область</td><td>ГРС Арамиль (5)</td>
    <td>50,000</td><td>31,350</td><td>17,445</td><td>1,205</td><td>(2 %)</td>
    <td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
  </tr>
  <tr>
    <td>115</td><td>Свердловская область</td><td>ГРС Арти (выход №1)</td>
    <td>44,000</td><td>2,000</td><td>1,800</td><td>42,200</td><td>(96 %)</td>
    <td>ТУ №77</td><td>2028</td><td>увеличение до 60</td><td>—</td><td>—</td>
  </tr>
  <tr>
    <td>ГРС Арти (выход №2)</td><td>22,000</td><td>—</td><td>—</td>
    <td>—</td><td>—</td><td>—</td>
  </tr>

  <tr><td>КУРГАНСКАЯ ОБЛАСТЬ</td></tr>
  <tr>
    <td>200</td><td>Курганская область</td><td>ГРС Кетово</td>
    <td>30,000</td><td>10,000</td><td>5,000</td><td>20,000</td><td>(66 %)</td>
    <td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
  </tr>
</table>
</body></html>
"""


def test_parse_asof_extracted() -> None:
    """«по состоянию на 01.06.2026» из первых строк → asof на каждой записи."""
    recs = gg.parse_grs_table(_GTE_HTML)
    assert recs, "ожидались записи по Свердловской области"
    assert all(r["asof"] == date(2026, 6, 1) for r in recs)


def test_parse_region_filter_kurgan_excluded() -> None:
    """Регион-разделитель «КУРГАНСКАЯ ОБЛАСТЬ» отсекает не-свердловские строки."""
    recs = gg.parse_grs_table(_GTE_HTML)
    names = [r["grs_name"] for r in recs]
    assert "ГРС Кетово" not in names  # Курганская отсечена регион-гейтом
    assert all(r["region"] == "Свердловская Область" for r in recs)  # title-case


def test_parse_full_row_mapping_and_pct() -> None:
    """FULL-строка (n>=10): idx-маппинг + процент «(2 %)» → 2.0; объём ТУ → raw."""
    recs = gg.parse_grs_table(_GTE_HTML)
    aramil = next(r for r in recs if r["grs_name"] == "ГРС Арамиль (5)")
    # «(5)» — НЕ выход, остаётся в имени; output_name пуст.
    assert aramil["output_name"] is None
    assert aramil["design_capacity_th_m3_h"] == 50.0  # idx3
    assert aramil["current_load_th_m3_h"] == 31.35  # idx4
    assert aramil["free_capacity_th_m3_h"] == 1.205  # idx6 (свободная)
    assert aramil["free_capacity_pct"] == 2.0  # idx7 «(2 %)» → 2.0
    assert aramil["upgrade_due"] is None  # все ячейки уведомления «—»
    # Объём газа по ТУ (idx5) — ТОЛЬКО в raw, без колонки в схеме.
    assert aramil["raw"]["volume_by_tu"] == "17,445"
    assert "cells" in aramil["raw"]


def test_parse_output_suffix_extracted() -> None:
    """«(выход №1)»/«(выход №2)» → output_name отдельно, grs_name без суффикса."""
    recs = gg.parse_grs_table(_GTE_HTML)
    arti = [r for r in recs if r["grs_name"] == "ГРС Арти"]
    assert len(arti) == 2  # FULL «выход №1» + CONTINUATION «выход №2»
    assert {r["output_name"] for r in arti} == {"выход №1", "выход №2"}


def test_parse_continuation_row_shifted_and_pct96() -> None:
    """CONTINUATION-строка (n=7): сдвиг индексов; «(96 %)» из FULL «выход №1»."""
    recs = gg.parse_grs_table(_GTE_HTML)
    out1 = next(r for r in recs if r["output_name"] == "выход №1")
    assert out1["free_capacity_pct"] == 96.0  # «(96 %)» → 96.0
    assert out1["design_capacity_th_m3_h"] == 44.0
    # Уведомление собрано join(« · ») из непустых Основание/Срок/Параметры.
    assert out1["upgrade_due"] == "ТУ №77 · 2028 · увеличение до 60"

    out2 = next(r for r in recs if r["output_name"] == "выход №2")
    # CONTINUATION: имя=idx0, проектная=idx1 → 22.0; остальное «—» → None.
    assert out2["grs_name"] == "ГРС Арти"
    assert out2["design_capacity_th_m3_h"] == 22.0
    assert out2["current_load_th_m3_h"] is None  # «—» → None
    assert out2["free_capacity_th_m3_h"] is None
    assert out2["free_capacity_pct"] is None
    assert out2["upgrade_due"] is None


def test_parse_no_table_returns_empty() -> None:
    assert gg.parse_grs_table("<html><body><p>нет таблиц</p></body></html>") == []


def test_parse_no_header_row_returns_empty() -> None:
    """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
    html = (
        "<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
        "<tr><td>1</td><td>2</td></tr></table>"
    )
    assert gg.parse_grs_table(html) == []


# ── _split_grs_name: «(выход №N)» в конце И в середине имени ───────────────────


def test_split_grs_name_suffix_at_end() -> None:
    """«(выход №N)» в конце → output отдельно, grs_name без суффикса."""
    assert gg._split_grs_name("ГРС Арти (выход №2)") == ("ГРС Арти", "выход №2")


def test_split_grs_name_digit_suffix_not_output() -> None:
    """«(5)» (только цифры) — НЕ выход, остаётся в имени."""
    assert gg._split_grs_name("ГРС Арамиль (5)") == ("ГРС Арамиль (5)", None)


def test_split_grs_name_output_in_middle() -> None:
    """Фрагмент «(выход №1)» В СЕРЕДИНЕ имени вырезается, остаток склеивается.

    Живые данные: «ГРС-1 Свердловск (выход №1) (5)» — «(выход №1)» стоит перед
    станционным «(5)». Ранее $-якорь матчил только конец → выход терялся, а «(5)»
    ошибочно попадал бы под выход-логику. Теперь search по всему имени.
    """
    grs_name, output = gg._split_grs_name("ГРС-1 Свердловск (выход №1) (5)")
    assert output == "выход №1"
    # «(5)» остаётся; двойной пробел на месте выреза нормализован.
    assert grs_name == "ГРС-1 Свердловск (5)"


def test_split_grs_name_no_output() -> None:
    """Имя без «(выход …)» → без изменений, output=None."""
    assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)


def test_split_grs_name_output_before_station_digit_real_rows() -> None:
    """B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается.

    Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход №N)»
    стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым
    output_name (5 строк). Локим на точных прод-именах — не регрессировать.
    """
    cases = {
        "ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"),
        "ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"),
        "ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"),
        "ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"),
        "ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"),
    }
    for raw, expected in cases.items():
        assert gg._split_grs_name(raw) == expected, raw


def test_split_grs_name_output_canonicalized() -> None:
    """output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№».

    «Выход № 1» / «выход 1» / «выход  №1» → все дают «выход №1» (иначе разный
    output_name → дубли по ON CONFLICT (grs_name_norm, output_name)).
    """
    for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход  №1)"):
        grs_name, output = gg._split_grs_name(raw)
        assert output == "выход №1", raw
        assert grs_name == "ГРС Х"


# ── _passes_region_filter ─────────────────────────────────────────────────────


def test_region_filter_no_region_keeps_all() -> None:
    recs = [{"region": None, "grs_name": "ГРС А"}, {"region": None, "grs_name": "ГРС Б"}]
    assert gg._passes_region_filter(recs) == recs


def test_region_filter_present_keeps_sverdlovsk() -> None:
    recs = [
        {"region": "Свердловская область", "grs_name": "ГРС А"},
        {"region": "Челябинская область", "grs_name": "ГРС Б"},
    ]
    out = gg._passes_region_filter(recs)
    assert len(out) == 1
    assert out[0]["grs_name"] == "ГРС А"


# ── Fake DB session для upsert- и query-тестов ────────────────────────────────


class _FakeResult:
    def __init__(self, scalar_value: Any = True, mappings_rows: list[dict] | None = None) -> None:
        self._scalar = scalar_value
        self._mappings_rows = mappings_rows or []

    def scalar(self) -> Any:
        return self._scalar

    def mappings(self) -> _FakeResult:
        return self

    def all(self) -> list[dict]:
        return self._mappings_rows

    def first(self) -> dict | None:
        return self._mappings_rows[0] if self._mappings_rows else None


class _FakeSession:
    """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах).

    Один набор строк на все execute (mappings_rows) ИЛИ скрипт по вызовам
    (results_script: список наборов строк, отдаётся по порядку execute-вызовов; после
    исчерпания — последний). results_script нужен для функций с НЕСКОЛЬКИМИ разными
    SELECT (напр. _query_gas_city_grs: счётчики точек выхода + агрегация ГРС).
    """

    def __init__(
        self,
        scalar_value: Any = True,
        mappings_rows: list[dict] | None = None,
        execute_raises: Exception | None = None,
        results_script: list[list[dict]] | None = None,
    ) -> None:
        self.calls: list[tuple[str, Any]] = []
        self._scalar = scalar_value
        self._mappings_rows = mappings_rows
        self._execute_raises = execute_raises
        self._results_script = results_script
        self._call_idx = 0
        self.committed = False

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

    def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
        self.calls.append((str(stmt), params))
        if self._execute_raises is not None:
            raise self._execute_raises
        if self._results_script is not None:
            idx = min(self._call_idx, len(self._results_script) - 1)
            rows = self._results_script[idx]
            self._call_idx += 1
            return _FakeResult(self._scalar, rows)
        return _FakeResult(self._scalar, self._mappings_rows)

    def commit(self) -> None:
        self.committed = True

    def rollback(self) -> None:
        pass


# ── _upsert_grs_rows ──────────────────────────────────────────────────────────


def test_upsert_grs_rows_counts_and_savepoint() -> None:
    db = _FakeSession(scalar_value=True)
    recs = gg.parse_grs_table(_GTE_HTML)
    assert len(recs) == 3  # Свердловская: Арамиль + Арти(вых1) + Арти(вых2)
    counts = gg._upsert_grs_rows(db, recs)  # type: ignore[arg-type]
    assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0}
    # grs_name_norm нормализован в params; asof проброшен в upsert.
    assert db.calls[0][1]["grs_name_norm"] == "грс арамиль (5)"
    assert db.calls[0][1]["asof"] == date(2026, 6, 1)
    # «(выход №N)» ушёл в отдельный output_name (не в grs_name_norm).
    assert {c[1]["output_name"] for c in db.calls} == {None, "выход №1", "выход №2"}


# ── _query_gas_city_grs: агрегация ────────────────────────────────────────────


# Счётчики точек выхода (первый execute в _query_gas_city_grs) — нейтральный дефолт для
# тестов B1-агрегации, которым важна только ГРС-часть (второй execute).
_OUTLET_COUNTS_ROW = {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}


def test_query_gas_city_grs_aggregation() -> None:
    """Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total."""
    rows = [
        {
            "grs_name": "ГРС Свердловская",
            "design_sum": 210.0,
            "free_sum": 40.5,
            "pct_avg": 20.25,
            "upgrade_due": "2028",
            "outputs_count": 3,
        },
        {
            "grs_name": "ГРС Екатеринбург Северная",
            "design_sum": 100.0,
            "free_sum": 10.0,
            "pct_avg": 10.0,
            "upgrade_due": None,
            "outputs_count": 1,
        },
    ]
    # execute #1 = счётчики точек выхода, #2 = агрегация ГРС.
    db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], rows])
    out = ccl._query_gas_city_grs(db)  # type: ignore[arg-type]
    assert len(out["city_grs"]) == 2
    assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская"
    assert out["city_grs"][0]["free_capacity_th_m3_h"] == 40.5
    assert out["city_grs"][0]["free_capacity_pct"] == 20.2  # round(20.25, 1)
    assert out["city_grs"][0]["outputs_count"] == 3
    assert out["total_free_th_m3_h"] == 50.5  # 40.5 + 10.0
    # Счётчики точек выхода вплетены в тот же gas-блок (аддитивно).
    assert out["outlets_total"] == 0
    assert out["outlets_deficit"] == 0
    assert out["outlets_needs_calc"] == 0


def test_query_gas_city_grs_empty_table() -> None:
    db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], []])
    out = ccl._query_gas_city_grs(db)  # type: ignore[arg-type]
    assert out == {
        "city_grs": [],
        "total_free_th_m3_h": None,
        "outlets_total": 0,
        "outlets_deficit": 0,
        "outlets_needs_calc": 0,
    }


def test_query_gas_city_grs_missing_table_degrades() -> None:
    """Таблица gas_grs_capacity не существует (миграция 181) → пустой блок, не падаем.

    _query_gas_outlet_counts (первый execute) тоже упадёт на той же raises-заглушке →
    счётчики деградируют в нули, блок цел.
    """
    db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist'))
    out = ccl._query_gas_city_grs(db)  # type: ignore[arg-type]
    assert out == {
        "city_grs": [],
        "total_free_th_m3_h": None,
        "outlets_total": 0,
        "outlets_deficit": 0,
        "outlets_needs_calc": 0,
    }


# ── _query_gas_outlet_counts: счётчики точек выхода (city-level) ───────────────


def test_query_gas_outlet_counts_flags() -> None:
    """COUNT + FILTER (дефицит / needs_calc) маппятся в счётчики (по одной row-агрегату)."""
    row = {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144}
    db = _FakeSession(mappings_rows=[row])
    out = ccl._query_gas_outlet_counts(db)  # type: ignore[arg-type]
    assert out == {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144}


def test_query_gas_outlet_counts_null_row_zeroes() -> None:
    """Пустая таблица (агрегат вернул NULL/нет строки) → все нули."""
    db = _FakeSession(mappings_rows=[])  # .first() → None
    out = ccl._query_gas_outlet_counts(db)  # type: ignore[arg-type]
    assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}


def test_query_gas_outlet_counts_missing_table_degrades() -> None:
    """Таблица не существует (миграция 184 не применена) → нули, не падаем."""
    db = _FakeSession(
        execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist')
    )
    out = ccl._query_gas_outlet_counts(db)  # type: ignore[arg-type]
    assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}


# ── _query_gas_outlet_points: гео-точки выхода в радиусе (слой карты §3) ───────


def test_query_gas_outlet_points_maps_and_flags() -> None:
    """Строки радиус-запроса → точки: дистанция округлена, needs_calc/дефицит, lat/lon."""
    rows = [
        {
            "outlet_name": "Котельная (г.Екатеринбург, Складской проезд,4а)",
            "consumer_type": "котельная",
            "free_capacity_mln_m3": 0.512,
            "needs_calc": False,
            "distance_m": 145.27,
            "lat": 56.812345,
            "lon": 60.611111,
        },
        {
            # Дефицит со знаком сохраняется; needs_calc отдельным флагом.
            "outlet_name": "Промплощадка №2",
            "consumer_type": None,
            "free_capacity_mln_m3": -0.084,
            "needs_calc": False,
            "distance_m": 980.4,
            "lat": 56.80,
            "lon": 60.60,
        },
        {
            # «0.000*» → free_capacity_mln_m3=NULL, needs_calc=True.
            "outlet_name": "ТЭЦ узел",
            "consumer_type": "ТЭЦ",
            "free_capacity_mln_m3": None,
            "needs_calc": True,
            "distance_m": 1500.0,
            "lat": None,
            "lon": None,
        },
    ]
    db = _FakeSession(mappings_rows=rows)
    out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)")  # type: ignore[arg-type]
    assert len(out) == 3
    # Порядок сохранён как пришёл из SQL (ORDER BY distance ASC) — ближайший первым.
    assert out[0]["distance_m"] == 145.3  # round(145.27, 1)
    assert out[0]["free_capacity_mln_m3"] == 0.512
    assert out[0]["needs_calc"] is False
    assert out[0]["lat"] == 56.812345
    # дефицит сохраняет знак.
    assert out[1]["free_capacity_mln_m3"] == -0.084
    # «0.000*»: NULL мощность + needs_calc=True; координаты None → пробрасываются null.
    assert out[2]["free_capacity_mln_m3"] is None
    assert out[2]["needs_calc"] is True
    assert out[2]["lat"] is None and out[2]["lon"] is None


def test_query_gas_outlet_points_passes_radius_and_limit() -> None:
    """Радиус-запрос параметризован _GAS_OUTLET_RADIUS_M и _GAS_OUTLET_LIMIT."""
    db = _FakeSession(mappings_rows=[])
    ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)")  # type: ignore[arg-type]
    assert db.calls, "ожидался хотя бы один execute"
    params = db.calls[0][1]
    assert params["radius_m"] == ccl._GAS_OUTLET_RADIUS_M
    assert params["limit"] == ccl._GAS_OUTLET_LIMIT
    assert params["wkt"] == "POINT(60.6 56.81)"


def test_query_gas_outlet_points_empty() -> None:
    db = _FakeSession(mappings_rows=[])
    out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)")  # type: ignore[arg-type]
    assert out == []


def test_query_gas_outlet_points_missing_table_degrades() -> None:
    """Таблица не существует (миграция 184 не применена) → [], не падаем."""
    db = _FakeSession(
        execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist')
    )
    out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)")  # type: ignore[arg-type]
    assert out == []


# ── _query_heat_latest: per-org период + суммарный резерв + graceful ───────────


def test_query_heat_latest_aggregation_per_org() -> None:
    """Строки heat_system_reserves маппятся в systems + суммарный резерв (знак сохр.)."""
    rows = [
        {"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"},
        {
            "org": "Екатеринбургэнерго",
            "system_name": "ТЭЦ Зона-1",
            "reserve_gcal_h": -5.0,  # дефицит
            "period": "2026-Q1",
        },
    ]
    db = _FakeSession(mappings_rows=rows)
    out = ccl._query_heat_latest(db)  # type: ignore[arg-type]
    assert len(out["systems"]) == 2
    assert out["systems"][0]["org"] == "ЕТК"
    assert out["systems"][0]["reserve_gcal_h"] == 3.26
    assert out["systems"][1]["reserve_gcal_h"] == -5.0  # знак дефицита сохранён
    assert out["total_reserve_gcal_h"] == -1.74  # 3.26 + (-5.0), round(…, 3)


def test_query_heat_latest_null_reserve_excluded_from_total() -> None:
    """Система с reserve_gcal_h=NULL входит в systems, но не в total."""
    rows = [
        {"org": "ЕТК", "system_name": "СТ №1", "reserve_gcal_h": 2.0, "period": "2026-Q2"},
        {"org": "ЕТК", "system_name": "СТ №2", "reserve_gcal_h": None, "period": "2026-Q2"},
    ]
    db = _FakeSession(mappings_rows=rows)
    out = ccl._query_heat_latest(db)  # type: ignore[arg-type]
    assert len(out["systems"]) == 2
    assert out["systems"][1]["reserve_gcal_h"] is None
    assert out["total_reserve_gcal_h"] == 2.0


def test_query_heat_latest_empty_table() -> None:
    db = _FakeSession(mappings_rows=[])
    out = ccl._query_heat_latest(db)  # type: ignore[arg-type]
    assert out == {"systems": [], "total_reserve_gcal_h": None}


def test_query_heat_latest_missing_table_degrades() -> None:
    """Таблица не существует (миграция 182 не применена) → пустой блок, не падаем."""
    db = _FakeSession(execute_raises=RuntimeError('relation "heat_system_reserves" does not exist'))
    out = ccl._query_heat_latest(db)  # type: ignore[arg-type]
    assert out == {"systems": [], "total_reserve_gcal_h": None}


# ── _reserve_note: пометка ЕЭСК ───────────────────────────────────────────────


def test_reserve_note_eesk_null_reserve() -> None:
    note = ccl._reserve_note("АО «ЕЭСК»", None)
    assert note == ccl._EESK_RESERVE_NOTE
    assert "ЕЭСК" in note


def test_reserve_note_eesk_with_reserve_none() -> None:
    """ЕЭСК с известным резервом → без пометки."""
    assert ccl._reserve_note("АО «ЕЭСК»", 12.0) is None


def test_reserve_note_other_dzo_none() -> None:
    """ПС не-ЕЭСК без резерва → без пометки."""
    assert ccl._reserve_note("Россети Урал", None) is None
    assert ccl._reserve_note(None, None) is None
