"""Celery task: harvest ЗОУИТ ЕКБ-геопортала (gknspecial_zone) → ``ird_overlays`` (м.132).

#1067 GG-форсайт: закрывает ИРД-пункты:
  #1063 — сервитут (Зона публичного сервитута)
  #1064 — водоохранные зоны (Водоохранная зона / Прибрежная защитная полоса /
           Зона санитарной охраны / Санитарно-защитная зона)
  #1065 — приаэродромная территория (Приаэродромная территория — ЕГРН)
  #1066 — ОКН-зоны/ЗРЗ (Зона охраны объекта культурного / Зона регулирования застройки)

Источник: ЕКБ-геопортал, WFS-слой ``portal:portal_geo_gknspecial_zone`` (ключ 'zouit').
Геометрия уже в EPSG:4326 — ST_Transform НЕ нужен.

Sentinel source_layer_id'ы (вне диапазона НСПД): 990001–990008 + 990099 (unmatched).

Conventions (зеркало ird_harvest.py):
  • SessionLocal() + try/finally close.
  • SAVEPOINT per-row (with db.begin_nested():) — одна битая фича не валит батч.
  • db.commit() per-prefix (durability) — WorkerLost в середине прогона не теряет
    уже записанные префиксы (#1116).
  • CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
  • ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE.
  • content_* пишем мы же (геопортал как источник); content НЕ трогается НСПД-harvest'ом.
  • Кириллица в combined-CQL WFS ломает запрос → фильтр zone_state_v client-side.

Расписание: beat_schedule.py → gknspecial-harvest-weekly (пн 05:30 МСК).
"""

from __future__ import annotations

import json
import logging
import re
from datetime import UTC, datetime
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.db import SessionLocal
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
from app.workers.celery_app import celery_app

logger = logging.getLogger(__name__)

# ── Mapping: full_name-префикс → (layer_kind, sentinel source_layer_id) ─────
# Sentinels вне диапазона НСПД (990001+), чтобы не конфликтовать с реальными layer_id.
_PREFIX_MAP: list[tuple[str, str, int]] = [
    ("Зона публичного сервитута", "servitude", 990001),  # #1063
    ("Водоохранная зона", "water_protection", 990002),  # #1064
    ("Прибрежная защитная полоса", "coastal_strip", 990003),  # #1064
    ("Зона санитарной охраны", "water_sanitary", 990004),  # #1064
    ("Санитарно-защитная зона", "sanitary_protection", 990005),  # #1064
    ("Зона охраны объекта культурного", "okn_protection_zone", 990006),  # #1066
    ("Зона регулирования застройки", "zrz", 990007),  # #1066
    ("Приаэродромная территория", "airfield", 990008),  # #1065
]

# Sentinel для фич, чей full_name не попал ни под один префикс (defensive).
_UNMATCHED_LAYER_KIND = "gknspecial_unmatched"
_UNMATCHED_SOURCE_LAYER_ID = 990099

# Regex для извлечения reg_numb_border из full_name (формат «66:41-6.4125»).
_REG_NUMB_RE = re.compile(r"\d{2}:\d{2}-\d+\.\d+")

# UPSERT: геометрия уже EPSG:4326 → ST_SetSRID(ST_GeomFromGeoJSON, 4326), без ST_Transform.
# content_* поля обновляются в DO UPDATE (геопортал — их источник).
_UPSERT_SQL = text(
    """
    INSERT INTO ird_overlays (
        source_layer_id, layer_kind, geom_data_id, reg_numb_border,
        category, subcategory, geom, raw_props,
        zone_type_name, content, content_source, content_fetched_at, fetched_at
    ) VALUES (
        CAST(:source_layer_id AS integer),
        CAST(:layer_kind AS text),
        CAST(:geom_data_id AS bigint),
        CAST(:reg_numb_border AS text),
        CAST(:category AS integer),
        CAST(:subcategory AS integer),
        ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326),
        CAST(:raw_props AS jsonb),
        CAST(:zone_type_name AS text),
        CAST(:content AS jsonb),
        CAST(:content_source AS text),
        CAST(:content_fetched_at AS timestamptz),
        CAST(:fetched_at AS timestamptz)
    )
    ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE SET
        layer_kind          = EXCLUDED.layer_kind,
        reg_numb_border     = EXCLUDED.reg_numb_border,
        geom                = EXCLUDED.geom,
        raw_props           = EXCLUDED.raw_props,
        zone_type_name      = EXCLUDED.zone_type_name,
        content             = EXCLUDED.content,
        content_source      = EXCLUDED.content_source,
        content_fetched_at  = EXCLUDED.content_fetched_at,
        fetched_at          = EXCLUDED.fetched_at
    """
)


