"""Загрузчик адресных точек выхода газораспределительной сети (#2119 Phase B2, PR-2).

Источник — раскрытие информации АО «Екатеринбурггаз» (ГРО), форма 6 приложения №4
к Приказу ФАС России №960 от 08.12.2022 «О порядке раскрытия информации…»: построчная
выгрузка «точек выхода» ГРС (~1037 строк/мес) — объём заявок, удовлетворено, свободная
мощность точки выхода (млн. м³/МЕСЯЦ — НЕ тыс. м³/ч как в gas_grs_capacity!). UPSERT-ит
в ``gas_grs_outlet_points``.

Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery monthly / manual
docker exec) — как gas_grs_capacity (B1, ГТЕ) и rosseti-/vodokanal-/eias-лоадеры. Код не
предполагает локальный сетевой доступ. ТОЛЬКО apex-домен ``ekgas.ru`` — ``www.ekgas.ru``
даёт ``SSLV3_ALERT_HANDSHAKE_FAILURE``. SSL через общий ``_ru_ssl_context`` из
gazprom_grs_loader (Russian Trusted CA Минцифры), httpx с явным таймаутом.

Данные лежат за 2 GET-переходами:

  1. Каталог раскрытия (``/raskrytie-informacii/raskrytie-informatsii-1``, ~800KB HTML,
     585 ссылок на файлы). Целевой — «Форма 6 приложения № 4 к Приказу ФАС России № 960
     … (на|за) <месяц> <год>г..xlsx», путь ``/uploads/default/<hex>/<имя>``. Hex меняется
     с каждым файлом → URL НЕ ЯКОРИМ: парсим каталог, вытаскиваем период «(за|на) <месяц>
     <год>» + дату публикации (asof, DD.MM.YYYY рядом со ссылкой). Годовой «Форма №6
     (Приложение №2) … план на <год>» (.xls) — СКИП (не адресный, xlrd в стеке нет).

     Выбор свежайшего: приоритет ФАКТА над прогнозом. Берём максимальный (год, месяц)
     среди «за» (is_forecast=False); если файлов «за» НЕТ вообще — максимальный «на»
     (is_forecast=True, прогноз). ВНИМАНИЕ: даже если «на» новее по (год, месяц), чем
     любой «за», всё равно берём «за» (факт важнее прогноза). См. select_latest_file.

  2. XLSX (~91КБ, 1 лист): подписи в row 10 (1-indexed), нумерация 1..7 в row 11, данные
     с row 12 (~1037 строк, последняя — сноска про «*», single-cell → скип). Колонки
     (1-indexed): 1=«Точка входа» (ГРС, block-marker — заполнена ТОЛЬКО в первой строке
     блока → forward-fill; 5 блоков), 2=«Точка выхода» (свободный текст: юрлицо/объект,
     иногда адрес внутри), 3=тип (котельная/ТЭЦ/…), 4=группа потребления, 5=объём заявок,
     6=удовлетворено, 7=свободная мощность — float ИЛИ текст-маркер «0.000*» (НЕ ноль! →
     free_capacity_mln_m3=NULL, free_capacity_needs_calc=TRUE).

grs_name_norm нормализуется ТЕМ ЖЕ ``normalize_grs_name`` из gazprom_grs_loader (B1) —
иначе JOIN «точка выхода → свободная мощность ГРС» на gas_grs_capacity порвётся.
Числовой парс — общий ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «—» →
None, знак сохраняется: дефицит −0.084 хранится знаком). Вся строка целиком → ``raw``
jsonb (будущие/неизвестные колонки не теряются).
"""

import argparse
import io
import json
import logging
import re
import sys
from datetime import date
from urllib.parse import urljoin

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

from app.core.db import SessionLocal
from app.services.dadata_client import clean_address
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context, normalize_grs_name
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number

logger = logging.getLogger(__name__)

# ТОЛЬКО apex-домен — www.ekgas.ru отдаёт SSLV3_ALERT_HANDSHAKE_FAILURE (разведка
# 2026-07-03). Каталог раскрытия информации со ссылками на помесячные формы 6.
BASE_URL = "https://ekgas.ru"
CATALOG_URL = "https://ekgas.ru/raskrytie-informacii/raskrytie-informatsii-1"

_HTTP_TIMEOUT = 60

