"""Unit-тесты загрузчика тепло-резервов ФАС (ri.eias.ru) (#2119 Phase B2).

Pure / mock-based — без реальной сети и БД. Покрывает:
- extract_publications: (guid, id, период) из синтетического HTML грида + выбор
  свежайшего квартала (несколько публикаций одного квартала → все);
- extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом);
- parse_heat_sheet: column-aligned парс РЕАЛЬНОЙ сетки ЕТК (строка «Централизованная
  система» ↔ строка №5 «Резерв мощности…»), нуль-резерв сохранён, Flag_Row_Size не ловит
  рубрики анкеты (регрессия прод-бага);
- _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах).
"""

from __future__ import annotations

import base64
import io
import zipfile
from contextlib import contextmanager
from typing import Any

import openpyxl

from app.services.site_finder import eias_heat_loader as eh

# ── extract_publications: (guid, id, период) + свежайший квартал ──────────────


def _pub_row(guid: str, pub_id: str, period_text: str) -> str:
    """<tr> строки грида: ссылка на TemplatePrinter (guid+id) + текст периода."""
    href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}"
    return f'<tr><td><a href="{href}">выгрузить</a></td><td>{period_text}</td></tr>'


def test_extract_publications_real_onclick_form() -> None:
    """РЕАЛЬНАЯ разметка прода: ссылка в onclick=openTemplateDialog(...), href="#",
    амперсанды HTML-энкожены (&amp;), период «1 кв. 2026 года» в тексте строки."""
    url = (
        "https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66"
        "&amp;guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&amp;id=9125184878"
    )
    html = (
        "<table><tr>"
        '<td id="ASPxGridViewDet_tccell0_15" class="dxgv">'
        f"<a class='get_template' onclick=\"openTemplateDialog('{url}', "
        "'Акционерное общество «Екатеринбургская теплосетевая компания» "
        '(ИНН 6671019770, КПП 667101001)\');return false;" href="#">'
        "выгрузить</a></td>"
        "<td>1 кв. 2026 года</td>"
        "</tr></table>"
    )

    pubs = eh.extract_publications(html)

    assert len(pubs) == 1
    assert pubs[0]["guid"] == "40665f4f-c7e7-4121-ac15-0bff24c39f81"
    assert pubs[0]["id"] == "9125184878"
    assert pubs[0]["year"] == 2026
    assert pubs[0]["quarter"] == 1


def test_extract_publications_freshest_quarter_all_pubs() -> None:
    """Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
    html = (
        "<table>"
        + "".join(
            [
                _pub_row("g-old", "101", "1 квартал 2026"),
                _pub_row("g-new-a", "201", "2 квартал 2026"),  # свежайший, ЦС A
                _pub_row("g-new-b", "202", "2 квартала 2026"),  # свежайший, ЦС B
            ]
        )
        + "</table>"
    )

    pubs = eh.extract_publications(html)

    assert len(pubs) == 2
    guids = {p["guid"] for p in pubs}
    assert guids == {"g-new-a", "g-new-b"}
    assert all((p["year"], p["quarter"]) == (2026, 2) for p in pubs)
    by_guid = {p["guid"]: p for p in pubs}
    assert by_guid["g-new-a"]["id"] == "201"
    assert by_guid["g-new-b"]["id"] == "202"


def test_extract_publications_dedupes_repeated_guid_id() -> None:
    """Одна публикация в нескольких строках грида (передача/сбыт) → одна запись.

    На проде каждый (guid, id) повторяется ~3 раза — без дедупа лоадер качал бы
    один и тот же ZIP многократно.
    """
    html = (
        "<table>"
        + "".join(
            [
                _pub_row("g-dup", "301", "1 кв. 2026 года"),  # передача
                _pub_row("g-dup", "301", "1 кв. 2026 года"),  # сбыт (та же публикация)
                _pub_row("g-other", "302", "1 кв. 2026 года"),
            ]
        )
        + "</table>"
    )

    pubs = eh.extract_publications(html)

    assert len(pubs) == 2
    assert [(p["guid"], p["id"]) for p in pubs] == [("g-dup", "301"), ("g-other", "302")]


def test_extract_publications_year_beats_quarter() -> None:
    """4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
    html = (
        "<table>"
        + "".join(
            [
                _pub_row("g-2025q4", "10", "4 квартал 2025"),
                _pub_row("g-2026q1", "20", "1 квартал 2026"),
            ]
        )
        + "</table>"
    )

    pubs = eh.extract_publications(html)

    assert len(pubs) == 1
    assert pubs[0]["guid"] == "g-2026q1"
    assert (pubs[0]["year"], pubs[0]["quarter"]) == (2026, 1)


