"""Unit-тесты загрузчиков «точек подключения со свободной мощностью» (#2119).

Pure / mock-based — без реальной сети и БД. Покрывает:
- normalize_sc_name: «ПС 110/35/10 Нижне-Исетская» ↔ «Нижне-Исетская», ё, тире.
- parse_voltage_class: извлечение класса напряжения из имени ЦП.
- _map_load_index: sc_indexload_id → open/limited/closed/None.
- sanitize_tp_capacity_mva: кВА-санитайз (÷1000 для ТП > порога, РП не трогаем).
- parse_reserve_number / parse_water_number: запятая, знак (дефицит <0), «н/д».
- infer_period: «1kv.-2026» → '2026-Q1'.
- parse_docx_table_rows: forward-fill вертикального merge (vMerge) на xml-фрагменте.
- extract_water_rows: column-aligned извлечение ЦСВ/ЦСК из РЕАЛЬНОЙ сетки прода —
  оба layout (water 6-колонок «длинный» / sewerage 5-колонок «блочный»); резерв из
  строки «Резерв мощности…», мусор (№ п/п, рубрики, «Итого:», шапки) отфильтрован,
  нули сохранены — регрессия прод-бага #2126.
- load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом workbook.
- load_cp_reserves: row-offset (данные с строки 8) + матч по sc_name_norm.
"""

from __future__ import annotations

import io
from contextlib import contextmanager
from typing import Any

import openpyxl

from app.services.site_finder import rosseti_reserve_loader as rr
from app.services.site_finder import rosseti_wfs_loader as rw
from app.services.site_finder import vodokanal_reserve_loader as vk

# ── normalize_sc_name ─────────────────────────────────────────────────────────


def test_normalize_strips_ps_and_voltage_prefix() -> None:
    """«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская» (совпадает с голым именем)."""
    a = rw.normalize_sc_name("ПС 110/35/10 Нижне-Исетская")
    b = rw.normalize_sc_name("Нижне-Исетская")
    assert a == b == "нижне-исетская"


def test_normalize_yo_to_e() -> None:
    """ё → е при нормализации."""
    assert rw.normalize_sc_name("Берёзовская") == rw.normalize_sc_name("Березовская")
    assert rw.normalize_sc_name("Берёзовская") == "березовская"


def test_normalize_dash_variants_collapse() -> None:
    """Разные тире (—, –, -) и пробелы вокруг схлопываются в один дефис."""
    assert rw.normalize_sc_name("Нижне — Исетская") == "нижне-исетская"
    assert rw.normalize_sc_name("Нижне–Исетская") == "нижне-исетская"


def test_normalize_quotes_removed() -> None:
    """«ПС "Уктус"» → «уктус» (кавычки и префикс срезаны)."""
    assert rw.normalize_sc_name('ПС "Уктус"') == "уктус"


def test_normalize_empty() -> None:
    assert rw.normalize_sc_name(None) == ""
    assert rw.normalize_sc_name("") == ""


# ── parse_voltage_class ───────────────────────────────────────────────────────


def test_parse_voltage_class_triple() -> None:
    assert rw.parse_voltage_class("ПС 110/35/10 Нижне-Исетская") == "110/35/10"


def test_parse_voltage_class_double() -> None:
    assert rw.parse_voltage_class("ПС 110/10 Уктус") == "110/10"


def test_parse_voltage_class_none() -> None:
    assert rw.parse_voltage_class("Уктус") is None
    assert rw.parse_voltage_class(None) is None


# ── _map_load_index ───────────────────────────────────────────────────────────


def test_map_load_index_known() -> None:
    assert rw._map_load_index({"sc_indexload_id": 256184}) == "open"
    assert rw._map_load_index({"sc_indexload_id": 256186}) == "limited"
    assert rw._map_load_index({"sc_indexload_id": 256188}) == "closed"


def test_map_load_index_string_id_coerced() -> None:
    """id приходит строкой из json — коэрсим в int."""
    assert rw._map_load_index({"sc_indexload_id": "256184"}) == "open"


def test_map_load_index_unknown_and_missing() -> None:
    assert rw._map_load_index({"sc_indexload_id": 999999}) is None
    assert rw._map_load_index({}) is None
    assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None


# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────


def test_kva_sanitize_tp_above_threshold_divided() -> None:
    """ТП с installed=630 (кВА в колонке МВА) → 0.63 МВА, флаг True."""
    val, sanitized = rr.sanitize_tp_capacity_mva(630.0, is_rp=False)
    assert sanitized is True
    assert val == 0.63


def test_kva_sanitize_tp_below_threshold_kept() -> None:
    """ТП с installed=1.6 МВА (правдоподобно) → не трогаем."""
    val, sanitized = rr.sanitize_tp_capacity_mva(1.6, is_rp=False)
    assert sanitized is False
    assert val == 1.6


def test_kva_sanitize_rp_never_divided() -> None:
    """РП с высокой мощностью легитимен → не делим даже выше порога."""
    val, sanitized = rr.sanitize_tp_capacity_mva(10.0, is_rp=True)
    assert sanitized is False
    assert val == 10.0


def test_kva_sanitize_none() -> None:
    val, sanitized = rr.sanitize_tp_capacity_mva(None, is_rp=False)
    assert val is None and sanitized is False


# ── parse_reserve_number ──────────────────────────────────────────────────────


def test_parse_reserve_comma_decimal() -> None:
    assert rr.parse_reserve_number("1,63") == 1.63


def test_parse_reserve_negative_kept() -> None:
    """Отрицательный резерв (дефицит) сохраняет знак."""
    assert rr.parse_reserve_number("-2,5") == -2.5


def test_parse_reserve_thousands_space() -> None:
    """Неразрывный пробел-разделитель тысяч убирается."""
    assert rr.parse_reserve_number("1\xa0234,5") == 1234.5


def test_parse_reserve_nodata() -> None:
    assert rr.parse_reserve_number("н/д") is None
    assert rr.parse_reserve_number("") is None
    assert rr.parse_reserve_number(None) is None
    assert rr.parse_reserve_number("текст") is None


def test_parse_reserve_numeric_passthrough() -> None:
    assert rr.parse_reserve_number(5) == 5.0
    assert rr.parse_reserve_number(2.5) == 2.5


# ── parse_asof_date ───────────────────────────────────────────────────────────


def test_parse_asof_date_from_filename() -> None:
    d = rr.parse_asof_date("резерв по ЦП на 30.06.2026.xlsx")
    assert d is not None and d.isoformat() == "2026-06-30"


def test_parse_asof_date_none() -> None:
    assert rr.parse_asof_date("no date here", None) is None


# ── water: parse_water_number / infer_period ──────────────────────────────────


def test_parse_water_negative_unicode_minus() -> None:
    """Юникод-минус −168,5 (дефицит) → -168.5."""
    assert vk.parse_water_number("−168,5") == -168.5


def test_parse_water_nodata() -> None:
    assert vk.parse_water_number("н/д") is None
    assert vk.parse_water_number("") is None
    assert vk.parse_water_number(None) is None


def test_infer_period_from_filename() -> None:
    assert vk.infer_period("dlya-sajta-1kv.-2026.docx") == "2026-Q1"
    assert vk.infer_period("dlya-sajta-3кв-2025.docx") == "2025-Q3"


def test_infer_period_none() -> None:
    assert vk.infer_period("dlya-sajta.docx") is None


# ── DOCX vMerge forward-fill ──────────────────────────────────────────────────

_W = vk._W


def _tc(text: str = "", vmerge: str | None = None) -> str:
    """Строит XML одной ячейки <w:tc> с опциональным vMerge."""
    tc_pr = ""
    if vmerge == "restart":
        tc_pr = '<w:tcPr><w:vMerge w:val="restart"/></w:tcPr>'
    elif vmerge == "continue":
        tc_pr = "<w:tcPr><w:vMerge/></w:tcPr>"
    return f"<w:tc>{tc_pr}<w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:tc>"


def _doc_xml(rows: list[str]) -> bytes:
    """Строит минимальный document.xml с одной таблицей из строк-<w:tr>."""
    trs = "".join(f"<w:tr>{r}</w:tr>" for r in rows)
    xml = f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{trs}</w:tbl></w:body></w:document>'
    return xml.encode("utf-8")


