"""Загрузчик резервов свободной мощности АО «ЕЭСК» из xlsx-раскрытия (#2119 B2).

Хаб eesk.ru «Наличие объёма свободной для технологического присоединения
мощности» — статический HTML со ссылками на xlsx двух серий:
  (а) ПС 35-220 кВ («резерв мощности по центрам питания», текст ссылки БЕЗ «ниже»
      / содержит «35-220») → апдейт ``power_supply_centers`` (резерв в МВт!);
  (б) ТП/РП «ниже 35» кВ (текст ссылки содержит «ниже 35»)
      → UPSERT ``power_tp_rp_reserves``.

Свежайший файл каждой серии выбирается по max(YYYY, N-квартал) из ТЕКСТА ссылки
(«…N квартал(а) YYYY…»). period='YYYY-QN', reserve_asof = последний день квартала.

Источник — обычный SSL, БЕЗ гео-блока, но лоадер всё равно RUN-ON-PROD рядом с
rosseti/gas/vodokanal (единый weekly-воркер). httpx с явным таймаутом, per-row
SAVEPOINT при UPSERT (битая строка не валит батч).

Числовой парс/дата — переиспользуют ``parse_reserve_number`` / ``parse_asof_date``
из ``rosseti_reserve_loader``; матч ЦП — общий ``normalize_sc_name``.
"""

import io
import logging
import re
from datetime import date

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.rosseti_reserve_loader import (
    parse_asof_date,  # noqa: F401 — переэкспорт для соседей/тестов симметрии
    parse_reserve_number,
)
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name

logger = logging.getLogger(__name__)

# Хаб раскрытия (статический HTML). ЕЭСК — латиница, punycode не требуется.
HUB_URL = (
    "https://www.eesk.ru/Raskritie_informacii/standarty_raskrytia_info/"
    "Ob_osnovnih_potrebitelskih_harakteristik/"
    "Informacija_o_tehnicheskom_sostojanii_se/"
    "Nalichie_obema_svobodnoj_dlja_tehnologic"
)

_HTTP_TIMEOUT = 60

# Ссылки на xlsx-файлы в загрузках сайта: /upload/site1/document_file/<hash>.xls[x].
# Захватываем href + видимый ТЕКСТ ссылки (в тексте — серия и квартал/год).
_LINK_RE = re.compile(
    r'<a\b[^>]*href=["\'](?P<href>[^"\']*/upload/site1/document_file/'
    r'[^"\']+?\.xlsx?)["\'][^>]*>(?P<text>.*?)</a>',
    re.IGNORECASE | re.DOTALL,
)

# «N квартал[а] YYYY» в тексте ссылки → (N, YYYY). Слово «квартал» в любой форме.
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", re.IGNORECASE)

_BASE = "https://www.eesk.ru"

# Последний день квартала (месяц, день) по номеру квартала.
_QUARTER_LAST_DAY: dict[int, tuple[int, int]] = {
    1: (3, 31),
    2: (6, 30),
    3: (9, 30),
    4: (12, 31),
}


def _strip_tags(html_fragment: str) -> str:
    """Убирает html-теги/entity-мусор из текста ссылки → плоская строка."""
    text_only = re.sub(r"<[^>]+>", " ", html_fragment)
    text_only = text_only.replace("&nbsp;", " ").replace("\xa0", " ")
    return re.sub(r"\s+", " ", text_only).strip()


def _classify_series(link_text_low: str) -> str | None:
    """Классифицирует ссылку по её тексту: 'ps_35_220' | 'tp_rp_sub35' | None.

    Различаем серии ТОЛЬКО по тексту ссылки:
      (a) ПС 35-220 — содержит «35-220» ИЛИ «резерв мощности по центрам питания»,
          при этом НЕ содержит «ниже» (иначе это серия б);
      (b) ТП/РП <35 — содержит «ниже 35».
    """
    has_below = "ниже 35" in link_text_low or "ниже35" in link_text_low
    if has_below:
        return "tp_rp_sub35"
    if "35-220" in link_text_low or "по центрам питания" in link_text_low:
        return "ps_35_220"
    return None


