"""Детерминированная атрибуция застройщика для analyze_parcel (#1088 «GG-форсайт»).

Вызывает резолвер ``fn_developer_for_parcel(cad_num, radius_m)`` (миграция 149,
``data/sql/149_developer_attribution.sql``) и собирает атрибуцию «пятно земли ↔
застройщик»: топ-1 канон-застройщик участка (имя + норм-ИНН) с его проектом/статусом
и track-record (РНС/РВЭ + домрф ЖК/квартиры из ``developer_registry``), плюс список
``nearby_developers`` — «кто ещё строит рядом / в квартале».

Резолвер — 3-ступенчатый fallback (exact_cadastral > spatial_150m > quarter), уже
отсортированный лучшим матчем вперёд (ORDER BY method, distance). Один застройщик
может встретиться в нескольких ступенях (exact+spatial+quarter) — дедупим по
норм-ИНН, оставляя ЛУЧШИЙ match_method (первый по ORDER BY резолвера). Топ-1
дедупнутый застройщик → primary, остальные → nearby_developers.

Связка с ``competitors.py``: иная ответственность. competitors отдаёт ЖК-конкурентов
в радиусе (domrf, velocity), здесь — кто ЗАСТРОЙЩИК конкретного участка и его послужной
список по РНС+домрф. Отдельный сервис (НЕ метод в competitors).

Graceful: пустой результат / нет геометрии участка / БД-ошибка (incl. миграция 149 ещё
не задеплоена) → None — analyze не падает. Зеркалит стиль ``ird_overlay_lookup.py`` /
``okn_lookup.py``.
"""

from __future__ import annotations

import logging
from typing import Any

from sqlalchemy import text
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session

from app.schemas.parcel import (
    DeveloperAttribution,
    DeveloperAttributionResult,
    NearbyDeveloper,
)

logger = logging.getLogger(__name__)

# Резолвер участок→застройщик (миграция 149). Возвращает SETOF кандидатов, УЖЕ
# отсортированный лучшим матчем вперёд: exact_cadastral (dist=0) → spatial_150m →
# quarter, внутри метода — по distance_m ASC, затем ИНН. Caller (этот сервис) берёт
# топ-1 дедупнутый и дополняет nearby. CAST psycopg v3 — НИКОГДА :param::type
# (vault Pattern_CAST_AS_Type / backend.md).
_DEVELOPER_FOR_PARCEL_SQL = text(
    """
    SELECT
        developer_inn,
        developer_name,
        source,
        match_method,
        distance_m,
        project,
        status,
        obj_class,
        permits_total,
        domrf_objects_total,
        domrf_active_objects,
        in_both_sources
    FROM fn_developer_for_parcel(:cad_num, CAST(:radius AS double precision))
    """
)

# match_method, считающиеся «соседними» (вторичными) для nearby_developers. exact —
# это сам застройщик участка (primary), он в nearby не дублируется.
_NEARBY_METHODS = frozenset({"spatial_150m", "quarter"})


def _row_get(row: Any, key: str) -> Any:
    """Безопасно прочитать ключ из row-mapping → None если ключа нет.

    Только __getitem__: и SQLAlchemy RowMapping, и тестовые MagicMock-строки
    реализуют его корректно и кидают KeyError на отсутствующий ключ. (Не .get():
    на MagicMock это авто-атрибут-заглушка, возвращающая MagicMock вместо значения.)
    Зеркалит competitors._row_get — backward-safe чтение колонок из моков.
    """
    try:
        return row[key]
    except (KeyError, TypeError, IndexError):
        return None


def _as_int(value: Any) -> int | None:
    """None-safe int() — track-record-колонки могут быть NULL (FULL OUTER JOIN)."""
    return int(value) if value is not None else None


def _as_float(value: Any) -> float | None:
    """None-safe float() — distance_m NULL для quarter-ступени."""
    return float(value) if value is not None else None


def _attribution_fields(row: Any) -> dict[str, Any]:
    """Извлечь общий набор полей атрибуции из row резолвера (через _row_get).

    Используется и для primary (DeveloperAttribution), и для nearby (NearbyDeveloper)
    — у них идентичный набор полей. developer_inn — единственное обязательное (NOT NULL
    в резолвере, гарантирован WHERE c.inn IS NOT NULL); остальное nullable.
    """
    return {
        "developer_inn": str(_row_get(row, "developer_inn")),
        "developer_name": _row_get(row, "developer_name"),
        "source": _row_get(row, "source"),
        "match_method": _row_get(row, "match_method"),
        "distance_m": _as_float(_row_get(row, "distance_m")),
        "project": _row_get(row, "project"),
        "status": _row_get(row, "status"),
        "obj_class": _row_get(row, "obj_class"),
        "permits_total": _as_int(_row_get(row, "permits_total")),
        "domrf_objects_total": _as_int(_row_get(row, "domrf_objects_total")),
        "domrf_active_objects": _as_int(_row_get(row, "domrf_active_objects")),
        "in_both_sources": _row_get(row, "in_both_sources"),
    }