def test_docx_vmerge_forward_fill() -> None:
    """Ячейка колонки 0 в 'continue'-строках наследует значение из restart-строки."""
    rows = [
        _tc("ЗФС, ГСВ", vmerge="restart") + _tc("−168,5") + _tc("примечание"),
        _tc("", vmerge="continue") + _tc("12,3") + _tc(""),
        _tc("", vmerge="continue") + _tc("0,0") + _tc(""),
    ]
    matrix = vk.parse_docx_table_rows(_doc_xml(rows))
    assert len(matrix) == 3
    # forward-fill: имя системы протянуто во все три строки блока.
    assert matrix[0][0] == "ЗФС, ГСВ"
    assert matrix[1][0] == "ЗФС, ГСВ"
    assert matrix[2][0] == "ЗФС, ГСВ"
    # числовая колонка не наследуется.
    assert matrix[1][1] == "12,3"


def test_docx_empty_no_table() -> None:
    xml = f'<w:document xmlns:w="{_W}"><w:body/></w:document>'.encode()
    assert vk.parse_docx_table_rows(xml) == []


# ── extract_water_rows: РЕАЛЬНАЯ сетка прода (регрессия #2126) ────────────────
#
# Обе матрицы — сокращённые копии РЕАЛЬНОЙ сетки, снятой с прода 2026-07-03
# (dlya-sajta-vodosnabzhenie-1kv.-2026v1.docx / vodootvedenie). Прод-баг #2126:
# старый парсер брал «первое число строки» как резерв → в БД попадали № п/п
# (1.1, 2.18…) вместо тыс.м³/сут, а рубрики анкеты / «Итого:» / шапки — как «системы».
# Ниже фикстуры специально несут: шапку, рубрики (заявки/исполненные/отказы),
# строку «Итого:», строку-легенду, ноль-резерв с «Отсутствие резерва мощности».

# Наименования показателей формы (точь-в-точь с прода).
_M_REQ = "Количество поданных и зарегистрированных заявок на подключение"
_M_DONE = "Количество исполненных заявок на подключение"
_M_REFUSED = "Количество заявок на подключение, по которым принято решение об отказе в подключении"
_M_RESERVE_W = "Резерв мощности централизованной системы холодного водоснабжения"
_M_RESERVE_S = "Резерв мощности централизованной системы водоотведения"


def _water_block(
    num: str, name: str, req: str, refused: str, reserve: str, note: str
) -> list[list[str]]:
    """4 строки показателей одного ЦСВ водоснабжения (layout «water», 6 колонок)."""
    return [
        [num, name, _M_REQ, "шт", req, ""],
        [num, name, _M_DONE, "шт", "0", ""],
        [num, name, _M_REFUSED, "шт", refused, ""],
        [num, name, _M_RESERVE_W, "тыс. м3/сут", reserve, note],
    ]


def _real_water_matrix() -> list[list[str]]:
    """РЕАЛЬНАЯ сетка водоснабжения: шапка + 3 ЦСВ + ПТВ + «Итого:» + легенда."""
    return [
        ["1 квартал 2026г."],
        ["Информация о наличии\xa0(отсутствии) технической возможности…"],
        # шапка (col2 == 'Наименование показателя' → детект layout 'water')
        [
            "№ ЦСВ",
            "Название ЦСВ",
            "Наименование показателя",
            "Ед. изм.",
            "Период 01.01.2026 -31.03.2026",
            "Примечание",
        ],
        # ЗФС,ГСВ: резерв −168,5 (дефицит), 49 заявок; № п/п '1.1.' НЕ должен стать резервом
        *_water_block("1.1.", 'ЦСВ  "ЗФС, ГСВ"', "49", "0", "-168,5", ""),
        ["", "", "", "", "", ""],  # разделитель-пустышка
        # Изоплит: ноль-резерв «Отсутствие резерва мощности» — сохраняем 0.0 + note
        *_water_block("1.3.", 'ЦСВ "Изоплит"', "0", "0", "0,0", "Отсутствие резерва мощности"),
        ["", "", "", "", "", ""],
        # Шабровский: положительный резерв 1,03
        *_water_block("2.10.", 'ЦСВ "пос. Шабровский"', "0", "0", "1,03", ""),
        ["", "", "", "", "", ""],
        # ПТВ (№ ЦСВ = 'ПТВ', не числовой) — резерв 73,34, реальный крупный запас
        *_water_block("ПТВ", "ПТВ", "0", "0", "73,34", ""),
        ["", "", "", ""],
        # «Итого:» — агрегат по всем системам, НЕ резерв конкретной ЦСВ
        ["Итого:", _M_REQ, "шт", "65", ""],
        ["Итого:", _M_DONE, "шт", "41", ""],
        ["Итого:", _M_REFUSED, "шт", "0", ""],
        # легенда в конце
        ["ЦСВ – централизованная система водоснабжения", "", "", ""],
        ["ПТВ – производственно-техническое водоснабжение", "", "", ""],
    ]


