"""Унифицированный NSPD API client — foundation для G1/G2/G3/E1/#93.

Issue #94: при исследовании НСПД через debug browser найдено что
REST `/api/geoportal/v2/search/geoportal?query={cad}` возвращает GeoJSON
+ полные properties (ВРИ, категория земель, кадастровая стоимость, адрес)
за один запрос. Плюс WMS endpoints для тематических слоёв (ПЗЗ, ЗОУИТ,
территориальные зоны, risk zones).

Этот клиент покрывает 4 базовых метода API:

  - search_by_cad(cad) → SearchResult — поиск по cad-номеру
  - get_feature_info(layer_id, lon, lat) → list[Feature] — feature data на точке
  - get_features_in_bbox(layer_id, bbox) → list[Feature] — bbox bulk через
    GetFeatureInfo (WFS GetCapabilities → 404, workaround)
  - list_layers(theme_id) → list[Layer] — каталог слоёв в теме

Reuse: `fetch_geoportal` из `nspd_lite.py` (WAF-compatible urllib + rate limit).
Расширения сверх nspd_lite:
  - Typed wrappers (NSPDFeature, NSPDLayer, NSPDSearchResult)
  - WMS GetFeatureInfo / GetMap helpers (нет в nspd_lite)
  - Coordinate transform EPSG:3857 ↔ 4326 (NSPD WMS работает в Mercator)

WMS endpoints (per #94 issue body, TIER 1-6 каталог слоёв):
  - TIER 1 критичные: 36048 ЗУ, 36071 кварталы, 36049 здания, 36328 сооружения,
    875838 территориальные зоны (G1), 879243 красные линии
  - TIER 2 ЗОУИТ (G3): 37577 ОКН, 37578 энергетики/связи, 37580 природные,
    37579 охраняемых объектов, 37581 иные
  - TIER 3 риски: 872202 подтопление, 872205 затопление, 872203 заболачивание,
    872210 обвально-осыпные, 872211 абразия, etc.
"""

from __future__ import annotations

import asyncio
import datetime as _dt
import json
import logging
import math
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any

from app.services.scrapers.nspd_denorm import classify_engineering_kind
from app.services.scrapers.nspd_lite import (
    _SSL_CTX,
    HEADERS,
    NspdLiteError,
    NspdLiteWafError,
    fetch_geoportal,
)

logger = logging.getLogger(__name__)

# WMS base — отдельный путь от geoportal/v2/search
NSPD_WMS_BASE = "https://nspd.gov.ru/api/aeggis/v4"
# Layers catalog endpoint
NSPD_LAYERS_TREE = "https://nspd.gov.ru/api/geoportal/v1/layers-theme-tree"
# Themes list
NSPD_THEMES = "https://nspd.gov.ru/api/geoportal/v1/layers-theme"

# Стандартные theme ID
THEME_PKK = 1
THEME_ARN = 665


# ── Layer ID catalog (TIER 1-6 per #94) ──────────────────────────────────────
# Не enum, а module-level dict — для удобства интроспекции и тестов.
# Использовать как: LAYERS["territorial_zones"] = 875838.

LAYERS: dict[str, int] = {
    # TIER 1 — критичные для МКД (Gate-факторы)
    "parcels": 36048,  # Земельные участки ЕГРН
    "quarters": 36071,  # Кадастровые кварталы
    "buildings": 36049,  # Здания ЕГРН
    "engineering_structures": 36328,  # Сооружения ЕГРН
    "territorial_zones": 875838,  # ПЗЗ (G1 #28)
    "red_lines": 879243,  # Красные линии
    # TIER 2 — ЗОУИТ (G3 #30)
    "zouit_okn": 37577,  # ОКН
    "zouit_engineering": 37578,  # энергетики/связи/транспорта
    "zouit_natural": 37580,  # природные (водоохранные, СЗО)
    "zouit_protected": 37579,  # охраняемых объектов (СЗЗ предприятий)
    "zouit_other": 37581,  # иные (публичный сервитут, ОЭЗ)
    # TIER 3 — Risk / Negative
    "risk_flooding_underground": 872202,  # подтопление
    "risk_flooding": 872205,  # затопление
    "risk_swampification": 872203,  # заболачивание
    "risk_landslide": 872210,  # обвально-осыпные
    "risk_abrasion": 872211,  # абразия
    "risk_erosion_water": 872153,  # водная эрозия
    "risk_erosion_linear": 872155,
    "risk_erosion_wind": 872164,
    "risk_desertification": 872182,
    "risk_clutter": 872206,  # захламление
    "risk_burns": 872222,  # гари
    # TIER 4 — Opportunity / nature
    "protected_areas": 875845,  # ООПТ
    "coast_lines_river": 875832,
    "coast_lines_sea": 875835,
    "forestries": 875866,
    "auction_parcels": 37299,  # ЗУ на аукционе — opportunity
    "scheme_parcels": 37294,  # ЗУ по схеме расположения
    "free_parcels": 37298,  # ЗУ свободные от прав
    "future_parcels": 36473,  # ЗУ по проекту межевания
    "krt_territories": 37430,  # территории ККР (комплексное развитие) — opportunity (#1086)
    "okn_territory": 875840,
    "cadastral_cost_heatmap": 37236,
    "cadastral_cost_per_m2_heatmap": 37758,
}