def test_extract_publications_skips_rows_without_period_or_link() -> None:
    """Строка без ссылки ИЛИ без квартала игнорируется; нет валидных → []."""
    html = (
        "<table>"
        "<tr><td>шапка без ссылки 2 квартал 2026</td></tr>"
        + _pub_row("g-noperiod", "99", "без указания периода")
        + "</table>"
    )
    assert eh.extract_publications(html) == []


def test_quarter_from_text_helper() -> None:
    assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
    assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
    assert eh._quarter_from_text("1 кв. 2026 года") == (2026, 1)  # реальная форма прода
    assert eh._quarter_from_text("1 кв 2026") == (2026, 1)  # без точки
    assert eh._quarter_from_text("5 квартал 2026") is None  # квартал вне 1..4
    assert eh._quarter_from_text("без квартала 2026") is None


# ── extract_tpl_xlsx: base64 → ZIP → член 'tpl' ──────────────────────────────


def _make_tpl_zip_html(xlsx_bytes: bytes, member: str = "tpl") -> str:
    """Строит HTML TemplatePrinter: zip с членом <member>=xlsx → base64 в data-file."""
    zbuf = io.BytesIO()
    with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.writestr(member, xlsx_bytes)
        zf.writestr("meta.xml", b"<meta/>")  # посторонний член — должен игнорироваться
    b64 = base64.b64encode(zbuf.getvalue()).decode("ascii")
    return (
        "<html><body>"
        f'<div id="dataFile" data-filename="f.zip" data-file="{b64}"></div>'
        "</body></html>"
    )


# Сокращённая копия РЕАЛЬНОЙ сетки листа «ТП» ЕТК (снята с прода, raw jsonb). Колонки
# 0-indexed. Систем 3 (col7/8/9), последняя колонка = служебный Flag_Row_Size (числа-
# высоты строк шаблона ЕИАС) — ловушка, которая заставляла старый парсер хватать рубрики
# анкеты. Строки-рубрики анкеты (col3 == 1..4) специально несут БОЛЬШИЕ Flag_Row_Size
# (74/52) в последней колонке — регрессия прод-бага.
_ROW_SIZE_COL = 25  # индекс служебной колонки Flag_Row_Size

_LONG_RESERVE_NOTE = (
    "Указывается резерв мощности для системы теплоснабжения, тариф для которой не "
    "является отличным от тарифов других систем теплоснабжения регулируемой организации."
)


def _pad(cells: dict[int, object]) -> list[object]:
    """Строка листа как список из 26 колонок: dict{col_index: value} → выровненный ряд."""
    row: list[object] = [None] * 26
    for idx, value in cells.items():
        row[idx] = value
    return row


def _heat_xlsx_bytes() -> bytes:
    """XLSX с листом 'ТП' — сокращённая РЕАЛЬНАЯ сетка ЕТК (3 системы + строка №5).

    Воспроизводит прод-структуру: строка «Централизованная система» (имена в col7/8/9 +
    служебное число '15' в хвосте), строка №5 «Резерв мощности…» (0 / 3.26 / 11.28 в тех
    же колонках + текст-примечание), рубрики анкеты с большим Flag_Row_Size (регрессия),
    служебные строки diff_*/Flag_Col_Size.
    """
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "ТП"

    # Служебная строка diff_* (col7.. заголовки-технические) + Flag_Row_Size.
    ws.append(_pad({7: "diff_1", 8: "diff_5", 9: "diff_51", _ROW_SIZE_COL: 1}))
    # Строка-заголовок систем: имена в col7/8/9 (trailing-space у части), '15' в хвосте.
    ws.append(
        _pad(
            {
                4: "Централизованная система",
                7: "СТ № 56",
                8: "СТ № 54 ",  # trailing space — должен быть stripped
                9: "СТ № 60 ",
                20: "15",  # служебное ЧИСЛО в хвосте строки-заголовка → фильтруется
                _ROW_SIZE_COL: 15,
            }
        )
    )
    # Рубрика анкеты (col3==3): большой Flag_Row_Size 74 — старый парсер ловил его.
    ws.append(
        _pad(
            {
                3: 3,
                4: "Количество заявок, по которым отказано в заключении договора",
                5: "ед",
                7: 0,
                8: 0,
                9: 0,
                _ROW_SIZE_COL: 74,  # ЛОВУШКА: не должен попасть в системы
            }
        )
    )
    # Рубрика анкеты (col3==4): Flag_Row_Size 52 — тоже ловушка.
    ws.append(
        _pad(
            {
                3: 4,
                4: "Причины отказа в заключении договора",
                5: "x",
                10: "Указывается текстовое описание причин.",
                _ROW_SIZE_COL: 52,  # ЛОВУШКА
            }
        )
    )
    # Строка резерва №5: значения Гкал/ч в col7/8/9 (0 / 3.26 / 11.28) + примечание.
    ws.append(
        _pad(
            {
                3: 5,
                4: "Резерв мощности источников тепловой энергии, входящих в систему, в т.ч.:",
                5: "Гкал/час",
                6: 0,  # слот без имени системы (col6 header = None) → игнорируется
                7: 0,  # СТ № 56 → 0.0 (нулевой резерв — честный дефицит, сохраняем)
                8: 3.26,  # СТ № 54
                9: 11.28,  # СТ № 60
                10: _LONG_RESERVE_NOTE,  # текст-примечание в хвосте
                _ROW_SIZE_COL: 57,
            }
        )
    )
    # Кнопка «Добавить систему» с Flag_Row_Size 15 — тоже не должна стать системой.
    ws.append(_pad({4: "Добавить систему", _ROW_SIZE_COL: 15}))
    # Служебная последняя строка Flag_Col_Size.
    ws.append(_pad({0: "Flag_Col_Size", 1: 0, 4: 36, _ROW_SIZE_COL: 23}))

    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()