# ── Геокод address_extracted → geom (PR-3, #2119 B2) ──────────────────────────
# Cap вызовов DaData за один прогон (адресов на проде ~164 → влезает; страхует
# квоту от разбухшего файла). Тот же порядок, что GEO_DADATA_CALL_LIMIT в
# objective_backfill.
_GEO_DADATA_CAP = 200

# Префикс города: address_extracted — адресный ХВОСТ без города («ул. Стрелочников,
# д. 2»). DaData на строке без города отдаёт qc_geo=5 (нет координат) — прод-урок
# objective_backfill 2026-07-03. Точки выхода ЕКБ-газа привязаны к Екатеринбургу и
# окрестностям → безопасно префиксуем городом, если его в строке нет.
_EKB_PREFIX = "г Екатеринбург, "

# Sanity-bbox Екатеринбурга и ближних окрестностей (Арамиль/Реж/Берёзовский/Кольцово):
# DaData на кривом/усечённом адресе может «дорисовать» другой город — точку вне bbox
# НЕ пишем (честность: лучше NULL geom, чем ложная координата в Москве). Границы
# грубые с запасом: lat 56.5..57.2, lon 60.0..61.2.
_EKB_BBOX_LAT = (56.5, 57.2)
_EKB_BBOX_LON = (60.0, 61.2)

# Целевой лист XLSX (единственный) — берём активный, если имя не совпало.
# 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12.
# Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет
# header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8,
# сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла:
# опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк.
_LAYOUT_SCAN_ROWS = 25

# Колонки формы 6 (0-indexed для списка ячеек строки).
# Подписи колонок header-строки → внутренние ключи (substring-матч, lower).
_COLUMN_LABELS: dict[str, str] = {
    "точка входа": "entry",
    "точка выхода": "outlet",
    "наименование потребителя": "consumer_type",
    "номер группы": "group",
    "поступивш": "requested",
    "удовлетво": "approved",
    "свободная мощность": "free",
}


def _find_layout(rows: list[list[object]]) -> tuple[int, dict[str, int]] | None:
    """Ищет header-строку по подписям в первых _LAYOUT_SCAN_ROWS строках.

    Returns (data_start_idx, {key: col_idx}) — данные начинаются через строку
    после header (между ними строка-нумерация «1..7»). None — сетка не распознана
    (graceful: 0 записей + warning, не мусор — урок vodokanal).
    """
    for i, row in enumerate(rows[:_LAYOUT_SCAN_ROWS]):
        cols: dict[str, int] = {}
        for j, cell in enumerate(row):
            text = _clean_text(cell).lower()
            if not text:
                continue
            for label, key in _COLUMN_LABELS.items():
                if label in text and key not in cols:
                    cols[key] = j
        if "entry" in cols and "outlet" in cols and "free" in cols:
            return (i + 2, cols)  # +1 строка-нумерация, +1 → первая строка данных
    return None


# «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а
# «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True.
# Маркер = число, за которым идёт «*» (звёздочка). Настоящий 0 без «*» парсится как 0.0.
_NEEDS_CALC_RE = re.compile(r"^\s*[\d.,\s\xa0]+\*\s*$")

# Русские месяцы → номер (родительный падеж как в «за январЯ»? нет — в именах файлов
# именительный: «за январь 2026»). Ключи в нижнем регистре.
_MONTHS: dict[str, int] = {
    "январь": 1,
    "февраль": 2,
    "март": 3,
    "апрель": 4,
    "май": 5,
    "июнь": 6,
    "июль": 7,
    "август": 8,
    "сентябрь": 9,
    "октябрь": 10,
    "ноябрь": 11,
    "декабрь": 12,
}
_MONTHS_ALT = "|".join(_MONTHS)

# Период в имени файла формы 6: «… (за|на) <месяц> <год>г..xlsx». «за» = факт
# (is_forecast=False), «на» = прогноз (is_forecast=True). Год — 4 цифры.
_PERIOD_RE = re.compile(rf"\b(за|на)\s+({_MONTHS_ALT})\s+(\d{{4}})", re.IGNORECASE)

# Целевой файл — форма 6 приложения №4 (адресная, помесячная, .xlsx). Годовой «Форма №6
# (Приложение №2) … план на <год>» (.xls) сюда НЕ подходит: другое приложение + .xls.
_FORM6_MARKER_RE = re.compile(r"форма\s*6.*приложени\w*\s*№?\s*4", re.IGNORECASE | re.DOTALL)
_XLSX_RE = re.compile(r"\.xlsx(?:$|\?)", re.IGNORECASE)