# Layers where grid-walk (get_features_in_bbox_grid) must be used instead of
# the legacy single-pixel WMS probe (get_features_in_bbox). These are area/
# linear layers (territorial zones, red lines, engineering structures, ЗОУИТ,
# risks) that span large areas and are under-returned by a single GetFeatureInfo
# call.  Point/polygon EGRN layers (parcels, buildings) stay on legacy for now.
_GRID_WALK_LAYERS: frozenset[str] = frozenset(
    {
        "territorial_zones",
        "red_lines",
        "engineering_structures",
        # ЗОУИТ (TIER 2)
        "zouit_okn",
        "zouit_engineering",
        "zouit_natural",
        "zouit_protected",
        "zouit_other",
        # Risks (TIER 3)
        "risk_flooding_underground",
        "risk_flooding",
        "risk_swampification",
        "risk_landslide",
        "risk_abrasion",
        "risk_erosion_water",
        "risk_erosion_linear",
        "risk_erosion_wind",
        "risk_desertification",
        "risk_clutter",
        "risk_burns",
    }
)

# ── РИАСУРТ Свердл (folderId 1224) — агломерация ЕКБ, multi-city scaling (#108) ──
# РИАСУРТ Свердл агрегирует данные от МАЛЫХ МО Свердл (Берёзовский, В.Пышма,
# Среднеуральск, Арамиль, Сысерть) — НЕ от ЕКБ-сити (тот не интегрирован в ФГИС ТП).
# Слои на той же NSPD aeggis/v4 WMS-инфраструктуре, что и федеральные LAYERS, но
# layerId из регионального каталога (84xxxx/85xxxx). 14 ключевых слоёв (см. issue #108).
#
# layerId → (topic, человекочитаемое название). topic группирует слой для analyze-gate
# (тер.зона/функц.зона/красные линии/СЗЗ/ЗСО/затопление/КРТ и т.п.).
RIASURT_SVERDL_LAYERS: dict[int, tuple[str, str]] = {
    # ПЗЗ / Планирование
    845274: ("territorial_zone", "Территориальные зоны"),
    846381: ("functional_zone", "Функциональные зоны"),
    844759: ("red_lines", "Красные линии"),
    844774: ("setback_lines", "Линии отступа от красных линий"),
    845298: ("ppt_oks_boundary", "Границы ППТ ОКС"),
    # Инженерные ЗОУИТ
    846365: ("szz", "Санитарно-защитная зона (СЗЗ)"),
    846369: ("san_gap_engineering", "Санитарный разрыв инженерных коммуникаций"),
    845392: ("szo", "Зоны санитарной охраны питьевого водоснабжения (ЗСО)"),
    846373: ("water_protection", "Водоохранные зоны"),
    # Risk
    845425: ("flood_zone", "Зоны затопления / подтопления"),
    846378: ("emergency_alert", "Зона экстренного оповещения"),
    844795: ("emergency_risk", "Территории риска ЧС"),
    # Opportunity
    844478: ("krt", "Территории КРТ"),
    846379: ("oez", "Особые экономические зоны (ОЭЗ)"),
}


def riasurt_layer_topic(layer_id: int) -> str:
    """Topic (бакет для analyze-gate) по layerId РИАСУРТ Свердл.

    Неизвестный layerId → 'other' (graceful; не падаем на новых слоях каталога).
    """
    entry = RIASURT_SVERDL_LAYERS.get(layer_id)
    return entry[0] if entry else "other"


# Default rate limit (мс между запросами) — баланс между скоростью и WAF
DEFAULT_RATE_MS = 600

# SSL context — reuse `_SSL_CTX` из nspd_lite (one source of truth для WAF-
# compatible TLS settings; нет дублирования если что-то поменяется).


# ── Typed responses ──────────────────────────────────────────────────────────


@dataclass(frozen=True, slots=True)
class NSPDFeature:
    """Парсенный GeoJSON Feature из NSPD API.

    `geometry` — raw GeoJSON dict (Polygon/LineString/Point в EPSG:3857).
    `properties` — все returned attributes (cad_num, ВРИ, кост и т.д.).
    """

    feature_id: str | None
    geometry: dict[str, Any] | None
    properties: dict[str, Any]
    crs: str = "EPSG:3857"  # NSPD default; для search /v2 фактически 3857

    @classmethod
    def from_raw(cls, raw: dict[str, Any]) -> NSPDFeature:
        """Парсим GeoJSON Feature dict в typed NSPDFeature."""
        props = raw.get("properties") or {}
        return cls(
            feature_id=str(raw.get("id")) if raw.get("id") is not None else None,
            geometry=raw.get("geometry"),
            properties=props,
        )


@dataclass(frozen=True, slots=True)
class NSPDSearchResult:
    """Aggregated результат поиска по cad — typed wrapper над feature list."""

    cad_num: str
    features: list[NSPDFeature]
    raw: dict[str, Any]  # original response для debugging / future-proofing

    @property
    def first(self) -> NSPDFeature | None:
        """Удобный shortcut: features[0] или None."""
        return self.features[0] if self.features else None

    @property
    def is_empty(self) -> bool:
        return not self.features