def test_extract_tpl_xlsx_roundtrip() -> None:
    """base64 → zip → член 'tpl' извлекается и открывается openpyxl."""
    html = _make_tpl_zip_html(_heat_xlsx_bytes())
    out = eh.extract_tpl_xlsx(html)
    assert out is not None
    wb = openpyxl.load_workbook(io.BytesIO(out))
    assert "ТП" in wb.sheetnames


def test_extract_tpl_xlsx_missing_member_returns_none() -> None:
    """ZIP без члена 'tpl' → None (graceful, не падаем)."""
    html = _make_tpl_zip_html(_heat_xlsx_bytes(), member="other")
    assert eh.extract_tpl_xlsx(html) is None


def test_extract_tpl_xlsx_no_datafile_div_returns_none() -> None:
    """Нет <div id=dataFile> → None."""
    assert eh.extract_tpl_xlsx("<html><body>пусто</body></html>") is None


def test_extract_tpl_xlsx_bad_zip_returns_none() -> None:
    """data-file — валидный base64, но не ZIP → None (graceful)."""
    b64 = base64.b64encode(b"not a zip at all").decode("ascii")
    html = f'<div id="dataFile" data-file="{b64}"></div>'
    assert eh.extract_tpl_xlsx(html) is None


# ── parse_heat_sheet: column-aligned парс РЕАЛЬНОЙ сетки ЕТК ──────────────────


def test_parse_heat_sheet_column_aligned_three_systems() -> None:
    """Ровно 3 системы, читаемые по колонкам строки №5; имена stripped, нуль сохранён.

    Реальные прод-пары: СТ № 56 → 0.0, СТ № 54 → 3.26, СТ № 60 → 11.28.
    """
    systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())

    pairs = [(s["system_name"], s["reserve_gcal_h"]) for s in systems]
    assert pairs == [
        ("СТ № 56", 0.0),  # нулевой резерв — честный дефицит, СОХРАНЯЕТСЯ
        ("СТ № 54", 3.26),  # trailing-space в имени убран .strip()
        ("СТ № 60", 11.28),
    ]
    # all_cells содержит ВЕСЬ лист (7 строк) для raw.
    assert len(all_cells) == 7


def test_parse_heat_sheet_ignores_anketa_rubrics_flag_row_size() -> None:
    """Регрессия прод-бага: рубрики анкеты (Flag_Row_Size 74/52 в служебной колонке)
    и кнопка «Добавить систему» НЕ распознаются как системы теплоснабжения."""
    systems, _ = eh.parse_heat_sheet(_heat_xlsx_bytes())

    names = {s["system_name"] for s in systems}
    reserves = {s["reserve_gcal_h"] for s in systems}
    # ни одна рубрика/кнопка не просочилась
    assert not any("Количество" in n for n in names)
    assert not any("Причины" in n for n in names)
    assert "Добавить систему" not in names
    # значения Flag_Row_Size (74, 52, 15) не попали в резервы
    assert 74.0 not in reserves
    assert 52.0 not in reserves