# Дата публикации рядом со ссылкой (DD.MM.YYYY) → asof.
_ASOF_RE = re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b")

# Адрес-экстрактор: улица-токен + номер дома. Город опционален. Консервативно —
# нет уверенного адреса → None (не выдумываем). Паттерны из реальных строк:
#   «(г.Екатеринбург, Складской проезд,4а)» — в скобках;
#   «, пер. Базовый, 17» — хвостом;
#   «г.…, ул. К.Маркса,31».
# Улица-токен: ул|пер|пр|просп|проезд|б-р|ш|тракт|наб (с точкой или без, регистронезав.).
_STREET_TOKEN = r"(?:ул|пер|пр|просп|проспект|проезд|б-р|бульвар|ш|шоссе|тракт|наб)"
# Опциональный город впереди: «г.Екатеринбург,» / «г. Реж,».
_CITY_OPT = r"(?:г\.?\s*[А-ЯЁ][а-яё\-]+\s*,\s*)?"
# Название улицы: слова с кириллицей/точками/дефисами (напр. «К.Маркса», «Базовый»,
# «Складской проезд») — до номера дома. Номер: цифры + опц. буква/дробь («4а», «17»,
# «31/2»). Токен улицы может стоять ПЕРЕД названием («ул. Базовый») или ПОСЛЕ
# («Складской проезд»).
_ADDRESS_RE = re.compile(
    rf"{_CITY_OPT}"
    rf"(?:{_STREET_TOKEN}\.?\s*)?"  # ведущий токен («ул. …», «пер. …») — опционален
    r"[А-ЯЁ][А-Яа-яЁё.\- ]{1,40}?"  # название улицы (капитализировано)
    rf"(?:\s+{_STREET_TOKEN})?"  # хвостовой токен («… проезд», «… тракт») — опционален
    r"\s*,?\s*"
    r"(?:д\.?\s*)?"  # опц. «д.»
    r"(\d+[а-яё]?(?:[/\-]\d+[а-яё]?)?)",  # номер дома («4а», «17», «31/2»)
    re.IGNORECASE,
)
# Чтобы отсечь ложные срабатывания на юрлицах («ИП Рошканяну Владимир Антонович») —
# требуем ЯВНЫЙ адресный сигнал: либо улица-токен, либо «г.<Город>» перед номером.
_HAS_STREET_TOKEN_RE = re.compile(rf"\b{_STREET_TOKEN}\b\.?", re.IGNORECASE)
_HAS_CITY_RE = re.compile(r"\bг\.?\s*[А-ЯЁ][а-яё\-]+", re.IGNORECASE)

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


def _clean_text(value: object) -> str:
    """Ячейка/текст → strip-строка со схлопнутыми пробелами (\\xa0→пробел). None → ''."""
    if value is None:
        return ""
    return _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip()


def _parse_period(name: str) -> tuple[int, int, bool] | None:
    """Имя файла → (year, month, is_forecast). «за»→факт(False), «на»→прогноз(True).

    Нет распознанного «(за|на) <месяц> <год>» → None. Месяц вне словаря → None.
    """
    m = _PERIOD_RE.search(name)
    if not m:
        return None
    kind = m.group(1).lower()
    month = _MONTHS.get(m.group(2).lower())
    if month is None:
        return None
    year = int(m.group(3))
    return (year, month, kind == "на")


def _is_form6_monthly(link_text: str, href: str) -> bool:
    """Ссылка — помесячная адресная форма 6 приложения №4 (.xlsx)?

    Требуем: маркер «форма 6 … приложение № 4» (в тексте ссылки ИЛИ href), расширение
    .xlsx и распознаваемый период. Годовой «Форма №6 (Приложение №2) … план на <год>»
    (.xls) сюда НЕ проходит (другое приложение + не .xlsx).
    """
    haystack = f"{link_text} {href}"
    if not _FORM6_MARKER_RE.search(haystack):
        return False
    if not _XLSX_RE.search(href) and not _XLSX_RE.search(link_text):
        return False
    return _parse_period(haystack) is not None


