"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).

Источник — реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
reg=2644). Данные лежат за 3 GET-переходами:

  1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) — HTML-грид (DevExpress)
     со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
     ``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
     На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS
     ``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``,
     амперсанды бывают HTML-энкожены (``&amp;``) → чанк строки перед парсом
     прогоняем через ``html.unescape``. Период — «N кв. YYYY года» (реже «N квартал
     YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — их может
     быть несколько (по одной на центральную систему / ЦС).
  2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``<div id="dataFile"
     data-file="<base64>">``; base64 → ZIP (in-memory) → член ``tpl`` (XLSX).
  3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
     (живой пример: СТ №54 → 3.26 Гкал/час).

Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) —
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:

  * Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
    лежат в колонках ``j ≥ 6`` (текст: «СТ № 56», «СТ № 54 » с trailing-space, «Локальные
    зоны теплоснабжения МУП…»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
    ЕИАС) — отсекаем их требованием «имя содержит хотя бы одну букву».
  * Строка резерва (№5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
    мощности источников тепловой энергии» — значения Гкал/ч стоят в ТЕХ ЖЕ колонках
    ``j``, что и имена систем. В хвосте — длинный текст-примечание «Указывается резерв…»
    и служебные числа: они не в колонках систем и отсекаются сами.
  * ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) — служебные числа-высоты
    строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
    хватал именно их и распознавал рубрики анкеты («Количество заявок…», «Причины
    отказа…», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.

Все ячейки листа целиком → ``raw`` jsonb (будущие колонки не теряются). Нет строки
систем ИЛИ строки резерва → warning-лог + parsed=0, НЕ падаем.

Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
``_ru_ssl_context`` (бандл содержит GlobalSign-intermediate для eias.ru). httpx с
явным таймаутом, per-row SAVEPOINT при UPSERT.

Числовой парс переиспользует ``parse_reserve_number`` из rosseti_reserve_loader.
"""

import base64
import binascii
import io
import json
import logging
import re
import zipfile
from html import unescape

import httpx
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.db import SessionLocal
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number

logger = logging.getLogger(__name__)

# Реестр ФАС по сфере «теплоснабжение», регион Свердловская область (reg=2644).
_REG = "2644"
_REG_TEMPLATE = "RU.5.66"

# Организации-держатели: (человекочитаемое имя, orgId в реестре ФАС).
# Расширение 2026-07-03 (разведка реестра ri.eias.ru, 500 орг области, 11 страниц
# грида): добавлены операторы ЕКБ со СВЕЖИМИ публикациями формы 14 (2026-Q1) и
# ненулевыми резервами. Регионгаз-инвест (16/16 ненулевых) + Т Плюс Свердловский
# (15/13) дают ×10 к базе ненулевых резервов; хвост — по +1-2 системы. Свежие-но-
# нулевые операторы (ТЭЦ ВИЗа, Солнечное тепло...) намеренно НЕ добавлены — только
# раздули бы нулевые строки. orgId верифицированы по ИНН через грид-ключи DevExpress.
ORGS: list[tuple[str, int]] = [
    ("ЕТК", 30814352),
    ("Екатеринбургэнерго", 26479450),
    ("Регионгаз-инвест", 26479474),
    ("Т Плюс (Свердловский)", 28951102),
    ("АкадемТеплоСети", 31516224),
    ("Химмаш Энерго", 28881099),
    ("Энергоснабжающая компания", 26322592),
    ("ЕТК (ООО)", 31889852),
]

# Карточка организации: грид с публикациями форм 14 / 4_6 сферы WARM, раздел QUARTER.
_CARD_URL = "https://ri.eias.ru/Discl/PublicDisclosureInfoOrg.aspx"
_CARD_FORMS = "F_W_Q_14;F_W_Q_4_6;"

# Выгрузка публикации: HTML с base64-ZIP в data-file. Хост loader-домена.
_TEMPLATE_URL = "https://ri-loader.eias.ru/TemplatePrinter.aspx"

_HTTP_TIMEOUT = 60

# Лист формы 14 с резервом мощности источников тепла.
_HEAT_SHEET = "ТП"

# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
# варьироваться — берём каждый независимым lookahead-регэкспом на href).
# URL живёт либо в старом href="/Discl/TemplatePrinter.aspx?...", либо (реальная
# разметка прода) в JS onclick="openTemplateDialog('https://ri-loader.eias.ru/
# TemplatePrinter.aspx?...', ...)" при href="#" — поэтому ищем URL где угодно в чанке,
# останавливаясь на кавычках / скобках / пробелах (границы JS-строки и href-атрибута).
_PUB_HREF_RE = re.compile(
    r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
    re.IGNORECASE,
)
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)

# Период в тексте строки грида → (year, quarter). Реальная разметка прода —
# «1 кв. 2026 года»; встречаются также «N квартал YYYY» / «N квартала YYYY» / «N кв YYYY».
_QUARTER_RE = re.compile(r"(\d)\s*кв(?:артал\w*|\.)?\s*(\d{4})", re.IGNORECASE)

# base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
_DATAFILE_RE = re.compile(
    r'<div\b[^>]*id=["\']dataFile["\'][^>]*data-file=["\'](?P<b64>[^"\']+)["\']',
    re.IGNORECASE | re.DOTALL,
)

# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
_SYSTEMS_HEADER = "Централизованная система"

# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
_RESERVE_ROW_NUM = "5"
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"

# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
_SYSTEM_COL_START = 6

# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")

# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
_TR_SPLIT_RE = re.compile(r"<tr\b", re.IGNORECASE)


def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
    """«…N квартал YYYY…» → (year, quarter). Квартал вне 1..4 / нет совпадения → None."""
    m = _QUARTER_RE.search(row_text)
    if not m:
        return None
    quarter = int(m.group(1))
    year = int(m.group(2))
    if quarter not in (1, 2, 3, 4):
        return None
    return (year, quarter)


def build_card_url(org_id: int) -> str:
    """URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
    return (
        f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
    )


def build_template_url(guid: str, pub_id: str) -> str:
    """URL выгрузки публикации (TemplatePrinter, base64-ZIP в data-file)."""
    return f"{_TEMPLATE_URL}?reg={_REG_TEMPLATE}&guid={guid}&id={pub_id}"


def extract_publications(card_html: str) -> list[dict]:
    """Из HTML карточки → публикации свежайшего квартала: [{guid, id, year, quarter}].

    По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
    «N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
    возвращаем ВСЕ публикации именно этого квартала (их бывает несколько — по ЦС).
    Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
    встречается в нескольких строках грида (по видам деятельности: передача/сбыт) —
    дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
    """
    pubs: list[dict] = []
    for raw_chunk in _TR_SPLIT_RE.split(card_html):
        # Атрибуты грида могут содержать HTML-энтити (`&amp;guid=` вместо `&guid=`) —
        # раскрываем до поиска, иначе `[?&]guid=` / `[?&]id=` не сматчатся.
        chunk = unescape(raw_chunk)
        href_m = _PUB_HREF_RE.search(chunk)
        if not href_m:
            continue
        href = href_m.group("href")
        guid_m = _GUID_RE.search(href)
        pubid_m = _PUBID_RE.search(href)
        if not guid_m or not pubid_m:
            continue
        yq = _quarter_from_text(chunk)
        if yq is None:
            continue
        year, quarter = yq
        pubs.append(
            {"guid": guid_m.group(1), "id": pubid_m.group(1), "year": year, "quarter": quarter}
        )

    if not pubs:
        logger.warning("eias_heat: публикации не найдены в карточке (0 строк с guid+период)")
        return []

    freshest = max((p["year"], p["quarter"]) for p in pubs)
    latest: list[dict] = []
    seen: set[tuple[str, str]] = set()
    for p in pubs:
        if (p["year"], p["quarter"]) != freshest or (p["guid"], p["id"]) in seen:
            continue
        seen.add((p["guid"], p["id"]))
        latest.append(p)
    logger.info(
        "eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d → %d публикаций",
        len(pubs),
        freshest[0],
        freshest[1],
        len(latest),
    )
    return latest


def extract_tpl_xlsx(template_html: str) -> bytes | None:
    """HTML выгрузки → байты XLSX (член ``tpl`` из base64-ZIP в data-file). None — нет.

    ``<div id="dataFile" data-file="<base64>">`` → base64decode → ZIP → член ``tpl``.
    Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful).
    """
    m = _DATAFILE_RE.search(template_html)
    if not m:
        logger.warning("eias_heat: <div id=dataFile data-file> не найден в TemplatePrinter HTML")
        return None
    try:
        zip_bytes = base64.b64decode(m.group("b64"), validate=False)
    except (binascii.Error, ValueError) as e:
        logger.warning("eias_heat: base64-декод data-file упал: %s", e)
        return None

    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            names = zf.namelist()
            member = next((n for n in names if n == "tpl" or n.rsplit("/", 1)[-1] == "tpl"), None)
            if member is None:
                logger.warning("eias_heat: член 'tpl' не найден в ZIP (члены: %s)", names[:20])
                return None
            return zf.read(member)
    except zipfile.BadZipFile as e:
        logger.warning("eias_heat: data-file не является валидным ZIP: %s", e)
        return None


def _cell_str(value: object) -> str:
    """Ячейка → strip-строка (None → '')."""
    return "" if value is None else str(value).strip()


def _is_systems_header(cells: list[object]) -> bool:
    """Строка-заголовок систем: col4 == 'Централизованная система'."""
    return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER


def _is_reserve_row(cells: list[object]) -> bool:
    """Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
    if len(cells) <= 4:
        return False
    return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
        _RESERVE_ROW_PREFIX
    )


def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
    """Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.

    Имя обязано быть текстовым и содержать хотя бы одну букву — иначе это служебное
    число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
    """
    result: list[tuple[int, str]] = []
    for j in range(_SYSTEM_COL_START, len(header_cells)):
        value = header_cells[j]
        if value is None:
            continue
        name = str(value).strip()
        if name and _HAS_LETTER_RE.search(name):
            result.append((j, name))
    return result


def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
    """Парсит лист ``ТП`` формы 14 → (systems, all_cells).

    COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):

      1. Строка-заголовок систем — ``col4 == 'Централизованная система'``; имена систем
         берём из колонок ``j ≥ 6`` (текст, содержит букву — служебные числа хвоста
         отсеиваются), имя .strip() (у части систем trailing-space).
      2. Строка резерва (№5) — ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
         мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
         колонки ``j``, что и её имя. Нулевой резерв (0.0) — честный дефицит, СОХРАНЯЕМ;
         непарсибельная ячейка (текст/пусто) — систему пропускаем.

    Нет строки систем ИЛИ строки резерва → warning-лог + ([], all_cells) (graceful).
    Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает —
    именно она заставляла старый парсер хватать рубрики анкеты как «системы».

    ``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
    """
    wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
    ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active

    all_cells: list[list[object]] = []
    header_cells: list[object] | None = None
    reserve_cells: list[object] | None = None
    try:
        for row in ws.iter_rows(values_only=True):
            cells = list(row)
            all_cells.append(cells)
            if header_cells is None and _is_systems_header(cells):
                header_cells = cells
            elif reserve_cells is None and _is_reserve_row(cells):
                reserve_cells = cells
    finally:
        wb.close()

    if header_cells is None or reserve_cells is None:
        logger.warning(
            "eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
            header_cells is not None,
            reserve_cells is not None,
        )
        return [], all_cells

    systems: list[dict] = []
    name_counts: dict[str, int] = {}
    for j, name in _system_columns(header_cells):
        reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
        if reserve is None:
            continue  # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
        # Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках
        # (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198
        # и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей
        # данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)».
        name_counts[name] = name_counts.get(name, 0) + 1
        if name_counts[name] > 1:
            name = f"{name} ({name_counts[name]})"
        systems.append({"system_name": name, "reserve_gcal_h": reserve})

    return systems, all_cells


def _cells_to_jsonable(all_cells: list[list[object]]) -> list[list[object]]:
    """Ячейки листа → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
    out: list[list[object]] = []
    for row in all_cells:
        json_row: list[object] = []
        for value in row:
            if value is None or isinstance(value, bool | int | float | str):
                json_row.append(value)
            else:
                json_row.append(str(value))
        out.append(json_row)
    return out