def test_parse_heat_sheet_suffixes_duplicate_system_names() -> None:
    """Разные системы с БАЙТ-В-БАЙТ одинаковым именем не схлопываются.

    Живой прод-кейс (Екатеринбургэнерго, guid fe0c8c57): col7 и col8 обе называются
    «Локальные зоны теплоснабжения МУП "Екатеринбургэнерго"» с резервами 62.198 и 0 —
    UPSERT по (org, sphere, system_name, period) перезаписывал 62.198 нулём. Вторым
    и далее вхождениям добавляется суффикс « (N)».
    """
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "ТП"
    dup = 'Локальные зоны теплоснабжения МУП "Екатеринбургэнерго"'
    ws.append(
        _pad(
            {
                4: "Централизованная система",
                7: dup,
                8: dup,
                9: 'Локальные зоны теплоснабжения ПАО "Т Плюс"',
                _ROW_SIZE_COL: 15,
            }
        )
    )
    ws.append(
        _pad(
            {
                3: 5,
                4: "Резерв мощности источников тепловой энергии, в т.ч.:",
                5: "Гкал/час",
                7: 62.198,
                8: 0,
                9: 0,
                _ROW_SIZE_COL: 57,
            }
        )
    )
    buf = io.BytesIO()
    wb.save(buf)

    systems, _ = eh.parse_heat_sheet(buf.getvalue())

    pairs = [(s["system_name"], s["reserve_gcal_h"]) for s in systems]
    assert pairs == [
        (dup, 62.198),  # первое вхождение — без суффикса, 62.198 НЕ теряется
        (f"{dup} (2)", 0.0),
        ('Локальные зоны теплоснабжения ПАО "Т Плюс"', 0.0),
    ]


def test_parse_heat_sheet_no_systems_header_returns_empty() -> None:
    """Нет строки «Централизованная система» → ([], all_cells) (graceful, не падаем)."""
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "ТП"
    ws.append(_pad({4: "Какой-то посторонний заголовок", _ROW_SIZE_COL: 15}))
    ws.append(_pad({3: 5, 4: "Резерв мощности источников тепловой энергии, в т.ч.:", 7: 3.26}))
    buf = io.BytesIO()
    wb.save(buf)

    systems, all_cells = eh.parse_heat_sheet(buf.getvalue())
    assert systems == []
    assert len(all_cells) == 2


# ── Fake DB (SAVEPOINT + execute), как в test_eesk_reserve_loader.py ──────────


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

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


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

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

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

    def rollback(self) -> None:
        pass


def test_upsert_heat_rows_counters_and_params() -> None:
    """parsed/inserted счётчики; params корректны; raw содержит cells; CAST jsonb."""
    db = _FakeSession(scalar_value=True)  # xmax=0 → insert
    systems = [
        {"system_name": "СТ №54", "reserve_gcal_h": 3.26},
        {"system_name": "зона Юг", "reserve_gcal_h": -1.2},  # дефицит: знак сохраняется
    ]
    all_cells = [["СТ №54", "источник", 3.26], ["зона Юг", None, -1.2]]

    counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "guid-xyz", systems, all_cells)

    assert counts == {"parsed": 2, "inserted": 2, "updated": 0, "skipped": 0}
    assert len(db.calls) == 2

    sql, first = db.calls[0]
    assert first["org"] == "ЕТК"
    assert first["system_name"] == "СТ №54"
    assert first["reserve"] == 3.26
    assert first["period"] == "2026-Q2"
    assert first["source_guid"] == "guid-xyz"
    # raw — JSON-строка с ключом cells (весь лист), CAST в jsonb в SQL.
    assert '"cells"' in first["raw"]
    assert "CAST(:raw AS jsonb)" in sql
    assert "ON CONFLICT (org, sphere, system_name, period)" in sql
    # negative reserve (дефицит) прокинут знаком.
    assert db.calls[1][1]["reserve"] == -1.2


def test_upsert_heat_rows_update_counter() -> None:
    """scalar()=False (xmax≠0) → строка учтена как update, не insert."""
    db = _FakeSession(scalar_value=False)
    systems = [{"system_name": "СТ №1", "reserve_gcal_h": 1.0}]
    counts = eh._upsert_heat_rows(
        db, "Екатеринбургэнерго", "2026-Q2", "g1", systems, [["СТ №1", 1.0]]
    )
    assert counts == {"parsed": 1, "inserted": 0, "updated": 1, "skipped": 0}


def test_upsert_heat_rows_skip_on_error() -> None:
    """execute бросает → строка skipped, счётчик не растёт в inserted/updated."""

    class _RaisingSession(_FakeSession):
        def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
            raise RuntimeError("db boom")

    db = _RaisingSession()
    systems = [{"system_name": "СТ №9", "reserve_gcal_h": 2.0}]
    counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "g", systems, [["СТ №9", 2.0]])
    assert counts == {"parsed": 1, "inserted": 0, "updated": 0, "skipped": 1}