def extract_form6_links(catalog_html: str) -> list[dict]:
    """Из HTML каталога → список кандидатов формы 6: [{url, period_month, is_forecast, asof}].

    По каждой <a>-ссылке, похожей на помесячную форму 6 приложения №4 (.xlsx),
    вытаскиваем период «(за|на) <месяц> <год>» из текста/href → period_month (первое
    число месяца) + is_forecast, и дату публикации (DD.MM.YYYY рядом со ссылкой) → asof.
    Годовые/не-xlsx/без периода ссылки игнорируются. URL резолвится к apex-домену.
    """
    soup = BeautifulSoup(catalog_html, "html.parser")
    out: list[dict] = []
    for a in soup.find_all("a", href=True):
        href = a["href"].strip()
        link_text = _clean_text(a.get_text(" "))
        if not _is_form6_monthly(link_text, href):
            continue
        period = _parse_period(f"{link_text} {href}")
        if period is None:  # защита (уже проверено в _is_form6_monthly)
            continue
        year, month, is_forecast = period
        # asof — дата публикации рядом со ссылкой (в тексте ссылки или соседних узлах).
        asof = _extract_asof_near(a, link_text)
        out.append(
            {
                "url": urljoin(BASE_URL, href),
                "period_month": date(year, month, 1),
                "is_forecast": is_forecast,
                "asof": asof,
            }
        )
    logger.info("ekgas: кандидатов формы 6 в каталоге: %d", len(out))
    return out


def _extract_asof_near(anchor: object, link_text: str) -> date | None:
    """Дата публикации (DD.MM.YYYY) рядом со ссылкой → date. Нет — None.

    Ищем сначала в тексте самой ссылки, затем в тексте родительского контейнера
    (в каталоге дата обычно стоит в той же строке/ячейке, что и ссылка).
    """
    candidates = [link_text]
    parent = getattr(anchor, "parent", None)
    if parent is not None:
        candidates.append(_clean_text(parent.get_text(" ")))
    for src in candidates:
        m = _ASOF_RE.search(src)
        if m:
            day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
            try:
                return date(year, month, day)
            except ValueError:
                continue
    return None


def select_latest_file(candidates: list[dict]) -> dict | None:
    """Выбирает свежайший файл: ПРИОРИТЕТ ФАКТА («за») над прогнозом («на»).

    Правило (см. модульный docstring): если есть хоть один файл «за» (is_forecast=False),
    берём максимальный (year, month) СРЕДИ «за» — даже если какой-то «на» новее по
    (year, month). Файлов «за» нет вообще → максимальный «на» (is_forecast=True).
    Пусто → None.
    """
    if not candidates:
        return None
    facts = [c for c in candidates if not c["is_forecast"]]
    pool = facts if facts else candidates
    return max(pool, key=lambda c: (c["period_month"].year, c["period_month"].month))


def _parse_free_capacity(value: object) -> tuple[float | None, bool]:
    """Ячейка свободной мощности → (free_capacity_mln_m3, needs_calc).

    «0.000*» (число + звёздочка, любой вариант «0,000*»/«0.000 *») = НЕ ноль, а
    «требуется гидравлический расчёт» → (None, True). Иначе — обычный числовой парс:
    настоящий 0 (без «*») → (0.0, False); дефицит −0.084 сохраняется знаком; «—»/текст
    → (None, False).
    """
    if isinstance(value, str) and _NEEDS_CALC_RE.match(value):
        return None, True
    return parse_reserve_number(value), False


def extract_address(outlet_name: str) -> str | None:
    """Извлекает адресный хвост из «Точки выхода» для геокода DaData (PR-3). Нет — None.

    Консервативно: возвращаем адрес ТОЛЬКО при явном сигнале (улица-токен ул|пер|пр|
    проезд|б-р|ш|тракт|наб ИЛИ «г.<Город>») + номер дома. Юрлицо без адреса
    («ИП Рошканяну Владимир Антонович») → None (не выдумываем).

    Examples:
        «…(г.Екатеринбург, Складской проезд,4а)» → «г.Екатеринбург, Складской проезд,4а»
        «…, пер. Базовый, 17»                    → «пер. Базовый, 17»
        «ИП Рошканяну Владимир Антонович»         → None
    """
    if not outlet_name:
        return None
    # Требуем явный адресный сигнал во всей строке — иначе номер в названии юрлица/объекта
    # («ООО ТЭЦ-2», «цех №5») ложно распознался бы как дом.
    if not (_HAS_STREET_TOKEN_RE.search(outlet_name) or _HAS_CITY_RE.search(outlet_name)):
        return None
    m = _ADDRESS_RE.search(outlet_name)
    if not m:
        return None
    address = _MULTISPACE_RE.sub(" ", m.group(0)).strip().strip("(),; ")
    return address or None


