import datetime as _dt
import json
import logging
import math
import time
from typing import Annotated, Any

import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.config import settings
from app.core.db import get_db
from app.schemas.parcel import (
    AnalyzeRequest,
    BestLayoutsRequest,
    BestLayoutsResponse,
    CompetitorsRequest,
    CompetitorsResponse,
    ConnectionPointsResponse,
    OpportunityParcel,
    ParcelBboxResponse,
    ParcelDetail,
    ParcelMapMarker,
    ParcelMeta,
    ParcelSearchRequest,
    ParcelSearchResponse,
    RedLine,
    RiskZone,
)
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
from app.services.site_finder.best_layouts import get_best_layouts
from app.services.site_finder.cadastre_fetch import (
    cad_exists_in_db,
    find_or_enqueue_fetch,
)
from app.services.site_finder.cadastre_fetch import (
    fetch_status as _fetch_status,
)
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.custom_pois import (
    get_overlaps_for_scoring as _get_custom_poi_overlaps,
)
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7
from app.services.site_finder.quarter_dump_lookup import (
    get_connection_points,
    get_quarter_dump_data,
    make_empty_result,
)
from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import (
    _SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
)
from app.services.site_finder.weight_profiles import (
    ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
)
from app.services.site_finder.weight_profiles import (
    MAX_WEIGHT as _MAX_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
    MIN_WEIGHT as _MIN_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
    resolve_weights as _resolve_weights,
)

logger = logging.getLogger(__name__)

router = APIRouter()

# Базовые уровни шума по типу источника (дБ на 10м) — источник: WHO Environmental Noise Guidelines
NOISE_L_BASE: dict[str, float] = {
    "highway:motorway": 75.0,
    "highway:trunk": 75.0,
    "highway:primary": 70.0,
    "highway:secondary": 65.0,
    "highway:tertiary": 60.0,
    "highway:residential": 55.0,
    "railway": 72.0,
    "industrial": 65.0,
    "aerodrome": 70.0,
}


def _wind_label(deg: float) -> str:
    """Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
    rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
    idx = round(deg / 45) % 8
    return rose[idx]


def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
    """Синхронный запрос к Open-Meteo Air Quality API.

    Возвращает данные текущего часа (первый элемент hourly). None если API
    недоступен или вернул неожиданный формат.
    """
    try:
        with httpx.Client(timeout=5) as c:
            r = c.get(
                "https://air-quality-api.open-meteo.com/v1/air-quality",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    "hourly": "pm2_5,pm10,nitrogen_dioxide",
                    "forecast_days": 1,
                },
            )
            r.raise_for_status()
            data = r.json()
            hourly = data.get("hourly", {})
            if not hourly.get("time"):
                return None
            return {
                "pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
                "pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
                "no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
                "ts": hourly["time"][0],
                "source": "open-meteo",
            }
    except Exception as e:
        logger.warning("air quality fetch failed: %s", e)
        return None


def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
    """Open-Meteo Climate API — 30-летние нормали по сезонам.

    Использует модель MRI-AGCM3.2-S (японская, точная для всего мира).
    Группирует 1995-2024 по четырём сезонам. Медленнее прогноза — timeout 15s.
    """
    try:
        with httpx.Client(timeout=15) as c:
            r = c.get(
                "https://climate-api.open-meteo.com/v1/climate",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    "start_date": "1995-01-01",
                    "end_date": "2024-12-31",
                    "models": "MRI_AGCM3_2_S",
                    "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
                },
            )
            r.raise_for_status()
            data = r.json()
            daily = data.get("daily", {})
            times = daily.get("time") or []
            t_max = daily.get("temperature_2m_max") or []
            t_min = daily.get("temperature_2m_min") or []
            precip = daily.get("precipitation_sum") or []
            if not times:
                return None

            seasons_months = {
                "winter": [12, 1, 2],
                "spring": [3, 4, 5],
                "summer": [6, 7, 8],
                "autumn": [9, 10, 11],
            }
            buckets: dict[str, dict[str, list[float]]] = {
                k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
            }
            for i, t in enumerate(times):
                month = int(t[5:7])  # 'YYYY-MM-DD'
                for season, months in seasons_months.items():
                    if month in months:
                        if i < len(t_max) and t_max[i] is not None:
                            buckets[season]["t_max"].append(t_max[i])
                        if i < len(t_min) and t_min[i] is not None:
                            buckets[season]["t_min"].append(t_min[i])
                        if i < len(precip) and precip[i] is not None:
                            buckets[season]["precip"].append(precip[i])
                        break

            seasons: dict[str, Any] = {}
            for season, vals in buckets.items():
                if not vals["t_max"]:
                    seasons[season] = None
                    continue
                seasons[season] = {
                    "avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
                    "avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
                    "max_t_c": round(max(vals["t_max"]), 1),
                    "min_t_c": round(min(vals["t_min"]), 1),
                    "avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
                    "total_precip_mm": round(sum(vals["precip"]), 0),
                    "days_observed": len(vals["t_max"]),
                }
            return {
                "seasons": seasons,
                "period": "1995-2024 (30 лет)",
                "model": "MRI-AGCM3-2-S",
                "source": "open-meteo-climate",
                "note": ("Климатические нормали. Текущая погода — отдельный API."),
            }
    except Exception as e:
        logger.warning("seasonal weather fetch failed: %s", e)
        return None


def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
    """Open-Meteo Forecast API — 7-day weather + climate context.

    Free, no API key, JSON by coordinates. Покрывает РФ полностью.
    """
    try:
        with httpx.Client(timeout=5) as c:
            r = c.get(
                "https://api.open-meteo.com/v1/forecast",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    "daily": (
                        "temperature_2m_max,temperature_2m_min,"
                        "precipitation_sum,uv_index_max,"
                        "winddirection_10m_dominant,windspeed_10m_max"
                    ),
                    "timezone": "Europe/Moscow",
                    "forecast_days": 7,
                },
            )
            r.raise_for_status()
            daily = r.json().get("daily", {})
            if not daily.get("time"):
                return None

            t_max = daily.get("temperature_2m_max") or []
            t_min = daily.get("temperature_2m_min") or []
            precip = daily.get("precipitation_sum") or []
            uv = daily.get("uv_index_max") or []
            wind_d = daily.get("winddirection_10m_dominant") or []
            wind_s = daily.get("windspeed_10m_max") or []

            # Circular mean направления ветра (vector sum) — избегает jump 359→1
            x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
            y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
            dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
            rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
            wind_label = rose[round(dominant / 45) % 8]

            return {
                "forecast_days": len(daily.get("time", [])),
                "temperature": {
                    "min_c": round(min(t_min), 1) if t_min else None,
                    "max_c": round(max(t_max), 1) if t_max else None,
                    "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
                    "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
                },
                "precipitation_total_mm": round(sum(precip), 1) if precip else 0,
                "precipitation_days": sum(1 for p in precip if p and p > 0.5),
                "uv_index_max": round(max(uv), 1) if uv else None,
                "wind": {
                    "dominant_direction_deg": round(dominant),
                    "dominant_direction_label": wind_label,
                    "max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
                },
                "source": "open-meteo",
                "note": (
                    "7-day forecast. Для исторических норм и B2B-данных — "
                    "Yandex Business / Gismeteo (платно)."
                ),
            }
    except Exception as e:
        logger.warning("weather fetch failed: %s", e)
        return None


# Координаты центра ЕКБ — Площадь 1905 года
EKB_CENTER_LAT: float = 56.838011
EKB_CENTER_LON: float = 60.597474

# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
SCORE_MAX_REFERENCE: float = 40.0


def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Расстояние по формуле гаверсинуса между двумя точками (км)."""
    earth_r = 6371.0
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlam = math.radians(lon2 - lon1)
    a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
    return 2 * earth_r * math.atan2(math.sqrt(a), math.sqrt(1 - a))


def _score_label(s: float) -> str:
    """Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ."""
    if s < SCORE_THRESHOLDS["средне"]:
        return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне"
    return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"


def _confidence_label(c: float) -> str:
    """Текстовая интерпретация confidence (0..1).

    Пороги:
      high   — c > 0.75 (плотные актуальные данные)
      medium — 0.4-0.75
      low    — c < 0.4 (caveats обязательны)
    """
    if c >= 0.75:
        return "high"
    if c >= 0.4:
        return "medium"
    return "low"


# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
    "school": "Школа",
    "kindergarten": "Детсад",
    "pharmacy": "Аптека",
    "hospital": "Больница",
    "shop_mall": "ТЦ",
    "shop_supermarket": "Супермаркет",
    "shop_small": "Магазин",
    "park": "Парк",
    "bus_stop": "Автобус",
    "metro_stop": "Метро",
    "tram_stop": "Трамвай",
}

# Группировка POI по тематическим эшелонам — для stacked-bar % contribution
# (X1 score breakdown). Расширяй по мере добавления новых категорий.
_POI_GROUP: dict[str, str] = {
    "school": "Социалка",
    "kindergarten": "Социалка",
    "pharmacy": "Социалка",
    "hospital": "Социалка",
    "shop_mall": "Торговля",
    "shop_supermarket": "Торговля",
    "shop_small": "Торговля",
    "park": "Парки",
    "bus_stop": "Транспорт",
    "metro_stop": "Транспорт",
    "tram_stop": "Шум/трамвай",
}


def _verbal_for_poi(
    cat: str,
    name: str | None,
    distance_m: float,
    contribution: float,
) -> str:
    """Сгенерировать verbal explain для одного POI-вклада.

    Пример: "Школа №125 в 400м — +0.90 баллов".
    Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов".
    """
    label = _POI_CATEGORY_RU.get(cat, cat)
    safe_name = (name or "").strip()
    name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else ""
    sign = "+" if contribution >= 0 else "−"
    return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"


# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
# Добавляй регионы по мере расширения географии продукта.
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
    66: {  # Свердловская обл.
        "seismic_intensity_balls": 5,
        "seismic_label": "минимальная сейсмика (≤5 баллов)",
        "seismic_description": "Обычное строительство без специальных мер.",
        "permafrost": False,
    },
    77: {  # Москва
        "seismic_intensity_balls": 4,
        "seismic_label": "практически нет сейсмики",
        "seismic_description": "Обычное строительство.",
        "permafrost": False,
    },
}


# SF-20 — success_recommendation confidence thresholds
SUCCESS_REC_MIN_DEALS = 15  # ниже → не показываем (пусто)
SUCCESS_REC_STRONG_DEALS = 30  # ≥ → data_confidence='strong'; 15-29 → 'weak'

# D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга
# и аудита; severity пороги матчатся с acceptance #36.
PIPELINE_RADIUS_M = 5000
PIPELINE_HORIZON_MONTHS = 24
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500  # flats_total < это → low
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000  # flats_total >= это → high
PIPELINE_TOP_OBJECTS_LIMIT = 10


