"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).

Собирает по участку:
  - power_points: центры питания (power_supply_centers) в радиусе от центроида
    участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
    резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note
    «резерв по запросу в АО «ЕЭСК» (город)».
  - power_summary: ближайший ЦП с резервом + count по load_index.
  - water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
  - gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
    (gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
    + счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
    «Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
    гидрорасчёт». #2119 B2 PR-4.
  - gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
    участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
    на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
  - heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
    за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
  - nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе
    300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка
    дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с
    гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.

Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
fallback). Радиус по умолчанию 3000 м (cap 10000).
"""

import logging

from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind

logger = logging.getLogger(__name__)

_DEFAULT_RADIUS_M = 3000
_MAX_RADIUS_M = 10000

# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
_NETWORK_ZONE_RADIUS_M = 300
_NETWORK_ZONE_RADIUS_DEG = 0.006
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
_NETWORK_ZONE_PER_KIND = 2
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
_NETWORK_ZONE_TIMEOUT_MS = 4000

# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
_GAS_OUTLET_RADIUS_M = 2000
_GAS_OUTLET_LIMIT = 40

# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"


def get_connection_capacity(
    db: Session,
    cad_num: str,
    radius_m: int = _DEFAULT_RADIUS_M,
) -> dict:
    """Точки подключения со свободной мощностью вблизи участка.

    Args:
        db: SQLAlchemy session.
        cad_num: кадастровый номер участка.
        radius_m: радиус поиска ЦП, м (caller клампит 1..10000).

    Returns:
        {"power_points": [...], "power_summary": {...}, "water": [...],
         "gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
         "nearby_network_zones": [...]}

    Raises:
        ValueError: участок не найден в БД (нет geom).
    """
    # Lazy import — избегаем циклического импорта на module-load.
    from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt

    radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))

    parcel_wkt = _get_parcel_wkt(db, cad_num)
    if parcel_wkt is None:
        raise ValueError(f"Участок {cad_num!r} не найден в БД")

    power_points = _query_power_points(db, parcel_wkt, radius_m)
    power_summary = _build_power_summary(power_points)
    water = _query_water_latest(db)
    gas = _query_gas_city_grs(db)
    gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
    heat = _query_heat_latest(db)
    nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)

    return {
        "power_points": power_points,
        "power_summary": power_summary,
        "water": water,
        "gas": gas,
        "gas_outlet_points": gas_outlet_points,
        "heat": heat,
        "nearby_network_zones": nearby_network_zones,
    }


def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
    """Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
    rows = (
        db.execute(
            text("""
                SELECT sc_name, dzo_name, voltage_class, load_index,
                       installed_capacity_mva, current_load_mva, reserve_mva,
                       reserve_unit, district, reserve_asof,
                       ST_Distance(
                           geom::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                       ) AS distance_m,
                       ST_Y(geom) AS lat,
                       ST_X(geom) AS lon
                  FROM power_supply_centers
                 WHERE geom IS NOT NULL
                   AND ST_DWithin(
                       geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       CAST(:radius_m AS float)
                   )
                 ORDER BY distance_m ASC
            """),
            {"wkt": parcel_wkt, "radius_m": radius_m},
        )
        .mappings()
        .all()
    )

    points: list[dict] = []
    for r in rows:
        reserve_mva = _num(r["reserve_mva"])
        points.append(
            {
                "name": r["sc_name"],
                "dzo_name": r["dzo_name"],
                "voltage_class": r["voltage_class"],
                "load_index": r["load_index"],
                "installed_capacity_mva": _num(r["installed_capacity_mva"]),
                "current_load_mva": _num(r["current_load_mva"]),
                "reserve_mva": reserve_mva,
                # Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
                # district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
                "reserve_unit": r["reserve_unit"],
                "district": r["district"],
                "reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
                "reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
                "distance_m": round(float(r["distance_m"]), 1),
                "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
                "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
            }
        )
    return points


def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None:
    """Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1).

    Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в
    WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по
    запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None.
    """
    if reserve_mva is not None:
        return None
    if dzo_name and "еэск" in dzo_name.lower():
        return _EESK_RESERVE_NOTE
    return None