def get_developer_attribution(
    db: Session,
    cad_num: str,
    radius_m: float = 150.0,
) -> DeveloperAttributionResult | None:
    """Атрибуция застройщика участка: топ-1 + track-record + соседи (#1088).

    Шаги:
    1. Вызвать ``fn_developer_for_parcel(cad_num, radius_m)`` — SETOF кандидатов,
       уже отсортированный лучшим матчем вперёд (резолвер: exact > spatial > quarter).
    2. Дедупнуть по нормализованному developer_inn, сохраняя ЛУЧШИЙ (первый по
       ORDER BY) match_method каждого застройщика.
    3. Топ-1 дедупнутый → primary; остальные (match_method spatial/quarter) →
       nearby_developers.

    Args:
        db: SQLAlchemy-сессия.
        cad_num: кадастровый номер участка.
        radius_m: радиус spatial-ступени резолвера (default 150 м).

    Returns:
        ``DeveloperAttributionResult`` (primary + nearby_developers) либо None при:
          - пустом результате резолвера (нет матча застройщика);
          - отсутствии геометрии участка (spatial/quarter-domrf не сработали, а
            exact/quarter-permits ничего не нашли);
          - БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
    """
    try:
        # SAVEPOINT — db получена от caller (shared request-scoped Session,
        # analyze_parcel в parcels.py). Голый db.rollback() здесь НЕЛЬЗЯ — он
        # orphan'ит outer SessionTransaction и роняет persist_analysis_run сразу
        # после (см. velocity.py:170 / connection_capacity_lookup._query_nearby_
        # network_zones). Ошибка внутри begin_nested() откатывает ТОЛЬКО эту
        # savepoint до propagate — outer-транзакция остаётся usable для
        # последующих db.execute (#2464 cluster A finding 1).
        with db.begin_nested():
            rows = (
                db.execute(
                    _DEVELOPER_FOR_PARCEL_SQL,
                    {"cad_num": cad_num, "radius": radius_m},
                )
                .mappings()
                .all()
            )
    except (OperationalError, ProgrammingError, DataError) as exc:
        # Миграция 149 ещё не задеплоена / БД-ошибка — graceful degrade.
        logger.warning(
            "get_developer_attribution: fn_developer_for_parcel недоступна для cad=%s, skip: %s",
            cad_num,
            exc,
        )
        return None
    except Exception as exc:
        logger.warning(
            "get_developer_attribution: неожиданная ошибка для cad=%s, skip: %s",
            cad_num,
            exc,
        )
        return None

    if not rows:
        return None

    # ── Дедуп по норм-ИНН, сохраняя ПЕРВЫЙ матч (он же лучший — резолвер ORDER BY) ──
    # rows УЖЕ отсортированы (exact > spatial > quarter, distance ASC), поэтому первое
    # вхождение ИНН — его лучший match_method. dict сохраняет порядок вставки (3.7+),
    # значит первый ключ — топ-1 застройщик участка.
    deduped: dict[str, Any] = {}
    for row in rows:
        inn = _row_get(row, "developer_inn")
        if inn is None:
            # Резолвер гарантирует NOT NULL (WHERE c.inn IS NOT NULL), но защищаемся.
            continue
        inn_key = str(inn)
        if inn_key not in deduped:
            deduped[inn_key] = row

    if not deduped:
        return None

    deduped_rows = list(deduped.values())
    primary_row = deduped_rows[0]
    primary = DeveloperAttribution(**_attribution_fields(primary_row))

    # ── nearby: остальные дедупнутые застройщики (вторичные spatial/quarter матчи) ──
    # primary уже исключён (срез [1:]). Доп. фильтр по _NEARBY_METHODS: если у соседа
    # как-то оказался exact (другой ИНН, multi-value РНС на тот же ЗУ) — он всё равно
    # «другой застройщик участка», но в nearby кладём только spatial/quarter-контекст,
    # чтобы семантика nearby = «строят рядом/в квартале» оставалась чистой.
    nearby: list[NearbyDeveloper] = []
    for row in deduped_rows[1:]:
        if _row_get(row, "match_method") not in _NEARBY_METHODS:
            continue
        nearby.append(NearbyDeveloper(**_attribution_fields(row)))

    return DeveloperAttributionResult(primary=primary, nearby_developers=nearby)


__all__ = ["get_developer_attribution"]