def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]:
    """D4 (#36) — собрать pipeline_24mo aggregate из rows domrf_kn_objects.

    Метрики:
      - objects_count, flats_total
      - by_class: {economy: int, comfort: int, business: int, unknown: int}
      - by_quarter: {"2026-Q1": {objects: N, flats: M}, ...}
      - severity: low / medium / high (см. PIPELINE_SEVERITY_* пороги)
      - top_objects: PIPELINE_TOP_OBJECTS_LIMIT крупнейших ЖК по flat_count

    NB: `obj_class` в production часто NULL (см.
    `fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN`). Pipeline SQL обогащает
    obj_class через objective_lots + objective_complex_mapping (COALESCE
    fallback). Объекты без маппинга остаются "unknown".

    Используется для UI pipeline-bar и severity badge.
    """
    if not rows:
        return {
            "objects_count": 0,
            "flats_total": 0,
            "by_class": {},
            "by_quarter": [],
            "severity": "none",
            "top_objects": [],
            "note": (
                f"Нет ЖК в pipeline {PIPELINE_HORIZON_MONTHS}мес в радиусе "
                f"{PIPELINE_RADIUS_M // 1000}км — низкая будущая конкуренция"
            ),
        }

    by_class: dict[str, int] = {}
    by_quarter: dict[str, dict[str, int]] = {}
    flats_total = 0

    for r in rows:
        cls = (r["obj_class"] or "unknown").lower().strip() or "unknown"
        flats = int(r["flat_count"]) if r["flat_count"] else 0
        flats_total += flats
        by_class[cls] = by_class.get(cls, 0) + flats

        ready = r["ready_dt"]
        if ready:
            q = (ready.month - 1) // 3 + 1
            key = f"{ready.year}-Q{q}"
            slot = by_quarter.setdefault(key, {"objects": 0, "flats": 0})
            slot["objects"] += 1
            slot["flats"] += flats

    # Severity (#36 acceptance)
    if flats_total < PIPELINE_SEVERITY_MEDIUM_THRESHOLD:
        severity = "low"
    elif flats_total < PIPELINE_SEVERITY_HIGH_THRESHOLD:
        severity = "medium"
    else:
        severity = "high"

    severity_label = {
        "low": "низкая",
        "medium": "средняя",
        "high": "высокая",
    }[severity]

    # Sort quarters chronologically
    quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())]

    # Top objects — по flat_count desc.
    # Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает
    # внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.)
    # в API response. Не security issue, но schema-leak.
    top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[
        :PIPELINE_TOP_OBJECTS_LIMIT
    ]
    top_objects: list[dict[str, Any]] = []
    for r in top_rows:
        ready_dt = r.get("ready_dt")
        distance_m = r.get("distance_m")
        top_objects.append(
            {
                "obj_id": r["obj_id"],
                "comm_name": r.get("comm_name"),
                "dev_name": r.get("dev_name"),
                "obj_class": r.get("obj_class"),
                "flat_count": r.get("flat_count"),
                # ISO date string для JSON; distance_m — explicit None guard
                # (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе
                # упадёт в JSON serialization).
                "ready_dt": ready_dt.isoformat() if ready_dt else None,
                "distance_m": round(float(distance_m)) if distance_m is not None else None,
            }
        )

    return {
        "objects_count": len(rows),
        "flats_total": flats_total,
        "by_class": by_class,
        "by_quarter": quarters_sorted,
        "severity": severity,
        "severity_label": severity_label,
        "top_objects": top_objects,
        "radius_km": PIPELINE_RADIUS_M // 1000,
        "horizon_months": PIPELINE_HORIZON_MONTHS,
        "note": (
            "Будущая конкуренция за покупателя: planned_commissioning от Росреестра "
            "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный."
        ),
    }


# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие
# для ЖК; будут править — храним в одном месте)
_GEOM_MIN_AREA_HA = 0.2  # ниже → area_subscore = 0 (физический минимум)
_GEOM_AREA_COMFORT_HA = 0.3  # рекомендуемая комфортная площадь МКД (recommendation)
_GEOM_AREA_SCORE_FULL_HA = 0.5  # ≥ → area_subscore = 1.0 (premium)
_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0  # выше → вытянутый
_GEOM_ASPECT_PENALTY = 0.3
_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65  # ниже → изрезанный
_GEOM_CONVEX_PENALTY = 0.3
# Строительный минимум — physical possibility (под penalty)
_GEOM_MIN_WIDTH_PHYSICAL_M = 30
_GEOM_NARROW_PENALTY = 0.5
# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт)
_GEOM_MIN_WIDTH_COMFORT_M = 40
_GEOM_LABEL_MICRO_HA = 0.05  # ниже → label "микро" (комбинируется с penalties)
_GEOM_LABEL_GOOD = 0.7
_GEOM_LABEL_MEDIUM = 0.4


def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
    """P1 (#45) — physical suitability участка по метрикам shape.

    Метрики:
    - area_ha — площадь в гектарах (locally-projected metres via cos(lat))
    - perimeter_m — периметр
    - aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
    - convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
    - min_inscribed_rect_dim_m — длина короткой стороны MABR

    Suitability score 0..1 — composite (пороги — см. _GEOM_* константы):
    - area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear
    - −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD
    - −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD
    - −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M

    UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро"
    комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел
    обе проблемы сразу.
    """
    try:
        # Парсим WGS84 polygon (shapely imports теперь module-level)
        poly = _shp_wkt.loads(geom_wkt)
        if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"):
            return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"}

        # Берём наибольший компонент для MultiPolygon
        if poly.geom_type == "MultiPolygon":
            poly = max(poly.geoms, key=lambda g: g.area)
        assert isinstance(poly, Polygon)

        # Equirectangular-projection в метры через centroid-anchor.
        # На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK).
        centroid = poly.centroid
        lat_rad = math.radians(centroid.y)
        m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
        m_per_deg_lat = 110_540.0
        ext = list(poly.exterior.coords)
        ext_m = [
            (
                (x - centroid.x) * m_per_deg_lon,
                (y - centroid.y) * m_per_deg_lat,
            )
            for x, y in ext
        ]
        poly_m = Polygon(ext_m)
        area_m2 = poly_m.area
        area_ha = area_m2 / 10_000.0
        perimeter_m = poly_m.length

        # Convex hull ratio
        hull = poly_m.convex_hull
        convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0

        # MABR (minimum area bounding rectangle) → aspect_ratio + short side
        try:
            mabr = poly_m.minimum_rotated_rectangle
            mabr_coords = list(mabr.exterior.coords)
            # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны
            side_lens: list[float] = []
            for i in range(4):
                p1 = mabr_coords[i]
                p2 = mabr_coords[i + 1]
                side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1]))
            short_side = min(side_lens)
            long_side = max(side_lens)
            aspect_ratio = long_side / short_side if short_side > 0 else 1.0
        except Exception as mabr_err:
            logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err)
            short_side = math.sqrt(area_m2)
            aspect_ratio = 1.0

        # Suitability score composite
        if area_ha >= _GEOM_AREA_SCORE_FULL_HA:
            area_subscore = 1.0
        elif area_ha <= _GEOM_MIN_AREA_HA:
            area_subscore = 0.0
        else:
            # linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0
            area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / (
                _GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA
            )

        suitability = area_subscore
        penalties: list[str] = []
        if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD:
            suitability -= _GEOM_ASPECT_PENALTY
            penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})")
        if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD:
            suitability -= _GEOM_CONVEX_PENALTY
            penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})")
        if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M:
            suitability -= _GEOM_NARROW_PENALTY
            penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
        suitability = max(0.0, min(1.0, suitability))

        # Label — combine "микро" с penalties чтобы UI видел всё
        is_micro = area_ha < _GEOM_LABEL_MICRO_HA
        if suitability >= _GEOM_LABEL_GOOD and not is_micro:
            label = "подходящий"
        elif is_micro:
            # combine с penalties: "микро" + первая penalty (для краткости)
            if penalties:
                label = f"микро, {penalties[0].split(' (')[0]}"
            else:
                label = "микро"
        elif suitability >= _GEOM_LABEL_MEDIUM:
            label = "сложная форма"
        else:
            label = "слабо подходит"

        return {
            "data_available": True,
            "area_ha": round(area_ha, 3),
            "area_m2": round(area_m2),
            "perimeter_m": round(perimeter_m),
            "aspect_ratio": round(aspect_ratio, 2),
            "convex_hull_ratio": round(convex_hull_ratio, 2),
            "min_inscribed_rect_dim_m": round(short_side),
            "suitability_score": round(suitability, 2),
            "label": label,
            "penalties": penalties,
            "recommendation": (
                f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, "
                f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м "
                f"и площадь от {_GEOM_AREA_COMFORT_HA} га."
            ),
            "note": (
                "Оценка по форме участка (Shapely). Учитывает площадь, "
                "вытянутость, изрезанность, минимальную ширину MABR."
            ),
        }
    except Exception as e:
        logger.warning("polygon suitability failed: %s", e)
        return {
            "data_available": False,
            "note": f"Не удалось проанализировать геометрию: {e}",
        }


# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда
# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны
# эмпирически для ЕКБ.
_COST_PER_M2_MIN = 1000  # ₽/м² — ниже скорее всего ошибка ввода
_COST_PER_M2_MAX = 500_000  # ₽/м² — выше скорее всего outlier


def _parse_floors(raw: str | int | None) -> int | None:
    """cad_buildings.floors — после schema migration #169 теперь INT,
    но historically мог быть TEXT с диапазоном '5-7'. Поддерживаем оба
    для backwards-compat с legacy data + tests.

    Возвращаем верхнюю границу (более консервативный сосед-высотка).
    NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для
    multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation).
    """
    if raw is None or raw == "":
        return None
    # Post-migration: INT column → fast path
    if isinstance(raw, int):
        return raw if raw > 0 else None
    raw = raw.strip()
    if not raw:
        return None
    # range like "5-7" → 7
    if "-" in raw:
        parts = raw.split("-")
        try:
            return max(int(p.strip()) for p in parts if p.strip().isdigit())
        except ValueError:
            return None
    # single int
    try:
        return int(raw)
    except ValueError:
        return None


def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]:
    """P2 (#46) — cad_buildings соседи в 100м + overlap check.

    Возвращает aggregate (avg/max floors, median cost/m², count) + плоский
    список соседей для UI + флаг has_existing_buildings (overlap >50 м²).

    Использует GIST на cad_buildings.geom (уже создан в schema).
    """
    try:
        neighbor_rows = (
            db.execute(
                text("""
                SELECT cad_num,
                       building_name,
                       floors,
                       year_built,
                       cost_value,
                       area,
                       readable_address,
                       ST_Distance(
                           b.geom::geography,
                           ST_GeomFromText(:wkt, 4326)::geography
                       ) AS distance_m
                  FROM cad_buildings b
                 WHERE ST_DWithin(
                           b.geom::geography,
                           ST_GeomFromText(:wkt, 4326)::geography,
                           100
                       )
                   AND b.cad_num != :our_cad
                 ORDER BY distance_m ASC
                 LIMIT 30
            """),
                {"wkt": geom_wkt, "our_cad": our_cad_num},
            )
            .mappings()
            .all()
        )
    except Exception as e:
        logger.warning("neighbors query failed: %s", e)
        return {"data_available": False, "note": f"neighbors query failed: {e}"}

    # Aggregate floors + cost. Дефенсивный try/except: если cost_value/area
    # придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без
    # этого guard весь endpoint вернёт 500.
    try:
        floors_parsed: list[int] = []
        costs_per_m2: list[float] = []
        for r in neighbor_rows:
            f = _parse_floors(r.get("floors"))
            if f is not None and f > 0:
                floors_parsed.append(f)
            if r.get("cost_value") and r.get("area") and float(r["area"]) > 0:
                cost_per_m2 = float(r["cost_value"]) / float(r["area"])
                if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX:
                    costs_per_m2.append(cost_per_m2)

        avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None
        max_floors = max(floors_parsed) if floors_parsed else None
        median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None
    except (ValueError, TypeError) as e:
        logger.warning("neighbors aggregation failed: %s", e)
        return {
            "data_available": False,
            "note": f"neighbors aggregation failed: {e}",
        }

    # Overlap check — что-то построено непосредственно на нашем участке.
    # Если хоть один building пересекается с площадью >50 м² — hard warn.
    try:
        overlap_row = (
            db.execute(
                text("""
                SELECT cad_num,
                       building_name,
                       floors,
                       readable_address,
                       ST_Area(
                         ST_Intersection(
                           ST_Transform(b.geom, 32641),
                           ST_Transform(ST_GeomFromText(:wkt, 4326), 32641)
                         )
                       ) AS overlap_m2
                  FROM cad_buildings b
                 WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326))
                   AND b.cad_num != :our_cad
                 ORDER BY overlap_m2 DESC NULLS LAST
                 LIMIT 5
            """),
                {"wkt": geom_wkt, "our_cad": our_cad_num},
            )
            .mappings()
            .all()
        )
    except Exception as e:
        logger.warning("overlap check failed: %s", e)
        overlap_row = []

    overlap_buildings = [
        {
            "cad_num": o["cad_num"],
            "building_name": o.get("building_name"),
            "floors": o.get("floors"),
            "readable_address": o.get("readable_address"),
            "overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None,
        }
        for o in overlap_row
        if o.get("overlap_m2") and float(o["overlap_m2"]) > 50
    ]
    has_existing = len(overlap_buildings) > 0

    return {
        "data_available": True,
        "radius_m": 100,
        "count_buildings_100m": len(neighbor_rows),
        "avg_floors_100m": avg_floors,
        "max_floors_100m": max_floors,
        "median_cost_per_m2_100m": median_cost,
        "neighbors": [
            {
                "cad_num": r["cad_num"],
                "building_name": r.get("building_name"),
                "floors": r.get("floors"),
                "floors_parsed": _parse_floors(r.get("floors")),
                "year_built": r.get("year_built"),
                "area_m2": round(float(r["area"])) if r.get("area") else None,
                "cost_per_m2": (
                    round(float(r["cost_value"]) / float(r["area"]))
                    if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
                    else None
                ),
                "distance_m": round(float(r["distance_m"])),
                "readable_address": r.get("readable_address"),
            }
            for r in neighbor_rows[:20]
        ],
        "has_existing_buildings": has_existing,
        "overlap_buildings": overlap_buildings,
        "note": (
            "Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — "
            "agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная."
        ),
    }