def _sewer_block(title: str, req: str, reserve: str, note: str) -> list[list[str]]:
    """Строка-заголовок системы + шапка + 4 показателя (layout «block», 5 колонок)."""
    return [
        [title],  # одиночная строка-заголовок системы (только col0)
        ["№ п/п", "Наименование показателя", "Единица измерения", "Период", "Примечание"],
        ["1", _M_REQ, "шт.", req, ""],
        ["2", _M_DONE, "шт.", "0", ""],
        ["3", _M_REFUSED, "шт.", "0", ""],
        ["4", _M_RESERVE_S, "тыс.м3/сут", reserve, note],
    ]


def _real_sewer_matrix() -> list[list[str]]:
    """РЕАЛЬНАЯ сетка водоотведения: 3 системы-блока + «Итого:»."""
    return [
        ["1 квартал 2026г."],
        ["Информация о наличии\xa0(отсутствии) технической возможности…"],
        # Южный: дефицит −296,85 (крупнейший), 62 заявки; '4' в col0 НЕ должен стать резервом
        *_sewer_block(
            'Централизованная система водоотведения "Южный бассейн канализования"',
            "62",
            "-296,85",
            "Отсутствие резерва мощности",
        ),
        # Северный: положительный 2,02
        *_sewer_block(
            'Централизованная система водоотведения "Северный бассейн канализования"',
            "3",
            "2,02",
            "",
        ),
        # Транспортировка: ноль-резерв honestly сохраняем
        *_sewer_block(
            'Транспортировка сточных вод ЦСВО "Бассейн канализования "Чусовское озеро"',
            "0",
            "0",
            "Отсутствие резерва мощности",
        ),
        ["", "", "", "", ""],
        ["Итого:", _M_REQ, "шт.", "65", ""],
        ["Итого:", _M_DONE, "шт.", "37", ""],
        ["Итого:", _M_REFUSED, "шт.", "0", ""],
    ]


def test_vodokanal_water_column_aligned_real_grid() -> None:
    """Layout 'water': резерв из строки «Резерв мощности…», а НЕ № п/п; заявки верны."""
    recs = vk.extract_water_rows(_real_water_matrix())
    by_name = {r["system_name"]: r for r in recs}

    # ровно 4 системы (ЗФС, Изоплит, Шабровский, ПТВ) — ни одной рубрики/«Итого»/легенды
    assert len(recs) == 4
    # РЕАЛЬНЫЕ резервы из правильной колонки (тыс.м³/сут), знак дефицита сохранён
    assert by_name['ЦСВ  "ЗФС, ГСВ"']["reserve_thousand_m3_day"] == -168.5
    assert by_name['ЦСВ  "ЗФС, ГСВ"']["connections_requested"] == 49
    assert by_name["ПТВ"]["reserve_thousand_m3_day"] == 73.34
    assert by_name['ЦСВ "пос. Шабровский"']["reserve_thousand_m3_day"] == 1.03
    # ни один резерв не равен № п/п (1.1 / 1.3 / 2.10) — прод-баг #2126
    reserves = {r["reserve_thousand_m3_day"] for r in recs}
    assert 1.1 not in reserves
    assert 2.1 not in reserves