def _forward_fill_grs(rows: list[list[object]], cols: dict[str, int]) -> list[dict]:
    """Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС).

    «Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС → протягиваем
    последнее непустое значение вниз (5 блоков). Индексы колонок — из
    _find_layout (label-детект). Строки до первого block-marker (нет ГРС) и
    single-cell строки (сноска «*» в конце листа) пропускаются.
    """

    def _cell(cells: list[object], key: str) -> object:
        idx = cols.get(key, -1)
        return cells[idx] if 0 <= idx < len(cells) else None

    records: list[dict] = []
    current_grs: str | None = None
    for cells in rows:
        # Single-cell строка (сноска про «*» / служебная) → скип.
        non_empty = [c for c in cells if _clean_text(c)]
        if len(non_empty) <= 1:
            continue
        entry = _clean_text(_cell(cells, "entry"))
        if entry:
            current_grs = entry
        outlet = _clean_text(_cell(cells, "outlet"))
        if not current_grs or not outlet:
            continue  # строка до первого block-marker ИЛИ без точки выхода → скип
        free_raw = _cell(cells, "free")
        free, needs_calc = _parse_free_capacity(free_raw)
        records.append(
            {
                "grs_name": current_grs,
                "outlet_name": outlet,
                "consumer_type": _clean_text(_cell(cells, "consumer_type")) or None,
                "consumption_group": _clean_text(_cell(cells, "group")) or None,
                "volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")),
                "volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")),
                "free_capacity_mln_m3": free,
                "free_capacity_needs_calc": needs_calc,
                "address_extracted": extract_address(outlet),
                "raw_cells": _cells_to_jsonable(cells),
            }
        )
    return records


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


def _dedup_outlet_names(records: list[dict]) -> list[dict]:
    """Дедуп повторов outlet_name В ПРЕДЕЛАХ файла: 2-му и далее — суффикс « (N)».

    Один и тот же outlet_name у одной ГРС может встречаться несколько раз (разные
    группы потребления / объекты одного юрлица). UPSERT по (grs_name_norm, outlet_name,
    period_month, is_forecast) схлопнул бы их с потерей данных (урок тепла/воды) —
    добавляем порядковый суффикс. Ключ дедупа — (grs_name_norm, outlet_name).
    """
    seen: dict[tuple[str, str], int] = {}
    for rec in records:
        key = (normalize_grs_name(rec["grs_name"]), rec["outlet_name"])
        seen[key] = seen.get(key, 0) + 1
        if seen[key] > 1:
            rec["outlet_name"] = f"{rec['outlet_name']} ({seen[key]})"
    return records


def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]:
    """Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён).

    Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 — header
    row 11, слева колонка «№ п/п», данные с row 13 (разведка видела r10/r12 без
    «№ п/п» — формат плавает). Свободная мощность: float ИЛИ «0.000*» → needs_calc.
    Single-cell сноска про «*» в конце листа → скип. Сетка не распознана → 0 записей
    + warning (не мусор — урок vodokanal).

    Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group,
    volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3,
    free_capacity_needs_calc, address_extracted, raw_cells}. outlet_name дедуплен
    суффиксом « (N)» в пределах файла.
    """
    wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
    ws = wb.active
    try:
        all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)]
    finally:
        wb.close()

    layout = _find_layout(all_rows)
    if layout is None:
        logger.warning(
            "ekgas: сетка листа не распознана (нет header с подписями) — 0 записей; "
            "первые 5 строк: %s",
            [[_clean_text(c) for c in r[:9]] for r in all_rows[:5]],
        )
        return []
    data_start, cols = layout
    records = _forward_fill_grs(all_rows[data_start:], cols)
    records = _dedup_outlet_names(records)
    logger.info("ekgas: распознано точек выхода=%d", len(records))
    return records


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


def fetch_xlsx(url: str) -> bytes:
    """Тянет XLSX формы 6 по URL. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL."""
    resp = httpx.get(url, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context())
    resp.raise_for_status()
    return resp.content