def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
    """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.

    industrial_within_500m — proxy для возможного загрязнения почв (без
    реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
    инженерных изысканий (bore-holes).
    """
    region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
        region_code,
        {
            "seismic_intensity_balls": None,
            "seismic_label": "нет данных в встроенной таблице",
            "seismic_description": "ОСР-2016: уточняйте по карте.",
            "permafrost": False,
        },
    )

    industrial_close: int = (
        db.execute(
            text("""
            SELECT COUNT(*) AS n
              FROM osm_noise_sources_ekb
             WHERE source_type = 'industrial'
               AND ST_DWithin(
                   geom::geography,
                   ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                   500
               )
        """),
            {"wkt": geom_wkt},
        ).scalar()
        or 0
    )

    return {
        **region_data,
        "industrial_within_500m": int(industrial_close),
        "industrial_contamination_flag": int(industrial_close) > 0,
        "note": (
            "Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
            "Для строительства обычно достаточно ≤7 баллов без спецмер. "
            "Industrial proximity — proxy для возможного загрязнения почв. "
            "Точная геология/гидрогеология — требует bore-holes (изыскания)."
        ),
    }


def _compute_confidence(
    *,
    source: str,
    poi_rows: list[dict[str, Any]],
    district_row: dict[str, Any] | None,
    competitor_rows: list[dict[str, Any]],
    noise_sources_count: int,
    air_q: dict[str, Any] | None,
    weather: dict[str, Any] | None,
    market_trend: dict[str, Any] | None,
    zoning: dict[str, Any],
) -> dict[str, Any]:
    """X2 (#48) — composite confidence score 0..1 + caveats.

    Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
    доступны на main. Композитный балл = avg of subscore'ов; caveats — list
    конкретных проблем для UI ("Нет данных N, score K ненадёжен").
    """
    caveats: list[str] = []
    subscores: dict[str, float] = {}

    # 1) POI freshness — % POI с last_osm_edit_date в последние 2 года.
    # Для участков с малым числом POI (<5) — снижаем confidence как coverage.
    poi_total = len(poi_rows)
    if poi_total == 0:
        subscores["poi_freshness"] = 0.0
        caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим")
    else:
        cutoff = _dt.date.today() - _dt.timedelta(days=730)
        fresh = sum(
            1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff
        )
        ratio = fresh / poi_total
        # coverage penalty: <5 POI слабая статистика
        coverage_factor = min(1.0, poi_total / 10.0)
        subscores["poi_freshness"] = round(ratio * coverage_factor, 2)
        if poi_total < 5:
            caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен")
        elif ratio < 0.5:
            caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления")

    # 2) Geometry source confidence — участок > квартал
    subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6
    if source == "cad_quarter":
        caveats.append(
            "Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу"
        )

    # 3) District context — известен ли район
    subscores["district"] = 1.0 if district_row else 0.3
    if not district_row:
        caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")

    # 4) Market trend — есть ли rosreestr_deals.
    # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric
    # из external/legacy paths; без guard int() крашнет 500.
    n_recent_raw = (market_trend or {}).get("recent_deals_count")
    try:
        n_recent = int(n_recent_raw) if n_recent_raw is not None else 0
    except (ValueError, TypeError):
        n_recent = 0
    if n_recent > 0:
        # порог 5 сделок за 6 мес — достаточно для тренда
        subscores["market_trend"] = min(1.0, n_recent / 10.0)
        if n_recent < 5:
            caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый")
    else:
        subscores["market_trend"] = 0.0
        caveats.append("Нет ДДУ в 3км — тренд рынка недоступен")

    # 5) Competitors coverage
    n_competitors = len(competitor_rows)
    subscores["competitors"] = min(1.0, n_competitors / 5.0)
    if n_competitors == 0:
        caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")

    # 6) Environmental data freshness
    env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
    subscores["environment"] = env_ok / 3.0
    if noise_sources_count == 0:
        caveats.append("Шумовая карта не загружена — noise score = stub")
    if not air_q:
        caveats.append("Air Quality API недоступен — exposure unknown")

    # 7) ПЗЗ coverage — placeholder до G1
    has_zoning = bool(zoning.get("data_available")) if zoning else False
    subscores["zoning"] = 1.0 if has_zoning else 0.2
    if not has_zoning:
        caveats.append(
            "ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)"
        )

    composite = sum(subscores.values()) / len(subscores)
    composite = round(max(0.0, min(1.0, composite)), 2)

    return {
        "value": composite,
        "label": _confidence_label(composite),
        "breakdown": subscores,
        "caveats": caveats,
    }


# #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
#
# Tradeoff: sync `def analyze_parcel` блокирует один FastAPI threadpool slot
# на это время. Default threadpool в Starlette/FastAPI — 40 slots (anyio
# default). При concurrent burst >40 «миссинг cad» запросов будем saturate
# threadpool — последующие запросы (включая healthcheck) ждут free slot.
#
# 15s выбран как баланс: НСПД ~5-15s avg для quarter, ~10-20s для parcel —
# fast path сработает в ~70% случаев. Остальные 30% получают 202 +
# polling (без блокировки threadpool).
_INLINE_FETCH_WAIT_S = 15
_INLINE_FETCH_POLL_INTERVAL_S = 2


@router.get("/by-bbox", response_model=ParcelBboxResponse)
async def get_parcels_by_bbox(
    min_lat: Annotated[float, Query(ge=-90, le=90, description="Южная граница bbox")],
    min_lon: Annotated[float, Query(ge=-180, le=180, description="Западная граница bbox")],
    max_lat: Annotated[float, Query(ge=-90, le=90, description="Северная граница bbox")],
    max_lon: Annotated[float, Query(ge=-180, le=180, description="Восточная граница bbox")],
    limit: Annotated[int, Query(ge=1, le=1000)] = 200,
    user_id: Annotated[str | None, Query(description="user_id для overlay статуса")] = None,
    db: Annotated[Session, Depends(get_db)] = ...,
) -> ParcelBboxResponse:
    """GET /parcels/by-bbox — вернуть участки внутри bounding box для карты.

    Использует GIST-индекс на cad_parcels.geom (ST_Intersects).
    Если передан user_id — добавляет статус из parcel_user_status (overlay).
    last_analysis_date — placeholder до реализации B2 auth (#307).
    """
    if min_lat >= max_lat or min_lon >= max_lon:
        raise HTTPException(status_code=400, detail="Некорректный bbox: min >= max")

    # Площадь bbox в км² (приблизительно через формулу сферической трапеции)
    lat_mid = (min_lat + max_lat) / 2
    lat_km = (max_lat - min_lat) * 111.32
    lon_km = (max_lon - min_lon) * 111.32 * math.cos(math.radians(lat_mid))
    bbox_area_km2 = round(lat_km * lon_km, 4)

    # cad_parcels has only (cad_num, geom) — area derived via ST_Area on geography
    # cast for meter accuracy. land_category not stored on this table; returned NULL
    # until enrichment from EGRN is wired (tracking: vault B5 EGRN contract note).
    sql = text("""
        WITH bbox AS (
            SELECT ST_MakeEnvelope(
                CAST(:min_lon AS float),
                CAST(:min_lat AS float),
                CAST(:max_lon AS float),
                CAST(:max_lat AS float),
                4326
            ) AS env
        )
        SELECT
            p.cad_num,
            ST_Y(ST_Centroid(p.geom)) AS centroid_lat,
            ST_X(ST_Centroid(p.geom)) AS centroid_lon,
            ST_Area(p.geom::geography) AS area_m2,
            NULL::text AS land_category,
            pus.status AS user_status
        FROM cad_parcels p
        CROSS JOIN bbox
        LEFT JOIN parcel_user_status pus
            ON pus.cad_num = p.cad_num
            AND pus.user_id = CAST(:user_id AS text)
        WHERE p.geom IS NOT NULL
          AND ST_Intersects(p.geom, bbox.env)
        ORDER BY ST_Area(p.geom::geography) DESC NULLS LAST
        LIMIT CAST(:limit AS int)
    """)

    rows = (
        db.execute(
            sql,
            {
                "min_lat": min_lat,
                "min_lon": min_lon,
                "max_lat": max_lat,
                "max_lon": max_lon,
                "user_id": user_id,
                "limit": limit,
            },
        )
        .mappings()
        .all()
    )

    markers: list[ParcelMapMarker] = [
        ParcelMapMarker(
            cad_num=row["cad_num"],
            centroid_lat=float(row["centroid_lat"]) if row["centroid_lat"] is not None else 0.0,
            centroid_lon=float(row["centroid_lon"]) if row["centroid_lon"] is not None else 0.0,
            area_m2=float(row["area_m2"]) if row["area_m2"] is not None else None,
            land_category=row["land_category"],
            status=row["user_status"] if user_id else None,
            last_analysis_date=None,  # placeholder, real after B2 auth (#307)
        )
        for row in rows
    ]

    return ParcelBboxResponse(
        parcels=markers,
        count=len(markers),
        limit=limit,
        bbox_area_km2=bbox_area_km2,
    )


@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
    """Search parcels by filters + scoring.

    TODO Stage 2b: PostGIS query + scorer service.
    """
    return ParcelSearchResponse(items=[], total=0)


@router.get("/{parcel_id}", response_model=ParcelDetail)
async def get_parcel(parcel_id: str) -> ParcelDetail:
    """TODO Stage 2b: fetch parcel by id from DB."""
    raise HTTPException(status_code=501, detail="Not implemented yet")