@dataclass(frozen=True, slots=True)
class NSPDLayer:
    """Метаданные layer'а из layers-theme-tree response."""

    layer_id: int
    title: str
    layer_type: str | None  # 'wms' | 'wfs' | etc
    metadata: dict[str, Any]


@dataclass(frozen=True, slots=True)
class QuarterDump:
    """Comprehensive snapshot of NSPD data в пределах одного quarter.

    Foundation для PKK harvest pipeline (#94 follow-up):
      - `quarter` — собственно квартал (geometry в EPSG:3857)
      - per-layer feature lists в пределах квартала (bbox-filtered)

    Один dump = достаточно данных чтобы analyze_parcel мог отдать ВСЕ Gate-
    факторы (G1 ПЗЗ, G3 ЗОУИТ), neighbors, инженерку — без новых NSPD-вызовов
    в request-цикле. Беём дешевле для пользователя (1 HTTP вместо N).

    Layers распределены по 3 phases:
      - core: parcels + buildings + territorial_zones + red_lines + engineering
      - zouit: 5 ЗОУИТ layers (G3)
      - risks: 11 risk-zone layers (TIER 3)
    Default = только core, чтобы не сжигать rate-limit на 17 запросов.
    """

    quarter_cad: str
    quarter: NSPDFeature | None  # сам polygon квартала (может быть None если NSPD пуст)
    parcels: list[NSPDFeature]
    buildings: list[NSPDFeature]
    territorial_zones: list[NSPDFeature]  # ПЗЗ зоны покрывающие/пересекающие
    red_lines: list[NSPDFeature]
    engineering_structures: list[NSPDFeature]
    zouit: dict[str, list[NSPDFeature]]  # {"okn": [...], "engineering": [...], ...}
    risks: dict[str, list[NSPDFeature]]  # {"flooding": [...], "landslide": [...], ...}
    # TIER 4 opportunity layers (issue #94 PR2).
    # {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...}
    opportunity: dict[str, list[NSPDFeature]]
    # tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot)
    layers_fetched: tuple[str, ...]
    bbox_3857: tuple[float, float, float, float] | None  # bbox квартала
    fetched_at_utc: str  # ISO timestamp когда сделали запрос (для freshness check)

    @property
    def total_features(self) -> int:
        """Сумма всех features во всех layers — для smoke check."""
        return (
            len(self.parcels)
            + len(self.buildings)
            + len(self.territorial_zones)
            + len(self.red_lines)
            + len(self.engineering_structures)
            + sum(len(v) for v in self.zouit.values())
            + sum(len(v) for v in self.risks.values())
            + sum(len(v) for v in self.opportunity.values())
        )


# ── HTTP helper ──────────────────────────────────────────────────────────────


def _http_get_json(url: str, *, timeout: int = 15, rate_ms: int = 0) -> Any:
    """Внутренний GET-JSON helper с WAF-handling, paralleling nspd_lite.

    Используется для endpoints которые не покрыты `fetch_geoportal` (WMS,
    layers-tree). Reuse HEADERS + SSL context из nspd_lite — общая защита от
    WAF блокировки по TLS fingerprint.
    """
    if rate_ms > 0:
        time.sleep(rate_ms / 1000.0)
    req = urllib.request.Request(url, headers=HEADERS)
    try:
        with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
            body = r.read().decode("utf-8", "ignore")
            return json.loads(body)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
        if e.code in (403, 429):
            raise NspdLiteWafError(f"HTTP {e.code} (WAF/rate-limit): {body}") from e
        raise NspdLiteError(f"HTTP {e.code}: {body}") from e
    except urllib.error.URLError as e:
        raise NspdLiteError(f"Network error: {e}") from e


# ── Coordinate transforms ────────────────────────────────────────────────────


def lonlat_to_3857(lon: float, lat: float) -> tuple[float, float]:
    """WGS84 lon/lat → EPSG:3857 Web Mercator (метры).

    Стандартная формула; стандартная для всех Web mapping. NSPD WMS принимает
    bbox в 3857.
    """
    x = lon * 20037508.34 / 180.0
    y = math.log(math.tan((90.0 + lat) * math.pi / 360.0)) / (math.pi / 180.0)
    y = y * 20037508.34 / 180.0
    return (x, y)


def bbox_around_point_m(
    lon: float,
    lat: float,
    buffer_m: int,
) -> tuple[float, float, float, float]:
    """Микро-bbox вокруг точки в EPSG:3857 — для WMS GetFeatureInfo.

    Returns (xmin, ymin, xmax, ymax). buffer_m по m в каждую сторону.
    """
    x, y = lonlat_to_3857(lon, lat)
    return (x - buffer_m, y - buffer_m, x + buffer_m, y + buffer_m)


# ── Public client API ────────────────────────────────────────────────────────