def _build_power_summary(points: list[dict]) -> dict:
    """Сводка: ближайший ЦП с положительным резервом + count по load_index."""
    by_load_index: dict[str, int] = {}
    for p in points:
        li = p["load_index"] or "unknown"
        by_load_index[li] = by_load_index.get(li, 0) + 1

    # points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
    nearest_with_reserve = next(
        (p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
        None,
    )

    return {
        "total_power_points": len(points),
        "by_load_index": by_load_index,
        "nearest_with_reserve": nearest_with_reserve,
    }


def _query_water_latest(db: Session) -> list[dict]:
    """Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.

    «Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …),
    что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
    MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
    опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX
    молча выкинул бы отстающий вид целиком.
    """
    rows = (
        db.execute(
            text("""
                SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
                       w.note, w.period
                  FROM water_supply_reserves w
                 WHERE w.period IS NOT NULL
                   AND w.period = (
                       SELECT MAX(w2.period) FROM water_supply_reserves w2
                        WHERE w2.period IS NOT NULL
                          AND w2.system_kind = w.system_kind
                   )
                 ORDER BY w.system_kind, w.system_name
            """)
        )
        .mappings()
        .all()
    )
    return [
        {
            "system_kind": r["system_kind"],
            "system_name": r["system_name"],
            "reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
            "note": r["note"],
            "period": r["period"],
        }
        for r in rows
    ]


def _query_gas_city_grs(db: Session) -> dict:
    """Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС.

    Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%').
    Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free),
    AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица
    → city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
    существовать до применения миграции 181).

    Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
    gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
    period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
    См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».

    Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
              "outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
    Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
    (в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
    """
    outlet_counts = _query_gas_outlet_counts(db)
    try:
        # SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
        # Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
        # begin_nested откатывает только эту savepoint, outer tx остаётся usable.
        with db.begin_nested():
            rows = (
                db.execute(
                    text("""
                        SELECT grs_name,
                               SUM(design_capacity_th_m3_h) AS design_sum,
                               SUM(free_capacity_th_m3_h)   AS free_sum,
                               AVG(free_capacity_pct)       AS pct_avg,
                               MAX(upgrade_due)             AS upgrade_due,
                               COUNT(*)                     AS outputs_count
                          FROM gas_grs_capacity
                         WHERE grs_name ILIKE '%свердловск%'
                            OR grs_name ILIKE '%екатеринбург%'
                         GROUP BY grs_name
                         ORDER BY grs_name
                    """)
                )
                .mappings()
                .all()
            )
    except Exception as e:
        # Таблица может не существовать до применения миграции 181 (schema-first).
        # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
        # точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
        logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
        return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}

    city_grs: list[dict] = []
    total_free = 0.0
    has_free = False
    for r in rows:
        free = _num(r["free_sum"])
        if free is not None:
            total_free += free
            has_free = True
        city_grs.append(
            {
                "grs_name": r["grs_name"],
                "design_capacity_th_m3_h": _num(r["design_sum"]),
                "free_capacity_th_m3_h": free,
                "free_capacity_pct": (
                    round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None
                ),
                "upgrade_due": r["upgrade_due"],
                "outputs_count": int(r["outputs_count"]),
            }
        )
    return {
        "city_grs": city_grs,
        "total_free_th_m3_h": round(total_free, 3) if has_free else None,
        **outlet_counts,
    }


def _query_gas_outlet_counts(db: Session) -> dict:
    """Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.

    По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
    форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
    «грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
    источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
    (имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
    целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
    «сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.

    Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
    в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.

    Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
      - outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
      - outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
    Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
    """
    try:
        # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
        with db.begin_nested():
            row = (
                db.execute(
                    text("""
                        SELECT COUNT(*) AS outlets_total,
                               COUNT(*) FILTER (
                                   WHERE free_capacity_mln_m3 < 0
                               ) AS outlets_deficit,
                               COUNT(*) FILTER (
                                   WHERE free_capacity_needs_calc
                               ) AS outlets_needs_calc
                          FROM gas_grs_outlet_points
                         WHERE period_month = (
                                   SELECT MAX(period_month) FROM gas_grs_outlet_points
                               )
                    """)
                )
                .mappings()
                .first()
            )
    except Exception as e:
        # Таблица может не существовать до применения миграции 184 (schema-first).
        # Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
        logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
        return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}

    if row is None:
        return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
    return {
        "outlets_total": int(row["outlets_total"] or 0),
        "outlets_deficit": int(row["outlets_deficit"] or 0),
        "outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
    }


def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
    """Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.

    ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
    period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
    строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
    free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
    (миграция 184) → [] (graceful, блок аддитивный).

    План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
    geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
    по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.

    Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
               distance_m, lat, lon}] — отсортировано по distance ASC.
    """
    try:
        # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
        with db.begin_nested():
            rows = (
                db.execute(
                    text("""
                        SELECT outlet_name, consumer_type, free_capacity_mln_m3,
                               free_capacity_needs_calc AS needs_calc,
                               ST_Distance(
                                   geom::geography,
                                   ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                               ) AS distance_m,
                               ST_Y(geom) AS lat,
                               ST_X(geom) AS lon
                          FROM gas_grs_outlet_points
                         WHERE geom IS NOT NULL
                           AND period_month = (
                                   SELECT MAX(period_month) FROM gas_grs_outlet_points
                               )
                           AND ST_DWithin(
                                   geom::geography,
                                   ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                                   CAST(:radius_m AS float)
                               )
                         ORDER BY distance_m ASC
                         LIMIT CAST(:limit AS int)
                    """),
                    {
                        "wkt": parcel_wkt,
                        "radius_m": _GAS_OUTLET_RADIUS_M,
                        "limit": _GAS_OUTLET_LIMIT,
                    },
                )
                .mappings()
                .all()
            )
    except Exception as e:
        # Таблица может не существовать до применения миграции 184 (schema-first).
        # Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
        logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
        return []

    return [
        {
            "outlet_name": r["outlet_name"],
            "consumer_type": r["consumer_type"],
            # млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
            "free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
            "needs_calc": bool(r["needs_calc"]),
            "distance_m": round(float(r["distance_m"]), 1),
            "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
            "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
        }
        for r in rows
    ]