def _upsert_heat_rows(
    db: Session,
    org: str,
    period: str,
    source_guid: str,
    systems: list[dict],
    all_cells: list[list[object]],
) -> dict[str, int]:
    """UPSERT распознанных систем публикации в heat_system_reserves. Per-row SAVEPOINT.

    raw = {cells: <весь лист>} сохраняется у КАЖДОЙ строки публикации (одна выгрузка =
    один лист). ON CONFLICT (org, sphere, system_name, period) DO UPDATE.
    Returns: счётчики parsed/inserted/updated/skipped.
    """
    raw_json = json.dumps({"cells": _cells_to_jsonable(all_cells)}, ensure_ascii=False)
    inserted = 0
    updated = 0
    skipped = 0
    for sysrec in systems:
        params = {
            "org": org,
            "system_name": sysrec["system_name"],
            "reserve": sysrec["reserve_gcal_h"],
            "period": period,
            "source_guid": source_guid,
            "raw": raw_json,
        }
        try:
            with db.begin_nested():  # SAVEPOINT — битая строка не валит батч
                result = db.execute(
                    text("""
                        INSERT INTO heat_system_reserves
                            (org, sphere, system_name, reserve_gcal_h,
                             period, source_guid, raw, fetched_at)
                        VALUES (
                            :org, 'heat', :system_name, :reserve,
                            :period, :source_guid, CAST(:raw AS jsonb), NOW()
                        )
                        ON CONFLICT (org, sphere, system_name, period) DO UPDATE
                           SET reserve_gcal_h = EXCLUDED.reserve_gcal_h,
                               source_guid    = EXCLUDED.source_guid,
                               raw            = EXCLUDED.raw,
                               fetched_at     = NOW()
                        RETURNING (xmax = 0) AS is_insert
                    """),
                    params,
                ).scalar()
            if result:
                inserted += 1
            else:
                updated += 1
        except Exception as e:
            logger.warning("eias_heat upsert failed for %r/%r: %s", org, sysrec["system_name"], e)
            skipped += 1
    return {
        "parsed": len(systems),
        "inserted": inserted,
        "updated": updated,
        "skipped": skipped,
    }