class NSPDClient:
    """Foundation-level NSPD client. 4 core methods.

    Stateless — все методы могут вызываться независимо. Используй один instance
    на process (rate-limit shared между вызовами через time.sleep).
    """

    def __init__(self, rate_ms: int = DEFAULT_RATE_MS, timeout: int = 15) -> None:
        self.rate_ms = rate_ms
        self.timeout = timeout

    # ── 1. search_by_cad ────────────────────────────────────────────────────

    def search_by_cad(self, cad_num: str, thematic_id: int = 1) -> NSPDSearchResult:
        """REST поиск /v2/search/geoportal — 1 запрос = geometry + ВРИ + кост.

        Args:
            cad_num: канонический формат — `NN:NN:NNNNNN[:NN[:NN]]`, цифры через `:`.
                Примеры:
                  - 3-сегментный квартал: `'66:41:0204016'`
                  - 4-сегментный участок: `'66:41:0204016:10'`
                  - 5-сегментное здание: `'66:41:0204016:10:1'`
                Спецсимволы / пробелы корректно URL-encode'нутся (нет SQL/URL
                injection), но НСПД молча вернёт empty result. Caller обязан
                сам валидировать формат если нужна defensive обработка (см.
                `app.services.site_finder.cadastre_fetch.validate_cad_format`).
            thematic_id: 1 ЗУ / 2 квартал / 5 здание / 7 территориальная зона.
                Default 1 — соответствует use-case G2 ВРИ.

        Returns:
            NSPDSearchResult с typed features. `result.is_empty` → cad не найден.

        Raises:
            NspdLiteWafError при 403/429 — caller должен сделать backoff.
            NspdLiteError при прочих 4xx/5xx.

        Закрывает: G2 #29 ВРИ, поддержка on-demand #93/#51.
        """
        raw = fetch_geoportal(
            cad_num,
            thematic_id=thematic_id,
            timeout=self.timeout,
            rate_ms=self.rate_ms,
        )
        # Response shape: {"data": {"type": "FeatureCollection", "features": [...]}}
        data = raw.get("data") or {}
        feats = data.get("features") or []
        features = [NSPDFeature.from_raw(f) for f in feats]
        return NSPDSearchResult(cad_num=cad_num, features=features, raw=raw)

    # ── 2. get_feature_info ─────────────────────────────────────────────────

    def get_feature_info(
        self,
        layer_id: int,
        lon: float,
        lat: float,
        buffer_m: int = 100,
        feature_count: int = 10,
    ) -> list[NSPDFeature]:
        """WMS GetFeatureInfo на точке — какие feature'ы layer'а покрывают (lon, lat).

        Использует микро-bbox вокруг точки в EPSG:3857 (WMS требует bbox + I/J
        пиксель). Tile 256x256, I=128, J=128 — центр.

        Args:
            feature_count: макс. число фич в ответе (WMS FEATURE_COUNT). Default 10
                (паритет с порталом nspd.gov.ru). Без него NSPD по умолчанию отдаёт
                ТОЛЬКО 1 фичу на точку — а на ЕКБ в одной точке часто пересекаются
                несколько зон одного слоя (напр. 37581 «иные ЗОУИТ» вернул 2). Для
                overlay-ингеста (#1067) нужно >1.

        Returns features (может быть 0+, до feature_count).

        Use cases:
          - ВРИ конкретного участка (layer_id=875838 territorial_zones)
          - ЗОУИТ на участке (layer_id=37577..37581)
          - Risk zones overlap (872xxx)
        """
        xmin, ymin, xmax, ymax = bbox_around_point_m(lon, lat, buffer_m)
        params = {
            "SERVICE": "WMS",
            "VERSION": "1.3.0",
            "REQUEST": "GetFeatureInfo",
            "FORMAT": "image/png",
            "TRANSPARENT": "true",
            "QUERY_LAYERS": str(layer_id),
            "LAYERS": str(layer_id),
            "INFO_FORMAT": "application/json",
            # FEATURE_COUNT: NSPD по умолчанию отдаёт 1 фичу/точку → теряем
            # перекрывающиеся зоны. Портал шлёт 10. Без этого overlay-ингест неполон.
            "FEATURE_COUNT": str(feature_count),
            # STYLES обязателен (пустой), иначе INFO_FORMAT игнорируется per NSPD WAF
            "STYLES": "",
            "I": "128",
            "J": "128",
            "WIDTH": "256",
            "HEIGHT": "256",
            "CRS": "EPSG:3857",
            "BBOX": f"{xmin},{ymin},{xmax},{ymax}",
        }
        url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}"
        data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
        feats = (data or {}).get("features") or []
        return [NSPDFeature.from_raw(f) for f in feats]

    # ── 3. get_features_in_bbox ─────────────────────────────────────────────

    def get_features_in_bbox(
        self,
        layer_id: int,
        bbox_3857: tuple[float, float, float, float],
        *,
        width: int = 4096,
        height: int = 4096,
    ) -> list[NSPDFeature]:
        """WMS GetFeatureInfo на одном центральном пикселе bbox.

        DEPRECATED: возвращает 0-3 features под одним пикселем (I=W/2, J=H/2).
        НЕ является bulk fetch несмотря на исходный docstring — WMS GetFeatureInfo
        по стандарту OGC возвращает объекты строго под одной pixel-точкой, а не
        во всём bbox. Для получения всех объектов в bbox используй
        `get_features_in_bbox_grid`.

        See: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md

        Args:
            bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 метрах.
            width/height: размер виртуального tile.

        Returns:
            list[NSPDFeature]; пусто если ничего не найдено.
        """
        xmin, ymin, xmax, ymax = bbox_3857
        params = {
            "SERVICE": "WMS",
            "VERSION": "1.3.0",
            "REQUEST": "GetFeatureInfo",
            "FORMAT": "image/png",
            "TRANSPARENT": "true",
            "QUERY_LAYERS": str(layer_id),
            "LAYERS": str(layer_id),
            "INFO_FORMAT": "application/json",
            "STYLES": "",
            "I": str(width // 2),
            "J": str(height // 2),
            "WIDTH": str(width),
            "HEIGHT": str(height),
            "CRS": "EPSG:3857",
            "BBOX": f"{xmin},{ymin},{xmax},{ymax}",
        }
        url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}"
        data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
        feats = (data or {}).get("features") or []
        return [NSPDFeature.from_raw(f) for f in feats]

    # ── 3b. get_features_in_bbox_grid ───────────────────────────────────────

    def get_features_in_bbox_grid(
        self,
        layer_id: int,
        bbox: tuple[float, float, float, float],
        *,
        grid_n: int = 7,
        step_m: float = 50.0,
        tile_size: int = 512,
    ) -> list[NSPDFeature]:
        """Bulk-аппроксимация bbox через grid-walk WMS GetFeatureInfo.

        Разбивает bbox на grid_n × grid_n равных ячеек. В каждой ячейке
        вызывает WMS GetFeatureInfo в центральном пикселе. Дедуплицирует
        результаты по feature_id / cad_num / reg_numb_border — возвращает
        список уникальных NSPDFeature.

        Делегирует HTTP через NSPDBulkClient.wms_feature_info (async httpx
        с semaphore и retry), запуская asyncio event loop синхронно через
        asyncio.run(). Предназначен для вызова из синхронного кода (Celery
        task, FastAPI sync handler).

        Concurrency: NSPDBulkClient._sem (per-instance, capacity=3) ограничивает параллельные
        запросы. При grid_n=7 (49 ячеек) — все 49 ячеек запускаются одним
        gather; семафор пропускает не более 3 одновременно. Thread-safety:
        каждый вызов get_features_in_bbox_grid создаёт новый event loop
        через asyncio.run() — безопасно из разных Celery workers (process-
        уровень изоляции).

        Args:
            layer_id: NSPD layer ID (например 36328 сооружения, 37578 ЗОУИТ).
            bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры).
            grid_n: размер сетки по каждой оси. 7 → 49 запросов (~coarse),
                15 → 225 запросов (~fine). По умолчанию 7 для первичного scan.
            step_m: минимальный шаг ячейки в метрах. Если bbox меньше
                grid_n*step_m — grid_n уменьшается автоматически чтобы
                ячейки не становились меньше step_m.
            tile_size: размер виртуального WMS тайла (пиксели).

        Returns:
            Дедуплицированный list[NSPDFeature]. Может быть пуст если в bbox
            нет объектов данного layer'а.

        Note:
            Не делает live HTTP вызовы если вызван с mock NSPDBulkClient.
            Rate-limit управляется семафором NSPDBulkClient._sem (per-instance, capacity=3) +
            asyncio.sleep(0.05) jitter — не через self.rate_ms.
        """
        # Импортируем здесь чтобы избежать circular import:
        # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
        from app.scrapers.nspd_bulk_client import NSPDBulkClient

        xmin, ymin, xmax, ymax = bbox
        width_m = xmax - xmin
        height_m = ymax - ymin

        # Авто-коррекция grid_n если bbox мал для шага step_m
        effective_n = min(
            grid_n,
            max(1, int(width_m / step_m)),
            max(1, int(height_m / step_m)),
        )
        if effective_n < grid_n:
            logger.info(
                "get_features_in_bbox_grid layer=%d: bbox %.0fx%.0fm < grid_n=%d×step_m=%.0f"
                " — уменьшаем grid до %d×%d",
                layer_id,
                width_m,
                height_m,
                grid_n,
                step_m,
                effective_n,
                effective_n,
            )

        x_step = width_m / effective_n
        y_step = height_m / effective_n

        # Генерируем список (sub_bbox, click_xy) ячеек
        cells: list[tuple[tuple[float, float, float, float], tuple[int, int]]] = []
        click_px = tile_size // 2
        for i in range(effective_n):
            for j in range(effective_n):
                cell_xmin = xmin + i * x_step
                cell_ymin = ymin + j * y_step
                cell_xmax = cell_xmin + x_step
                cell_ymax = cell_ymin + y_step
                cells.append(((cell_xmin, cell_ymin, cell_xmax, cell_ymax), (click_px, click_px)))

        async def _run_grid() -> list[NSPDFeature]:
            async with NSPDBulkClient() as client:
                tasks = [
                    client.wms_feature_info(layer_id, sub_bbox, click_xy, tile_size, tile_size)
                    for sub_bbox, click_xy in cells
                ]
                results = await asyncio.gather(*tasks, return_exceptions=True)

            features: list[NSPDFeature] = []
            for r in results:
                if isinstance(r, Exception):
                    logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
                    continue
                for bulk_feat in r:
                    raw = {
                        "id": bulk_feat.id,
                        "geometry": bulk_feat.geometry,
                        "properties": bulk_feat.properties,
                    }
                    features.append(NSPDFeature.from_raw(raw))
            return features

        raw_features = asyncio.run(_run_grid())

        # Дедупликация — приоритет ключей: feature_id > cad_num > reg_numb_border
        seen: set[str] = set()
        deduped: list[NSPDFeature] = []
        for feat in raw_features:
            props = feat.properties
            dedup_key = (
                feat.feature_id
                or props.get("cad_num")
                or props.get("cad_number")
                or props.get("reg_numb_border")
                or props.get("label")
            )
            if dedup_key is not None:
                if dedup_key in seen:
                    continue
                seen.add(dedup_key)
            deduped.append(feat)

        logger.info(
            "get_features_in_bbox_grid layer=%d grid=%dx%d cells=%d raw=%d deduped=%d",
            layer_id,
            effective_n,
            effective_n,
            len(cells),
            len(raw_features),
            len(deduped),
        )
        return deduped

    # ── 3c. get_riasurt_sverdl_in_bbox (РИАСУРТ Свердл bulk, #108) ───────────

    def get_riasurt_sverdl_in_bbox(
        self,
        bbox_3857: tuple[float, float, float, float],
        layers: list[int] | None = None,
        *,
        grid_n: int = 7,
        step_m: float = 50.0,
    ) -> dict[int, list[NSPDFeature]]:
        """Bulk WMS GetFeatureInfo по слоям РИАСУРТ Свердл в пределах bbox.

        Для каждого layerId делает grid-walk (``get_features_in_bbox_grid``) — area/
        linear слои (зоны, линии) под-возвращаются single-pixel probe, поэтому grid.
        Слой, который упал (WAF / сеть), логируется и пропускается (его ключ = []),
        остальные слои продолжают — частичный harvest лучше полного провала по агломерации.

        Args:
            bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры) — bbox одного МО.
            layers: список layerId РИАСУРТ. None → все 14 ключевых
                (``RIASURT_SVERDL_LAYERS``). Неизвестный layerId всё равно
                запрашивается (topic='other' на стороне классификатора).
            grid_n / step_m: параметры grid-walk (см. get_features_in_bbox_grid).

        Returns:
            dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId
            (пустой list если слой пуст / упал). Стабильная форма для caller'а.
        """
        layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
        result: dict[int, list[NSPDFeature]] = {}
        for layer_id in layer_ids:
            try:
                feats = self.get_features_in_bbox_grid(
                    layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
                )
            except (NspdLiteError, NspdLiteWafError) as exc:
                logger.warning(
                    "get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
                    layer_id,
                    exc,
                )
                result[layer_id] = []
                continue
            result[layer_id] = feats
            logger.info(
                "get_riasurt_sverdl_in_bbox: layer=%d topic=%s count=%d",
                layer_id,
                riasurt_layer_topic(layer_id),
                len(feats),
            )
        return result

    # ── 4. list_layers ──────────────────────────────────────────────────────

    def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
        """Полный каталог слоёв в теме (PKK / ARN).

        Use case: admin UI «выбрать layer для bulk-импорта», discovery новых
        слоёв при изменении NSPD каталога.
        """
        url = f"{NSPD_LAYERS_TREE}?themeId={theme_id}"
        data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
        # Shape defense: NSPD иногда оборачивает в {"data": [...]} — паттерн уже
        # ловили в Bug_Nspd_Geo_Str_Object_No_Get_Fixed. Без проверки walker
        # молча вернул бы [], а caller увидел бы пустой catalog.
        if (
            isinstance(data, dict)
            and "data" in data
            and not data.get("children")
            and "layers" not in data
        ):
            data = data.get("data") or []
        # Реальная форма layers-theme-tree: {"layers": [...flat...], "tree": ...},
        # где каждая запись = {"layerId": N, "title", "layerName", "layerType"}.
        # Раньше walker искал id/children и для этой (актуальной) формы молча
        # возвращал [] — слои не в children-дереве, а в плоском layers[]
        # (см. research/NSPD_IRD_Layers_Automation_Recon_Jun06). Сначала пробуем
        # плоский layers[], иначе fallback на nested-tree walker (id/children).
        if isinstance(data, dict) and isinstance(data.get("layers"), list):
            raw_entries: list[dict[str, Any]] = data["layers"]
        elif isinstance(data, list | dict):
            raw_entries = _walk_layer_tree(data)
        else:
            logger.warning(
                "list_layers: unexpected NSPD response type %s for theme_id=%s",
                type(data).__name__,
                theme_id,
            )
            return []
        layers: list[NSPDLayer] = []
        for entry in raw_entries:
            # актуальная форма использует layerId; старая nested-tree — id
            raw_id = entry.get("layerId", entry.get("id"))
            if raw_id is None:
                continue
            layers.append(
                NSPDLayer(
                    layer_id=int(raw_id),
                    title=str(entry.get("title", "")),
                    layer_type=entry.get("layerType") or entry.get("layer_type"),
                    metadata=entry,
                )
            )
        return layers

    # ── 5. search_by_quarter (PKK harvest orchestrator) ─────────────────────

    # Layer groupings для search_by_quarter. Назван "phase" т.к. в Celery
    # task может включаться выборочно (rate-limit budget).
    QUARTER_CORE_LAYERS: dict[str, str] = {  # noqa: RUF012 — class-level constant
        "parcels": "parcels",
        "buildings": "buildings",
        "territorial_zones": "territorial_zones",
        "red_lines": "red_lines",
        "engineering_structures": "engineering_structures",
    }
    QUARTER_ZOUIT_LAYERS: dict[str, str] = {  # noqa: RUF012
        "okn": "zouit_okn",
        "engineering": "zouit_engineering",
        "natural": "zouit_natural",
        "protected": "zouit_protected",
        "other": "zouit_other",
    }
    QUARTER_RISK_LAYERS: dict[str, str] = {  # noqa: RUF012
        "flooding_underground": "risk_flooding_underground",
        "flooding": "risk_flooding",
        "swampification": "risk_swampification",
        "landslide": "risk_landslide",
        "abrasion": "risk_abrasion",
        "erosion_water": "risk_erosion_water",
        "erosion_linear": "risk_erosion_linear",
        "erosion_wind": "risk_erosion_wind",
        "desertification": "risk_desertification",
        "clutter": "risk_clutter",
        "burns": "risk_burns",
    }
    # TIER 4 — Opportunity layers (issue #94 PR2).
    # short_name → LAYERS dict key (for get_features_in_bbox lookup).
    # Features stored in features_json с layer = "opportunity_<short_name>".
    QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = {  # noqa: RUF012
        "auction_parcels": "auction_parcels",  # 37299 — аукционные ЗУ
        "scheme_parcels": "scheme_parcels",  # 37294 — схемы расположения ЗУ
        "free_parcels": "free_parcels",  # 37298 — свободные ЗУ
        "future_parcels": "future_parcels",  # 36473 — планируемые ЗУ
        "oopt": "protected_areas",  # 875845 — ООПТ
    }

    def search_by_quarter(
        self,
        quarter_cad: str,
        *,
        include_zouit: bool = True,
        include_risks: bool = False,
        include_opportunity: bool = False,
    ) -> QuarterDump:
        """Harvest всех NSPD-данных для квартала: 1 vacuum, N layers.

        Шаги:
          1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала
          2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
          3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)`
          4. Если include_zouit — то же для 5 ЗОУИТ layers
          5. Если include_risks — то же для 11 risk layers

        Стоимость HTTP:
          - core only: 1 (search) + 5 (core layers) = 6 запросов
          - +zouit: +5 = 11 запросов
          - +risks: +11 = 22 запроса
        При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё).

        Args:
            quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
            include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True.
            include_risks: Включать TIER 3 risk zones. Default False (rate-limit
                budget; для отдельного D-N risk score можно включить).
            include_opportunity: Включать TIER 4 opportunity layers (auction_parcels,
                scheme_parcels, free_parcels, future_parcels, oopt). Default False.
                +5 HTTP запросов при включении.

        Returns:
            QuarterDump с per-layer feature lists. Если NSPD пуст / quarter
            не найден — `quarter=None`, `bbox_3857=None`, все feature lists
            пустые (no bulk-fetch без bounds — нет смысла). При этом dict-
            поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми
            lists для каждого включённого short_name
            (структура контракта стабильна).
            `layers_fetched` в этом случае содержит только `('search',)`.

        Raises:
            NspdLiteWafError при 403/429 на любом из layer запросов — caller
            должен делать backoff. Partial-success НЕ возвращается; вся
            операция атомарна (failure → exception).

        Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
        E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.
        """
        # 1. Quarter geometry через REST search
        quarter_search = self.search_by_cad(quarter_cad, thematic_id=2)
        quarter_feat: NSPDFeature | None = quarter_search.first

        # 2. Compute bbox в 3857. Если NSPD ничего не нашёл — bbox=None,
        # все layer-запросы возвращаем как empty (нет смысла bulk-fetch'ить
        # без bounds).
        bbox: tuple[float, float, float, float] | None = None
        if quarter_feat and quarter_feat.geometry:
            bbox = _geojson_bbox_3857(quarter_feat.geometry)

        layers_fetched: list[str] = ["search"]

        def _fetch_layer(name_in_dump: str, layer_key: str) -> list[NSPDFeature]:
            """Helper: безопасно получить features для одного layer.

            Dispatch:
              - area/linear layers (_GRID_WALK_LAYERS) → grid-walk
                (get_features_in_bbox_grid, grid_n=7, step_m=50)
              - point/polygon EGRN layers (parcels, buildings, ons, enk)
                → legacy single-pixel WMS (get_features_in_bbox)

            Engineering structures features дополнительно обогащаются
            properties["classified_kind"] через classify_engineering_kind.
            """
            if bbox is None:
                return []
            layer_id = LAYERS.get(layer_key)
            if layer_id is None:
                logger.warning("search_by_quarter: unknown layer key %s", layer_key)
                return []
            layers_fetched.append(name_in_dump)
            if layer_key in _GRID_WALK_LAYERS:
                features = self.get_features_in_bbox_grid(layer_id, bbox, grid_n=7, step_m=50.0)
                # Обогатить engineering_structures classified_kind
                if layer_key == "engineering_structures":
                    for feat in features:
                        feat.properties["classified_kind"] = classify_engineering_kind(
                            feat.properties
                        )
                logger.info(
                    "search_by_quarter layer=%s method=grid_walk count=%d quarter=%s",
                    name_in_dump,
                    len(features),
                    quarter_cad,
                )
                return features
            features_legacy = self.get_features_in_bbox(layer_id, bbox)
            logger.info(
                "search_by_quarter layer=%s method=legacy count=%d quarter=%s",
                name_in_dump,
                len(features_legacy),
                quarter_cad,
            )
            return features_legacy

        # 3. Core layers
        parcels = _fetch_layer("parcels", "parcels")
        buildings = _fetch_layer("buildings", "buildings")
        territorial_zones = _fetch_layer("territorial_zones", "territorial_zones")
        red_lines = _fetch_layer("red_lines", "red_lines")
        engineering_structures = _fetch_layer("engineering_structures", "engineering_structures")

        # 4. ЗОУИТ (G3)
        zouit: dict[str, list[NSPDFeature]] = {}
        if include_zouit:
            for short_name, layer_key in self.QUARTER_ZOUIT_LAYERS.items():
                zouit[short_name] = _fetch_layer(f"zouit_{short_name}", layer_key)

        # 5. Risks (TIER 3)
        risks: dict[str, list[NSPDFeature]] = {}
        if include_risks:
            for short_name, layer_key in self.QUARTER_RISK_LAYERS.items():
                risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key)

        # 6. Opportunity layers (TIER 4, issue #94 PR2)
        opportunity: dict[str, list[NSPDFeature]] = {}
        if include_opportunity:
            for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items():
                opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key)

        return QuarterDump(
            quarter_cad=quarter_cad,
            quarter=quarter_feat,
            parcels=parcels,
            buildings=buildings,
            territorial_zones=territorial_zones,
            red_lines=red_lines,
            engineering_structures=engineering_structures,
            zouit=zouit,
            risks=risks,
            opportunity=opportunity,
            layers_fetched=tuple(layers_fetched),
            bbox_3857=bbox,
            fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(),
        )


