"""ЕКБ муниципальный геопортал (ИСОГД / Geometa) — WFS-клиент (#1067 C7).

Геопортал Екатеринбурга (`геопортал.екатеринбург.рф`) — открытый GeoServer на ~1149 слоёв,
без авторизации, нормальный TLS. Единый источник «что построят / землепользование» для ЕКБ:
ПЗЗ-регламент, функц.зоны генплана, КРТ, ППТ/ПМТ, ЗОУИТ — то, чего НСПД (только геометрия +
reg-номер) и городские документы по-отдельности не дают структурно.

Primary интерфейс — **стандартный OGC WFS GetFeature** (`/api/gis/ows/3/portal/wfs`):
OGC-стандарт стабильнее reverse-engineered `urbanCard`-API и покрывает все слои одним вызовом.

⚠️ WMS GetFeatureInfo на этом GeoServer багует на reproject («too close to a pole») —
использовать ТОЛЬКО WFS GetFeature.

CRS: WFS отдаёт `geoloc` (геометрия) в EPSG:4326 (lon/lat). В CQL `POINT(lon lat)` порядок
X=lon, Y=lat. `geoloc_orig` (МСК-66) НЕ запрашиваем.

Источник разведки (live-verified 2026-06-06): vault
research/EKB_Geoportal_WFS_Construction_Intel_Jun06.
Рич ПЗЗ-карточка (3 списка ВРИ + предельные параметры) — через urbanCard (#1067 C8):
searchByGeom → urbanCard → parse_zone_regulation (см. research/EKB_Geoportal_PZZ_Content_API_Jun06).
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from typing import Any

import httpx

logger = logging.getLogger(__name__)

# Punycode-хост геопортала (= геопортал.екатеринбург.рф).
# В коде только punycode — кириллица в URL ломает часть HTTP-стеков/логов.
EKB_GEOPORTAL_BASE = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai"
EKB_WFS_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/ows/3/portal/wfs"

# Reverse-eng JSON-API карточки территории (рич ПЗЗ-регламент). Менее стабилен чем WFS (OGC) —
# поэтому supplement к WFS, а не primary. Подтверждено live 2026-06-06 (vault
# research/EKB_Geoportal_PZZ_Content_API_Jun06): searchByGeom (точка → ключи объектов по слоям),
# urbanCard (ключи → полный градрегламент: 3 списка ВРИ 540н + предельные параметры).
EKB_SEARCH_BY_GEOM_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/spatialObject/searchByGeom"
EKB_URBAN_CARD_URL = f"{EKB_GEOPORTAL_BASE}/api/urbanCard/portal"
# layerKeys searchByGeom (внутренние числовые id портала, НЕ WFS typeNames):
# 17041 терзона ПЗЗ · 10019 участок ЕГРН · 16619 ЗОУИТ · 10079 кадастр.деление. Ответ:
# {layerKey: [{typeAlias, key, ...}]} — typeAlias UrbanTerZone/GknParcel/SpecialZone.
_SEARCH_LAYER_KEYS: tuple[int, ...] = (10079, 17041, 10019, 16619)

# Геометрическая колонка GeoServer-слоёв портала (таргет CQL-фильтра).
GEOM_COLUMN = "geoloc"

# Читаемый ключ → GeoServer typeName. Проверены live WFS GetFeature (vault recon 2026-06-06).
# Полный каталог — 1149 typeNames; тут только релевантные ИРД/supply-слои.
WFS_LAYERS: dict[str, str] = {
    # Зонирование / регламент (ИРД п.2 ПЗЗ, capacity)
    "territorial_zone": "portal:portal_geo_urban_ter_zone_exist",  # ПЗЗ терзоны (urban_index)
    "subzone": "portal:portal_geo_subzone_exist",  # ПЗЗ подзоны
    "functional_zone": "portal:portal_geo_urban_10_functional_zone",  # функц.зоны ГП (#1058)
    "genplan": "portal:portal_geo_dmd_genplan",  # генплан (документ-слой)
    # Future supply / что построят
    "krt": "portal:portal_geo_oks_krt",  # КРТ объекты/границы (ИРД #1060)
    "ppt": "portal:portal_geo_dmd_prj_plan",  # проекты планировки территории
    "pmt": "portal:portal_geo_dmd_prj_mej",  # проекты межевания территории
    "investment_zone": "portal:portal_geo_urban_10_investment_zone_polygon",  # инвест-зоны
    "draft_parcel": "portal:portal_geo_art_zu_draft",  # проектируемые/черновые ЗУ
    # Ограничения
    "zouit": "portal:portal_geo_gknspecial_zone",  # ЗОУИТ
}

DEFAULT_TIMEOUT = 20


# Геопортал ЕКБ отдаёт TLS-цепочку, корневой CA которой НЕ в trust-store наших контейнеров
# (российский гос-УЦ, как у НСПД). С verify=True из контейнера падает CERTIFICATE_VERIFY_FAILED
# (подтверждено live на проде 2026-06-06). Отключаем верификацию — данные ПУБЛИЧНЫЕ open-data
# (ПЗЗ/ППТ/КРТ), секреты не передаются → MITM-риск незначим. Прецедент: nspd_lite._SSL_CTX =
# ssl._create_unverified_context() для тех же росс-гос-эндпоинтов.
_VERIFY_TLS = False


# ── HTTP helper (module-level — мокается в тестах) ───────────────────────────


def _http_get_json(url: str, params: dict[str, str], *, timeout: int = DEFAULT_TIMEOUT) -> Any:
    """GET WFS-эндпоинта → JSON. Вынесен на module-level для monkeypatch в тестах.

    verify=_VERIFY_TLS (False): росс-гос-CA геопортала не в trust-store контейнера (см. выше).
    Raises httpx.HTTPStatusError на 4xx/5xx (caller решает).
    """
    resp = httpx.get(url, params=params, timeout=timeout, verify=_VERIFY_TLS)
    resp.raise_for_status()
    return resp.json()


def _http_post_json(url: str, body: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT) -> Any:
    """POST JSON → JSON (searchByGeom / urbanCard). Module-level для monkeypatch в тестах.

    verify=_VERIFY_TLS (False): см. _http_get_json — росс-гос-CA не в trust-store контейнера.
    """
    resp = httpx.post(url, json=body, timeout=timeout, verify=_VERIFY_TLS)
    resp.raise_for_status()
    return resp.json()


# ── Typed response ───────────────────────────────────────────────────────────


@dataclass(frozen=True, slots=True)
class EKBFeature:
    """Парсенный WFS GeoJSON Feature геопортала ЕКБ.

    `geometry` — raw GeoJSON dict в EPSG:4326 (lon/lat). `properties` — все атрибуты слоя
    (для терзоны: urban_index/full_name/urban_kind_obj; для КРТ: num_oks_krt/status/type/area;
    для ППТ: doc_full_name/doc_status/dmd_actual_year).
    """

    feature_id: str | None
    geometry: dict[str, Any] | None
    properties: dict[str, Any]
    crs: str = "EPSG:4326"

    @classmethod
    def from_raw(cls, raw: dict[str, Any]) -> EKBFeature:
        return cls(
            feature_id=str(raw.get("id")) if raw.get("id") is not None else None,
            geometry=raw.get("geometry"),
            properties=raw.get("properties") or {},
        )


@dataclass(frozen=True, slots=True)
class ZoneRegulation:
    """Градрегламент ПЗЗ-зоны из urbanCard (#1067 C8) — ИРД-content для #1059.

    `zone_index` — индекс зоны ('Ц-1'); `zone_full_name` — полное имя
    ('Ц-1 Общественно-деловая зона городского центра'). ВРИ — строки «код 540н + текст»
    (напр. '2.6 Среднеэтажная жилая застройка'). `limit_params` — предельные параметры
    свободным текстом (min площадь ЗУ / max % застройки / этажность / отступы — нуждается в
    regex/LLM-экстракте в числовые поля, #1067 далее).
    """

    zone_index: str | None
    zone_full_name: str | None
    main_vri: list[str]
    conditional_vri: list[str]
    auxiliary_vri: list[str]
    limit_params: list[str]


def _vri_names(items: Any) -> list[str]:
    """Извлечь непустые .name из списка *TypesPermittedUse."""
    out: list[str] = []
    for it in items or []:
        name = (it or {}).get("name")
        if name:
            out.append(str(name).strip())
    return out


def parse_zone_regulation(card: dict[str, Any]) -> ZoneRegulation | None:
    """Парс ответа urbanCard → ZoneRegulation (берёт первый pzzList).

    None если в карточке нет pzzList (участок без ПЗЗ-регламента). Shape подтверждён live
    2026-06-06 (vault research/EKB_Geoportal_PZZ_Content_API_Jun06).
    """
    pzz_list = (card or {}).get("pzzList") or []
    if not pzz_list:
        return None
    pzz = pzz_list[0] or {}
    full = pzz.get("urbanTerZone")
    full_name = str(full).strip() if full else None
    index = full_name.split()[0] if full_name else None
    limit_params: list[str] = []
    for limit in pzz.get("limitTypesPermittedUse") or []:
        for par in (limit or {}).get("typePermittedUsePars") or []:
            desc = (par or {}).get("otherDescription")
            if desc and str(desc).strip():
                limit_params.append(str(desc).strip())
    return ZoneRegulation(
        zone_index=index,
        zone_full_name=full_name,
        main_vri=_vri_names(pzz.get("mainTypesPermittedUse")),
        conditional_vri=_vri_names(pzz.get("conditionTypesPermittedUse")),
        auxiliary_vri=_vri_names(pzz.get("additionTypesPermittedUse")),
        limit_params=limit_params,
    )


# ── Client ───────────────────────────────────────────────────────────────────


class EKBGeoportalClient:
    """WFS-клиент геопортала ЕКБ. Stateless; один instance на процесс.

    Методы возвращают list[EKBFeature]; пусто если по фильтру ничего не найдено.
    """

    def __init__(self, timeout: int = DEFAULT_TIMEOUT) -> None:
        self.timeout = timeout

    def _resolve_type_name(self, layer: str) -> str:
        """Читаемый ключ WFS_LAYERS → typeName; либо сырой typeName (`portal:...`) as-is."""
        return WFS_LAYERS.get(layer, layer)

    def _get_feature(self, type_name: str, cql_filter: str) -> list[EKBFeature]:
        """WFS 2.0.0 GetFeature с CQL_FILTER → typed features (EPSG:4326)."""
        params = {
            "service": "WFS",
            "version": "2.0.0",
            "request": "GetFeature",
            "typeNames": type_name,
            "outputFormat": "application/json",
            "srsName": "EPSG:4326",
            "CQL_FILTER": cql_filter,
        }
        data = _http_get_json(EKB_WFS_URL, params, timeout=self.timeout)
        feats = (data or {}).get("features") or []
        return [EKBFeature.from_raw(f) for f in feats]

    def features_at_point(self, layer: str, lon: float, lat: float) -> list[EKBFeature]:
        """Фичи слоя, покрывающие точку (lon, lat) — CQL INTERSECTS.

        Порядок координат в CQL POINT — X=lon, Y=lat (EPSG:4326).

        Args:
            layer: ключ WFS_LAYERS ('territorial_zone', 'krt', ...) или сырой typeName.
        """
        type_name = self._resolve_type_name(layer)
        cql = f"INTERSECTS({GEOM_COLUMN}, POINT({lon} {lat}))"
        return self._get_feature(type_name, cql)

    def features_in_bbox(
        self, layer: str, bbox_4326: tuple[float, float, float, float]
    ) -> list[EKBFeature]:
        """Фичи слоя в bbox (minlon, minlat, maxlon, maxlat) EPSG:4326 — CQL BBOX."""
        minx, miny, maxx, maxy = bbox_4326
        type_name = self._resolve_type_name(layer)
        cql = f"BBOX({GEOM_COLUMN}, {minx}, {miny}, {maxx}, {maxy}, 'EPSG:4326')"
        return self._get_feature(type_name, cql)

    def zone_index_at(self, lon: float, lat: float) -> str | None:
        """Удобный shortcut: индекс ПЗЗ-терзоны ('Ц-1', 'Ж-2') в точке, либо None.

        Закрывает crosswalk «координата → индекс зоны ЕКБ» для резолва ИРД-content
        (#1059): НСПД даёт геометрию зоны без индекса, геопортал — индекс по точке.
        """
        feats = self.features_at_point("territorial_zone", lon, lat)
        if not feats:
            return None
        idx = feats[0].properties.get("urban_index")
        return str(idx) if idx is not None else None

    # ── urbanCard (рич ПЗЗ-регламент, supplement к WFS) ─────────────────────

    def _search_keys(self, lon: float, lat: float) -> dict[str, int]:
        """searchByGeom: точка → {typeAlias: key} (UrbanTerZone / GknParcel / SpecialZone)."""
        body = {
            "project": "portal",
            "geoJsonGeometries": [json.dumps({"type": "Point", "coordinates": [lon, lat]})],
            "layerKeys": list(_SEARCH_LAYER_KEYS),
            "countByLayers": [],
        }
        data = _http_post_json(EKB_SEARCH_BY_GEOM_URL, body, timeout=self.timeout)
        keys: dict[str, int] = {}
        if isinstance(data, dict):
            for objs in data.values():
                for o in objs or []:
                    alias = (o or {}).get("typeAlias")
                    key = (o or {}).get("key")
                    if alias and key is not None:
                        keys[str(alias)] = int(key)
        return keys

    def _urban_card(self, terzone_key: int | None, parcel_key: int | None) -> dict[str, Any]:
        """urbanCard: ключи терзоны/участка → полная карточка территории (с pzzList)."""
        body = {
            "construction": [],
            "adrCapitalBuilding": [],
            "pzz": [terzone_key] if terzone_key is not None else [],
            "parcel": [parcel_key] if parcel_key is not None else [],
            "urbanOtherAreaPZZ": [],
            "urbanProtAreaPZZ": [],
            "urbanSanGapPZZ": [],
        }
        return _http_post_json(EKB_URBAN_CARD_URL, body, timeout=self.timeout)

    def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation | None:
        """Полный градрегламент ПЗЗ-зоны в точке (#1059 content): индекс + 3 списка ВРИ +
        предельные параметры. None если в точке нет терзоны/регламента.

        Pipeline: searchByGeom (точка → UrbanTerZone+GknParcel ключи) → urbanCard → parse.
        Кэшировать вызывающему по zone_index (регламент идентичен для всех участков зоны).
        """
        keys = self._search_keys(lon, lat)
        terzone_key = keys.get("UrbanTerZone")
        if terzone_key is None:
            return None
        card = self._urban_card(terzone_key, keys.get("GknParcel"))
        return parse_zone_regulation(card)

    def features_by_cql(self, layer: str, cql_filter: str) -> list[EKBFeature]:
        """Фичи слоя по произвольному CQL-фильтру (напр. ``full_name LIKE 'X%'``).

        Тонкая обёртка над ``_resolve_type_name`` + ``_get_feature``. Используется
        для harvest ЗОУИТ-слоя по префиксу full_name (#1063/#1064/#1065/#1066).

        Args:
            layer: ключ WFS_LAYERS ('zouit', ...) или сырой typeName.
            cql_filter: CQL-выражение (WFS 2.0 OGC CQL). Кириллица в одиночных
                LIKE-выражениях работает. Составные AND с кириллицей могут давать
                HTTP-ошибку геопортала — фильтруй client-side.
        """
        type_name = self._resolve_type_name(layer)
        return self._get_feature(type_name, cql_filter)


__all__ = [
    "EKB_GEOPORTAL_BASE",
    "EKB_WFS_URL",
    "WFS_LAYERS",
    "EKBFeature",
    "EKBGeoportalClient",
    "ZoneRegulation",
    "parse_zone_regulation",
]