def test_vodokanal_water_zero_reserve_preserved_with_note() -> None:
    """Ноль-резерв («Отсутствие резерва мощности») сохраняется честно: 0.0 + note."""
    recs = vk.extract_water_rows(_real_water_matrix())
    izoplit = next(r for r in recs if r["system_name"] == 'ЦСВ "Изоплит"')
    assert izoplit["reserve_thousand_m3_day"] == 0.0  # НЕ None, НЕ отброшен
    assert izoplit["note"] == "Отсутствие резерва мощности"


def test_vodokanal_water_filters_rubrics_totals_legend() -> None:
    """Рубрики анкеты, «Итого:», шапка, легенда НЕ распознаются как системы."""
    recs = vk.extract_water_rows(_real_water_matrix())
    names = {r["system_name"] for r in recs}
    assert not any("Количество" in n for n in names)  # рубрики заявок/отказов
    assert not any(n.startswith("Итого") for n in names)
    assert "Название ЦСВ" not in names and "№ ЦСВ" not in names  # шапка
    assert not any(n.startswith("ЦСВ –") or n.startswith("ПТВ –") for n in names)  # легенда


def test_vodokanal_sewer_block_layout_real_grid() -> None:
    """Layout 'block' (водоотведение): имя — строка-заголовок, резерв из «Резерв…»."""
    recs = vk.extract_water_rows(_real_sewer_matrix())
    by_name = {r["system_name"]: r for r in recs}

    assert len(recs) == 3  # 3 системы, «Итого:»/шапки отфильтрованы
    yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
    assert by_name[yuzhny]["reserve_thousand_m3_day"] == -296.85  # крупнейший дефицит
    assert by_name[yuzhny]["connections_requested"] == 62
    severny = 'Централизованная система водоотведения "Северный бассейн канализования"'
    assert by_name[severny]["reserve_thousand_m3_day"] == 2.02
    # '4' (№ п/п строки резерва) не попал в резерв
    assert 4.0 not in {r["reserve_thousand_m3_day"] for r in recs}


def test_vodokanal_sewer_zero_reserve_preserved() -> None:
    """Ноль-резерв транспортировки сохранён (0.0 + note), не отброшен."""
    recs = vk.extract_water_rows(_real_sewer_matrix())
    transp = next(r for r in recs if r["system_name"].startswith("Транспортировка"))
    assert transp["reserve_thousand_m3_day"] == 0.0
    assert transp["note"] == "Отсутствие резерва мощности"


def test_vodokanal_water_no_header_returns_empty() -> None:
    """Нет строки-шапки (layout не распознан) → [] (graceful, warning наверху)."""
    matrix = [
        ["мусор", "без шапки"],
        ["1.1.", 'ЦСВ "X"', "какой-то показатель", "шт", "5", ""],
    ]
    assert vk.extract_water_rows(matrix) == []


def test_vodokanal_dedupe_names_suffixes_byte_identical() -> None:
    """БАЙТ-В-БАЙТ одинаковые имена разводятся суффиксом « (N)» (защита UNIQUE)."""
    recs = [
        {"system_name": "ЦСВ X", "reserve_thousand_m3_day": 1.0},
        {"system_name": "ЦСВ X", "reserve_thousand_m3_day": -2.0},
        {"system_name": "ЦСВ Y", "reserve_thousand_m3_day": 0.0},
    ]
    out = vk._dedupe_names(recs)
    assert [r["system_name"] for r in out] == ["ЦСВ X", "ЦСВ X (2)", "ЦСВ Y"]
    # первое вхождение — без суффикса, его резерв 1.0 НЕ теряется
    assert out[0]["reserve_thousand_m3_day"] == 1.0


# ── Fake DB session для upsert-тестов (SAVEPOINT + execute) ───────────────────


class _FakeResult:
    def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
        self._scalar = scalar_value
        self.rowcount = rowcount

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