# ── Geometry helpers (module-level — для unit-test'ов) ──────────────────────


def _geojson_bbox_3857(
    geometry: dict[str, Any],
) -> tuple[float, float, float, float] | None:
    """Compute bbox в EPSG:3857 из GeoJSON geometry.

    NSPD search /v2 возвращает coordinates в EPSG:3857 (Web Mercator metres) —
    напрямую compute min/max по координатам. Если geometry уже в WGS84
    (нестандартный сценарий) — caller должен трансформировать заранее.

    Поддерживает Polygon/MultiPolygon/LineString/Point.
    Возвращает None для пустой/некорректной geometry.
    """
    coords = geometry.get("coordinates")
    geom_type = geometry.get("type")
    if not coords or not geom_type:
        return None

    def _walk(node: Any) -> list[tuple[float, float]]:
        """Рекурсивный extract координат до получения list of (x, y) tuples."""
        if isinstance(node, int | float):
            return []
        if (
            isinstance(node, list)
            and len(node) >= 2
            and all(isinstance(v, int | float) for v in node[:2])
        ):
            return [(float(node[0]), float(node[1]))]
        if isinstance(node, list):
            out: list[tuple[float, float]] = []
            for sub in node:
                out.extend(_walk(sub))
            return out
        return []

    pts = _walk(coords)
    if not pts:
        return None
    xs = [p[0] for p in pts]
    ys = [p[1] for p in pts]
    return (min(xs), min(ys), max(xs), max(ys))


