"""Клиент городского геопортала Екатеринбурга (геопортал.екатеринбург.рф).

Strangler-порт (#2307) боевого `app.services.scrapers.ekb_geoportal_client`,
дословный (модуль изначально не зависел от `app.*` — только `httpx`, поэтому
Protocol-инжекция контрактов не требуется). Parity доказана
tests/scrapers/test_ekb_geoportal_client_kit_parity.py.

Открытый GeoServer WFS, без авторизации. Слой `portal:portal_geo_topo_build` —
футпринты зданий ЕКБ (намного полнее чем NSPD cad_buildings, который пропускает
~70% зданий города).

ВАЖНО: сервер отдаёт 403 на дефолтный User-Agent curl/httpx. Нужны браузерные
заголовки (UA + Referer + Accept) — тогда 200.

Геометрия `geoloc` в EPSG:4326 (MultiPolygon footprint). BBOX axis order =
lon_min,lat_min,lon_max,lat_max. Сервер режет каждый ответ на 20000 features —
лоадер тайлит мелко, чтобы не упереться.

Центроид считается чистым Python'ом (area-weighted, без shapely — shapely не входит
в зависимости tradein-mvp backend).

Легаси-версия используется лоадером `app.tasks.ekb_geoportal_ingest` (переключение
на этот kit-модуль — issue #2310).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass

import httpx

logger = logging.getLogger(__name__)

# Punycode-хост геопортал.екатеринбург.рф
GEOPORTAL_HOST = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai"
WFS_PATH = "/api/gis/ows/3/portal/wfs"
BUILD_LAYER = "portal:portal_geo_topo_build"

# Браузерные заголовки — без них хост отдаёт 403.
_BROWSER_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/148.0.0.0 Safari/537.36"
    ),
    "Referer": f"{GEOPORTAL_HOST}/",
    "Accept": "application/json",
}


@dataclass(frozen=True, slots=True)
class TopoBuilding:
    """Одна часть-фича здания из слоя portal_geo_topo_build.

    Здание может быть разбито на несколько part-features (одинаковые street/house,
    разные key) — лоадер группирует их по (street_norm, house_norm).
    """

    street: str
    house: str
    floors: int | None
    purpose: str | None
    material: str | None
    key: int | None
    lat: float
    lon: float


def _parse_int(value: object) -> int | None:
    if value is None:
        return None
    try:
        return int(value)
    except (ValueError, TypeError):
        return None


def _parse_str(value: object) -> str | None:
    if value is None:
        return None
    s = str(value).strip()
    return s or None


def _ring_centroid(ring: list) -> tuple[float, float, float] | None:
    """Area-weighted centroid of one linear ring → (cx, cy, signed_area2).

    signed_area2 = 2× signed area (используется как вес при усреднении колец/полигонов).
    Возвращает None если кольцо вырождено (нулевая площадь).
    """
    n = len(ring)
    if n < 3:
        return None
    a2 = 0.0  # 2× signed area
    cx = 0.0
    cy = 0.0
    for i in range(n):
        x0, y0 = ring[i][0], ring[i][1]
        x1, y1 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
        cross = x0 * y1 - x1 * y0
        a2 += cross
        cx += (x0 + x1) * cross
        cy += (y0 + y1) * cross
    if a2 == 0.0:
        return None
    cx /= 3.0 * a2
    cy /= 3.0 * a2
    return (cx, cy, a2)


def _multipolygon_centroid(coords: list) -> tuple[float, float] | None:
    """Центроид GeoJSON MultiPolygon/Polygon-частей: усреднение по |площади| внешних колец.

    coords — список полигонов, каждый = [outer_ring, hole1, ...]. Берём только
    внешнее кольцо каждого полигона (дыр у footprint'ов зданий практически нет,
    их вклад в положение центра пренебрежим). Возвращает (lon, lat) или None.
    """
    sum_w = 0.0
    sum_x = 0.0
    sum_y = 0.0
    for polygon in coords:
        if not polygon:
            continue
        outer = polygon[0]
        c = _ring_centroid(outer)
        if c is None:
            continue
        cx, cy, a2 = c
        w = abs(a2)
        sum_w += w
        sum_x += cx * w
        sum_y += cy * w
    if sum_w == 0.0:
        return None
    return (sum_x / sum_w, sum_y / sum_w)


def _geometry_centroid(geom: dict) -> tuple[float, float] | None:
    """Центроид GeoJSON geometry (MultiPolygon или Polygon) → (lon, lat) или None."""
    gtype = geom.get("type")
    coords = geom.get("coordinates")
    if not coords:
        return None
    if gtype == "MultiPolygon":
        return _multipolygon_centroid(coords)
    if gtype == "Polygon":
        # Polygon coordinates = [outer_ring, hole, ...] → обернём в один полигон.
        return _multipolygon_centroid([coords])
    return None


def _feature_to_building(feature: dict) -> TopoBuilding | None:
    """GeoJSON Feature → TopoBuilding. None если нет улицы/дома или геометрии."""
    props = feature.get("properties") or {}
    street = _parse_str(props.get("topo_street"))
    house = _parse_str(props.get("topo_num_house"))
    if not street or not house:
        return None

    geom = feature.get("geometry")
    if not isinstance(geom, dict):
        return None
    try:
        centroid = _geometry_centroid(geom)
    except Exception:
        logger.warning("geoportal: bad geometry for street=%r house=%r", street, house)
        return None
    if centroid is None:
        return None
    lon, lat = centroid

    return TopoBuilding(
        street=street,
        house=house,
        floors=_parse_int(props.get("topo_floor")),
        purpose=_parse_str(props.get("topo_purpose")),
        material=_parse_str(props.get("topo_material")),
        key=_parse_int(props.get("key")),
        lat=lat,
        lon=lon,
    )


def parse_feature_collection(payload: dict) -> list[TopoBuilding]:
    """GeoJSON FeatureCollection → list[TopoBuilding]. Пропускает невалидные."""
    features = payload.get("features")
    if not isinstance(features, list):
        return []
    out: list[TopoBuilding] = []
    for feature in features:
        if not isinstance(feature, dict):
            continue
        building = _feature_to_building(feature)
        if building is not None:
            out.append(building)
    return out


class EkbGeoportalClient:
    """Async WFS-клиент геопортала ЕКБ. Один клиент переиспользуется лоадером."""

    def __init__(self, *, timeout: float = 30.0) -> None:
        self._timeout = timeout

    async def fetch_topo_buildings(
        self,
        bbox: tuple[float, float, float, float],
        *,
        count: int = 20000,
    ) -> list[TopoBuilding]:
        """Запрашивает здания в bbox (lon_min, lat_min, lon_max, lat_max).

        Graceful: на non-200/parse-error логирует warning и возвращает [].
        """
        lon_min, lat_min, lon_max, lat_max = bbox
        cql = f"BBOX(geoloc,{lon_min},{lat_min},{lon_max},{lat_max})"
        params = {
            "service": "WFS",
            "version": "2.0.0",
            "request": "GetFeature",
            "typeNames": BUILD_LAYER,
            "outputFormat": "application/json",
            "CQL_FILTER": cql,
            "count": str(count),
        }
        url = f"{GEOPORTAL_HOST}{WFS_PATH}"
        try:
            # Геопортал ЕКБ отдаёт сертификат российского CA (Минцифры), которого нет в
            # стандартном trust store httpx → TLS verify падает. Данные публичные,
            # read-only → verify=False (тот же подход, что curl -k при разведке).
            async with httpx.AsyncClient(
                timeout=self._timeout, headers=_BROWSER_HEADERS, verify=False
            ) as client:
                resp = await client.get(url, params=params)
            if resp.status_code != 200:
                logger.warning("geoportal WFS → HTTP %d for bbox=%s", resp.status_code, bbox)
                return []
            payload = resp.json()
        except Exception as exc:
            logger.warning("geoportal WFS fetch/parse failed for bbox=%s: %s", bbox, exc)
            return []

        return parse_feature_collection(payload)