def _upsert_outlet_rows(db: Session, records: list[dict], meta: dict) -> dict[str, int]:
    """UPSERT точек выхода в gas_grs_outlet_points. Per-row SAVEPOINT (битая строка не валит батч).

    meta = {period_month: date, is_forecast: bool, asof: date|None, source_url: str}.
    ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast) DO UPDATE —
    обновляем объёмы/free/needs_calc/address/raw/fetched_at. Returns счётчики
    inserted/updated/skipped/needs_calc/with_address.
    """
    inserted = 0
    updated = 0
    skipped = 0
    needs_calc = 0
    with_address = 0
    for rec in records:
        grs_name = rec["grs_name"]
        params = {
            "grs_name": grs_name,
            "grs_name_norm": normalize_grs_name(grs_name),
            "outlet_name": rec["outlet_name"],
            "consumer_type": rec["consumer_type"],
            "consumption_group": rec["consumption_group"],
            "requested": rec["volume_requested_mln_m3"],
            "approved": rec["volume_approved_mln_m3"],
            "free": rec["free_capacity_mln_m3"],
            "needs_calc": rec["free_capacity_needs_calc"],
            "address": rec["address_extracted"],
            "period_month": meta["period_month"],
            "is_forecast": meta["is_forecast"],
            "asof": meta.get("asof"),
            "source_url": meta.get("source_url"),
            "raw": json.dumps({"cells": rec["raw_cells"]}, ensure_ascii=False),
        }
        try:
            with db.begin_nested():  # SAVEPOINT — откат только этой строки
                result = db.execute(
                    text("""
                        INSERT INTO gas_grs_outlet_points
                            (grs_name, grs_name_norm, outlet_name, consumer_type,
                             consumption_group, volume_requested_mln_m3,
                             volume_approved_mln_m3, free_capacity_mln_m3,
                             free_capacity_needs_calc, address_extracted,
                             period_month, is_forecast, asof, source_url, raw, fetched_at)
                        VALUES (
                            :grs_name, :grs_name_norm, :outlet_name, :consumer_type,
                            :consumption_group, :requested,
                            :approved, :free,
                            :needs_calc, :address,
                            :period_month, :is_forecast, :asof, :source_url,
                            CAST(:raw AS jsonb), NOW()
                        )
                        ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast)
                        DO UPDATE
                           -- geom НАМЕРЕННО НЕ в SET: geocode_gas_outlets (PR-3)
                           -- пишет его отдельным UPDATE. Помесячный рефреш INSERT-ит
                           -- без geom → EXCLUDED.geom = NULL; включив его в SET, мы бы
                           -- ЗАТЁРЛИ уже геокоженную точку в NULL. Оставляем как есть.
                           SET grs_name                 = EXCLUDED.grs_name,
                               consumer_type            = EXCLUDED.consumer_type,
                               consumption_group        = EXCLUDED.consumption_group,
                               volume_requested_mln_m3  = EXCLUDED.volume_requested_mln_m3,
                               volume_approved_mln_m3   = EXCLUDED.volume_approved_mln_m3,
                               free_capacity_mln_m3     = EXCLUDED.free_capacity_mln_m3,
                               free_capacity_needs_calc = EXCLUDED.free_capacity_needs_calc,
                               address_extracted        = EXCLUDED.address_extracted,
                               asof                     = EXCLUDED.asof,
                               source_url               = EXCLUDED.source_url,
                               raw                      = EXCLUDED.raw,
                               fetched_at               = NOW()
                        RETURNING (xmax = 0) AS is_insert
                    """),
                    params,
                ).scalar()
            if result:
                inserted += 1
            else:
                updated += 1
            if rec["free_capacity_needs_calc"]:
                needs_calc += 1
            if rec["address_extracted"]:
                with_address += 1
        except Exception as e:
            logger.warning("ekgas upsert failed for %r/%r: %s", grs_name, rec["outlet_name"], e)
            skipped += 1
    return {
        "rows": len(records),
        "inserted": inserted,
        "updated": updated,
        "skipped": skipped,
        "needs_calc": needs_calc,
        "with_address": with_address,
    }