def _walk_layer_tree(node: Any) -> list[dict[str, Any]]:
    """Рекурсивный walker для NSPD layers-theme-tree. Yields leaf nodes."""
    out: list[dict[str, Any]] = []
    if isinstance(node, dict):
        # Leaf если есть id + нет children (или children пустой)
        children = node.get("children") or []
        if "id" in node and not children:
            out.append(node)
        elif "id" in node and children:
            # Branch с id — может быть и сам layer, и группа. NSPD сейчас
            # treats group nodes как "не имеющий wms-endpoint" — но включаем
            # на случай если они оба leaves (хитрая branch).
            out.append(node)
        for ch in children:
            out.extend(_walk_layer_tree(ch))
    elif isinstance(node, list):
        for item in node:
            out.extend(_walk_layer_tree(item))
    return out


__all__ = [
    "LAYERS",
    "NSPD_THEMES",
    "NSPD_WMS_BASE",
    "RIASURT_SVERDL_LAYERS",
    "THEME_ARN",
    "THEME_PKK",
    "NSPDClient",
    "NSPDFeature",
    "NSPDLayer",
    "NSPDSearchResult",
    "NspdLiteError",
    "NspdLiteWafError",
    "QuarterDump",
    "bbox_around_point_m",
    "lonlat_to_3857",
    "riasurt_layer_topic",
]