@router.get("/{cad_num}/fetch-status")
def get_fetch_status(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
    """Polling endpoint для on-demand cadastre fetch (см. issue #93).

    Frontend polling каждые 2с после 202 Accepted из /analyze.

    Returns:
        {
            "status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format",
            "job_id": int | None,
            "error_msg": str | None,
            "eta_seconds": int | None,
        }

    Frontend поведение:
    - "ready" → автоматически re-trigger POST /analyze
    - "fetching" → continue polling
    - "not_in_nspd" → показать пользователю «cad не найден в НСПД»
    - "failed" → retry button + retry-after message
    - "invalid_format" → подсказка формата
    """
    return _fetch_status(db, cad_num)


@router.post("/{cad_num}/analyze")
def analyze_parcel(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    response: Response,
    profile_id: Annotated[
        int | None,
        Query(ge=1, description="Переопределить веса POI через конкретный weight profile"),
    ] = None,
    profile_user_id: Annotated[
        str | None,
        Query(description="user_id для fallback на default-профиль пользователя"),
    ] = None,
    body: Annotated[
        AnalyzeRequest | None,
        Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
    ] = None,
    x_session_id: Annotated[
        str | None,
        Header(description="Session ID пользователя для custom POI scoring (#254)"),
    ] = None,
) -> dict[str, Any]:
    """Анализ участка: близость к социалке + district context + конкуренты.

    Порядок поиска геометрии: cad_quarters_geom → cad_buildings → cad_parcels_geom.

    Issue #93 — Graceful fallback при отсутствии geometry:
    - Не возвращаем 404 сразу. Вместо: enqueue NSPD on-demand fetch, ждём
      inline до _INLINE_FETCH_WAIT_S (~15с). Если за это время геометрия
      появилась в БД — продолжаем analyze (fast path).
    - Иначе → 202 Accepted + {status, job_id, eta_seconds} для polling
      через GET /fetch-status.
    - Дедупликация (через `find_active_on_demand_job`): параллельные запросы
      на тот же cad → один Celery job, оба клиента ждут.
    """
    # 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON
    row = (
        db.execute(
            text("""
            SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
                   g.geom              AS geom_wkb,
                   'cad_quarter'       AS source
              FROM cad_quarters_geom g
             WHERE g.cad_number = :c
            UNION ALL
            SELECT ST_AsGeoJSON(b.geom) AS geom_geojson,
                   b.geom               AS geom_wkb,
                   'cad_building'       AS source
              FROM cad_buildings b
             WHERE b.cad_num = :c
            UNION ALL
            SELECT ST_AsGeoJSON(p.geom) AS geom_geojson,
                   p.geom               AS geom_wkb,
                   'cad_parcel'         AS source
              FROM cad_parcels_geom p
             WHERE p.cad_num = :c
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    if not row:
        # #93 — graceful fallback: enqueue NSPD fetch, await inline до 15s
        # (см. _INLINE_FETCH_WAIT_S — снижено с 25 для threadpool safety).
        status, job_id, error_msg = find_or_enqueue_fetch(db, cad_num)
        if status == "invalid_format":
            raise HTTPException(status_code=400, detail=error_msg)
        if status == "not_in_nspd":
            raise HTTPException(status_code=404, detail=error_msg)
        if status == "failed":
            # 503 — НСПД временно недоступен (rate-limit / WAF)
            response.headers["Retry-After"] = "60"
            raise HTTPException(status_code=503, detail=error_msg)
        # status == "fetching" → inline await fast path
        deadline = time.monotonic() + _INLINE_FETCH_WAIT_S
        while time.monotonic() < deadline:
            time.sleep(_INLINE_FETCH_POLL_INTERVAL_S)
            if cad_exists_in_db(db, cad_num):
                # Re-fetch row для analyze
                row = (
                    db.execute(
                        text("""
                        SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
                               g.geom              AS geom_wkb,
                               'cad_quarter'       AS source
                          FROM cad_quarters_geom g
                         WHERE g.cad_number = :c
                        UNION ALL
                        SELECT ST_AsGeoJSON(b.geom), b.geom, 'cad_building'
                          FROM cad_buildings b
                         WHERE b.cad_num = :c
                        UNION ALL
                        SELECT ST_AsGeoJSON(p.geom), p.geom, 'cad_parcel'
                          FROM cad_parcels_geom p
                         WHERE p.cad_num = :c
                         LIMIT 1
                        """),
                        {"c": cad_num},
                    )
                    .mappings()
                    .first()
                )
                if row:
                    break
        if not row:
            # Timeout — frontend будет poll'ить /fetch-status.
            response.status_code = 202
            return {
                "status": "fetching",
                "cad_num": cad_num,
                "job_id": job_id,
                "eta_seconds": 15,
                "message": ("Геометрия загружается из НСПД. Обычно занимает 15-30 секунд."),
            }

    geom_geojson: str = row["geom_geojson"]
    source: str = row["source"]

    # Используем ST_AsText для передачи геометрии в последующие запросы.
    # Все PostGIS-запросы принимают текстовый WKT через ST_GeomFromText.
    geom_row = (
        db.execute(
            text("""
            SELECT ST_AsText(g.geom) AS wkt
              FROM (
                SELECT g.geom FROM cad_quarters_geom g WHERE g.cad_number = :c
                UNION ALL
                SELECT b.geom FROM cad_buildings b WHERE b.cad_num = :c
                UNION ALL
                SELECT p.geom FROM cad_parcels_geom p WHERE p.cad_num = :c
              ) g
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    geom_wkt: str = geom_row["wkt"]  # type: ignore[index]

    # 2) District context — ближайший район ЕКБ
    # median_price_per_m2: предпочитаем median_12m из mv_quarter_price_per_m2 (12 мес),
    # fallback на ekb_districts.median_price_per_m2 (24 мес).
    district_row = (
        db.execute(
            text("""
            SELECT d.district_name,
                   COALESCE(mq.median_12m, d.median_price_per_m2) AS median_price_per_m2,
                   ST_Distance(
                       d.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                   ) AS dist_to_center
              FROM ekb_districts d
              LEFT JOIN mv_quarter_price_per_m2 mq
                     ON mq.quarter_cad_number = REGEXP_REPLACE(:cad_num, ':[^:]+$', '')
             WHERE ST_DWithin(
                       d.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       5000
                   )
             ORDER BY dist_to_center ASC
             LIMIT 1
        """),
            {"wkt": geom_wkt, "cad_num": cad_num},
        )
        .mappings()
        .first()
    )

    # 3) POI в радиусе 1 км — список с distance_m
    poi_rows = (
        db.execute(
            text("""
            SELECT category,
                   name,
                   lat,
                   lon,
                   ST_Distance(
                       p.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                   ) AS distance_m,
                   last_osm_edit_date
              FROM osm_poi_ekb p
             WHERE ST_DWithin(
                       p.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       1000
                   )
             ORDER BY distance_m ASC
        """),
            {"wkt": geom_wkt},
        )
        .mappings()
        .all()
    )

    # 3b) Resolve effective POI weights (inline → profile → user default → system)
    _inline_weights: dict[str, float] | None = body.weights if body is not None else None

    if _inline_weights is not None:
        # Validate inline weights: keys и диапазон значений (#201)
        bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
        if bad_keys:
            raise HTTPException(
                status_code=422,
                detail=(
                    f"Неизвестные POI-категории: {sorted(bad_keys)}. "
                    f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}"
                ),
            )
        out_of_range = {
            k: v
            for k, v in _inline_weights.items()
            if not math.isfinite(v) or v < _MIN_WEIGHT or v > _MAX_WEIGHT
        }
        if out_of_range:
            raise HTTPException(
                status_code=422,
                detail=(
                    f"Веса за пределами допустимого диапазона "
                    f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}"
                ),
            )
        # Inline weights applied — merge поверх системных defaults (partial override)
        _effective_weights = {**_POI_WEIGHTS, **_inline_weights}
        _weights_source = "inline"
    else:
        _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
        _weights_source = (
            "profile"
            if profile_id is not None
            else ("user_default" if profile_user_id is not None else "system")
        )

    # 4) Scoring: weighted sum с distance decay
    score = 0.0
    by_category: dict[str, list[dict[str, Any]]] = {}
    # X1 (#47): per-POI breakdown с verbal explain для UI
    factors_detailed: list[dict[str, Any]] = []
    for idx, p in enumerate(poi_rows):
        cat: str = p["category"]
        w = _effective_weights.get(cat, _POI_WEIGHTS.get(cat, 0.0))
        # distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
        distance_m = float(p["distance_m"])
        decay = max(0.0, 1.0 - distance_m / 1000.0)
        contribution = w * decay
        score += contribution
        by_category.setdefault(cat, []).append(
            {
                "name": p["name"],
                "distance_m": round(distance_m),
                "lat": float(p["lat"]) if p["lat"] is not None else None,
                "lon": float(p["lon"]) if p["lon"] is not None else None,
                "last_edit": (
                    p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
                ),
            }
        )
        # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен.
        if abs(contribution) < 0.01:
            continue
        factors_detailed.append(
            {
                # Include idx чтобы избежать React key collision: два POI одной
                # категории на одинаково округлённом расстоянии иначе дали бы
                # дубль (например, two аптеки 450м в плотном районе).
                "factor": f"{cat}_{round(distance_m)}m_{idx}",
                "category": cat,
                "category_ru": _POI_CATEGORY_RU.get(cat, cat),
                "group": _POI_GROUP.get(cat, "Прочее"),
                "value": round(distance_m, 1),
                "weight": w,
                "contribution": round(contribution, 2),
                "verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution),
                "lat": float(p["lat"]) if p["lat"] is not None else None,
                "lon": float(p["lon"]) if p["lon"] is not None else None,
            }
        )

    # 5) Конкуренты в радиусе 3 км из DOM.РФ с ценами из objective_lots.
    # OBJ-3: обогащаем каждый ЖК данными objective_lots через маппинг
    # domrf_kn_objects.obj_id → objective_complex_mapping.domrf_obj_id
    #   → objective_lots.project_name.
    # Агрегат: avg_price_per_m2_rub (81% coverage), avg_area_pd, units_sold,
    # units_available — для UI-блока «Конкуренты».
    # LEFT JOIN — ЖК без маппинга остаются в выдаче (поля = NULL).
    # NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
    # latest snapshot, иначе дубликаты ЖК в выдаче.
    competitor_rows = (
        db.execute(
            text("""
            WITH latest_obj AS (
                SELECT DISTINCT ON (obj_id) *
                  FROM domrf_kn_objects
                 WHERE latitude IS NOT NULL
                 ORDER BY obj_id, snapshot_date DESC NULLS LAST
            ),
            obj_pricing AS (
                SELECT
                    cm.domrf_obj_id,
                    ROUND(AVG(ol.price_per_m2_rub)::numeric, 0)  AS avg_price_per_m2_rub,
                    ROUND(AVG(ol.area_pd)::numeric, 1)            AS avg_area_pd,
                    COUNT(*) FILTER (WHERE ol.is_sold)            AS units_sold,
                    COUNT(*) FILTER (WHERE NOT ol.is_sold)        AS units_available,
                    COUNT(*) FILTER (
                        WHERE ol.price_per_m2_rub IS NOT NULL
                    )                                             AS lots_with_price
                  FROM objective_complex_mapping cm
                  JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
                 GROUP BY cm.domrf_obj_id
            )
            SELECT o.obj_id,
                   o.comm_name,
                   o.dev_name,
                   o.obj_class,
                   o.flat_count,
                   o.district_name,
                   o.site_status,
                   o.ready_dt,
                   p.avg_price_per_m2_rub,
                   p.avg_area_pd,
                   p.units_sold,
                   p.units_available,
                   p.lots_with_price,
                   ST_Distance(
                       ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                   ) AS distance_m
              FROM latest_obj o
              LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
             WHERE ST_DWithin(
                       ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       3000
                   )
             ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
                      distance_m ASC
             LIMIT 20
        """),
            {"wkt": geom_wkt},
        )
        .mappings()
        .all()
    )

    # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
    # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
    # + по кварталам сдачи. Константы — см. PIPELINE_* выше.
    # NB: full seq scan на ~3000 строк OK; при росте — нужен GIST/index на
    # (latitude, longitude) — отдельный issue для database-expert.
    pipeline_rows = (
        db.execute(
            text("""
            WITH latest_obj AS (
                SELECT DISTINCT ON (obj_id) *
                  FROM domrf_kn_objects
                 WHERE latitude IS NOT NULL
                   AND ready_dt IS NOT NULL
                 ORDER BY obj_id, snapshot_date DESC NULLS LAST
            )
            SELECT obj_id,
                   comm_name,
                   dev_name,
                   COALESCE(
                       obj_class,
                       (SELECT DISTINCT ol.class
                          FROM objective_lots ol
                          JOIN objective_complex_mapping m
                            ON m.objective_complex_name = ol.project_name
                         WHERE m.domrf_obj_id = o.obj_id
                           AND ol.class IS NOT NULL
                         LIMIT 1)
                   ) AS obj_class,
                   flat_count,
                   ready_dt,
                   ST_Distance(
                       ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                   ) AS distance_m
              FROM latest_obj o
             WHERE ST_DWithin(
                       ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       :radius_m
                   )
               AND ready_dt >= CURRENT_DATE
               AND ready_dt <  CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
             ORDER BY ready_dt ASC
        """),
            {
                "wkt": geom_wkt,
                "radius_m": PIPELINE_RADIUS_M,
                "horizon_months": str(PIPELINE_HORIZON_MONTHS),
            },
        )
        .mappings()
        .all()
    )

    # 6) Centroid координаты для внешних API (air quality / wind)
    centroid_row = (
        db.execute(
            text("""
            SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
                   ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
        """),
            {"wkt": geom_wkt},
        )
        .mappings()
        .first()
    )
    centroid_lat: float = float(centroid_row["lat"]) if centroid_row else 56.838
    centroid_lon: float = float(centroid_row["lon"]) if centroid_row else 60.605

    # 6b) Distance to EKB center + center bonus
    dist_to_center_km = _haversine_km(centroid_lat, centroid_lon, EKB_CENTER_LAT, EKB_CENTER_LON)
    if dist_to_center_km < 5:
        center_bonus = 3.0
    elif dist_to_center_km < 10:
        center_bonus = 1.5
    elif dist_to_center_km < 15:
        center_bonus = 0.5
    else:
        center_bonus = 0.0

    # X1 (#47): centrality как отдельный synthetic factor в breakdown.
    # NB: для centrality decay не применяется (bonus IS the value), поэтому
    # weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
    if center_bonus > 0:
        factors_detailed.append(
            {
                "factor": f"center_bonus_{round(dist_to_center_km)}km",
                "category": "centrality",
                "category_ru": "Центральность",
                "group": "Локация",
                "value": round(dist_to_center_km, 2),
                "weight": 1.0,
                "contribution": round(center_bonus, 2),
                "verbal": (
                    f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
                    f"+{center_bonus:.2f} баллов"
                ),
                "lat": None,
                "lon": None,
            }
        )

    # 7) Noise score — шумовые источники в радиусе 2 км
    noise_rows = (
        db.execute(
            text("""
            SELECT source_type, road_class, name,
                   ST_Distance(
                       n.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                   ) AS distance_m
              FROM osm_noise_sources_ekb n
             WHERE ST_DWithin(
                       n.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       2000
                   )
             ORDER BY distance_m ASC
             LIMIT 30
        """),
            {"wkt": geom_wkt},
        )
        .mappings()
        .all()
    )

    noise_db_max = 0.0
    nearby_noise_sources: list[dict[str, Any]] = []
    for nr in noise_rows:
        src = nr["source_type"]
        key = f"{src}:{nr['road_class']}" if src == "highway" and nr["road_class"] else src
        base_db = NOISE_L_BASE.get(key, 50.0)
        d = max(float(nr["distance_m"]), 10.0)
        noise_db = base_db - 20.0 * math.log10(d / 10.0)
        noise_db_max = max(noise_db_max, noise_db)
        if noise_db >= 50.0:  # WHO порог дискомфорта
            nearby_noise_sources.append(
                {
                    "source_type": src,
                    "road_class": nr["road_class"],
                    "name": nr["name"],
                    "distance_m": round(d),
                    "estimated_db": round(noise_db, 1),
                }
            )

    # noise_score: 0..1, чем тише тем лучше. 45 dB=1.0 (тихо), 75 dB=0.0 (шумно).
    noise_score = max(0.0, min(1.0, (75.0 - noise_db_max) / 30.0))

    if noise_db_max < 50.0:
        noise_level = "тихо"
    elif noise_db_max < 65.0:
        noise_level = "умеренный"
    else:
        noise_level = "шумно"

    # 8) Air quality — Open-Meteo (best-effort, null при недоступности)
    air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)

    # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
    weather = _fetch_weather_sync(centroid_lat, centroid_lon)

    # 9b) Seasonal weather — 30-летние нормали Climate API
    seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)

    # 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
    hydrology: dict[str, Any] | None = None
    try:
        hydro_rows = (
            db.execute(
                text("""
                SELECT source_type, road_class, name,
                       ST_Distance(
                           n.geom::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                       ) AS distance_m
                  FROM osm_noise_sources_ekb n
                 WHERE source_type = 'water'
                   AND ST_DWithin(
                       n.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       2000
                   )
                 ORDER BY distance_m ASC
                 LIMIT 10
            """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .all()
        )
        hydrology = {
            "nearest": [
                {
                    "subtype": r["road_class"],
                    "name": r["name"],
                    "distance_m": round(float(r["distance_m"])),
                }
                for r in hydro_rows[:5]
            ],
            "flood_risk_flag": any(
                float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
                for r in hydro_rows
            ),
            "note": (
                "Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
                "зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
                "подтопления') через ФГИС ТП."
            ),
        }
    except Exception as e:
        logger.warning("hydrology query failed for %s: %s", cad_num, e)
        hydrology = None

    # 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
    utilities: dict[str, Any] | None = None
    try:
        util_rows = (
            db.execute(
                text("""
                SELECT road_class, name,
                       ST_Distance(
                           n.geom::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                       ) AS distance_m
                  FROM osm_noise_sources_ekb n
                 WHERE source_type = 'utility'
                   AND ST_DWithin(
                       n.geom::geography,
                       ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                       2000
                   )
                 ORDER BY distance_m ASC
                 LIMIT 10
            """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .all()
        )
        # Группировка по типу для compactness
        by_subtype: dict[str, dict[str, Any]] = {}
        for r in util_rows:
            sub = r["road_class"] or "other"
            if sub not in by_subtype:
                by_subtype[sub] = {
                    "subtype": sub,
                    "nearest_m": round(float(r["distance_m"])),
                    "name": r["name"],
                    "count_within_2km": 0,
                }
            by_subtype[sub]["count_within_2km"] += 1
        utilities = {
            "summary": list(by_subtype.values()),
            "power_line_охранная_зона_flag": any(
                float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
            ),
            "note": (
                "Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). "
                "В зоне ОЗ нельзя строить капитальные объекты. "
                "Точная классификация напряжения / магистральности — ЗОУИТ "
                "5 (ОЗ ЛЭП) и 9 (ОЗ газопровода) через ФГИС ТП."
            ),
        }
    except Exception as e:
        logger.warning("utilities query failed for %s: %s", cad_num, e)
        utilities = None

    # 9e) NSPD quarter dump — ПЗЗ зона + ЗОУИТ + инженерные сооружения из кеша
    try:
        nspd_dump_data = get_quarter_dump_data(db, cad_num, geom_wkt)
    except Exception as e:
        logger.warning("nspd quarter dump lookup failed for %s: %s", cad_num, e)
        # Independent dict per request — never mutate module singleton.
        nspd_dump_data = make_empty_result()

    # 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
    parcel_meta: ParcelMeta | None = None
    try:
        pm_row = (
            db.execute(
                text("""
                    SELECT permitted_use_established_by_document AS permitted_use,
                           land_record_category_type             AS land_category,
                           land_record_subtype                   AS land_subtype,
                           cost_value                            AS cad_cost
                      FROM cad_parcels
                     WHERE cad_num = CAST(:c AS text)
                     LIMIT 1
                """),
                {"c": cad_num},
            )
            .mappings()
            .first()
        )
        if pm_row:
            parcel_meta = ParcelMeta(
                permitted_use=pm_row["permitted_use"],
                land_category=pm_row["land_category"],
                land_subtype=pm_row["land_subtype"],
                cad_cost=float(pm_row["cad_cost"]) if pm_row["cad_cost"] is not None else None,
            )
    except Exception as e:
        logger.warning("parcel_meta query failed for %s: %s", cad_num, e)

    # B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
    egrn_block: dict[str, Any] = {}
    try:
        egrn_row = (
            db.execute(
                text("""
                    SELECT cost_value                            AS cadastral_value_rub,
                           cost_index                           AS cost_index_per_m2,
                           land_record_category_type            AS land_category,
                           permitted_use_established_by_document AS permitted_use_text,
                           cost_registration_date               AS last_egrn_update_date,
                           land_record_area                     AS area_m2,
                           ownership_type,
                           right_type,
                           status,
                           readable_address,
                           registration_date
                      FROM cad_parcels
                     WHERE cad_num = CAST(:c AS text)
                     LIMIT 1
                """),
                {"c": cad_num},
            )
            .mappings()
            .first()
        )
        if egrn_row:
            _cad_val = (
                float(egrn_row["cadastral_value_rub"])
                if egrn_row["cadastral_value_rub"] is not None
                else None
            )
            _area_m2 = float(egrn_row["area_m2"]) if egrn_row["area_m2"] is not None else None
            _idx = egrn_row["cost_index_per_m2"]
            _cad_per_m2: float | None = None
            if _idx is not None:
                _cad_per_m2 = float(_idx)
            elif _cad_val is not None and _area_m2 and _area_m2 > 0:
                _cad_per_m2 = round(_cad_val / _area_m2, 2)
            egrn_block = {
                "cadastral_value_rub": _cad_val,
                "cadastral_value_per_m2": _cad_per_m2,
                "land_category": egrn_row["land_category"],
                "permitted_use_text": egrn_row["permitted_use_text"],
                "last_egrn_update_date": (
                    egrn_row["last_egrn_update_date"].isoformat()
                    if egrn_row["last_egrn_update_date"] is not None
                    else None
                ),
                "area_m2": _area_m2,
                "ownership_type": egrn_row["ownership_type"],
                "right_type": egrn_row["right_type"],
                "parcel_status": egrn_row["status"],
                "address": egrn_row["readable_address"],
                "registration_date": (
                    egrn_row["registration_date"].isoformat()
                    if egrn_row["registration_date"] is not None
                    else None
                ),
            }
    except Exception as e:
        logger.warning("egrn_block query failed for %s: %s", cad_num, e)

    # B5-2) Encumbrance block — ЗОУИТ из cad_zouit (SF-B5)
    encumbrance_block: dict[str, Any] = {
        "has_zouit": False,
        "zouit_types": [],
        "zouit_count": 0,
    }
    try:
        zouit_rows = (
            db.execute(
                text("""
                    SELECT type_zone, name_by_doc
                      FROM cad_zouit
                     WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
                     ORDER BY id
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .all()
        )
        if zouit_rows:
            _zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
            encumbrance_block = {
                "has_zouit": True,
                "zouit_types": _zouit_types,
                "zouit_count": len(zouit_rows),
            }
    except Exception as e:
        logger.warning("encumbrance_block query failed for %s: %s", cad_num, e)

    # B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
    red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
    try:
        rl_row = (
            db.execute(
                text("""
                    SELECT COUNT(*) AS cnt
                      FROM cad_red_lines
                     WHERE ST_Intersects(
                               geom::geometry,
                               ST_GeomFromText(:wkt, 4326)
                           )
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .first()
        )
        if rl_row:
            _rl_cnt = int(rl_row["cnt"])
            red_lines_block = {
                "intersects": _rl_cnt > 0,
                "count": _rl_cnt,
            }
    except Exception as e:
        logger.warning("red_lines_block query failed for %s: %s", cad_num, e)

    # B5-4) Metro placeholder — заполнится после merge 22h metro scraper
    metro_block: dict[str, Any] = {"nearest_top3": None}

    # B5-5) District price ranges из objective_lots (SF-B5)
    district_price_block: dict[str, Any] = {
        "district_price_per_m2_min": None,
        "district_price_per_m2_max": None,
        "district_price_per_m2_median": None,
        "district_price_sample_size": None,
    }
    if district_row and district_row["district_name"]:
        try:
            dp_row = (
                db.execute(
                    text("""
                        SELECT
                            MIN(price_per_m2_rub)                     AS price_min,
                            MAX(price_per_m2_rub)                     AS price_max,
                            PERCENTILE_CONT(0.5) WITHIN GROUP (
                                ORDER BY price_per_m2_rub
                            )                                          AS price_median,
                            COUNT(*)                                   AS sample_size
                          FROM objective_lots
                         WHERE district = CAST(:dn AS text)
                           AND price_per_m2_rub IS NOT NULL
                           AND price_per_m2_rub BETWEEN 30000 AND 600000
                    """),
                    {"dn": district_row["district_name"]},
                )
                .mappings()
                .first()
            )
            if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
                district_price_block = {
                    "district_price_per_m2_min": (
                        round(float(dp_row["price_min"])) if dp_row["price_min"] else None
                    ),
                    "district_price_per_m2_max": (
                        round(float(dp_row["price_max"])) if dp_row["price_max"] else None
                    ),
                    "district_price_per_m2_median": (
                        round(float(dp_row["price_median"])) if dp_row["price_median"] else None
                    ),
                    "district_price_sample_size": int(dp_row["sample_size"]),
                }
        except Exception as e:
            logger.warning("district_price_block query failed for %s: %s", cad_num, e)

    # B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5)
    risks_block: dict[str, Any] = {
        "flood_zone": False,
        "noise_score": round(noise_score, 2),
        "geology_risk_label": None,
    }
    try:
        flood_row = (
            db.execute(
                text("""
                    SELECT COUNT(*) AS cnt
                      FROM cad_risk_zones
                     WHERE ST_Intersects(
                               geom::geometry,
                               ST_GeomFromText(:wkt, 4326)
                           )
                       AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
                            OR risk_type ILIKE '%затоп%')
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .first()
        )
        _flood = bool(flood_row and int(flood_row["cnt"]) > 0)
        # Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
        _geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
        _has_flood = _flood or _geo_flood
        # geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low
        if _has_flood:
            _geo_label: str | None = "high"
        elif noise_db_max >= 65.0:
            _geo_label = "medium"
        else:
            _geo_label = "low"
        risks_block = {
            "flood_zone": _has_flood,
            "noise_score": round(noise_score, 2),
            "geology_risk_label": _geo_label,
        }
    except Exception as e:
        logger.warning("risks_block query failed for %s: %s", cad_num, e)

    # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
    market_trend: dict[str, Any] | None = None
    try:
        trend_row = (
            db.execute(
                text("""
                WITH district_deals AS (
                    SELECT d.period_start_date AS deal_date,
                           d.price_per_sqm     AS price_per_m2
                      FROM rosreestr_deals d
                     WHERE d.region_code = 66
                       AND d.doc_type = 'ДДУ'
                       AND d.realestate_type_code = '002001003000'
                       AND d.price_per_sqm BETWEEN 30000 AND 500000
                       AND d.period_start_date > NOW() - INTERVAL '12 months'
                       AND ST_DWithin(
                           (SELECT ST_Centroid(geom)
                              FROM cad_quarters_geom
                             WHERE cad_number = d.quarter_cad_number)::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                           3000
                       )
                )
                SELECT
                    AVG(price_per_m2)
                        FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
                        AS recent_avg,
                    AVG(price_per_m2)
                        FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
                                               AND NOW() - INTERVAL '6 months')
                        AS prior_avg,
                    COUNT(*)
                        FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
                        AS recent_n,
                    COUNT(*)
                        FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
                                               AND NOW() - INTERVAL '6 months')
                        AS prior_n
                FROM district_deals
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .first()
        )
        if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
            recent_p = float(trend_row["recent_avg"])
            prior_p = float(trend_row["prior_avg"])
            # 6-месячное изменение; ×2 даёт годовой эквивалент
            delta_6m_pct = round((recent_p - prior_p) / prior_p * 100, 1)
            if delta_6m_pct > 8:
                perspective_label = "Сильный рост — рынок растёт быстрее инфляции"
            elif delta_6m_pct > 0:
                perspective_label = "Умеренный рост — стабильный спрос"
            elif delta_6m_pct > -5:
                perspective_label = "Стагнация — рынок остыл"
            else:
                perspective_label = "Падение — риск переоценки"
            market_trend = {
                "recent_avg_price_per_m2": round(recent_p),
                "prior_avg_price_per_m2": round(prior_p),
                "delta_6m_pct": delta_6m_pct,
                "recent_deals_count": int(trend_row["recent_n"]),
                "prior_deals_count": int(trend_row["prior_n"]),
                "label": perspective_label,
                "radius_km": 3,
            }
    except Exception as e:
        logger.warning("market_trend query failed for %s: %s", cad_num, e)
        market_trend = None

    # 10b) Zoning — территориальная зона ПЗЗ.
    # NB: Росреестр PKK6 API закрыт в 2024 → редирект на NSPD (anti-bot WAF).
    # Open-data shapefile ПЗЗ ЕКБ публично не выкладывается (data.midural.ru
    # содержит только metadata). Реальный per-parcel zoning требует либо
    # ручного импорта shapefile из ГИС ЕКБ (с авторизацией) либо платный
    # API (egrn.reestr.net). Здесь — fallback на pzz_zones_ekb (если есть)
    # + deep-links на публичные геопорталы для drill-down.
    zoning: dict[str, Any] = {
        "zone_code": None,
        "zone_name": None,
        "description": None,
        "data_available": False,
        "note": (
            "Автоматический per-parcel zoning недоступен: Росреестр PKK6 API "
            "закрыт (2024), NSPD блокирует bot-доступ. Используй внешние "
            "геопорталы для определения зоны вручную."
        ),
        "links": {
            "nspd_portal": f"https://nspd.gov.ru/map?lat={centroid_lat}&lng={centroid_lon}&z=17",
            "ekb_geoportal": "https://xn--80acgfbsl1azdqr.xn--p1ai/",
            "midural_data": "https://data.midural.ru/",
        },
        "lat": centroid_lat,
        "lon": centroid_lon,
    }
    try:
        with db.begin_nested():
            zoning_row = (
                db.execute(
                    text("""
                        SELECT zone_code, zone_name, description, rosreestr_id
                          FROM pzz_zones_ekb
                         WHERE ST_Within(
                             ST_Centroid(ST_GeomFromText(:wkt, 4326)),
                             geom
                         )
                         LIMIT 1
                    """),
                    {"wkt": geom_wkt},
                )
                .mappings()
                .first()
            )
            if zoning_row:
                zoning.update(
                    {
                        "zone_code": zoning_row["zone_code"],
                        "zone_name": zoning_row["zone_name"],
                        "description": zoning_row["description"],
                        "rosreestr_id": zoning_row["rosreestr_id"],
                        "data_available": True,
                        "source": "rosreestr-pkk6-cached",
                    }
                )
    except Exception as e:
        logger.warning("zoning query failed for %s: %s", cad_num, e)

    # 10c) Success recommendation — топ квартирография по district из v_bucket_success_score
    # SF-20: адаптивный порог — MIN_DEALS=15 (показываем), STRONG=30 (confidence='strong').
    # Если n_deals всех строк < STRONG → data_confidence='weak' + UI badge «слабые данные».
    success_recommendation: dict[str, Any] | None = None
    if district_row:
        try:
            with db.begin_nested():
                success_rows = (
                    db.execute(
                        text("""
                        SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2,
                               velocity_z, price_z, area_z
                          FROM v_bucket_success_score
                         WHERE district_name = :dn
                         ORDER BY success_score DESC
                         LIMIT 5
                    """),
                        {"dn": district_row["district_name"]},
                    )
                    .mappings()
                    .all()
                )
                # Фильтруем строки ниже абсолютного минимума (< SUCCESS_REC_MIN_DEALS)
                valid_rows = [r for r in success_rows if int(r["n_deals"]) >= SUCCESS_REC_MIN_DEALS]
                if valid_rows:
                    max_deals = max(int(r["n_deals"]) for r in valid_rows)
                    data_confidence = "strong" if max_deals >= SUCCESS_REC_STRONG_DEALS else "weak"
                    success_recommendation = {
                        "district": district_row["district_name"],
                        "data_confidence": data_confidence,
                        "ranking": [
                            {
                                "bucket": r["bucket"],
                                "success_score": round(float(r["success_score"]), 2),
                                "n_deals": int(r["n_deals"]),
                                "avg_price_per_m2": (
                                    int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None
                                ),
                                "avg_area_m2": (
                                    round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None
                                ),
                            }
                            for r in valid_rows
                        ],
                        "top_bucket": dict(valid_rows[0]),
                        "note": (
                            "Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 "
                            "- area×0.2. Min 15 сделок в группе за 24 мес. "
                            "Используй для квартирографии проекта."
                        ),
                    }
        except Exception as e:
            logger.warning("success_recommendation query failed for %s: %s", cad_num, e)
            success_recommendation = None

    # 10d-pre) Market price — ценовая статистика квартала из mv_quarter_price_per_m2 (#33)
    # quarter_cad_number — первые три части кад. номера: "66:41:0204016:10" → "66:41:0204016"
    _cad_parts = cad_num.split(":")
    _quarter_cad = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num
    market_price: dict[str, Any]
    try:
        with db.begin_nested():
            mp_row = (
                db.execute(
                    text("""
                        SELECT p25, median, p75, mean, deals_count,
                               median_6m, median_12m, median_24m, last_deal_date
                          FROM mv_quarter_price_per_m2
                         WHERE quarter_cad_number = :q
                    """),
                    {"q": _quarter_cad},
                )
                .mappings()
                .first()
            )
        if mp_row:
            market_price = {
                "p25": float(mp_row["p25"]) if mp_row["p25"] is not None else None,
                "median": float(mp_row["median"]) if mp_row["median"] is not None else None,
                "p75": float(mp_row["p75"]) if mp_row["p75"] is not None else None,
                "mean": float(mp_row["mean"]) if mp_row["mean"] is not None else None,
                "deals_count": int(mp_row["deals_count"]),
                "median_6m": (
                    float(mp_row["median_6m"]) if mp_row["median_6m"] is not None else None
                ),
                "median_12m": (
                    float(mp_row["median_12m"]) if mp_row["median_12m"] is not None else None
                ),
                "median_24m": (
                    float(mp_row["median_24m"]) if mp_row["median_24m"] is not None else None
                ),
                "last_deal_date": (
                    mp_row["last_deal_date"].isoformat()
                    if mp_row["last_deal_date"] is not None
                    else None
                ),
                "source": "quarter_mv",
            }
        else:
            market_price = {"deals_count": 0, "source": "no_data"}
    except Exception as e:
        logger.warning("market_price query failed for %s: %s", cad_num, e)
        market_price = {"deals_count": 0, "source": "no_data"}

    # 10d-pre2) #105 Phase 5: РНС/РВЭ в квартале (cad_num quarter prefix match).
    # Spatial filter через geom недоступен до Phase 3 (geocoding → geom).
    # Используем quarter prefix (первые 3 сегмента кад. номера) как прокси.
    # После Phase 3: заменить на ST_DWithin(..., 500) по geom.
    recent_permits: list[dict[str, Any]] = []
    permits_summary: dict[str, Any] = {
        "rns_count": 0,
        "rve_count": 0,
        "rns_total_area_sqm": 0.0,
        "by_developer": [],
    }
    try:
        with db.begin_nested():
            permits_rows = (
                db.execute(
                    text("""
                        SELECT
                            permit_type, permit_number, issue_date,
                            developer_name, developer_inn, object_name, object_type,
                            construction_address, total_area_sqm
                          FROM ekburg_construction_permits
                         WHERE LEFT(cadastral_number, LENGTH(CAST(:q AS text))) = CAST(:q AS text)
                           AND issue_date > NOW() - INTERVAL '24 months'
                         ORDER BY issue_date DESC
                         LIMIT 50
                    """),
                    {"q": _quarter_cad},
                )
                .mappings()
                .all()
            )
        recent_permits = [
            {
                "permit_type": r["permit_type"],
                "permit_number": r["permit_number"],
                "issue_date": r["issue_date"].isoformat() if r["issue_date"] else None,
                "developer_name": r["developer_name"],
                "developer_inn": r["developer_inn"],
                "object_name": r["object_name"],
                "object_type": r["object_type"],
                "construction_address": r["construction_address"],
                "total_area_sqm": float(r["total_area_sqm"]) if r["total_area_sqm"] else None,
            }
            for r in permits_rows
        ]
        rns_list = [p for p in recent_permits if p["permit_type"] == "RNS"]
        by_dev: dict[str, int] = {}
        for p in recent_permits:
            if p["developer_name"]:
                by_dev[p["developer_name"]] = by_dev.get(p["developer_name"], 0) + 1
        permits_summary = {
            "rns_count": len(rns_list),
            "rve_count": len([p for p in recent_permits if p["permit_type"] == "RVE"]),
            "rns_total_area_sqm": sum(p["total_area_sqm"] or 0.0 for p in rns_list),
            "by_developer": [
                {"name": name, "permits_count": cnt}
                for name, cnt in sorted(by_dev.items(), key=lambda x: -x[1])[:3]
            ],
        }
    except Exception as e:
        logger.warning("ekburg permits query failed for %s: %s", cad_num, e)

    # 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
    karpinsky_url = (
        f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
        f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
    )
    efgi_url = "https://efgi.ru/"
    geology: dict[str, Any] = {
        "data_available": False,
        "note": (
            "Подробная геология (типы пород, грунты, мощность ОС) "
            "доступна только через ВСЕГЕИ-200/1000 шейпы — требуется "
            "ручной импорт в PostGIS (multi-day задача). Для drill-down "
            "используй внешние ссылки ниже."
        ),
        "links": {
            "karpinsky_webgis": karpinsky_url,
            "efgi_federal_registry": efgi_url,
        },
        "lat": centroid_lat,
        "lon": centroid_lon,
    }

    # 4b) #254: Custom POI scoring — user-defined points с произвольным весом.
    # Добавляем после основного POI loop, не трогаем OSM логику.
    # user_id берём из X-Session-Id header (workaround до #67 NextAuth).
    custom_poi_items: list[dict[str, Any]] = []
    _session_id = x_session_id.strip() if x_session_id and x_session_id.strip() else None
    if _session_id:
        try:
            _custom_overlaps = _get_custom_poi_overlaps(
                db, geom_wkt, _session_id, parcel_cad=cad_num
            )
            for cp in _custom_overlaps:
                _distance_m = cp["distance_m"]
                _decay = max(0.0, 1.0 - _distance_m / 1000.0)
                _contribution = cp["weight"] * _decay
                score += _contribution
                _item: dict[str, Any] = {
                    "source": "custom",
                    "id": cp["id"],
                    "label": cp["name"],
                    "category": cp["category"],
                    "weight": cp["weight"],
                    "distance_m": round(_distance_m),
                    "contribution": round(_contribution, 3),
                    "lat": cp["lat"],
                    "lon": cp["lon"],
                }
                custom_poi_items.append(_item)
                if abs(_contribution) >= 0.01:
                    factors_detailed.append(
                        {
                            "factor": f"custom_{cp['id']}_{round(_distance_m)}m",
                            "category": f"custom_{cp['category'] or 'poi'}",
                            "category_ru": f"Custom: {cp['name']}",
                            "group": "Custom POI",
                            "value": round(_distance_m, 1),
                            "weight": cp["weight"],
                            "contribution": round(_contribution, 2),
                            "verbal": (
                                f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — "
                                f"{'+'if _contribution >= 0 else ''}{round(_contribution, 2)} б."
                            ),
                            "lat": cp["lat"],
                            "lon": cp["lon"],
                        }
                    )
            if _custom_overlaps:
                logger.debug(
                    "custom POI scoring: user=%s cad=%s poi_count=%d",
                    _session_id,
                    cad_num,
                    len(_custom_overlaps),
                )
        except Exception as _cpe:
            logger.warning("custom POI scoring failed cad=%s: %s", cad_num, _cpe)

    score_final = score + center_bonus

    # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
    # Базис для процентов — сумма абсолютных значений всех факторов; это даёт
    # стабильное соотношение независимо от знака и не делится на 0.
    abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0
    for f in factors_detailed:
        f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)

    factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
    # Convention: оба top-list'а отсортированы "dominant first":
    #   positives → most-positive first (factors_sorted desc → [:3])
    #   negatives → most-negative first (sort negatives asc → [:3])
    score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
    negatives_only = [f for f in factors_sorted if f["contribution"] < 0]
    score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3]

    # By-group totals — для stacked-bar в UI. count это int, contribution* — float.
    group_totals: dict[str, dict[str, float | int]] = {}
    for f in factors_detailed:
        g = group_totals.setdefault(
            f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}
        )
        g["contribution"] += f["contribution"]
        g["count"] += 1
    group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0
    for g_val in group_totals.values():
        g_val["contribution"] = round(g_val["contribution"], 2)
        g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1)
    score_by_group = [
        {"group": k, **v}
        for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"]))
    ]

    # X2 (#48): composite confidence + caveats
    confidence_info = _compute_confidence(
        source=source,
        poi_rows=[dict(p) for p in poi_rows],
        district_row=dict(district_row) if district_row else None,
        competitor_rows=[dict(c) for c in competitor_rows],
        noise_sources_count=len(noise_rows),
        air_q=air_q,
        weather=weather,
        market_trend=market_trend,
        zoning=zoning,
    )

    # D4 (#36): aggregate pipeline_24mo
    pipeline_24mo = _aggregate_pipeline(pipeline_rows)

    # D2 (#34): velocity-score — темп продаж конкурентов вокруг участка.
    # SAVEPOINT защищает outer transaction если velocity SQL падает —
    # иначе следующие queries (_geotech_risk и пр.) крашатся
    # с InFailedSqlTransaction.
    # SF#17: передаём cad_quarter для rosreestr_fallback (первые 3 сегмента cad_num).
    _cad_parts = cad_num.split(":")
    _cad_quarter = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else None
    velocity_data: dict[str, Any] | None = None
    try:
        with db.begin_nested():
            v_result = compute_velocity(
                db,
                parcel_geom_wkt=geom_wkt,
                radius_km=3.0,
                cad_quarter=_cad_quarter,
            )
            if v_result is not None:
                velocity_data = v_result.as_dict()
    except Exception as _ve:
        logger.warning("velocity compute failed for %s: %s", cad_num, _ve)

    # OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
    # Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
    # в competitor list для карты, но исключаются из расчётов рыночных метрик.
    _competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
    _competitors_total = len(competitor_rows)
    _competitors_priced = len(_competitors_with_price)
    if _competitors_with_price:
        _prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
        _market_avg_price = round(sum(_prices) / len(_prices))
        # top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
        _with_sales = [
            c
            for c in _competitors_with_price
            if c["units_sold"] is not None and int(c["units_sold"]) > 0
        ]
        _top_sellers = sorted(
            _with_sales,
            key=lambda c: int(c["units_sold"]),
            reverse=True,
        )[:5]
        _top_sellers_list = [
            {
                "obj_id": c["obj_id"],
                "comm_name": c["comm_name"],
                "dev_name": c["dev_name"],
                "units_sold": int(c["units_sold"]),
                "avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
            }
            for c in _top_sellers
        ]
    else:
        _market_avg_price = None
        _top_sellers_list = []

    _coverage_pct = (
        round(_competitors_priced * 100.0 / _competitors_total, 1)
        if _competitors_total > 0
        else 0.0
    )
    # avg_velocity_m2 — берём из velocity_data если есть; это уже только по
    # ЖК с objective_corpus_room_month данными (non-null by construction).
    _avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
    market_pulse: dict[str, Any] = {
        "avg_velocity_m2": _avg_velocity,
        "market_avg_price_per_m2": _market_avg_price,
        "competitors_total": _competitors_total,
        "competitors_with_price": _competitors_priced,
        "coverage_pct": _coverage_pct,
        "top_sellers": _top_sellers_list,
    }

    return {
        "cad_num": cad_num,
        "source": source,
        "geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
        "district": dict(district_row) if district_row else None,
        "score": round(score_final, 2),
        "score_without_center": round(score, 2),
        "score_label": _score_label(score_final),
        "score_max_reference": SCORE_MAX_REFERENCE,
        "score_explanation": (
            "Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. "
            ">40 = редко, типичный город. центр 15-30."
        ),
        "score_breakdown": by_category,
        # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.
        "score_breakdown_detailed": factors_sorted,
        "score_top_3_positives": score_top_3_positives,
        "score_top_3_negatives": score_top_3_negatives,
        "score_by_group": score_by_group,
        "poi_count": len(poi_rows),
        "location": {
            "distance_to_center_km": round(dist_to_center_km, 2),
            "center_bonus": center_bonus,
            "ekb_center": {"lat": EKB_CENTER_LAT, "lon": EKB_CENTER_LON},
            "note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0",
        },
        "competitors": [dict(c) for c in competitor_rows],
        # OBJ-3 fix: aggregate market metrics — только non-null competitors.
        # ЖК без objective_mapping остаются на карте (competitors list),
        # но исключены из avg/velocity/top_sellers расчётов.
        "market_pulse": market_pulse,
        "market_avg_price_per_m2": market_pulse["market_avg_price_per_m2"],
        "market_data_coverage_pct": market_pulse["coverage_pct"],
        # D4 (#36): 24-month pipeline competition
        "pipeline_24mo": pipeline_24mo,
        # D2 (#34): velocity-score из objective_corpus_room_month (OBJ-3 migrated)
        "velocity": velocity_data,
        "noise": {
            "score": round(noise_score, 2),
            "estimated_db": round(noise_db_max, 1),
            "level": noise_level,
            "sources": nearby_noise_sources[:10],
        },
        "air_quality": air_q,
        "weather": weather,
        "seasonal_weather": seasonal_weather,
        "wind": (weather or {}).get("wind") if weather else None,  # backward compat
        "geology": geology,
        "hydrology": hydrology,
        "utilities": utilities,
        "geotech_risk": _geotech_risk(66, db, geom_wkt),
        # P1 (#45) — physical suitability участка
        "geometry_suitability": _polygon_suitability(geom_wkt),
        # P2 (#46) — соседи-здания + overlap check
        "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num),
        "market_trend": market_trend,
        # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2
        "market_price": market_price,
        # #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость)
        "parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None,
        # #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
        "recent_permits_in_quarter": recent_permits,
        "permits_summary": permits_summary,
        "zoning": zoning,
        "success_recommendation": success_recommendation,
        "isochrones_available": bool(settings.openrouteservice_api_key),
        # X2 (#48) — confidence indicator
        "confidence": confidence_info["value"],
        "confidence_label": confidence_info["label"],
        "confidence_breakdown": confidence_info["breakdown"],
        "confidence_caveats": confidence_info["caveats"],
        # Sprint 1.1 item #4 — NSPD quarter dump fields
        # nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1)
        # nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
        # nspd_engineering_nearby: инженерные сооружения в 200м (I3)
        # nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
        # nspd_opportunity_parcels: TIER 4 opportunity ЗУ (#94 PR2)
        # nspd_red_lines: TIER 4 красные линии застройки (#94 PR2, #54 Generative)
        # nspd_dump: freshness metadata — available, stale, harvest_triggered
        "nspd_zoning": nspd_dump_data["nspd_zoning"],
        "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
        "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
        "nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
        "nspd_opportunity_parcels": [
            OpportunityParcel(**op) for op in nspd_dump_data.get("nspd_opportunity_parcels", [])
        ],
        "nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
        "nspd_dump": nspd_dump_data["nspd_dump"],
        # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
        "gate_verdict": compute_gate_verdict(
            nspd_zoning=nspd_dump_data["nspd_zoning"],
            nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
            nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
            nspd_dump=nspd_dump_data["nspd_dump"],
        ),
        # #114/#201: кастомные веса POI — source + applied dict для прозрачности.
        "weights_profile": {
            "source": _weights_source,
            "profile_id": profile_id,
            "user_id": profile_user_id,
            "weights_applied": _effective_weights,
            "inline_weights": _inline_weights,
        },
        # #254: custom POI scoring — user-defined points (via X-Session-Id header).
        "custom_poi_score_items": custom_poi_items,
        # SF-B5: EGRN + encumbrance + red_lines + metro + district prices + risks
        "egrn": egrn_block,
        "encumbrance": encumbrance_block,
        "red_lines": red_lines_block,
        "metro": metro_block,
        "district_price_per_m2_min": district_price_block["district_price_per_m2_min"],
        "district_price_per_m2_max": district_price_block["district_price_per_m2_max"],
        "district_price_per_m2_median": district_price_block["district_price_per_m2_median"],
        "district_price_sample_size": district_price_block["district_price_sample_size"],
        "risks": risks_block,
    }


@router.get(
    "/{cad_num}/connection-points",
    response_model=ConnectionPointsResponse,
    summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
)
async def get_parcel_connection_points(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
) -> ConnectionPointsResponse:
    """Инженерные структуры (ТП, ЦТП, ЛЭП) и охранные зоны коммуникаций вблизи участка.

    Источник данных: nspd_quarter_dumps (НСПД cat 36328 / 37578).
    Если дамп для квартала ещё не загружен → dump_available=false,
    пустые массивы (harvest запускается автоматически).
    Если участок не найден в БД (нет geom) → 404.

    Query params:
        radius_m: радиус поиска инженерных структур, 50–2000 м (default 500).
    """
    try:
        data = get_connection_points(db, cad_num, radius_m)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    return ConnectionPointsResponse(
        engineering_structures=data["engineering_structures"],
        zouit_engineering_overlaps=data["zouit_engineering_overlaps"],
        summary=data["summary"],
        dump_available=data["dump_available"],
        dump_fetched_at=data["dump_fetched_at"],
    )


_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})