def load_gas_outlet_points(db: Session | None = None) -> dict:
    """Тянет каталог ekgas → свежайшая форма 6 → парс XLSX → UPSERT точек выхода.

    RUN-ON-PROD (гео-блок источника). Owns-session как соседи (gas/rosseti/eias): ``db``
    None → своя SessionLocal (fetch + запись sync). Выбор файла — приоритет факта («за»)
    над прогнозом («на»), см. select_latest_file.

    Returns счётчики {rows, inserted, updated, skipped, needs_calc, with_address,
    period, is_forecast} или {"error": ...} если подходящий файл в каталоге не найден.
    """
    catalog_html = fetch_catalog_html()
    candidates = extract_form6_links(catalog_html)
    chosen = select_latest_file(candidates)
    if chosen is None:
        logger.warning("ekgas: подходящая форма 6 в каталоге не найдена (0 кандидатов)")
        return {"error": "no form6 file found in catalog"}

    meta = {
        "period_month": chosen["period_month"],
        "is_forecast": chosen["is_forecast"],
        "asof": chosen["asof"],
        "source_url": chosen["url"],
    }
    logger.info(
        "ekgas: выбран файл period=%s is_forecast=%s asof=%s url=%s",
        chosen["period_month"],
        chosen["is_forecast"],
        chosen["asof"],
        chosen["url"],
    )

    xlsx_bytes = fetch_xlsx(chosen["url"])
    records = parse_outlet_sheet(xlsx_bytes)

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

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

    counts["period"] = chosen["period_month"].isoformat()
    counts["is_forecast"] = chosen["is_forecast"]
    logger.info("load_gas_outlet_points done: %s", counts)
    return counts


# ── Геокод address_extracted → geom (PR-3) ────────────────────────────────────

# Кандидаты геокода: строки последнего period_month с адресом, но БЕЗ geom.
# MAX(period_month) в подзапросе — работаем только по свежайшему снапшоту (старые
# месяцы не трогаем). geom стабилен между месяцами (UPSERT его не затирает — см.
# DO UPDATE выше), так что уже геокоженные строки прошлых месяцев не пере-геокодим.
_GEOCODE_CANDIDATES_SQL = text("""
    SELECT id, address_extracted
    FROM gas_grs_outlet_points
    WHERE address_extracted IS NOT NULL
      AND geom IS NULL
      AND period_month = (SELECT MAX(period_month) FROM gas_grs_outlet_points)
    ORDER BY id
""")

# UPDATE geom = точка WGS84. CAST(:lon/:lat AS float) — psycopg3-канон (backend.md:
# НИКОГДА :lon::float). Пишем ТОЛЬКО если geom всё ещё NULL (idempotent при повторе).
_GEOCODE_UPDATE_SQL = text("""
    UPDATE gas_grs_outlet_points
       SET geom = ST_SetSRID(
                      ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
                      4326
                  )
     WHERE id = CAST(:id AS bigint)
       AND geom IS NULL
""")


def _ekb_prefixed(address: str) -> str:
    """address_extracted → строка для DaData с городом-префиксом при его отсутствии.

    «ул. Стрелочников, д. 2» → «г Екатеринбург, ул. Стрелочников, д. 2». Если город
    уже есть в строке («г.Екатеринбург, …» / «г. Реж, …») — не дублируем.
    """
    if "екатеринбург" in address.lower() or _HAS_CITY_RE.search(address):
        return address
    return f"{_EKB_PREFIX}{address}"


def _in_ekb_bbox(lat: float, lon: float) -> bool:
    """Точка в bbox Екатеринбурга и окрестностей? (sanity-гейт против чужого города)."""
    return (
        _EKB_BBOX_LAT[0] <= lat <= _EKB_BBOX_LAT[1] and _EKB_BBOX_LON[0] <= lon <= _EKB_BBOX_LON[1]
    )