def _classify_full_name(full_name: str) -> tuple[str, int] | None:
    """Сопоставить full_name с записью _PREFIX_MAP → (layer_kind, source_layer_id) или None."""
    for prefix, layer_kind, source_layer_id in _PREFIX_MAP:
        if full_name.startswith(prefix):
            return layer_kind, source_layer_id
    return None


def _extract_reg_numb(full_name: str) -> str | None:
    """Извлечь reg_numb_border из full_name по паттерну \\d{2}:\\d{2}-\\d+\\.\\d+."""
    m = _REG_NUMB_RE.search(full_name)
    return m.group(0) if m else None


def _extract_geom_data_id(feature_id: str | None) -> int | None:
    """Trailing int из WFS feature_id (формат 'portal_geo_gknspecial_zone.NNNN') или None."""
    if not feature_id:
        return None
    parts = feature_id.split(".")
    if len(parts) < 2:
        return None
    try:
        return int(parts[-1])
    except (ValueError, TypeError):
        return None


def _build_content(props: dict[str, Any]) -> str:
    """Собрать content-jsonb из свойств фичи (только присутствующие ключи, ensure_ascii=False)."""
    mapping: dict[str, str] = {
        "restrictions": "gkn_specialz_contentrestrictions",
        "protected_object": "gkn_specialz_protectedobject",
        "authority_decision": "authority_decision",
        "document": "gkn_document",
        "registration_date": "registration_date",
        "area": "area",
        "zone_state": "zone_state_v",
    }
    content: dict[str, Any] = {}
    for out_key, prop_key in mapping.items():
        val = props.get(prop_key)
        if val is not None:
            content[out_key] = val
    return json.dumps(content, ensure_ascii=False)


def _upsert_feature(
    db: Session,
    *,
    source_layer_id: int,
    layer_kind: str,
    geom_data_id: int,
    props: dict[str, Any],
    geom: dict[str, Any],
    fetched_at: str,
) -> bool:
    """UPSERT одной фичи в ird_overlays под SAVEPOINT. True если записана."""
    full_name: str = str(props.get("full_name") or "")
    reg_numb = _extract_reg_numb(full_name)
    content_json = _build_content(props)
    raw_props_json = json.dumps(props, ensure_ascii=False)
    try:
        with db.begin_nested():
            db.execute(
                _UPSERT_SQL,
                {
                    "source_layer_id": source_layer_id,
                    "layer_kind": layer_kind,
                    "geom_data_id": geom_data_id,
                    "reg_numb_border": reg_numb,
                    "category": None,
                    "subcategory": None,
                    "geojson": json.dumps(geom),
                    "raw_props": raw_props_json,
                    "zone_type_name": full_name or None,
                    "content": content_json,
                    "content_source": "ekb_geoportal",
                    "content_fetched_at": fetched_at,
                    "fetched_at": fetched_at,
                },
            )
        return True
    except Exception as exc:
        logger.warning(
            "gknspecial_harvest: upsert failed layer=%s geom_data_id=%s: %s",
            layer_kind,
            geom_data_id,
            exc,
        )
        return False


