"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1).

No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН
HTML-таблицу «Информация о наличии (отсутствии) технической возможности…»
(~350 строк) со свободной мощностью газораспределительных станций (ГРС) —
проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о
расшивке. UPSERT-ит в ``gas_grs_capacity``.

Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает
локальный сетевой доступ.

Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02):

  * caption-строка (1 ячейка) → скип;
  * «по состоянию на DD.MM.YYYY» в первых строках → asof;
  * строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption);
  * ниже идут суб-заголовки + строка нумерации «0..7» → скипаются как не-данные;
  * single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») → задаёт
    ``current_region``; данные пишутся ТОЛЬКО пока текущий регион — Свердловский;
  * FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ,
    6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры);
  * CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 — idx 0=имя (с выходом),
    1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление;
  * имя с суффиксом «(выход №N)» → output_name отдельно; суффикс-число «(5)» —
    НЕ выход, остаётся в имени.

Числа — через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая,
«—» → None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки).
Полная строка целиком → ``raw`` jsonb (неизвестные/будущие колонки не теряются).
"""

import json
import logging
import re
import ssl
from datetime import date
from functools import lru_cache
from pathlib import Path

import httpx
from bs4 import BeautifulSoup
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,
    parse_reserve_number,
)

logger = logging.getLogger(__name__)

# Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей.
# Punycode не требуется — латинский хост.
DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/"

_HTTP_TIMEOUT = 60

# Домен ГТЕ использует сертификат российского УЦ (Russian Trusted CA, Минцифры) —
# его нет в стандартном CA-bundle → CERTIFICATE_VERIFY_FAILED (прод-инцидент
# 2026-07-02). Подключаем публичный бандл (Госуслуги) ТОЛЬКО для этого лоадера,
# системные CA остаются в контексте (create_default_context + load_verify_locations).
_RU_CA_BUNDLE = (
    Path(__file__).resolve().parents[2] / "resources" / "certs" / "russian_trusted_ca_bundle.pem"
)


@lru_cache(maxsize=1)
def _ru_ssl_context() -> ssl.SSLContext:
    """SSL-контекст: системные CA + Russian Trusted Root/Sub CA (Минцифры)."""
    ctx = ssl.create_default_context()
    ctx.load_verify_locations(cafile=str(_RU_CA_BUNDLE))
    return ctx


_MULTISPACE_RE = re.compile(r"\s+")

# «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None.
_NODATA_TEXT_TOKENS = frozenset(
    {"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"}
)

# Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн».
# Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от
# суб-заголовков / строки нумерации.
_HEADER_MARKERS = ("наименование", "проектн")

# Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и
# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
_SVERDLOVSK_TOKEN = "свердлов"

# Фрагмент «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
# ON CONFLICT (grs_name_norm, output_name)).
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)

# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
_FULL_IDX = {
    "grs_name": 2,
    "design": 3,
    "load": 4,
    "volume_tu": 5,
    "free": 6,
    "pct": 7,
    "notice": (8, 9, 10),
}
# CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2,
# объём ТУ=3, свободная=4, %=5, уведомление=6+.
_CONT_IDX = {
    "grs_name": 0,
    "design": 1,
    "load": 2,
    "volume_tu": 3,
    "free": 4,
    "pct": 5,
    "notice": (6, 7, 8),
}

# Границы длины строки: full = данные с полным набором столбцов; continuation =
# усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип.
_FULL_MIN_LEN = 10
_CONT_MIN_LEN = 5
_CONT_MAX_LEN = 9


def normalize_grs_name(name: str | None) -> str:
    """Нормализует имя ГРС для матча/дедупа.

    Правила: lower, ё→е, схлопывание пробелов и дефисов (все виды тире → одиночный
    дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ —
    они различают станции. Пусто → ''.

    Examples:
        «ГРС Кольцово»          → «грс кольцово»
        «ГРС  Берёзовский»      → «грс березовский»
        «ГРС Северная — 2»      → «грс северная-2»
    """
    if not name:
        return ""
    s = name.strip().lower().replace("ё", "е")
    s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
    # Все виды тире (—, –, -) в одиночный дефис, пробелы вокруг убираем.
    s = re.sub(r"\s*[—–-]\s*", "-", s)
    s = _MULTISPACE_RE.sub(" ", s).strip()
    return s


def _parse_pct(value: str | None) -> float | None:
    """Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None.

    Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки,
    «%», пробелы — и парсим числом. Отрицательные (дефицит) сохраняются знаком.
    """
    if value is None:
        return None
    stripped = value.strip("()%\xa0 ").strip()
    if not stripped or stripped in _NODATA_TEXT_TOKENS:
        return None
    return parse_reserve_number(stripped)


def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
    """Имя ячейки → (grs_name без выхода, output_name|None).

    «ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
    и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
    «выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
    output_name канонизируется в «выход №N» (нижний регистр, ровно один пробел после
    «выход», сразу «№N» без пробела) — разнобой пробелов вокруг «№» иначе дал бы
    разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
    «ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
    """
    m = _OUTPUT_SUFFIX_RE.search(raw_name)
    if not m:
        return raw_name, None
    # Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
    output = f"{m.group(1).lower()} №{m.group(2)}"
    # Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
    # двойные пробелы, оставшиеся на месте выреза в середине).
    grs_name = raw_name[: m.start()] + raw_name[m.end() :]
    grs_name = _MULTISPACE_RE.sub(" ", grs_name).strip()
    return grs_name, output


def _is_header_row(texts: list[str]) -> bool:
    """Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)?

    Отличает header от caption («Информация о наличии…») и суб-заголовков.
    """
    joined = " ".join(texts).lower()
    return all(marker in joined for marker in _HEADER_MARKERS)


def _extract_table(html: str) -> object | None:
    """Находит наиболее «содержательную» <table> (с макс. числом строк). None — нет."""
    soup = BeautifulSoup(html, "html.parser")
    tables = soup.find_all("table")
    if not tables:
        return None
    # Основная таблица — с наибольшим числом строк (шапки/навигация мелкие).
    return max(tables, key=lambda t: len(t.find_all("tr")))


def _row_cells(tr: object) -> list[object]:
    """Ячейки строки (<th> и <td> в порядке появления)."""
    return tr.find_all(["th", "td"])  # type: ignore[union-attr]


def _row_texts(cells: list[object]) -> list[str]:
    """Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов."""
    out: list[str] = []
    for cell in cells:
        raw = cell.get_text(" ").replace("\xa0", " ")  # type: ignore[union-attr]
        out.append(_MULTISPACE_RE.sub(" ", raw).strip())
    return out


def _cell_at(texts: list[str], idx: int) -> str | None:
    """Текст ячейки по индексу → None если пусто / «—»-токен / за границей."""
    if idx >= len(texts):
        return None
    s = texts[idx].strip()
    if not s or s.lower() in _NODATA_TEXT_TOKENS:
        return None
    return s


def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None:
    """Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых.

    Все «—»/пусто → None (нет уведомления о расшивке).
    """
    parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None]
    return " · ".join(parts) if parts else None


def _build_record(
    texts: list[str], idx: dict, current_region: str, asof: date | None
) -> dict | None:
    """Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION).

    Имя обязательно — иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw.
    """
    raw_name = _cell_at(texts, idx["grs_name"])
    if not raw_name:
        return None
    grs_name, output_name = _split_grs_name(raw_name)
    return {
        "grs_name": grs_name,
        "output_name": output_name,
        "region": current_region,
        "design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])),
        "current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])),
        "free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])),
        "free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])),
        "upgrade_due": _build_upgrade_due(texts, idx["notice"]),
        "asof": asof,
        # Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw.
        "raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])},
    }


def parse_grs_table(html: str) -> list[dict]:
    """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).

    Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring):
      - основная <table> = с макс. числом строк;
      - asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках;
      - строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до
        неё (caption / asof) и суб-заголовки/нумерация сразу после — скипаются;
      - single-cell строка = регион-разделитель → current_region (title-case);
      - данные пишутся ТОЛЬКО когда current_region содержит «свердлов»;
      - FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) → фиксированные
        индексы; иначе строка не-данные → скип с warning-логом.

    Каждая запись:
      {grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h,
       free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}.
    """
    table = _extract_table(html)
    if table is None:
        logger.warning("gazprom_grs: <table> не найдена в HTML")
        return []

    rows = table.find_all("tr")  # type: ignore[union-attr]
    if not rows:
        return []

    row_texts = [_row_texts(_row_cells(tr)) for tr in rows]

    # asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка).
    asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4]))

    # Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже.
    header_idx: int | None = None
    for i, texts in enumerate(row_texts):
        if _is_header_row(texts):
            header_idx = i
            break
    if header_idx is None:
        logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS)
        return []

    records: list[dict] = []
    current_region: str | None = None
    in_sverdlovsk = False

    for texts in row_texts[header_idx + 1 :]:
        n = len(texts)
        if n == 0 or not any(t for t in texts):
            continue  # пустая строка → скип

        if n == 1:
            # Single-cell → регион-разделитель. title-case, обновляем контекст.
            region = _cell_at(texts, 0)
            if region:
                current_region = region.title()
                in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower()
            continue

        if not in_sverdlovsk:
            continue  # регион не Свердловский → строку не пишем

        if n >= _FULL_MIN_LEN:
            idx_map = _FULL_IDX
        elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN:
            idx_map = _CONT_IDX
        else:
            logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts)
            continue

        rec = _build_record(texts, idx_map, current_region or "", asof)
        if rec is not None:
            records.append(rec)

    logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof)
    return records


def _passes_region_filter(records: list[dict]) -> list[dict]:
    """Фильтр Свердловской области по полю region записи.

    Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region-
    разделителям). Эта функция — второй барьер на случай записей с чужим/пустым
    region (совместимость с прежним пайплайном load_grs_capacity). Если region есть
    хоть у одной — оставляем только «свердлов»; иначе всё.
    """
    if any(r.get("region") for r in records):
        return [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()]
    return records


def fetch_grs_html() -> str:
    """Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом."""
    resp = httpx.get(
        DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
    )
    resp.raise_for_status()
    return resp.text


def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
    """UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч).

    ON CONFLICT (grs_name_norm, output_name) — NULLS NOT DISTINCT в схеме (output_name
    nullable). Returns: счётчики rows/inserted/updated/skipped.
    """
    inserted = 0
    updated = 0
    skipped = 0
    for rec in records:
        grs_name = rec["grs_name"]
        params = {
            "grs_name": grs_name,
            "grs_name_norm": normalize_grs_name(grs_name),
            "output_name": rec["output_name"],
            "region": rec["region"],
            "design": rec["design_capacity_th_m3_h"],
            "load": rec["current_load_th_m3_h"],
            "free": rec["free_capacity_th_m3_h"],
            "pct": rec["free_capacity_pct"],
            "upgrade_due": rec["upgrade_due"],
            "asof": rec.get("asof"),
            "raw": json.dumps(rec["raw"], ensure_ascii=False),
        }
        try:
            with db.begin_nested():  # SAVEPOINT — откат только этой строки
                result = db.execute(
                    text("""
                        INSERT INTO gas_grs_capacity
                            (grs_name, grs_name_norm, output_name, region,
                             design_capacity_th_m3_h, current_load_th_m3_h,
                             free_capacity_th_m3_h, free_capacity_pct,
                             upgrade_due, asof, raw, fetched_at)
                        VALUES (
                            :grs_name, :grs_name_norm, :output_name, :region,
                            :design, :load, :free, :pct,
                            :upgrade_due, :asof, CAST(:raw AS jsonb), NOW()
                        )
                        ON CONFLICT (grs_name_norm, output_name) DO UPDATE
                           SET grs_name                = EXCLUDED.grs_name,
                               region                  = EXCLUDED.region,
                               design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h,
                               current_load_th_m3_h    = EXCLUDED.current_load_th_m3_h,
                               free_capacity_th_m3_h   = EXCLUDED.free_capacity_th_m3_h,
                               free_capacity_pct       = EXCLUDED.free_capacity_pct,
                               upgrade_due             = EXCLUDED.upgrade_due,
                               asof                    = EXCLUDED.asof,
                               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("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e)
            skipped += 1
    return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped}


def load_grs_capacity(db: Session | None = None) -> dict[str, int]:
    """Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС.

    RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None →
    своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader).

    Returns: счётчики {rows, inserted, updated, skipped}.
    """
    html = fetch_grs_html()
    records = _passes_region_filter(parse_grs_table(html))

    owns_session = db is None
    if db is None:
        db = SessionLocal()

    try:
        counts = _upsert_grs_rows(db, records)
        db.commit()
    except Exception as e:
        db.rollback()
        logger.exception("load_grs_capacity: outer tx rolled back: %s", e)
        raise
    finally:
        if owns_session:
            db.close()

    logger.info("load_grs_capacity done: %s", counts)
    return counts
