"""Per-building помещения ЕГРН + машино-места + parking_ratio (#96, MVP on-demand).

Тонкая обёртка над уже-готовым foundation `NSPDBulkClient.list_objects_in_building`
(#168 Q3-deferred). Связывает кадастровый номер здания → objdoc_id → NSPD
tab-group `objectsList` → counts помещений/машино-мест → `parking_ratio`.

Зачем именно так (#96 scope decision):
  - Полный план #96 (cad_premises/cad_parking таблицы, per-flat кадастровая
    стоимость, bulk_parking_for_district, миграция) упирается в незакрытый
    data-foundation gap: здания-конкуренты в analyze живут в координатах ДОМ.РФ
    (obj_id + lat/lon), а НЕ имеют cad_num/objdoc_id НСПД. Массовый backfill
    помещений по всем зданиям ЕКБ (38k × tab-group) — тяжёлый rate-limited
    отдельный таск. Поэтому MVP: on-demand по конкретному cad_num здания, без
    схемы и без bulk-сбора.

Поток (2 HTTP-запроса на здание):
  1. NSPDClient.search_by_cad(building_cad) → options.objdoc_id  (sync urllib)
  2. NSPDBulkClient.list_objects_in_building(objdoc_id) → ObjectsListing  (async)

objdoc_id из НСПД search приходит в camelCase `objdocId` — парсится через
NSPDOptions AliasChoices (см. schemas/nspd_bulk.py). Verified live на
66:41:0106036:183 (МКД, 232 помещения, 0 машино-мест).

Вызывается из синхронного кода (FastAPI threadpool handler / Celery task) —
async-часть изолирована в asyncio.run() внутри функции (паттерн poi_loader.py /
pzz_loader.py). НЕ блокирует основной event loop, т.к. analyze_parcel — `def`.
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.schemas.nspd_bulk import ObjectsListing
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
from app.services.scrapers.nspd_client import NSPDClient, NspdLiteError

logger = logging.getLogger(__name__)

# thematic_id=5 — НСПД search-фильтр «Здания» (см. NSPDClient.search_by_cad
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
_THEMATIC_BUILDING = 5

# #96 wiring: дефолтный радиус geom-match domrf↔cad_buildings (метры). Подобран по
# прод-замеру (1543 domrf latest-snapshot vs 38016 cad_buildings): nearest-footprint
# медиана 56м, within-50м покрывает 47%, within-100м 65%. 50м — консервативный
# дефолт: domrf lat/lon = центроид ЖК-ПЛОЩАДКИ (несколько домов + дворы), поэтому
# смещён от ближайшего building-footprint; шире радиус ловит больше, но рискует
# приписать СОСЕДНЕЕ здание (precision важнее recall — parking_ratio чужого дома
# хуже отсутствия). ST_Contains (точка внутри footprint) всегда матчит независимо
# от радиуса. Caller может расширить через max_dist_m.
_DEFAULT_MATCH_RADIUS_M: float = 50.0


@dataclass(frozen=True, slots=True)
class BuildingMatch:
    """Результат geom-match domrf-объекта на здание ЕГРН (#96 wiring).

    cad_num — кадастровый номер сматченного здания (ключ к premises-lookup).
    objdoc_id — НСПД objdoc этого здания ИЗ cad_buildings (может быть None для
    части строк). Если он есть — get_building_premises может ПРОПУСТИТЬ НСПД
    search_by_cad и сразу дёрнуть tab-group (1 НСПД-запрос вместо 2).
    distance_m — расстояние от domrf-центроида до footprint (0.0 = точка внутри
    площадного footprint).
    """

    cad_num: str
    objdoc_id: int | None
    distance_m: float


# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text("""
    SELECT
        b.cad_num,
        b.objdoc_id,
        ST_Distance(
            b.geom::geography,
            ST_SetSRID(
                ST_MakePoint(
                    CAST(:lon AS double precision),
                    CAST(:lat AS double precision)
                ),
                4326
            )::geography
        ) AS distance_m
    FROM cad_buildings b
    WHERE b.geom IS NOT NULL
    ORDER BY b.geom::geography <-> ST_SetSRID(
        ST_MakePoint(
            CAST(:lon AS double precision),
            CAST(:lat AS double precision)
        ),
        4326
    )::geography
    LIMIT 1
""")


def resolve_cad_for_domrf(
    db: Session,
    *,
    lat: float,
    lon: float,
    max_dist_m: float = _DEFAULT_MATCH_RADIUS_M,
) -> BuildingMatch | None:
    """Резолв domrf-объекта (lat/lon центроид) → здание cad_buildings (#96 wiring).

    Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
    cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
    GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
    принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
    площадного footprint → distance 0.

    Returns:
        BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
        радиусе, иначе None. None трактуется как «здание не сопоставлено» —
        caller продолжает без parking_ratio (graceful, не выдумываем).

    Note:
        objdoc_id из cad_buildings возвращается для short-circuit НСПД search в
        get_building_premises_for_match — может быть None (тогда фоллбэк на
        cad_num search).
    """
    try:
        with db.begin_nested():
            row = (
                db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
                .mappings()
                .first()
            )
    except Exception:
        # SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
        # (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).
        logger.exception("premises_lookup: resolve_cad_for_domrf failed lat=%s lon=%s", lat, lon)
        return None

    if row is None:
        return None

    distance_m = float(row["distance_m"])
    if distance_m > max_dist_m:
        logger.info(
            "premises_lookup: nearest building %.1fm > %.1fm radius for lat=%s lon=%s — no match",
            distance_m,
            max_dist_m,
            lat,
            lon,
        )
        return None

    raw_objdoc = row["objdoc_id"]
    objdoc_id = int(raw_objdoc) if raw_objdoc is not None else None
    return BuildingMatch(cad_num=row["cad_num"], objdoc_id=objdoc_id, distance_m=distance_m)


def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
    """objdoc_id здания по cad_num через NSPD geoportal search.

    Возвращает None если здание не найдено или НСПД не отдал objdocId (часть
    объектов его не имеет). Сетевые/WAF-ошибки логируются и дают None — caller
    трактует как «нет данных», analyze не падает (graceful).
    """
    try:
        result = client.search_by_cad(cad_num, thematic_id=_THEMATIC_BUILDING)
    except NspdLiteError as e:
        # WAF/сеть/4xx — не наш баг, не валим analyze. Один building без
        # parking_ratio лучше, чем 500 на весь отчёт.
        logger.warning("premises_lookup: search_by_cad failed cad_num=%s: %s", cad_num, e)
        return None

    feature = result.first
    if feature is None:
        logger.info("premises_lookup: здание не найдено в НСПД cad_num=%s", cad_num)
        return None

    options = feature.properties.get("options") or {}
    raw = options.get("objdocId") or options.get("objdoc_id")
    if raw is None:
        # Fallback: feature.id у зданий = objdoc (verified live на
        # 66:41:0106036:183 — tab-group по feature.id вернул помещения).
        raw = feature.feature_id
    if raw is None:
        logger.info("premises_lookup: нет objdoc_id для cad_num=%s", cad_num)
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        logger.warning("premises_lookup: невалидный objdoc_id %r для cad_num=%s", raw, cad_num)
        return None


async def _fetch_listing(objdoc_id: int) -> ObjectsListing | None:
    """list_objects_in_building в одноразовом async-клиенте.

    Изолированный async context (`async with`) — корректный lifecycle сокета +
    semaphore под текущим event loop (см. NSPDBulkClient.__aenter__ / #260).
    """
    try:
        async with NSPDBulkClient() as client:
            return await client.list_objects_in_building(objdoc_id)
    except NspdBulkError as e:
        logger.warning("premises_lookup: list_objects failed objdoc_id=%d: %s", objdoc_id, e)
        return None


def get_building_premises(cad_num: str) -> ObjectsListing | None:
    """Помещения + машино-места + parking_ratio для здания по кад. номеру (#96).

    On-demand, 2 НСПД-запроса. Возвращает None если здание не резолвится в
    objdoc_id или НСПД недоступен (graceful — caller продолжает без сигнала).
    `ObjectsListing.parking_ratio` = машино-места / помещения (None если
    помещений 0).

    Args:
        cad_num: кадастровый номер ЗДАНИЯ (5-сегментный, напр.
            `66:41:0106036:183`). Квартал/участок дадут пустой/нерелевантный
            результат — caller обязан передавать именно здание.

    Returns:
        ObjectsListing | None. None трактуется как «нет данных», НЕ как нулевой
        паркинг (не выдумываем дефицит из недоступности НСПД).
    """
    sync_client = NSPDClient()
    objdoc_id = _resolve_objdoc_id(cad_num, sync_client)
    if objdoc_id is None:
        return None

    listing = asyncio.run(_fetch_listing(objdoc_id))
    if listing is not None:
        logger.info(
            "premises_lookup: cad_num=%s objdoc_id=%d flats=%d parking=%d ratio=%s",
            cad_num,
            objdoc_id,
            listing.flats_count,
            listing.parking_count,
            listing.parking_ratio,
        )
    return listing


def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None:
    """Помещения/parking_ratio для уже-сматченного здания (#96 wiring).

    Тонкая обёртка над get_building_premises: если geom-match дал objdoc_id ИЗ
    cad_buildings, ПРОПУСКАЕМ НСПД search_by_cad (1 НСПД-запрос вместо 2 — search
    тратит rate-limit и иногда промахивается на не-канон cad_num). Иначе фоллбэк
    на cad_num-резолв через get_building_premises.

    Graceful: НСПД недоступен / objdoc не отдал помещения → None (как у
    get_building_premises). Изолирует async в asyncio.run() — вызывается из
    sync-контекста (FastAPI threadpool handler / Celery task).
    """
    if match.objdoc_id is not None:
        listing = asyncio.run(_fetch_listing(match.objdoc_id))
        if listing is not None:
            logger.info(
                "premises_lookup: match cad_num=%s objdoc_id=%d (cad_buildings) "
                "flats=%d parking=%d ratio=%s",
                match.cad_num,
                match.objdoc_id,
                listing.flats_count,
                listing.parking_count,
                listing.parking_ratio,
            )
        return listing
    # objdoc неизвестен в cad_buildings — полный путь по cad_num (2 НСПД-запроса).
    return get_building_premises(match.cad_num)


__all__ = [
    "BuildingMatch",
    "get_building_premises",
    "get_building_premises_for_match",
    "resolve_cad_for_domrf",
]