def _query_heat_latest(db: Session) -> dict:
    """Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.

    Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
    как в _query_water_latest: одна организация может опубликовать свежий квартал
    раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
    исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
    Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).

    Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
              "total_reserve_gcal_h": float|None}.
    """
    try:
        # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
        with db.begin_nested():
            rows = (
                db.execute(
                    text("""
                        SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
                          FROM heat_system_reserves h
                         WHERE h.period IS NOT NULL
                           AND h.period = (
                               SELECT MAX(h2.period) FROM heat_system_reserves h2
                                WHERE h2.period IS NOT NULL
                                  AND h2.org = h.org
                           )
                         ORDER BY h.org, h.system_name
                    """)
                )
                .mappings()
                .all()
            )
    except Exception as e:
        # Таблица может не существовать до применения миграции 182 (schema-first).
        # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
        logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
        return {"systems": [], "total_reserve_gcal_h": None}

    systems: list[dict] = []
    total_reserve = 0.0
    has_reserve = False
    for r in rows:
        reserve = _num(r["reserve_gcal_h"])
        if reserve is not None:
            total_reserve += reserve
            has_reserve = True
        systems.append(
            {
                "org": r["org"],
                "system_name": r["system_name"],
                "reserve_gcal_h": reserve,
                "period": r["period"],
            }
        )
    return {
        "systems": systems,
        "total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
    }


def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
    """Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.

    Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м →
    врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3
    нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.

    План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
    ``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
    parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
    = geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
    (psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).

    #1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
    category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.

    Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
    (general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
    human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до
    _NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).

    Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально
    медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
    аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
    """
    try:
        with db.begin_nested():
            # SET не принимает bind-параметры (extended protocol → syntax error
            # at or near "$1") — только литерал, как у offer_trend (parcels.py).
            db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
            rows = (
                db.execute(
                    text("""
                        SELECT DISTINCT ON (z.reg_numb_border)
                               z.type_zone,
                               z.name_by_doc,
                               z.reg_numb_border,
                               round(
                                   ST_Distance(
                                       z.geom::geography,
                                       ST_GeomFromText(:wkt, 4326)::geography
                                   )::numeric
                               ) AS distance_m
                          FROM cad_zouit z
                         WHERE z.geom IS NOT NULL
                           AND z.type_zone IN (
                               'Охранная зона тепловых сетей',
                               'Охранная зона инженерных коммуникаций',
                               'Охранная зона объектов электроэнергетики'
                               ' (объектов электросетевого хозяйства и объектов по'
                               ' производству электрической энергии)',
                               'Охранная зона трубопроводов (газопроводов,'
                               ' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
                               'Охранная зона линий и сооружений связи'
                           )
                           AND ST_DWithin(
                               z.geom,
                               ST_GeomFromText(:wkt, 4326),
                               CAST(:radius_deg AS float)
                           )
                           AND ST_DWithin(
                               z.geom::geography,
                               ST_GeomFromText(:wkt, 4326)::geography,
                               CAST(:radius_m AS float)
                           )
                         ORDER BY z.reg_numb_border, distance_m ASC
                    """),
                    {
                        "wkt": parcel_wkt,
                        "radius_deg": _NETWORK_ZONE_RADIUS_DEG,
                        "radius_m": _NETWORK_ZONE_RADIUS_M,
                    },
                )
                .mappings()
                .all()
            )
            # SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
            # сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
            # не утёк на последующие запросы этой транзакции.
            db.execute(text("SET LOCAL statement_timeout = 0"))
    except SQLAlchemyError as e:
        # QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
        # (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
        logger.warning("nearby_network_zones query failed: %s", e)
        return []

    return _shape_network_zones(rows)


def _shape_network_zones(rows: list) -> list[dict]:
    """DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.

    Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
    name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
    Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
    """
    per_kind: dict[str, list[dict]] = {}
    for r in rows:
        type_zone = r["type_zone"] or ""
        name_by_doc = r["name_by_doc"]
        net_kind = refine_net_kind(type_zone, name_by_doc)
        if net_kind is None:
            # type_zone из белого списка, но не классифицировался — пропускаем (не должно).
            continue
        distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
        per_kind.setdefault(net_kind, []).append(
            {
                "net_kind": net_kind,
                "distance_m": distance_m,
                "name_by_doc": name_by_doc,
                "human_label": human_label(net_kind, name_by_doc),
            }
        )

    result: list[dict] = []
    for kind_rows in per_kind.values():
        kind_rows.sort(key=lambda z: z["distance_m"])
        result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
    # Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
    result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
    return result


def _num(value: object) -> float | None:
    """NUMERIC (Decimal) из БД → float | None."""
    return float(value) if value is not None else None  # type: ignore[arg-type]