@celery_app.task(name="tasks.gknspecial_harvest.harvest_gknspecial_zones")
def harvest_gknspecial_zones() -> dict[str, int]:
    """Harvest ЗОУИТ-слоя ЕКБ-геопортала (gknspecial_zone) → ird_overlays.

    Для каждого full_name-префикса запрашивает WFS с CQL ``full_name LIKE 'X%'``,
    фильтрует архивные записи client-side (zone_state_v != 'Действующий'), UPSERT-ит
    в ird_overlays под SAVEPOINT per-row.

    Фичи, чей full_name не попал ни под один известный префикс, пишутся под
    layer_kind='gknspecial_unmatched' (source_layer_id=990099) — defensive, чтобы
    новые типы зон не терялись молча.

    Returns:
        {'types': N_prefixes_fetched, 'features': M_upserted}
    """
    fetched_at = datetime.now(UTC).isoformat()
    client = EKBGeoportalClient()
    db = SessionLocal()
    n_types = 0
    n_features = 0
    try:
        for prefix, expected_layer_kind, _ in _PREFIX_MAP:
            cql = f"full_name LIKE '{prefix}%'"
            try:
                feats = client.features_by_cql("zouit", cql)
            except Exception as exc:
                logger.warning(
                    "gknspecial_harvest: WFS failed prefix=%r layer_kind=%s: %s",
                    prefix,
                    expected_layer_kind,
                    exc,
                )
                continue
            n_types += 1
            n_written = 0
            for feat in feats:
                props = feat.properties or {}
                # Фильтр активности client-side: пропускаем архивные зоны.
                # Если поля нет вообще — не скипаем (неизвестное = считаем активным).
                zone_state = props.get("zone_state_v")
                if zone_state is not None and zone_state != "Действующий":
                    continue
                geom = feat.geometry
                if not geom:
                    logger.debug(
                        "gknspecial_harvest: нет геометрии layer=%s feature_id=%s, skip",
                        expected_layer_kind,
                        feat.feature_id,
                    )
                    continue
                geom_data_id = _extract_geom_data_id(feat.feature_id)
                if geom_data_id is None:
                    logger.debug(
                        "gknspecial_harvest: нет geom_data_id feature_id=%s, skip",
                        feat.feature_id,
                    )
                    continue
                # Роутинг: классифицируем по full_name (единый источник истины).
                # Defensive: WFS мог вернуть фичу с неожиданным full_name — пишем в unmatched.
                full_name: str = str(props.get("full_name") or "")
                cls = _classify_full_name(full_name)
                if cls is None:
                    logger.warning(
                        "gknspecial_harvest: unmatched full_name=%r geom_data_id=%s, "
                        "пишем в unmatched",
                        full_name,
                        geom_data_id,
                    )
                    actual_layer_kind = _UNMATCHED_LAYER_KIND
                    actual_source_layer_id = _UNMATCHED_SOURCE_LAYER_ID
                else:
                    actual_layer_kind, actual_source_layer_id = cls
                if _upsert_feature(
                    db,
                    source_layer_id=actual_source_layer_id,
                    layer_kind=actual_layer_kind,
                    geom_data_id=geom_data_id,
                    props=props,
                    geom=geom,
                    fetched_at=fetched_at,
                ):
                    n_written += 1
                    n_features += 1
            logger.info(
                "gknspecial_harvest: prefix=%r layer_kind=%s fetched=%d written=%d",
                prefix,
                expected_layer_kind,
                len(feats),
                n_written,
            )
            # Durable per-prefix commit: WorkerLostError (OOM / рестарт хоста) в середине
            # прогона не теряет уже записанные префиксы (#1116). Mirror ird_harvest.py.
            db.commit()

        db.commit()  # финальный — no-op если всё уже коммитнуто per-prefix (подстраховка)
        logger.info("gknspecial_harvest: готово, types=%d features=%d", n_types, n_features)
        return {"types": n_types, "features": n_features}
    finally:
        db.close()
