"""NSPD lite-fetcher через urllib (без Playwright/Chromium).

ОТКРЫТИЕ 2026-05-11: WAF nspd.gov.ru блокирует httpx/curl/requests по
TLS-fingerprint, но **stdlib urllib проходит**. Это позволяет полностью
избавиться от Playwright + Chromium (~300 MB Docker image, медленный
browser-startup) для NSPD-задач.

Стратегия:
  1. urllib.request.Request + ssl._create_unverified_context()
  2. полный набор Chrome-headers (referer, origin, sec-ch-ua, ...)
  3. простой rate-limiter между запросами

Эндпоинты:
  - thematicSearchId=1: участки (ЗУ)
  - thematicSearchId=2: кадастровые кварталы
  - thematicSearchId=4: административно-территориальное деление
  - thematicSearchId=5: ОКС (здания)
  - thematicSearchId=7: территориальные зоны
  - thematicSearchId=15: комплексы объектов

Совместимость: API-контракт совместим с nspd_kn.py (те же ResponseShape),
чтобы переключение через feature flag было безболезненным.
"""

from __future__ import annotations

import json
import logging
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any

logger = logging.getLogger(__name__)

NSPD_BASE = "https://nspd.gov.ru/api/geoportal/v2/search/geoportal"

# Headers критичны: order, casing — как в браузере.
HEADERS = {
    "accept": "*/*",
    "accept-language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,es;q=0.6",
    "cache-control": "no-cache",
    "pragma": "no-cache",
    "referer": "https://nspd.gov.ru/map?thematic=PKK",
    "origin": "https://nspd.gov.ru",
    "user-agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
    ),
}

# Глобальный SSL-контекст — используется во всех запросах.
_SSL_CTX = ssl._create_unverified_context()


# Семантические alias-ы для thematicSearchId (понятнее в коде)
THEMATIC = {
    "parcel": 1,  # земельные участки (ЗУ)
    "quarter": 2,  # кадастровые кварталы
    "admin": 4,  # административно-территориальное деление
    "building": 5,  # ОКС (здания)
    "zone": 7,  # территориальные зоны
    "complex": 15,  # комплексы объектов
}


class NspdLiteError(RuntimeError):
    """Неуспешный ответ от NSPD."""


class NspdLiteWafError(NspdLiteError):
    """WAF/Rate-limit (HTTP 403/429). Триггер для backoff."""


def fetch_geoportal(
    query: str,
    thematic_id: int | str = 2,
    timeout: int = 15,
    rate_ms: int = 600,
) -> dict[str, Any]:
    """Запрос к /api/geoportal/v2/search/geoportal через urllib.

    Args:
        query: cad-номер ('66:41:0204016' для квартала, '66:41:0204016:10' для участка)
        thematic_id: int (1/2/4/5/7/15) или alias из THEMATIC
        timeout: HTTP timeout (сек)
        rate_ms: пауза перед запросом (для rate-limiting; вызывается до urlopen)

    Returns:
        {"data": {"type": "FeatureCollection", "features": [...]}, "meta": {...}}

    Raises:
        NspdLiteWafError при 403/429 — сигнал для caller'а сделать backoff
        NspdLiteError при прочих 4xx/5xx
    """
    if rate_ms > 0:
        time.sleep(rate_ms / 1000.0)

    if isinstance(thematic_id, str):
        thematic_id = THEMATIC.get(thematic_id, thematic_id)

    qs = urllib.parse.urlencode(
        {
            "thematicSearchId": int(thematic_id),
            "query": query,
        }
    )
    url = f"{NSPD_BASE}?{qs}"
    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")
            try:
                return json.loads(body)
            except json.JSONDecodeError as e:
                # HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
                # пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
                # что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
                # (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
                # строку постоянным harvest_error.
                raise NspdLiteWafError(
                    f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
                ) from e
    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


def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
    """Конкретный кадастровый квартал (typed wrapper)."""
    return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)


def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
    """Конкретный участок ЗУ (typed wrapper)."""
    return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)


def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
    """Конкретное здание ОКС (typed wrapper)."""
    return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)


# ── Bulk через rosreestr2coord library ──────────────────────────────────────


def fetch_via_rosreestr2coord(
    cad_num: str,
    area_type: int = 1,
    timeout: int = 15,
    delay: float = 0.0,  # legacy-параметр, оставлен для совместимости signature
) -> dict[str, Any] | None:
    """Тот же запрос но через community-библиотеку rosreestr2coord (v5+).

    Преимущество: они поддерживают upstream обновления headers/WAF-tricks.
    Использовать когда наш fetch_* поломается из-за изменений WAF.

    Параметр `delay` сохранён в signature для обратной совместимости с воркером,
    но в rosreestr2coord v5 он убран — rate limiting между запросами теперь
    делает наш воркер через `rate_ms` (см. nspd_geo.py).

    Returns:
        GeoJSON Feature или None если participant не найден.
    """
    _ = delay  # silence unused — см. docstring выше
    try:
        from rosreestr2coord.parser import Area
        from rosreestr2coord.request.exceptions import (
            HTTPErrorException,
            HTTPForbiddenException,
            RequestException,
            TimeoutException,
        )
    except ImportError as e:
        raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e

    # rosreestr2coord по дефолту пишет media-cache в `./tmp/` (относительно CWD).
    # У нас CWD = /app, контейнер worker'а запущен под non-root user → Permission
    # denied при первом fetch.
    # Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
    # Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
    #
    # NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
    # (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
    # отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
    try:
        a = Area(
            code=cad_num,
            area_type=area_type,
            timeout=timeout,
            with_log=False,
            use_cache=False,
            media_path="/tmp/rosreestr2coord",
        )
        # dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
        # строку. Default в v5 = True → строка → крах в `_persist_target` который
        # ожидает dict с `.get("properties")` etc.
        return a.to_geojson_poly(dumps=False)
    except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
        # WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
        # HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
        # чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
        # waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
        # features (false run-status). См. nspd_geo.py:464.
        raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
    except RequestException as e:
        # Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
        # сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
        # но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
        # None без исключения). Классифицируем как ошибку, не как пустой результат.
        raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