@router.get("/{cad_num}/isochrones")
def get_isochrones(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    mode: str = "foot-walking",
    times_min: Annotated[list[int], Query()] = [10, 15],  # noqa: B006
) -> dict[str, Any]:
    """Изохроны доступности от центроида участка через OpenRouteService.

    Modes: foot-walking | cycling-regular | driving-car.
    times_min — список минут (1-60), например ?times_min=10&times_min=15.
    Возвращает GeoJSON FeatureCollection для рендера на карте.
    """
    if not settings.openrouteservice_api_key:
        raise HTTPException(
            status_code=503,
            detail=(
                "OPENROUTESERVICE_API_KEY не задан в env. "
                "Получи free key на https://openrouteservice.org/dev/#/signup "
                "и пропиши в backend/.env.runtime"
            ),
        )

    if mode not in _ORS_VALID_MODES:
        raise HTTPException(
            status_code=422,
            detail=f"Недопустимый mode '{mode}'. Допустимые: {sorted(_ORS_VALID_MODES)}",
        )

    invalid_times = [t for t in times_min if not (1 <= t <= 60)]
    if invalid_times:
        raise HTTPException(
            status_code=422,
            detail=f"times_min значения вне диапазона 1-60: {invalid_times}",
        )

    # Получить координаты центроида из доступных геометрий участка
    coord_row = (
        db.execute(
            text("""
            SELECT ST_X(ST_Centroid(g.geom)) AS lon,
                   ST_Y(ST_Centroid(g.geom)) AS lat
              FROM (
                SELECT geom FROM cad_quarters_geom WHERE cad_number = :c
                UNION ALL
                SELECT geom FROM cad_buildings WHERE cad_num = :c
                UNION ALL
                SELECT geom FROM cad_parcels_geom WHERE cad_num = :c
              ) g
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    if not coord_row:
        raise HTTPException(
            status_code=404,
            detail=f"Геометрия для {cad_num} не найдена.",
        )

    lat = float(coord_row["lat"])
    lon = float(coord_row["lon"])

    # OpenRouteService isochrones API — POST с JSON body
    url = f"{_ORS_BASE}/{mode}"
    body: dict[str, Any] = {
        "locations": [[lon, lat]],
        "range": [t * 60 for t in times_min],  # ORS ожидает секунды
        "range_type": "time",
        "units": "m",
    }
    headers = {
        "Authorization": settings.openrouteservice_api_key,
        "Content-Type": "application/json",
        "Accept": "application/geo+json",
    }

    try:
        with httpx.Client(timeout=10) as client:
            resp = client.post(url, json=body, headers=headers)
            resp.raise_for_status()
            geojson = resp.json()
    except httpx.HTTPStatusError as exc:
        if exc.response.status_code == 429:
            raise HTTPException(
                status_code=429,
                detail="OpenRouteService daily limit (2000 req) exceeded.",
            ) from exc
        logger.error("ORS HTTP error for %s: %s — %s", cad_num, exc.response.status_code, exc)
        raise HTTPException(
            status_code=500,
            detail=f"ORS error {exc.response.status_code}: {exc.response.text[:200]}",
        ) from exc
    except Exception as exc:
        logger.error("ORS request failed for %s: %s", cad_num, exc)
        raise HTTPException(
            status_code=500,
            detail=f"Isochrones fetch failed: {exc}",
        ) from exc

    return {
        "cad_num": cad_num,
        "lat": lat,
        "lon": lon,
        "mode": mode,
        "times_min": times_min,
        "geojson": geojson,
        "source": "openrouteservice.org",
        "note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
    }


@router.get(
    "/{cad_num}/poi-score",
    response_model=PoiScoreResponse,
    summary="POI weighted top-7 (B6)",
)
async def get_poi_score(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
) -> PoiScoreResponse:
    """Вернуть top-7 ближайших POI для участка, взвешенных по формуле:

        weight = (1 / (distance_m + 100)) * category_weight

    POI берутся из osm_poi_ekb в заданном радиусе (default 2000м).
    Отсортированы по weight DESC — наиболее значимые объекты первыми.
    """
    # Получить координаты центроида участка из геометрических таблиц
    coord_row = (
        db.execute(
            text("""
                SELECT ST_X(ST_Centroid(g.geom)) AS lon,
                       ST_Y(ST_Centroid(g.geom)) AS lat
                  FROM (
                    SELECT geom FROM cad_quarters_geom WHERE cad_number = :c
                    UNION ALL
                    SELECT geom FROM cad_buildings WHERE cad_num = :c
                    UNION ALL
                    SELECT geom FROM cad_parcels_geom WHERE cad_num = :c
                  ) g
                 LIMIT 1
            """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    if not coord_row:
        raise HTTPException(
            status_code=404,
            detail=f"Геометрия для {cad_num} не найдена.",
        )

    lat = float(coord_row["lat"])
    lon = float(coord_row["lon"])

    return compute_poi_weighted_top7(db, cad_num, lat, lon, radius_m=radius_m)


@router.post("/{cad_num}/competitors", response_model=CompetitorsResponse)
async def get_parcel_competitors(
    cad_num: str,
    body: CompetitorsRequest,
    db: Annotated[Session, Depends(get_db)],
) -> CompetitorsResponse:
    """Активные конкуренты ЖК в радиусе от участка (Issue #112).

    Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
    участка с рассчитанным velocity_per_month за указанный time_window.
    """
    try:
        return get_competitors(db=db, cad_num=cad_num, request=body)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except Exception as exc:
        logger.error("competitors endpoint failed for %s: %s", cad_num, exc)
        raise HTTPException(
            status_code=500,
            detail="Ошибка расчёта конкурентов",
        ) from exc


@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
async def get_parcel_best_layouts(
    cad_num: str,
    body: BestLayoutsRequest,
    db: Annotated[Session, Depends(get_db)],
) -> BestLayoutsResponse:
    """Top layouts (rooms × area_bin) у конкурентов с ranking по velocity.

    Issue #113 Phase 2.1: "Анализ лучших планировок конкурентов → ТЗ на проектирование".
    Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
    × objective_complex_mapping).
    """
    try:
        return get_best_layouts(db=db, cad_num=cad_num, request=body)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except Exception as exc:
        logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc)
        raise HTTPException(status_code=500, detail="Internal server error") from exc


@router.post("/{cad_num}/best-layouts/pdf")
async def get_parcel_best_layouts_pdf(
    cad_num: str,
    body: BestLayoutsRequest,
    db: Annotated[Session, Depends(get_db)],
) -> Response:
    """ТЗ на проектирование (PDF) — генерируется из /best-layouts данных.

    Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
    """
    try:
        response = get_best_layouts(db=db, cad_num=cad_num, request=body)
        pdf_bytes = render_layout_tz_pdf(
            response,
            cad_num=cad_num,
            radius_km=body.radius_km,
            time_window=body.time_window,
        )
        today = _dt.date.today().strftime("%Y-%m-%d")
        cad_safe = cad_num.replace(":", "-")
        filename = f"tz-layout-{cad_safe}-{today}.pdf"
        return Response(
            content=pdf_bytes,
            media_type="application/pdf",
            headers={"Content-Disposition": f'attachment; filename="{filename}"'},
        )
    except HTTPException:
        raise
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except Exception as exc:
        logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc)
        raise HTTPException(status_code=500, detail="Internal server error") from exc


@router.get(
    "/{cad_num}/snapshot.pdf",
    summary="1-page PDF snapshot участка (НСПД + POI + конкуренты)",
)
def parcel_snapshot_pdf(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
) -> Response:
    """Генерирует одностраничный PDF-снимок участка (A4).

    Содержимое:
      - Header: кадастровый номер, адрес, район, площадь
      - Block 1: 5 KPI (площадь, кадастровая стоимость, категория, ВРИ, дата обновления)
      - Block 2: Топ-7 POI по взвешенному баллу (из osm_poi_ekb, радиус 1 км)
      - Block 3: Топ-5 конкурентов (из domrf_kn_objects, радиус 3 км)
      - Footer: gendsgn.ru + дата генерации

    Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
    Генерация <2 сек. Открывается в Adobe Reader / Chrome.
    """
    # 1) Получить метаданные участка из cad_parcels
    parcel_row = (
        db.execute(
            text("""
            SELECT readable_address          AS address,
                   land_record_area          AS area_m2,
                   land_record_category_type AS land_category,
                   permitted_use_established_by_document AS vri,
                   cost_value                AS cadastral_cost,
                   updated_at               AS last_update
              FROM cad_parcels
             WHERE cad_num = CAST(:c AS text)
             LIMIT 1
            """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    if not parcel_row:
        raise HTTPException(
            status_code=404,
            detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.",
        )

    # 2) Получить геометрию (WKT) для POI / competitor queries
    geom_row = (
        db.execute(
            text("""
            SELECT ST_AsText(COALESCE(
                (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
                (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
            )) AS wkt
            """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )
    geom_wkt: str | None = geom_row["wkt"] if geom_row else None

    # 3) POI в радиусе 1 км (только если есть геометрия)
    poi_rows: list[dict[str, Any]] = []
    if geom_wkt:
        poi_rows = [
            dict(r)
            for r in db.execute(
                text("""
                SELECT category,
                       name,
                       ST_Distance(
                           p.geom::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                       ) AS distance_m
                  FROM osm_poi_ekb p
                 WHERE ST_DWithin(
                           p.geom::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                           1000
                       )
                 ORDER BY distance_m ASC
                 LIMIT 50
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .all()
        ]

    # 4) Конкуренты в радиусе 3 км (только если есть геометрия)
    competitor_rows: list[dict[str, Any]] = []
    if geom_wkt:
        competitor_rows = [
            dict(r)
            for r in db.execute(
                text("""
                WITH latest_obj AS (
                    SELECT DISTINCT ON (obj_id) *
                      FROM domrf_kn_objects
                     WHERE latitude IS NOT NULL
                     ORDER BY obj_id, snapshot_date DESC NULLS LAST
                )
                SELECT obj_id,
                       comm_name,
                       dev_name,
                       obj_class,
                       flat_count,
                       ST_Distance(
                           ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                       ) AS distance_m
                  FROM latest_obj o
                 WHERE ST_DWithin(
                           ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                           ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
                           3000
                       )
                 ORDER BY flat_count DESC NULLS LAST
                 LIMIT 20
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .all()
        ]

    # 5) Получить district (через пересечение с ekb_districts если есть геом)
    district: str | None = None
    if geom_wkt:
        district_row = (
            db.execute(
                text("""
                SELECT d.district_name
                  FROM ekb_districts d
                 WHERE ST_Contains(d.geom, ST_Centroid(ST_GeomFromText(:wkt, 4326)))
                 LIMIT 1
                """),
                {"wkt": geom_wkt},
            )
            .mappings()
            .first()
        )
        if district_row:
            district = district_row["district_name"]

    # 6) Форматировать last_update
    raw_update = parcel_row["last_update"]
    last_update_str: str | None = None
    if raw_update is not None:
        try:
            last_update_str = raw_update.strftime("%d.%m.%Y")
        except AttributeError:
            last_update_str = str(raw_update)[:10]

    # 7) Сгенерировать PDF
    try:
        pdf_bytes = generate_snapshot_pdf(
            cad_num=cad_num,
            address=parcel_row["address"],
            district=district,
            area_m2=float(parcel_row["area_m2"]) if parcel_row["area_m2"] is not None else None,
            cadastral_cost_rub=(
                float(parcel_row["cadastral_cost"])
                if parcel_row["cadastral_cost"] is not None
                else None
            ),
            land_category=parcel_row["land_category"],
            vri=parcel_row["vri"],
            last_update=last_update_str,
            poi_rows=poi_rows,
            competitor_rows=competitor_rows,
            competitors_limit=5,
        )
    except Exception as exc:
        logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc)
        raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc

    cad_safe = cad_num.replace(":", "-")
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="snapshot-{cad_safe}.pdf"'},
    )