def _quarter_from_text(link_text: str) -> tuple[int, int] | None:
    """Извлекает (year, quarter) из текста ссылки «…N квартал(а) YYYY…». Нет → None."""
    m = _QUARTER_RE.search(link_text)
    if not m:
        return None
    quarter = int(m.group(1))
    year = int(m.group(2))
    if quarter not in _QUARTER_LAST_DAY:
        return None
    return (year, quarter)


def quarter_period(year: int, quarter: int) -> str:
    """(2026, 2) → '2026-Q2'."""
    return f"{year}-Q{quarter}"


def quarter_last_day(year: int, quarter: int) -> date:
    """Последний календарный день квартала → date (для reserve_asof)."""
    month, day = _QUARTER_LAST_DAY[quarter]
    return date(year, month, day)


def fetch_hub_links() -> dict[str, dict]:
    """Скрейпит хаб → СВЕЖАЙШИЙ xlsx каждой серии (ps_35_220 + tp_rp_sub35).

    RUN-ON-PROD. GET с follow_redirects, regex по <a href …upload…xlsx>ТЕКСТ</a>,
    классификация серии + квартал по ТЕКСТУ ссылки, выбор max(year, quarter).

    Returns: {series: {"url": str, "year": int, "quarter": int}} — только найденные.
    """
    resp = httpx.get(HUB_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
    resp.raise_for_status()
    return select_freshest_links(resp.text)


def select_freshest_links(html: str) -> dict[str, dict]:
    """Из HTML-хаба выбирает свежайшую ссылку каждой серии (чистая, тестируемая).

    Свежайший = max((year, quarter)) среди ссылок одной серии. Ссылки без
    распознанного квартала игнорируются (нельзя датировать).
    """
    best: dict[str, dict] = {}
    n_links = 0
    for m in _LINK_RE.finditer(html):
        n_links += 1
        link_text = _strip_tags(m.group("text"))
        series = _classify_series(link_text.lower())
        if series is None:
            continue
        yq = _quarter_from_text(link_text)
        if yq is None:
            continue
        year, quarter = yq
        href = m.group("href")
        url = href if href.startswith("http") else _BASE + href
        prev = best.get(series)
        if prev is None or (year, quarter) > (prev["year"], prev["quarter"]):
            best[series] = {"url": url, "year": year, "quarter": quarter}

    logger.info(
        "eesk hub links: ps_35_220=%r tp_rp_sub35=%r (from %d xlsx links)",
        best.get("ps_35_220"),
        best.get("tp_rp_sub35"),
        n_links,
    )
    return best


def _cell(row: tuple, idx: int) -> object:
    """Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
    return row[idx] if idx < len(row) else None


def _pct_share_to_percent(value: object) -> float | None:
    """Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.

    В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
    None/мусор → None.
    """
    num = parse_reserve_number(value)
    if num is None:
        return None
    # Доля 0..1 → *100. Значения >1 считаем уже процентами (защита от смешанного ввода).
    return round(num * 100.0, 2) if -1.0 <= num <= 1.0 else num


def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
    """Апдейт power_supply_centers резервом ЕЭСК ПС 35-220 из xlsx.

    ВЕРИФИЦИРОВАННАЯ структура (лист ``Лист1``, снята с живого файла): шапка строка
    4 (1-based), данные строки 7-63 и 65-83, между секциями строки-разделители.
    Скипаем строки где колонка C (имя ПС) пуста или D (мощность) не парсится числом.
    Колонки 0-based: A=№, B=адм. район, C=имя ПС, D=макс полная мощность МВА,
    E=% загрузки ДОЛЯ (0.41→41.0), F=степень МУ250 (в raw), G=свободная МВт.

    Матч к power_supply_centers по normalize_sc_name(C) AND dzo_name ILIKE '%ЕЭСК%'.
    reserve_mva=G, reserve_unit='МВт', installed_capacity_mva=D, district=B,
    capacity_source='eesk_35_220', reserve_asof. Unmatched имена — warning-лог.

    Returns: счётчики rows/matched/unmatched.
    """
    wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
    ws = wb["Лист1"] if "Лист1" in wb.sheetnames else wb.active

    rows_seen = 0
    matched = 0
    unmatched = 0
    unmatched_names: list[str] = []

    try:
        # min_row=7 (1-based) → данные с 7-й строки; секции 7-63 и 65-83 идут подряд,
        # строки-разделители отсеиваются проверкой C пусто / D не число.
        for row in ws.iter_rows(min_row=7, values_only=True):
            sc_name = _cell(row, 2)  # C
            installed = parse_reserve_number(_cell(row, 3))  # D
            # Строка-разделитель между секциями: нет имени ПС ИЛИ мощность не число.
            if sc_name is None or not str(sc_name).strip() or installed is None:
                continue
            rows_seen += 1

            district = _cell(row, 1)  # B
            load_pct = _pct_share_to_percent(_cell(row, 4))  # E (доля → %)
            reserve = parse_reserve_number(_cell(row, 6))  # G (свободная МВт)
            name_norm = normalize_sc_name(str(sc_name))

            params = {
                "installed": installed,
                "reserve": reserve,
                "asof": reserve_asof,
                "district": str(district).strip() if district else None,
                "load_pct": load_pct,
                "name_norm": name_norm,
            }

            try:
                with db.begin_nested():  # SAVEPOINT — битая строка не валит батч
                    res = db.execute(
                        text("""
                            UPDATE power_supply_centers
                               SET reserve_mva            = :reserve,
                                   reserve_unit           = 'МВт',
                                   installed_capacity_mva = :installed,
                                   district               = :district,
                                   load_index             = COALESCE(
                                       load_index,
                                       CAST(:load_pct AS text)
                                   ),
                                   capacity_source        = 'eesk_35_220',
                                   reserve_asof           = :asof
                             WHERE sc_name_norm = :name_norm
                               AND dzo_name ILIKE '%ЕЭСК%'
                        """),
                        params,
                    )
                if res.rowcount and res.rowcount > 0:
                    matched += res.rowcount
                else:
                    unmatched += 1
                    if len(unmatched_names) < 50:
                        unmatched_names.append(str(sc_name).strip())
            except Exception as e:
                logger.warning("eesk ps_35_220 update failed for %r: %s", sc_name, e)
                unmatched += 1

        db.commit()
    except Exception as e:
        db.rollback()
        logger.exception("load_ps_35_220: outer tx rolled back: %s", e)
        raise
    finally:
        wb.close()

    if unmatched_names:
        logger.warning(
            "eesk ps_35_220 unmatched (%d) sc_name список: %s",
            unmatched,
            unmatched_names,
        )
    logger.info(
        "load_ps_35_220 done: rows=%d matched=%d unmatched=%d",
        rows_seen,
        matched,
        unmatched,
    )
    return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}


def load_tp_rp_sub35(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
    """UPSERT резервов ЕЭСК ТП/РП «ниже 35» кВ в power_tp_rp_reserves из xlsx.

    ВЕРИФИЦИРОВАННАЯ структура (лист ``Публикация``): строка 1 — title, строка 3 —
    шапка, ДАННЫЕ С СТРОКИ 4 (~2014 строк). Колонки 0-based: A=номер ТП/РП
    («10003»), B=предельно допустимая загрузка МВт, C=загрузка по 4 замерам МВт,
    D=текущий резерв МВт.

    UPSERT: name=«ТП/РП <A>», name_norm=str(A).strip().lower(),
    municipality='Екатеринбург', voltage_kv=NULL, installed=B, current=C,
    reserve=D, reserve_asof. ON CONFLICT (name_norm, municipality, voltage_kv) DO
    UPDATE. БЕЗ кВА-санитайза. Per-row SAVEPOINT.

    Returns: счётчики rows/inserted/updated/skipped.
    """
    wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
    ws = wb["Публикация"] if "Публикация" in wb.sheetnames else wb.active

    rows_seen = 0
    inserted = 0
    updated = 0
    skipped = 0

    try:
        # min_row=4 (1-based) → данные с 4-й строки; 1 title, 3 шапка выше.
        for row in ws.iter_rows(min_row=4, values_only=True):
            num = _cell(row, 0)  # A
            if num is None or not str(num).strip():
                continue
            # Номер ТП/РП бывает float из Excel (10003.0) — нормализуем к целому виду.
            num_raw = num
            if isinstance(num, float) and num.is_integer():
                num_raw = int(num)
            num_str = str(num_raw).strip()
            rows_seen += 1

            installed = parse_reserve_number(_cell(row, 1))  # B
            current = parse_reserve_number(_cell(row, 2))  # C
            reserve = parse_reserve_number(_cell(row, 3))  # D

            params = {
                "name": f"ТП/РП {num_str}",
                "name_norm": num_str.lower(),
                "municipality": "Екатеринбург",
                "installed": installed,
                "current": current,
                "reserve": reserve,
                "asof": reserve_asof,
            }

            try:
                with db.begin_nested():  # SAVEPOINT — битая строка не валит батч
                    result = db.execute(
                        # voltage_kv намеренно NULL: у ЕЭСК-серии «ниже 35» отдельного
                        # класса напряжения нет; settlement=municipality (одна колонка).
                        text("""
                            INSERT INTO power_tp_rp_reserves
                                (name, name_norm, municipality, settlement,
                                 voltage_kv, installed_capacity_mva, current_load_mva,
                                 reserve_mva, reserve_asof, fetched_at)
                            VALUES (
                                :name, :name_norm, :municipality, :municipality,
                                NULL, :installed, :current,
                                :reserve, :asof, NOW()
                            )
                            ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
                               SET name                   = EXCLUDED.name,
                                   installed_capacity_mva = EXCLUDED.installed_capacity_mva,
                                   current_load_mva       = EXCLUDED.current_load_mva,
                                   reserve_mva            = EXCLUDED.reserve_mva,
                                   reserve_asof           = EXCLUDED.reserve_asof,
                                   fetched_at             = NOW()
                            RETURNING (xmax = 0) AS is_insert
                        """),
                        params,
                    ).scalar()
                if result:
                    inserted += 1
                else:
                    updated += 1
            except Exception as e:
                logger.warning("eesk tp_rp_sub35 upsert failed for %r: %s", num_str, e)
                skipped += 1

        db.commit()
    except Exception as e:
        db.rollback()
        logger.exception("load_tp_rp_sub35: outer tx rolled back: %s", e)
        raise
    finally:
        wb.close()

    result_dict = {
        "rows": rows_seen,
        "inserted": inserted,
        "updated": updated,
        "skipped": skipped,
    }
    logger.info("load_tp_rp_sub35 done: %s", result_dict)
    return result_dict


def load_eesk_reserves(db: Session | None = None) -> dict[str, dict]:
    """Полный прогон ЕЭСК: discovery ссылок + скачивание + загрузка ПС и ТП/РП.

    RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal). Каждый шаг
    graceful: сбой одного файла не валит другой.
    Returns: {"ps": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
    """
    owns_session = db is None
    if db is None:
        db = SessionLocal()

    out: dict[str, dict] = {}
    try:
        links = fetch_hub_links()
        with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
            ps = links.get("ps_35_220")
            if ps:
                try:
                    r = client.get(ps["url"])
                    r.raise_for_status()
                    asof = quarter_last_day(ps["year"], ps["quarter"])
                    out["ps"] = load_ps_35_220(db, r.content, asof)
                    out["ps"]["period"] = quarter_period(ps["year"], ps["quarter"])
                except Exception as e:
                    logger.exception("load_eesk_reserves: ps file failed: %s", e)
                    out["ps"] = {"error": str(e)}
            else:
                out["ps"] = {"error": "no ps_35_220 link found"}

            tp = links.get("tp_rp_sub35")
            if tp:
                try:
                    r = client.get(tp["url"])
                    r.raise_for_status()
                    asof = quarter_last_day(tp["year"], tp["quarter"])
                    out["tp_rp"] = load_tp_rp_sub35(db, r.content, asof)
                    out["tp_rp"]["period"] = quarter_period(tp["year"], tp["quarter"])
                except Exception as e:
                    logger.exception("load_eesk_reserves: tp_rp file failed: %s", e)
                    out["tp_rp"] = {"error": str(e)}
            else:
                out["tp_rp"] = {"error": "no tp_rp_sub35 link found"}
    finally:
        if owns_session:
            db.close()

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