def geocode_gas_outlets(db: Session | None = None, *, apply: bool = False) -> dict:
    """Геокодит address_extracted точек выхода → geom (последний period_month).

    Дефолт dry-run (apply=False): считает счётчики + печатает первые 20 пар
    адрес→(lat,lon), НО В БД НЕ ПИШЕТ. apply=True — UPDATE geom.

    Алгоритм:
      1. SELECT строки последнего period_month c address_extracted IS NOT NULL AND
         geom IS NULL (уже геокоженные не трогаем — geom стабилен между месяцами).
      2. Каждый адрес: префикс «г Екатеринбург, » при отсутствии города → clean_address.
      3. Sanity-bbox ЕКБ+окрестности: координата вне bbox → failed (DaData «дорисовал»
         чужой город на кривом адресе) — geom НЕ пишем (честность).
      4. Кэш в памяти (адреса повторяются) + cap _GEO_DADATA_CAP вызовов DaData.
         Per-row SAVEPOINT (битая строка/UPDATE не валит батч, backend.md).

    Returns счётчики {candidates, geocoded, failed, dadata_calls, cap_hit, applied}.
    """
    owns_session = db is None
    if db is None:
        db = SessionLocal()

    candidates: list[tuple[int, str]] = [
        (int(row[0]), str(row[1])) for row in db.execute(_GEOCODE_CANDIDATES_SQL).all()
    ]

    counts = {
        "candidates": len(candidates),
        "geocoded": 0,
        "failed": 0,
        "dadata_calls": 0,
        "cap_hit": False,
        "applied": apply,
    }
    # Кэш прогона: address → (lat, lon) | None (None = «пробовали, DaData не дал/вне
    # bbox» — кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
    geo_cache: dict[str, tuple[float, float] | None] = {}
    sample: list[tuple[str, tuple[float, float]]] = []

    try:
        for row_id, address in candidates:
            if address in geo_cache:
                coords = geo_cache[address]
            elif counts["dadata_calls"] >= _GEO_DADATA_CAP:
                counts["cap_hit"] = True
                counts["failed"] += 1
                continue
            else:
                counts["dadata_calls"] += 1
                raw = clean_address(_ekb_prefixed(address))
                # bbox-гейт ДО кэша: вне ЕКБ → трактуем как «нет координат» (None).
                if raw is not None and not _in_ekb_bbox(raw[0], raw[1]):
                    logger.info(
                        "ekgas geocode: %r вне bbox ЕКБ (lat=%.4f lon=%.4f) → failed",
                        address[:60],
                        raw[0],
                        raw[1],
                    )
                    raw = None
                coords = raw
                geo_cache[address] = coords

            if coords is None:
                counts["failed"] += 1
                continue

            lat, lon = coords
            if len(sample) < 20:
                sample.append((address, (lat, lon)))

            if not apply:
                counts["geocoded"] += 1
                continue

            try:
                with db.begin_nested():  # SAVEPOINT — откат только этой строки
                    db.execute(_GEOCODE_UPDATE_SQL, {"id": row_id, "lat": lat, "lon": lon})
                counts["geocoded"] += 1
            except Exception as e:
                logger.warning("ekgas geocode UPDATE failed for id=%s %r: %s", row_id, address, e)
                counts["failed"] += 1

        if apply:
            db.commit()
    except Exception as e:
        if apply:
            db.rollback()
        logger.exception("geocode_gas_outlets: rolled back: %s", e)
        raise
    finally:
        if owns_session:
            db.close()

    logger.info("geocode_gas_outlets done (apply=%s): %s", apply, counts)
    if not apply:
        for addr, (lat, lon) in sample:
            logger.info("  sample: %r → (%.6f, %.6f)", addr[:80], lat, lon)
    return counts


def _main(argv: list[str] | None = None) -> int:
    """CLI: помесячная загрузка формы 6 (--load) и геокод адресов → geom (--geocode-outlets).

    RUN-ON-PROD (гео-блок источника + DaData-ключи на проде). Пример:
        python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets
        python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets --apply
    """
    parser = argparse.ArgumentParser(
        description="Загрузчик точек выхода ГРС «Екатеринбурггаз» (#2119 B2): "
        "load формы 6 + геокод address_extracted → geom."
    )
    parser.add_argument(
        "--load",
        action="store_true",
        help="Загрузить свежайшую форму 6 из каталога ekgas → UPSERT точек выхода.",
    )
    parser.add_argument(
        "--geocode-outlets",
        action="store_true",
        help="Геокодить address_extracted (последний period_month, geom IS NULL) → geom.",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Реально писать в БД (для --geocode-outlets). Без флага — dry-run.",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")

    if not args.load and not args.geocode_outlets:
        parser.error("нужен --load и/или --geocode-outlets")

    if args.load:
        logger.info("load result: %s", load_gas_outlet_points())
    if args.geocode_outlets:
        logger.info("geocode result: %s", geocode_gas_outlets(apply=args.apply))
    return 0


if __name__ == "__main__":  # pragma: no cover
    sys.exit(_main())