def fetch_card_html(org_id: int) -> str:
    """Тянет HTML карточки организации. RUN-ON-PROD (гео-блок + российский УЦ)."""
    resp = httpx.get(
        build_card_url(org_id),
        timeout=_HTTP_TIMEOUT,
        follow_redirects=True,
        verify=_ru_ssl_context(),
    )
    resp.raise_for_status()
    return resp.text


def fetch_template_html(client: httpx.Client, guid: str, pub_id: str) -> str:
    """Тянет HTML выгрузки публикации (base64-ZIP в data-file). RUN-ON-PROD."""
    resp = client.get(build_template_url(guid, pub_id))
    resp.raise_for_status()
    return resp.text


def load_org_reserves(db: Session, org: str, org_id: int) -> dict[str, int]:
    """Полный прогон одной организации: карточка → публикации → выгрузки → UPSERT.

    Аккумулирует счётчики по всем публикациям свежайшего квартала (по ЦС).
    Ноль распознанных систем во всех публикациях → warning-лог с первыми 20 строками
    последнего листа, parsed=0, НЕ падаем.
    """
    counts = {"parsed": 0, "inserted": 0, "updated": 0, "skipped": 0, "publications": 0}

    card_html = fetch_card_html(org_id)
    pubs = extract_publications(card_html)
    if not pubs:
        logger.warning("eias_heat[%s]: нет публикаций свежайшего квартала", org)
        return counts

    period = f"{pubs[0]['year']}-Q{pubs[0]['quarter']}"
    last_cells: list[list[object]] = []

    with httpx.Client(
        timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
    ) as client:
        for pub in pubs:
            try:
                template_html = fetch_template_html(client, pub["guid"], pub["id"])
                xlsx_bytes = extract_tpl_xlsx(template_html)
                if xlsx_bytes is None:
                    continue
                systems, all_cells = parse_heat_sheet(xlsx_bytes)
                last_cells = all_cells
                counts["publications"] += 1
                if not systems:
                    continue
                sub = _upsert_heat_rows(db, org, period, pub["guid"], systems, all_cells)
                counts["parsed"] += sub["parsed"]
                counts["inserted"] += sub["inserted"]
                counts["updated"] += sub["updated"]
                counts["skipped"] += sub["skipped"]
            except Exception as e:
                logger.exception("eias_heat[%s]: публикация guid=%s упала: %s", org, pub["guid"], e)

    if counts["parsed"] == 0:
        logger.warning(
            "eias_heat[%s]: 0 систем распознано (period=%s); первые 20 строк листа: %s",
            org,
            period,
            last_cells[:20],
        )

    counts["period"] = period  # type: ignore[assignment]
    logger.info("eias_heat[%s] done: %s", org, counts)
    return counts


def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
    """Полный прогон тепло-резервов ФАС по всем организациям (ЕТК, Екатеринбургэнерго).

    RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal/eesk). Каждая
    организация graceful: падение одной не валит вторую. Коммит один в конце
    (per-row SAVEPOINT уже изолирует битые строки).

    Returns: {org_name: {parsed, inserted, updated, skipped, publications, period}}
    или {org_name: {"error": ...}} при падении организации.
    """
    owns_session = db is None
    if db is None:
        db = SessionLocal()

    out: dict[str, dict] = {}
    try:
        for org, org_id in ORGS:
            try:
                out[org] = load_org_reserves(db, org, org_id)
            except Exception as e:
                logger.exception("load_heat_reserves: org %s failed: %s", org, e)
                out[org] = {"error": str(e)}
        db.commit()
    except Exception as e:
        db.rollback()
        logger.exception("load_heat_reserves: outer tx rolled back: %s", e)
        raise
    finally:
        if owns_session:
            db.close()

    logger.info("load_heat_reserves done: %s", out)
    return out