class _FakeSession:
    """Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""

    def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
        self.calls: list[tuple[str, Any]] = []
        self._scalar = scalar_value
        self._rowcount = rowcount
        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))
        return _FakeResult(self._scalar, self._rowcount)

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

    def rollback(self) -> None:
        pass


# ── load_water_reserves_from_docx: end-to-end (parse → dedupe → upsert) ────────


def _matrix_to_docx(matrix: list[list[str]]) -> bytes:
    """Матрица текстов → байты .docx (zip с word/document.xml, одна таблица)."""
    import zipfile as _zip
    from xml.sax.saxutils import escape

    trs = []
    for row in matrix:
        tcs = "".join(
            f"<w:tc><w:p><w:r><w:t xml:space='preserve'>{escape(c)}</w:t></w:r></w:p></w:tc>"
            for c in row
        )
        trs.append(f"<w:tr>{tcs}</w:tr>")
    document = (
        f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{"".join(trs)}</w:tbl></w:body></w:document>'
    )
    buf = io.BytesIO()
    with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf:
        zf.writestr("word/document.xml", document.encode("utf-8"))
    return buf.getvalue()


def test_vodokanal_load_from_docx_end_to_end() -> None:
    """Полный путь water: docx → 4 записи, реальные резервы прокинуты в upsert-params."""
    db = _FakeSession(scalar_value=True, rowcount=1)
    docx = _matrix_to_docx(_real_water_matrix())

    result = vk.load_water_reserves_from_docx(
        db, "water", docx, source_url="https://x/dlya-sajta-1kv.-2026.docx"
    )

    assert result["records"] == 4
    assert result["inserted"] == 4
    assert db.committed
    # период инферится из имени файла источника
    assert db.calls[0][1]["period"] == "2026-Q1"
    by_name = {c[1]["system_name"]: c[1] for c in db.calls}
    # РЕАЛЬНЫЙ резерв ЗФС,ГСВ (−168,5), НЕ № п/п 1.1 — прод-баг #2126
    assert by_name['ЦСВ  "ЗФС, ГСВ"']["reserve"] == -168.5
    assert by_name['ЦСВ  "ЗФС, ГСВ"']["requested"] == 49
    assert by_name["ПТВ"]["reserve"] == 73.34
    # ноль-резерв Изоплита сохранён (0.0 + note), не отброшен и не None
    assert by_name['ЦСВ "Изоплит"']["reserve"] == 0.0
    assert by_name['ЦСВ "Изоплит"']["note"] == "Отсутствие резерва мощности"


def test_vodokanal_load_from_docx_sewer_layout() -> None:
    """Полный путь sewerage (block layout): 3 системы, крупнейший дефицит −296,85."""
    db = _FakeSession(scalar_value=True, rowcount=1)
    docx = _matrix_to_docx(_real_sewer_matrix())

    result = vk.load_water_reserves_from_docx(db, "sewerage", docx, source_url="x-3kv-2025.docx")

    assert result["records"] == 3
    by_name = {c[1]["system_name"]: c[1] for c in db.calls}
    yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
    assert by_name[yuzhny]["reserve"] == -296.85
    assert db.calls[0][1]["period"] == "2025-Q3"


# ── load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом xlsx ──


def _build_tp_rp_workbook() -> bytes:
    """Строит xlsx с верифицированной раскладкой ТП/РП: данные с СТРОКИ 9."""
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "ТП РП на 30.06.2026"
    # Строки 1-8 — заголовки/нумерация (шум), должны игнорироваться.
    for r in range(1, 9):
        ws.cell(row=r, column=1, value=f"header-{r}")
    # Строка 9+ — данные. Колонки (1-based): name, РЭС, region, mun, кВ, inst, load, reserve.
    ws.append(["ТП-721", "Центральный РЭС", "СО", "Екатеринбург", "10", 630.0, 100.0, 530.0])
    ws.append(["РП-5", "Западный РЭС", "СО", "Екатеринбург", "10", 8.0, 2.0, 6.0])
    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()


def test_load_tp_rp_row_offset_and_kva() -> None:
    """Данные читаются с строки 9; ТП-721 630→0.63 МВА (кВА-санитайз); РП не делится."""
    db = _FakeSession(scalar_value=True, rowcount=1)
    result = rr.load_tp_rp_reserves(db, _build_tp_rp_workbook(), "тп рп на 30.06.2026.xlsx")

    assert result["rows"] == 2, result
    assert result["inserted"] == 2
    assert result["kva_sanitized"] == 1  # только ТП-721

    # Первый upsert — ТП-721 с санитайзнутой мощностью.
    tp_params = db.calls[0][1]
    assert tp_params["name"] == "ТП-721"
    assert tp_params["installed"] == 0.63  # 630 кВА → 0.63 МВА
    assert tp_params["reserve"] == 530.0
    # РП-5 — мощность не тронута (легитимно высокая для РП, но тут 8 < порога всё равно).
    rp_params = db.calls[1][1]
    assert rp_params["name"] == "РП-5"
    assert rp_params["installed"] == 8.0


# ── load_cp_reserves: row-offset (строка 8) + матч по sc_name_norm ─────────────


def _build_cp_workbook() -> bytes:
    """Строит xlsx с раскладкой ЦП: данные с СТРОКИ 8.

    Колонки (1-based): A=№, B=ЦП name, C=ПО, D=?, E=municipality, F=voltage,
    G=installed, H=current, I=reserve.
    """
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "ЦП на 30.06.2026"
    for r in range(1, 8):
        ws.cell(row=r, column=1, value=f"header-{r}")
    # Строка 8 — первая строка данных.
    ws.append([1, "ПС 110/10 Уктус", "ПО-1", "", "Екатеринбург", "110/10", 40.0, 25.0, 15.0])
    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()


def test_load_cp_reserves_row_offset_and_norm_match() -> None:
    """Данные с строки 8; UPDATE по sc_name_norm; matched считает rowcount."""
    db = _FakeSession(scalar_value=None, rowcount=1)  # UPDATE matched 1 row
    result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")

    assert result["rows"] == 1, result
    assert result["matched"] == 1
    assert result["unmatched"] == 0

    params = db.calls[0][1]
    # sc_name_norm совпадает с нормализацией голого имени «Уктус».
    assert params["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
    assert params["installed"] == 40.0
    assert params["reserve"] == 15.0
    assert params["asof"] is not None and params["asof"].isoformat() == "2026-06-30"


def test_load_cp_reserves_unmatched_counted() -> None:
    """ЦП без совпадения в power_supply_centers (rowcount=0) → unmatched, не падаем."""
    db = _FakeSession(scalar_value=None, rowcount=0)  # UPDATE matched 0 rows
    result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")
    assert result["matched"] == 0
    assert result["unmatched"] == 1


# ── fetch_disclosure_links (прод-инцидент 2026-07-02: URL-энкоженные href) ────


def test_fetch_disclosure_links_decodes_urlencoded_hrefs(monkeypatch):
    """href'ы на странице раскрытия URL-энкоженные (кириллица как %D0%A1...) —
    фильтр обязан работать по декодированному имени и выбирать свежайший файл.
    Пермский файл отфильтровывается, из двух ЦП-дат берётся поздняя."""
    from urllib.parse import quote, unquote

    base = "/public/upload/Documents/2026/Transmission/"
    cp_tpl = (
        "Информация о наличии объема свободной мощности по ЦП ПАО «Россети Урал» "
        "на территории {region} (на {d}).xlsx"
    )
    tp_name = (
        "Информация о наличии свободной мощности для ТП по ПС и РП напряжением "
        "ниже 35 кВ филиала Свердловэнерго на 30.06.2026.xlsx"
    )
    hrefs = [
        base + quote(cp_tpl.format(region="Свердловской области", d="30.03.2026")),
        base + quote(cp_tpl.format(region="Свердловской области", d="30.06.2026")),
        base + quote(cp_tpl.format(region="Пермского края", d="30.06.2026")),
        base + quote(tp_name),
    ]
    html = "".join(f'<a href="{h}">x</a>' for h in hrefs)

    class _Resp:
        status_code = 200
        text = html

        def raise_for_status(self):
            return None

    monkeypatch.setattr(
        "app.services.site_finder.rosseti_reserve_loader.httpx.get",
        lambda *a, **k: _Resp(),
    )
    links = rr.fetch_disclosure_links()
    assert "30.06.2026" in unquote(links["cp"])
    assert "Пермск" not in unquote(links["cp"])
    assert "%D0" in links["cp"]  # скачиваем по СЫРОМУ (энкоженному) href
    assert "Свердловэнерго" in unquote(links["tp_rp"])
