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

import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
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 (
    AnalysisRunDetail,
    AnalysisRunListResponse,
    AnalysisRunSummary,
    AnalyzeRequest,
    AnalyzeResponse,
    BestLayoutsRequest,
    BestLayoutsResponse,
    CompetitorsParkingResponse,
    CompetitorsRequest,
    CompetitorsResponse,
    ConnectionCapacityResponse,
    ConnectionPointsResponse,
    OpportunityParcel,
    ParcelBboxResponse,
    ParcelMapMarker,
    ParcelMeta,
    ParcelSearchRequest,
    ParcelSearchResponse,
    RedLine,
    ReportBuildResponse,
    ReportStatusResponse,
    RiskZone,
    UtilityInfrastructureResponse,
)
from app.services.analysis_runs.repository import (
    ANALYZE_SCHEMA_VERSION,
    get_run,
    latest_run_dates,
    latest_run_for,
    list_runs_for,
    persist_analysis_run,
)
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.exporters.report_docx import render_report_docx
from app.services.exporters.report_md import (
    render_report_markdown,
    render_report_telegram_summary,
)
from app.services.exporters.report_pdf import export_report_pdf
from app.services.exporters.report_pptx import render_report_pptx
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
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, get_competitors_parking
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
from app.services.site_finder.custom_pois import (
    get_overlaps_for_scoring as _get_custom_poi_overlaps,
)
from app.services.site_finder.developer_attribution import get_developer_attribution
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.ird_analyze import build_ird_analyze_block
from app.services.site_finder.market_metrics import compute_offer_price_trend
from app.services.site_finder.osrm_client_local import (
    OsrmLocalUnavailableError,
    get_road_distances_m,
)
from app.services.site_finder.parcel_financial import (
    _as_float as _fin_as_float,
)
from app.services.site_finder.parcel_financial import (
    _as_int as _fin_as_int,
)
from app.services.site_finder.parcel_financial import (
    select_calibrated_price,
    synthesize_parcel_financial,
)
from app.services.site_finder.permits_nearby import get_permits_nearby
from app.services.site_finder.poi_score import (
    PoiScoreResponse,
    compute_poi_routing_decay,
    compute_poi_weighted_top7,
)
from app.services.site_finder.program_optimizer import optimize_program
from app.services.site_finder.quarter_dump_lookup import (
    get_connection_points,
    get_quarter_dump_data,
    make_empty_result,
)
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
from app.services.site_finder.saturation import compute_district_saturation
from app.services.site_finder.utility_infrastructure_loader import (
    get_nearby_utility_infrastructure,
)
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,
)
from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation
from app.services.weather_cache import (
    get_air_quality_cached,
    get_seasonal_weather_cached,
    get_weather_cached,
)

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]


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

# ── Гео-радиусная калибровка цены продажи (geo_radius_price) ────────────────────
# Покрывает пробел name-match district_reference: только 4 из 9 админ-районов ЕКБ
# матчатся с Objective-данными по имени, остальные 5 проваливались в district_reference/
# class_norm без реальной гео-цены. Берём медиану price_per_m2_rub новостроек Objective
# (objective_lots) в радиусе вокруг центроида участка (коорды — через complexes.lat/lon).
_GEO_PRICE_RADIUS_M: float = 3000.0  # 3 км — городской радиус сопоставимых ЖК (ЕКБ)
# Минимум лотов для устойчивой медианы — зеркалит порог n≥10 квартальной MV
# (mv_quarter_price_per_m2). Дополнительно требуем ≥2 ЖК, чтобы один ЖК не доминировал.
_GEO_PRICE_MIN_LOTS: int = 10
_GEO_PRICE_MIN_COMPLEXES: int = 2

# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
# Тонкие кварталы (1 ДКП → 64k) больше не «выигрывают» у newbuild-basis.
_QUARTER_MEDIAN_MIN_DEALS: int = 5

# Эмпирические пороги 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 по эмпирическим порогам ЕКБ.

    Шкала: <5 плохо, 5-15 средне, 15-25 хорошо, >=25 отлично.
    """
    if s < SCORE_THRESHOLDS["плохо"]:
        return "плохо"
    if s < SCORE_THRESHOLDS["средне"]:
        return "средне"
    if s < SCORE_THRESHOLDS["хорошо"]:
        return "хорошо"
    return "отлично"


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"


_ROMAN_QUARTERS = ("I", "II", "III", "IV")


def _deals_quarter_label(d: _dt.date) -> str:
    """Дата сделок Росреестра → «I квартал 2026».

    Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
    квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
    Месяц 1-3→I, 4-6→II, 7-9→III, 10-12→IV (римские).
    """
    return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"


# Человеко-читаемые имена категорий для 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} баллов"


def _clean_tag(value: Any) -> str | None:
    """Нормализовать строковое значение OSM-тега: trim + отбросить пустое/мусорное."""
    if not isinstance(value, str):
        return None
    s = value.strip()
    if not s or s.lower() in {"yes", "no", "unknown", "fixme", "?"}:
        return None
    return s


def _poi_detail_from_tags(tags: dict[str, Any] | None) -> dict[str, str | None]:
    """Извлечь человекочитаемые детали POI из OSM tags jsonb.

    Возвращает фиксированный набор ключей (всегда присутствуют, None если нет),
    чтобы фронтенд-попап карты §1 показывал «максимум информации» по точке:
    адрес, оператор/бренд, часы работы, сайт, телефон.

    Сайт/телефон берём из `contact:*` приоритетно, затем из «голых» тегов —
    OSM по факту хранит и так, и так. Сайт не валидируем здесь (это делает
    фронтенд через safeUrl при рендере href).
    """
    # jsonb-колонка всегда приходит dict'ом, но тип допускает None — fail-safe.
    t = tags if isinstance(tags, dict) else {}
    street = _clean_tag(t.get("addr:street"))
    house = _clean_tag(t.get("addr:housenumber"))
    address = ", ".join(p for p in (street, house) if p) or None

    operator = _clean_tag(t.get("operator")) or _clean_tag(t.get("brand"))
    opening_hours = _clean_tag(t.get("opening_hours"))
    website = (
        _clean_tag(t.get("contact:website"))
        or _clean_tag(t.get("website"))
        or _clean_tag(t.get("url"))
    )
    phone = _clean_tag(t.get("contact:phone")) or _clean_tag(t.get("phone"))

    return {
        "address": address,
        "operator": operator,
        "opening_hours": opening_hours,
        "website": website,
        "phone": phone,
    }


def _apply_osrm_road_distances(
    pois: list[dict[str, Any]],
    *,
    origin_lon: float,
    origin_lat: float,
    label: str,
) -> None:
    """In-place заменить `distance_m` каждого POI на дорожное расстояние из OSRM.

    Per-category routing (#39 A3): walk-релевантные POI (category ∈
    settings.osrm_walk_categories — школа/магазин/парк/остановки) маршрутизируются
    по FOOT-графу (settings.osrm_walk_local_url, profile='foot'), потому что driving
    переоценивает пешую доступность. Всё остальное (incl. shop_mall, hospital и
    любая неизвестная/None категория) — по DRIVING-графу (settings.osrm_local_url,
    profile='driving') как безопасный дефолт.

    POI без валидных lon/lat и POI, до которых OSRM не построил маршрут (None),
    сохраняют свой straight-line `distance_m`. Каждая группа (walk/drive) дёргает
    свой OSRM-сервис в отдельном try/except: если FOOT-сервис лёг, walk-POI остаются
    straight-line, а drive-POI всё равно получают дорожное расстояние (и наоборот).
    Эта функция НИКОГДА не бросает исключение — /analyze не падает, даже если оба
    OSRM лежат.

    Args:
        pois: список dict с ключами 'lon', 'lat', 'distance_m', 'category'
            (мутируется in-place).
        origin_lon, origin_lat: центроид участка = OSRM sources=0.
        label: контекст для логов (например, "osm cad=66:...").
    """
    # Берём только POI с валидными координатами; разбиваем на walk/drive по
    # категории. Каждый элемент несёт ORIGINAL index → корректный write-back.
    walk: list[tuple[int, tuple[float, float]]] = []
    drive: list[tuple[int, tuple[float, float]]] = []
    for i, p in enumerate(pois):
        if p.get("lon") is None or p.get("lat") is None:
            continue
        coord = (float(p["lon"]), float(p["lat"]))
        if p.get("category") in settings.osrm_walk_categories:
            walk.append((i, coord))
        else:
            drive.append((i, coord))

    if not walk and not drive:
        return

    def _route_group(
        group: list[tuple[int, tuple[float, float]]],
        *,
        base_url: str | None,
        profile: str,
        group_label: str,
    ) -> int:
        """Маршрутизировать одну группу, записать distance_m in-place, вернуть replaced.

        Свой try/except на группу: сбой одного OSRM-сервиса не трогает другую группу.
        """
        if not group:
            return 0
        try:
            road = get_road_distances_m(
                origin_lon,
                origin_lat,
                [coord for _, coord in group],
                profile=profile,
                base_url=base_url,
            )
        except OsrmLocalUnavailableError as exc:
            # Graceful: этот OSRM-сервис недоступен → straight-line по группе.
            logger.warning(
                "OSRM road-distance unavailable (%s, %s) — straight-line fallback: %s",
                label,
                group_label,
                exc,
            )
            return 0
        replaced = 0
        for (orig_idx, _coord), dist in zip(group, road, strict=True):
            if dist is not None:
                pois[orig_idx]["distance_m"] = dist
                replaced += 1
        return replaced

    walk_replaced = _route_group(
        walk,
        base_url=settings.osrm_walk_local_url,
        profile="foot",
        group_label="walk",
    )
    drive_replaced = _route_group(
        drive,
        base_url=None,  # → settings.osrm_local_url (DRIVING-граф)
        profile="driving",
        group_label="drive",
    )
    logger.info(
        "OSRM road-distance applied (%s): walk %d/%d, drive %d/%d POI заменены "
        "(остальные straight-line)",
        label,
        walk_replaced,
        len(walk),
        drive_replaced,
        len(drive),
    )


# Сейсмика по ОСР-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
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
PIPELINE_SELF_EXCLUDE_M = 80


def _coord_round(value: Any) -> float | None:
    """#999 — привести координату (lat/lon) к float, округлённому до 6 dp.

    Источник — PostGIS latitude/longitude (float8/numeric → может прийти Decimal).
    None/невалидное значение → None (graceful: объект без координат не ломает
    ответ, фронт просто не рисует маркер). 6 dp ≈ 0.1м точности — достаточно
    для карты.
    """
    if value is None:
        return None
    try:
        return round(float(value), 6)
    except (TypeError, ValueError):
        return None


def _competitor_with_coords(row: Any) -> dict[str, Any]:
    """#999 (958-B4) — competitor-dict + nullable lat/lon (EPSG:4326).

    Additive shape: сохраняет ВСЕ существующие ключи competitor_rows через
    {**dict(row)} (distance_m и пр. без изменений), затем перезаписывает lat/lon
    округлёнными float|None (исходные SQL-алиасы lat/lon приходят сырыми
    float/Decimal). Никакие текущие поля не удаляются и не меняются.
    """
    out = dict(row)
    out["lat"] = _coord_round(out.get("lat"))
    out["lon"] = _coord_round(out.get("lon"))
    # #2111 — средняя площадь квартиры конкурента (ROUND(AVG(area_pd),1) из
    # obj_pricing CTE). dict(row) уже несёт ключ, но фиксируем явно: попап
    # ожидает avg_area_pd, не завязываемся на спред-стиль выше.
    out["avg_area_pd"] = out.get("avg_area_pd")
    return out


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,
                # #999 (958-B4): lat/lon (EPSG:4326) для Leaflet-слоёв. Источник —
                # та же geom (latitude/longitude), что и distance_m. Nullable: объект
                # без координат → None (latest_obj фильтрует, но guard на всякий).
                "lat": _coord_round(r.get("lat")),
                "lon": _coord_round(r.get("lon")),
            }
        )

    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": (
            "Будущая конкуренция за покупателя: плановые сроки ввода (по данным "
            "Росреестра) часто оптимистичны — на практике сдвигаются. "
            "Балл давления — относительный."
        ),
    }


# 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

# ПЗЗ-градрегламент в analyze (#1067/#1850): bounded timeout для live-geoportal вызова на
# cache-miss теперь единый — settings.geoportal_timeout_s (раньше локальная константа 3s
# расходилась с ird_analyze 4s для того же резолвера). cache-hit (норма для ЕКБ) +
# мемоизация coord→zone_index сетевых вызовов не делают вообще.


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


# `_neighbors_summary` SQL — module-level чтобы integration-тест (см.
# `backend/tests/integration/test_analyze_parcels_sql.py`) мог EXPLAIN-нуть его
# против реальной prod-PG и поймать SQL-уровневые регрессии: PG keyword conflict
# в CTE (`overlaps` — #1195→#1197), `:x::type` antipattern в psycopg v3,
# несуществующая колонка/PostGIS-функция. Inline `text(...)` внутри функции — не
# проверяемый, mock-тест db.execute() ловит только Python-уровень.
#
# CTE name `overlap_rows` (НЕ `overlaps`): `OVERLAPS` — PG keyword (binary
# operator для time-periods), парсер ругается на `overlaps AS (`.
_NEIGHBORS_SUMMARY_SQL = text("""
    WITH neighbors AS (
        SELECT cad_num,
               building_name,
               floors,
               year_built,
               cost_value,
               area,
               readable_address,
               purpose,
               status,
               ST_Distance(
                   b.geom::geography,
                   ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
               ) AS distance_m
          FROM cad_buildings b
         WHERE ST_DWithin(
                   b.geom::geography,
                   ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
                   100
               )
           AND b.cad_num != CAST(:our_cad AS text)
         ORDER BY distance_m ASC
         LIMIT 30
    ),
    neighbors_total AS (
        -- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
        -- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
        -- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
        -- в радиусе больше.
        SELECT COUNT(*) AS n
          FROM cad_buildings b
         WHERE ST_DWithin(
                   b.geom::geography,
                   ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
                   100
               )
           AND b.cad_num != CAST(:our_cad AS text)
    ),
    overlap_rows AS (
        SELECT cad_num,
               building_name,
               floors,
               readable_address,
               ST_Area(
                 ST_Intersection(
                   ST_Transform(b.geom, 32641),
                   ST_Transform(ST_GeomFromText(CAST(:wkt AS text), 4326), 32641)
                 )
               ) AS overlap_m2
          FROM cad_buildings b
         WHERE ST_Intersects(b.geom, ST_GeomFromText(CAST(:wkt AS text), 4326))
           AND b.cad_num != CAST(:our_cad AS text)
         ORDER BY overlap_m2 DESC NULLS LAST
         LIMIT 5
    )
    SELECT
      COALESCE(
        (SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC)
           FROM neighbors n),
        '[]'::json
      ) AS neighbors,
      COALESCE(
        (SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
           FROM overlap_rows o),
        '[]'::json
      ) AS overlap_rows,
      (SELECT n FROM neighbors_total) AS neighbors_total_count
""")


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).

    PR #1130 Phase B: соседи (LIMIT 30) и overlap-check (LIMIT 5) объединены в
    ОДИН SQL-statement через две CTE + scalar-агрегацию в `json_agg`. Это срезает
    один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не
    меняются. Формат возвращаемого dict идентичен прежнему.

    #2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
    (CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
    `neighbors_truncated` — True если в радиусе больше соседей, чем показано в
    списке `neighbors`.

    SQL — module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
    integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
    """
    try:
        row = (
            db.execute(
                _NEIGHBORS_SUMMARY_SQL,
                {"wkt": geom_wkt, "our_cad": our_cad_num},
            )
            .mappings()
            .first()
        )
        neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
        overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
        # #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
        # НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
        neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
    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.
    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

    # neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
    # режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
    # тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
    # neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
    # где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
    neighbors_list = [
        {
            "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"),
            # #2111 — функциональное назначение + статус здания (cad_buildings,
            # populated bulk_harvest.py). purpose: TEXT категория, status:
            # commissioned/under-construction и т.п.
            "purpose": r.get("purpose"),
            "status": r.get("status"),
        }
        for r in neighbor_rows[:20]
    ]

    return {
        "data_available": True,
        "radius_m": 100,
        # #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
        # len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
        "count_buildings_100m": neighbors_total_count,
        "neighbors_truncated": neighbors_total_count > len(neighbors_list),
        "avg_floors_100m": avg_floors,
        "max_floors_100m": max_floors,
        "median_cost_per_m2_100m": median_cost,
        "neighbors": neighbors_list,
        "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 для site-finder analyze.

    Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
    сигналов: POI, район, рынок, конкуренты, источник геометрии) — отдельная
    by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
    (`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
    спроса/цены и работает над форсайт-входами (coverage/confounded/
    special_indices/sales_series), которых в analyze hot-path нет — поэтому
    analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.

    Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
    для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
    раздельно by-design; «stub до G1/G2/D1/D2» — устаревшая формулировка,
    те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
    """
    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,
    }


def _build_market_pulse(
    competitor_rows: list[dict[str, Any]],
    competitors_total: int,
    velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
    """OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.

    Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
    в `competitor_rows` (детальный список для карты/карточек), но исключаются
    из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).

    #2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
    кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
    len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
    заодно раздувало `coverage_pct` — competitors_priced/20 вместо
    competitors_priced/true_total). PURE — вызывающий код передаёт честный total
    отдельно, эта функция его не пересчитывает.
    """
    competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
    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
    return {
        "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,
    }


# #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

# §22-форсайт (3b-ii): schema_version §22-рана в analysis_runs — это "1.0"
# (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0")
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
_FORECAST_SCHEMA_VERSION = "1.0"
# Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет
# 6/12/18/24; 24 добавлен (Q1). Движок уже считает 24 на каждом ране — это первый
# горизонт в _DEFAULT_HORIZONS=(6,12,18,24) всего §9.x-стека (orchestrator/forecast-
# task/demand_supply_forecast/scenarios/special_indices/report_assembler); фазированный
# выход скрытого ПД (_hidden_release_fraction) и взвешивание future_supply по горизонту
# (_horizon_weight) корректно масштабируются/клампятся на 24 без деградации. Сетка
# дискретная (а не ge/le-диапазон): UI даёт фиксированные пресет-кнопки.
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18, 24})


@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 — реальная дата последнего анализа из v_analysis_runs_latest
    (#994, миграция 127): один batch-запрос на весь bbox, участки без рана → None.
    """
    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()
    )

    # #994: дата последнего анализа на участок — один batch-запрос на весь bbox
    # к v_analysis_runs_latest (НЕ N+1). Участки без рана отсутствуют в dict → None.
    # Best-effort: провал чтения (напр. view 127 ещё не применён в deploy-окне, или
    # будущий drift) НЕ должен ронять карту → fallback {} (last_analysis_date=None).
    try:
        last_analysis = latest_run_dates(db, [row["cad_num"] for row in rows])
    except Exception:
        logger.exception("by-bbox: latest_run_dates failed → last_analysis_date=None для всех")
        last_analysis = {}

    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=(
                last_analysis[row["cad_num"]].date().isoformat()
                if row["cad_num"] in last_analysis
                else None
            ),
        )
        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)


# ── #994 (961-C3, EPIC 961): run-history read endpoints ───────────────────────
#
# #251: мёртвый 1-сегментный catch-all `GET /{parcel_id}` (501 Not implemented,
# Stage 2b так и не наступил, фронт его не звал) удалён. Поэтому 1-сегментного
# конфликта с `/runs/{run_id}` больше нет. Catch-all на этом router снова заводить
# НЕ стоит без явной нужды — он перехватывал бы любой 1-сегментный путь.


@router.get("/runs/{run_id}", response_model=AnalysisRunDetail)
def get_analysis_run(
    run_id: int,
    db: Annotated[Session, Depends(get_db)],
) -> AnalysisRunDetail:
    """Полная строка одного рана анализа по id (включая `result`-блоб) — #994.

    Для re-open / детального просмотра сохранённого анализа. `result` отдаём как
    есть (форма analyze-1.0 ParcelAnalysis или §22 "1.0" SiteFinderReport — модель
    истории её не навязывает).

    404 (graceful HTTPException), если рана с таким id нет.
    """
    run = get_run(db, run_id)
    if run is None:
        raise HTTPException(status_code=404, detail=f"analysis run {run_id} not found")
    return AnalysisRunDetail.model_validate(run, from_attributes=True)


@router.get("/{cad_num}/runs", response_model=AnalysisRunListResponse)
def list_analysis_runs(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    limit: Annotated[
        int,
        Query(ge=1, le=100, description="Сколько недавних ранов вернуть (newest-first)"),
    ] = 20,
) -> AnalysisRunListResponse:
    """Недавние раны анализа на участок, newest-first (LIGHT-список) — #994.

    Облегчённый список истории анализов: метаданные ранов БЕЗ тяжёлого `result`-
    блоба (его отдаёт GET /runs/{run_id}). Пустой список (200, НЕ 404), если ранов
    на участок ещё не было.
    """
    rows = list_runs_for(db, cad_num, limit=limit)
    runs = [AnalysisRunSummary.model_validate(r, from_attributes=True) for r in rows]
    return AnalysisRunListResponse(runs=runs)


@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.get("/{cad_num}/forecast")
def get_parcel_forecast(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    response: Response,
    x_authenticated_user: Annotated[
        str | None,
        Header(
            alias="X-Authenticated-User",
            description="Идентичность из Caddy basic_auth (#994, nullable, read-only здесь)",
        ),
    ] = None,
) -> dict[str, Any]:
    """Read-only fetch §22-форсайта участка (3b-ii, #995).

    Клиент поллит после POST /analyze (тот fire-and-forget enqueue'ит фон-таску,
    считающую §22 SiteFinderReport ~30-180s). Читает ПОСЛЕДНИЙ ран schema_version
    "1.0" (§22-форсайт, НЕ analyze-1.0 inline-dict) через schema-filtered
    `latest_run_for` (3b-i seam).

    Returns:
        • 200 {"status": "ready", "run_id", "created_at", "report"} — §22 готов
          (report = SiteFinderReport.as_dict() из run.result).
        • 202 {"status": "pending"} — ещё не посчитан, клиент продолжает поллить.

    Graceful: нет рана → 202 pending (НЕ 404/500 на happy "not yet" path);
    ошибка БД → 202 pending + warning (клиент ретраит, не видит 500).
    """
    try:
        run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
    except Exception:
        # БД-сбой на read-only поллинге — не валим клиента 500-кой, отдаём pending.
        logger.warning(
            "forecast read failed for cad=%s — returning pending", cad_num, exc_info=True
        )
        response.status_code = 202
        return {"status": "pending"}

    if run is not None:
        report = run.result
        # #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт
        # «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом.
        # Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем
        # противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта.
        try:
            analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
            gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None
            if (
                isinstance(gate, dict)
                and gate.get("can_build_mkd") is False
                and isinstance(report, dict)
                and isinstance(report.get("product_tz"), dict)
            ):
                report["product_tz"]["gate_caveat"] = (
                    "Строительство МКД под вопросом (gate: Нельзя) — "
                    "рекомендация носит условный характер"
                )
        except Exception:
            logger.warning(
                "gate_caveat enrich failed for forecast cad=%s — report unaffected",
                cad_num,
                exc_info=True,
            )
        return {
            "status": "ready",
            "run_id": run.id,
            "created_at": run.created_at,
            "report": report,
        }

    # Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
    response.status_code = 202
    return {"status": "pending"}


@router.get("/{cad_num}/forecast/export")
def export_parcel_forecast(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    format: Annotated[
        Literal["md", "json", "tg", "docx", "pptx", "pdf"],
        Query(
            description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
            "tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
        ),
    ] = "md",
) -> Response:
    """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).

    Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
    /{cad}/forecast inline) и отдаёт его в нужной форме.
      • format=md → `render_report_markdown(run.result)` — attachment (.md, чистый Markdown).
      • format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения).
      • format=docx → `render_report_docx(run.result)` — attachment (.docx, Word-документ,
        зеркало содержания md/pdf; python-docx).
      • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
        сжатое зеркало содержания docx; python-pptx).
      • format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт,
        зеркало содержания md/docx; WeasyPrint).
      • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
        для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
        и отправить, а не файл-download).

    В отличие от read-only поллинга GET /{cad}/forecast (нет рана → 202 pending), это
    export-эндпоинт: нет рана → 404 (graceful HTTPException «прогноз ещё не посчитан»,
    НЕ 500). 3-сегментный путь (`/{cad}/forecast/export`) не конфликтует с 2-сегментными
    `/{cad}/forecast`, `/{cad}/runs`, `/runs/{run_id}` и 1-сегментным `/{parcel_id}`.

    Args:
        cad_num: кадастровый номер участка (в имени файла `:` → `_`).
        format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".

    Returns:
        Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
        `gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
    """
    run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
    if run is None:
        raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")

    # tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
    if format == "tg":
        return Response(
            content=render_report_telegram_summary(run.result),
            media_type="text/plain; charset=utf-8",
        )

    cad_safe = cad_num.replace(":", "_")
    today = _dt.date.today().strftime("%Y-%m-%d")
    base_name = f"gendesign_forecast_{cad_safe}_{today}"

    # docx — байты (Word OOXML), а не строка: отдельной веткой (зеркало tg early-return),
    # чтобы md/json string-ветки остались байт-в-байт прежними.
    if format == "docx":
        return Response(
            content=render_report_docx(run.result),
            media_type=("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
            headers={"Content-Disposition": f'attachment; filename="{base_name}.docx"'},
        )

    # pptx — байты (PowerPoint OOXML), отдельной веткой (зеркало docx early-return),
    # чтобы md/json string-ветки остались байт-в-байт прежними.
    if format == "pptx":
        return Response(
            content=render_report_pptx(run.result),
            media_type=(
                "application/vnd.openxmlformats-officedocument.presentationml.presentation"
            ),
            headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
        )

    # pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт
    # из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними.
    # WeasyPrint-рендер — отдельная зона отказа (шрифты, шаблон, движок): без обёртки
    # ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
    # traceback причины 500 (#1739), пользователю — безопасное RU-сообщение.
    if format == "pdf":
        try:
            pdf_bytes = export_report_pdf(run.result)
        except HTTPException:
            raise
        except Exception as exc:
            logger.exception("forecast PDF render failed for %s", cad_num)
            raise HTTPException(status_code=500, detail="Ошибка генерации PDF-форсайта") from exc
        return Response(
            content=pdf_bytes,
            media_type="application/pdf",
            headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'},
        )

    if format == "json":
        payload = json.dumps(run.result, ensure_ascii=False, default=str)
        media_type = "application/json"
        filename = f"{base_name}.json"
    else:
        payload = render_report_markdown(run.result)
        media_type = "text/markdown; charset=utf-8"
        filename = f"{base_name}.md"

    return Response(
        content=payload,
        media_type=media_type,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ── Полный PDF-отчёт ПТИЦА (эпик #2259 PR-D) ─────────────────────────────────
# §1–§7 + карты, собирается фоновой Celery-таской и хранится файлом на volume
# `/app/reports/` + метадата-строкой в analysis_runs (schema `report-pdf-1.0`).
# 3 эндпоинта — enqueue / status / download; пути ≥2-сегментные (рядом с
# /forecast/export), не конфликтуют с 1-сегментным /{parcel_id}.


def _iso_or_none(value: Any) -> str | None:
    """`datetime` → ISO-строка, иначе None (даты ранов для report-status ответов)."""
    return value.isoformat() if isinstance(value, _dt.datetime) else None


def _cached_report_result(db: Session, cad_num: str) -> dict[str, Any] | None:
    """Метадата готового отчёта (`report-pdf-1.0`), если файл реально на диске.

    Читает последний метадата-ран схемы `report-pdf-1.0`; возвращает его `result`
    только если `pdf_path` существует на диске (файл могли не смонтировать / удалить).
    Иначе None (отчёт не готов). Read-only, без исключений наружу (сбой → None).
    """
    try:
        row = latest_run_for(db, cad_num, schema_version=REPORT_SCHEMA_VERSION)
    except Exception:
        logger.exception("report status: чтение report-pdf-1.0 рана упало для %s", cad_num)
        return None
    if row is None:
        return None
    result = row.result if isinstance(row.result, dict) else {}
    pdf_path = result.get("pdf_path")
    if isinstance(pdf_path, str) and Path(pdf_path).exists():
        return result
    return None


@router.post(
    "/{cad_num}/report",
    response_model=ReportBuildResponse,
    summary="Собрать полный PDF-отчёт участка (§1–§7 + карты) — enqueue (#2259 PR-D)",
)
def build_parcel_report(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    response: Response,
) -> ReportBuildResponse:
    """Поставить в очередь сборку полного PDF-отчёта участка (или вернуть готовый кэш).

    Готовый кэш (метадата-ран `report-pdf-1.0` для последних analyze+forecast ранов +
    файл на диске) → 200 {status:"ready"} (качать сразу через /report/download). Иначе
    enqueue фоновой Celery-таски `build_full_report_task(cad)` → 202 {status:"building"}.
    Best-effort enqueue: провал Celery/Redis НЕ роняет ответ (503 честнее 500, но и он
    крайне редок — /analyze уже enqueue'ит форсайт тем же брокером).

    В ответе — даты базовых ранов (по каким данным собирается/собран отчёт).

    Args:
        cad_num: кадастровый номер участка.
    """
    analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
    if analyze_run is None:
        raise HTTPException(
            status_code=404, detail="нет анализа участка — сначала запустите /analyze"
        )
    forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
    analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
    forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))

    # Готовый кэш → 200 ready (не enqueue'им повторно). Отдаём и дату генерации PDF из
    # метадаты рана — чтобы fast-path кнопка показала дату без отдельного GET /status.
    cached = _cached_report_result(db, cad_num)
    if cached is not None:
        return ReportBuildResponse(
            status="ready",
            analyze_run_at=analyze_at,
            forecast_run_at=forecast_at,
            report_generated_at=cached.get("generated_at"),
        )

    # Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут
    # резолвится в момент вызова, патчибельно в тестах. Провал брокера → 503 (не 500).
    try:
        from app.workers.tasks.full_report import build_full_report_task

        build_full_report_task.delay(cad_num)
    except Exception as exc:
        logger.exception("report enqueue failed for %s", cad_num)
        raise HTTPException(
            status_code=503, detail="очередь сборки отчёта недоступна, повторите позже"
        ) from exc

    response.status_code = 202
    return ReportBuildResponse(
        status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
    )


@router.get(
    "/{cad_num}/report/status",
    response_model=ReportStatusResponse,
    summary="Статус полного PDF-отчёта участка (#2259 PR-D)",
)
def parcel_report_status(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
) -> ReportStatusResponse:
    """Состояние полного PDF-отчёта: ready / building / none.

    ready — метадата-ран `report-pdf-1.0` есть И файл на диске (качать через
    /report/download); building — analyze-ран есть, но готового PDF нет (собирается или
    ещё не запускался POST /report); none — нет даже analyze-рана (отчётировать нечего).
    Read-only поллинг: сбой чтения → building (не 500), клиент повторит.

    Args:
        cad_num: кадастровый номер участка.
    """
    try:
        analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
        forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
    except Exception:
        logger.exception("report status: чтение ранов упало для %s", cad_num)
        return ReportStatusResponse(status="building")

    if analyze_run is None:
        return ReportStatusResponse(status="none")

    analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
    forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))

    cached = _cached_report_result(db, cad_num)
    if cached is not None:
        return ReportStatusResponse(
            status="ready",
            analyze_run_at=analyze_at,
            forecast_run_at=forecast_at,
            report_generated_at=cached.get("generated_at"),
        )
    return ReportStatusResponse(
        status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
    )


# Медиа-типы + расширения по формату скачивания полного отчёта (PR-D pdf + PR-F docx).
_REPORT_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"


# HEAD нужен фронтовому preflight'у (#2338): FastAPI НЕ добавляет HEAD к @router.get
# автоматически (прод отдавал 405 → UI не скачивал DOCX вовсе). FileResponse при
# HEAD нативно отдаёт только заголовки (Starlette send_header_only).
@router.head("/{cad_num}/report/download", include_in_schema=False)
@router.get(
    "/{cad_num}/report/download",
    summary="Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F)",
)
def download_parcel_report(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    format: Annotated[
        Literal["pdf", "docx"],
        Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"),
    ] = "pdf",
) -> FileResponse:
    """Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).

    Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с
    Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
    файл не найден → 404 (сначала POST /report, дождаться status=ready).

    `format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате →
    честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
    ключ кэша не изменился, но файла docx нет — пере-рендер запишет оба).

    Args:
        cad_num: кадастровый номер участка.
        format: "pdf" (default) | "docx".
    """
    cached = _cached_report_result(db, cad_num)
    if cached is None:
        raise HTTPException(
            status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready"
        )

    if format == "docx":
        file_path = cached.get("docx_path")
        media_type = _REPORT_DOCX_MEDIA_TYPE
        ext = "docx"
        # Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report.
        if not isinstance(file_path, str) or not Path(file_path).exists():
            raise HTTPException(
                status_code=404,
                detail=(
                    "DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)"
                ),
            )
    else:
        file_path = cached["pdf_path"]
        media_type = "application/pdf"
        ext = "pdf"

    cad_safe = cad_num.replace(":", "_")
    # Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на
    # cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
    # build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
    date_str = _dt.date.today().strftime("%Y-%m-%d")
    generated_at = cached.get("generated_at")
    if isinstance(generated_at, str):
        try:
            date_str = _dt.datetime.fromisoformat(generated_at).strftime("%Y-%m-%d")
        except ValueError:
            logger.warning("report download: битый generated_at %r для %s", generated_at, cad_num)
    filename = f"gendesign_report_{cad_safe}_{date_str}.{ext}"
    return FileResponse(
        file_path,
        media_type=media_type,
        filename=filename,
    )


@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse)
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,
    horizon: Annotated[
        int,
        Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"),
    ] = 12,
    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,
    x_authenticated_user: Annotated[
        str | None,
        Header(
            alias="X-Authenticated-User",
            description="Идентичность из Caddy basic_auth → created_by рана (#994, nullable)",
        ),
    ] = 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, оба клиента ждут.

    §22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
    `forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget,
    провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18, 24}
    (ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
    §22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready).
    """
    # Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.
    if horizon not in _ALLOWED_FORECAST_HORIZONS:
        raise HTTPException(
            status_code=422,
            detail=(
                f"horizon должен быть одним из {sorted(_ALLOWED_FORECAST_HORIZONS)}, "
                f"получено: {horizon}"
            ),
        )

    # 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 AND g.geom IS NOT NULL
            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 AND b.geom IS NOT NULL
            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 AND p.geom IS NOT NULL
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    # NB: geom IS NOT NULL во всех ветках — участок с meta-строкой, но NULL-geometry
    # (964 таких в cad_parcels_geom) НЕ должен «найтись» с пустой геометрией (иначе
    # ST_Centroid→NULL→float(None)→500). Без geom он попадает в #93 graceful fallback
    # ниже (enqueue NSPD fetch → 202), как и участок, которого нет в БД вовсе.
    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 AND g.geom IS NOT NULL
                        UNION ALL
                        SELECT ST_AsGeoJSON(b.geom), b.geom, 'cad_building'
                          FROM cad_buildings b
                         WHERE b.cad_num = :c AND b.geom IS NOT NULL
                        UNION ALL
                        SELECT ST_AsGeoJSON(p.geom), p.geom, 'cad_parcel'
                          FROM cad_parcels_geom p
                         WHERE p.cad_num = :c AND p.geom IS NOT NULL
                         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 AND g.geom IS NOT NULL
                UNION ALL
                SELECT b.geom FROM cad_buildings b
                 WHERE b.cad_num = :c AND b.geom IS NOT NULL
                UNION ALL
                SELECT p.geom FROM cad_parcels_geom p
                 WHERE p.cad_num = :c AND p.geom IS NOT NULL
              ) g
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

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

    # 2) District context — ближайший район ЕКБ
    # #1960: НЕ COALESCE'им median_12m в SQL. Раньше карточка «Медиана рынка» брала
    # median_12m из mv_quarter_price_per_m2 — это РОСРЕЕСТРОВСКАЯ квартальная медиана
    # (ДКП), которая для тонких кварталов считается по 1 сделке (=64k для
    # 66:41:0205010:287) и противоречит остальной странице, построенной на NEWBUILD
    # Objective/DOM.РФ basis (geo_radius ≈132k). Поэтому возвращаем компоненты по
    # отдельности — финальный выбор basis делается в Python (см. блок «#1960» ниже),
    # где доступны district_price_block (Objective по имени района) и geo_radius_price.
    # ekb_districts.median_price_per_m2 — справочный DDU-fallback (24 мес).
    district_row_raw = (
        db.execute(
            text("""
            SELECT d.district_name,
                   d.median_price_per_m2          AS ekb_reference_median,
                   mq.median_12m                  AS quarter_median_12m,
                   mq.deals_count                 AS quarter_deals_count,
                   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, '^([^:]+:[^:]+:[^:]+).*$', '\\1')
             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()
    )
    # district_row сохраняет тот же контракт, что и раньше (district_name,
    # median_price_per_m2, dist_to_center). median_price_per_m2 пока = справочный
    # ekb-fallback; финальный newbuild-consistent basis проставляется ниже (#1960),
    # когда посчитаны district_price_block + geo_radius_price.
    district_row: dict[str, Any] | None
    if district_row_raw is not None:
        district_row = {
            "district_name": district_row_raw["district_name"],
            "median_price_per_m2": district_row_raw["ekb_reference_median"],
            "dist_to_center": district_row_raw["dist_to_center"],
        }
    else:
        district_row = None

    # 3) POI в радиусе 1 км — список с distance_m (straight-line, ST_Distance).
    # В dict, а не RowMapping (read-only) — чтобы при включённом OSRM (#39 A2)
    # можно было перезаписать distance_m реальным дорожным расстоянием.
    poi_rows: list[dict[str, Any]] = [
        dict(r)
        for r in 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,
                   tags
              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()
    ]

    # 3a) OSRM road-distance (#39 A2) — flag-gated, default OFF.
    # При use_osrm_distances=True заменяем straight-line distance_m на реальное
    # дорожное (центроид→POI) из локального OSRM. Любой сбой OSRM → straight-line
    # fallback (analyze НЕ падает). При OFF — байт-в-байт как раньше (нет вызова).
    if settings.use_osrm_distances and poi_rows:
        _osrm_origin = (
            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()
        )
        if _osrm_origin and _osrm_origin["lon"] is not None and _osrm_origin["lat"] is not None:
            _apply_osrm_road_distances(
                poi_rows,
                origin_lon=float(_osrm_origin["lon"]),
                origin_lat=float(_osrm_origin["lat"]),
                label=f"osm cad={cad_num}",
            )

    # 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
                ),
                # «Максимум информации» по точке (#по запросу): адрес, оператор,
                # часы работы, сайт, телефон — из OSM tags jsonb. Фронтенд-попап
                # карты §1 показывает то, что не None.
                **_poi_detail_from_tags(p.get("tags")),
            }
        )
        # 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
            ),
            nearby_obj AS (
                -- Ближние конкуренты (3 км) — единственные obj_id, чьи цены реально
                -- попадают в выдачу. Считаем геофильтр один раз, переиспользуем в
                -- obj_pricing (pushdown scope) и в финальном SELECT.
                SELECT *
                  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
                       )
            ),
            nearby_projects AS (
                -- Имена Objective-проектов ближних obj_id — scope для physflat-дедупа.
                SELECT DISTINCT cm.domrf_obj_id, cm.objective_complex_name
                  FROM objective_complex_mapping cm
                 WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
            ),
            obj_lots_latest AS (
                -- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти lot_id
                -- на физлот) → units_sold/units_available карточки конкурента были
                -- завышены. Дедупим INLINE (DISTINCT ON physflat-ключ, последний
                -- снапшот), scope протолкнут через project_name ближних ЖК. НЕ через
                -- общий physflat-VIEW: он материализует ВСЮ таблицу (join не проходит
                -- ниже DISTINCT ON) → seq-scan+sort 1.76M (~5.7 s на request-path
                -- analyze_parcel); inline scoped → ~доли секунды (прод-EXPLAIN #1964).
                SELECT DISTINCT ON (
                    ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
                )
                    ol.project_name,
                    ol.price_per_m2_rub,
                    ol.area_pd,
                    ol.is_sold
                  FROM objective_lots ol
                 WHERE ol.project_name IN (
                           SELECT objective_complex_name FROM nearby_projects
                       )
                 ORDER BY ol.project_name, ol.corpus_name, ol.section, ol.floor,
                          ol.lot_number, ol.snapshot_date DESC, ol.id DESC
            ),
            obj_pricing AS (
                -- Pushdown: агрегируем ТОЛЬКО для ближних obj_id. AVG/COUNT per
                -- domrf_obj_id независимы между группами → scope даёт идентичные
                -- значения для джойнящихся obj_id. Маппинг прод 1:1 по domrf_obj_id
                -- (303 строки = 303 distinct) → COUNT(*) по дедуп-физлотам корректен.
                SELECT
                    np.domrf_obj_id,
                    ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
                    ROUND(AVG(oll.area_pd)::numeric, 1)          AS avg_area_pd,
                    COUNT(*) FILTER (WHERE oll.is_sold)          AS units_sold,
                    COUNT(*) FILTER (WHERE NOT oll.is_sold)      AS units_available,
                    COUNT(*) FILTER (
                        WHERE oll.price_per_m2_rub IS NOT NULL
                    )                                            AS lots_with_price
                  FROM nearby_projects np
                  JOIN obj_lots_latest oll
                    ON oll.project_name = np.objective_complex_name
                 GROUP BY np.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,
                   o.latitude  AS lat,
                   o.longitude AS lon,
                   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 nearby_obj o
              LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
             ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
                      distance_m ASC
             LIMIT 20
        """),
            {"wkt": geom_wkt},
        )
        .mappings()
        .all()
    )

    # 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
    # competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
    # детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
    # тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
    # ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
    # (нужны только для цен детального списка, не для total) — на порядок дешевле
    # полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
    _competitors_total_true = (
        db.execute(
            text("""
            WITH latest_obj AS (
                SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
                  FROM domrf_kn_objects
                 WHERE latitude IS NOT NULL
                 ORDER BY obj_id, snapshot_date DESC NULLS LAST
            )
            SELECT COUNT(*) 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
                   )
            """),
            {"wkt": geom_wkt},
        ).scalar()
        or 0
    )

    # 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,
                   o.latitude  AS lat,
                   o.longitude AS lon,
                   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
                   )
               -- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке
               -- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом.
               AND NOT ST_DWithin(
                       ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                       ST_GeomFromText(:wkt, 4326)::geography,
                       :self_exclude_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,
                "self_exclude_m": PIPELINE_SELF_EXCLUDE_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()
    )
    # Defensive: centroid_row может существовать, но с NULL lat/lon (вырожденная/
    # невалидная геометрия → ST_Centroid NULL). Проверяем именно значения, а не только
    # наличие строки — иначе float(None) → 500. Fallback = центр ЕКБ.
    centroid_is_fallback: bool = (
        centroid_row is None or centroid_row["lat"] is None or centroid_row["lon"] is None
    )
    centroid_lat: float = (
        float(centroid_row["lat"]) if centroid_row and centroid_row["lat"] is not None else 56.838
    )
    centroid_lon: float = (
        float(centroid_row["lon"]) if centroid_row and centroid_row["lon"] is not None 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 при недоступности)
    # PR #1130 Phase B: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
    air_q = get_air_quality_cached(centroid_lat, centroid_lon)

    # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
    # PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
    weather = get_weather_cached(centroid_lat, centroid_lon)

    # 9b) Seasonal weather — 30-летние нормали Climate API (TTL 7 суток).
    seasonal_weather = get_seasonal_weather_cached(centroid_lat, centroid_lon)

    # 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
    hydrology: dict[str, Any] | None = None
    try:
        with db.begin_nested():
            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:
        with db.begin_nested():
            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. util_rows отсортированы по
        # distance_m ASC → первый встреченный road_class = ближайший.
        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

        # I3 (#44): именованные расстояния до ближайшей инж.сети по типу.
        # road_class сопоставлен в noise_loader._NOISE_QUERIES. None если в 2 км
        # нет ни одного объекта данного типа.
        def _nearest(*subtypes: str) -> int | None:
            cands = [by_subtype[s]["nearest_m"] for s in subtypes if s in by_subtype]
            return min(cands) if cands else None

        # Водопровод: магистраль (pipeline=water) ИЛИ узловой объект ВКХ.
        nearest_water_main_m = _nearest("water_main", "water_works", "water_tower")
        # Электроснабжение: подстанция ИЛИ трансформатор.
        nearest_substation_m = _nearest("substation", "transformer")
        nearest_gas_m = _nearest("gas_pipeline")
        nearest_heat_m = _nearest("heat_substation")

        utilities = {
            "summary": list(by_subtype.values()),
            "nearest_water_main_m": nearest_water_main_m,
            "nearest_substation_m": nearest_substation_m,
            "nearest_gas_m": nearest_gas_m,
            "nearest_heat_m": nearest_heat_m,
            "power_line_охранная_зона_flag": any(
                float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
            ),
            "note": (
                "OSM-покрытие инж.сетей в РФ неполное (особенно подземные трубы) — "
                "nearest_*_m нижняя оценка по доступным OSM-объектам. "
                "Охранная зона ЛЭП ≥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()

    # 9e-bis) Реальный ПЗЗ-градрегламент зоны → merge/synth в nspd_zoning (Route 2, #1067).
    # Резолвим числовые предельные параметры (КСИТ/max_far, высота, этажность,
    # %застройки, min площадь ЗУ) по centroid через get_or_fetch_zone_regulation:
    # cache-first (кэш прогрет для ЕКБ), live-geoportal только на miss и закапан
    # единым timeout'ом (settings.geoportal_timeout_s), чтобы медленный геопортал не
    # вешал analyze. Hot-path-safe: любой сбой/таймаут → поля остаются None, analyze
    # не падает.
    #
    # PR-A (#financial-zoning-decouple): резолвер геопортала РАЗГЕЙЧЕН от dump-зоны.
    # nspd_quarter_dumps территориальные зоны НЕ замощают квартал плотно — участки
    # сидят в 45-86м ЗАЗОРАХ между валидными зонами → _get_zoning=None на 94%
    # участков, что глушило РАБОТАЮЩИЙ geoportal-резолвер (он резолвит gap-участки).
    # Теперь запускаем резолвер ВСЕГДА при наличии centroid. Когда dump зону НЕ дал —
    # СИНТЕЗИРУЕМ минимальный nspd_zoning из geoportal-результата (zone_code = индекс
    # зоны напр. "Ж-2", чтобы gate_verdict.is_residential_zone ^Ж regex сработал) и
    # ПИШЕМ ОБРАТНО в nspd_dump_data, чтобы gate/финмост/ответ увидели зону.
    # Когда dump зону ДАЛ — zone_code НЕ перетираем (он несёт кадастровый рег-номер +
    # raw_props.subcategory, по которым gate детектит жильё), только ДОБАВЛЯЕМ
    # regulation-поля. Расширение dump-резидентности — OUT OF SCOPE (PR-B).
    _nspd_zoning = nspd_dump_data.get("nspd_zoning")
    # Регламент, резолвленный ЗДЕСЬ (zoning-merge), переиспользуется ИРД-блоком (#1850 Fix 2):
    # _zone_regulation_resolved=True означает «этот блок отработал и _regulation валиден
    # (dict|None)» → build_ird_analyze_block получит готовое значение, без повторного резолва.
    # Когда блок НЕ отработал (флаг off / не-ЕКБ / нет centroid) — ИРД-блок резолвит сам.
    _regulation: dict[str, Any] | None = None
    _zone_regulation_resolved = False
    # Region-guard: EKB-геопортал покрывает ТОЛЬКО Екатеринбург (КН 66:41). Для
    # участков вне ЕКБ zone_index_at всё равно вернул бы None — экономим WFS-round-trip
    # (резолвер теперь запускается на каждом analyze, а не только при dump-зоне).
    if (
        settings.enable_zoning_regulation_in_analyze
        and cad_num.startswith("66:41")
        and centroid_lat is not None
        and centroid_lon is not None
    ):
        _zone_regulation_resolved = True
        try:
            _client = EKBGeoportalClient(timeout=settings.geoportal_timeout_s)
            _regulation = get_or_fetch_zone_regulation(
                db, centroid_lon, centroid_lat, client=_client
            )
        except Exception as e:
            # Никогда не роняем analyze из-за регламента — деградируем в None.
            logger.warning("zone regulation resolve failed for %s: %s", cad_num, e)
            _regulation = None
        if _regulation is not None:
            _synthesized = _nspd_zoning is None
            if _synthesized:
                # Dump зону не дал (gap между терзонами) — синтезируем из geoportal.
                # zone_code = индекс зоны ("Ж-2") → is_residential_zone ^Ж сработает.
                _nspd_zoning = {
                    "zone_code": _regulation.get("zone_index"),
                    "zone_name": None,
                    "source": "ekb-geoportal-urbancard",
                    "raw_props": None,
                }
                # Write-back — gate (compute_gate_verdict), финмост
                # (synthesize_parcel_financial) и ответ читают nspd_dump_data["nspd_zoning"]
                # ПО КЛЮЧУ, поэтому замена ссылки доходит до всех трёх.
                nspd_dump_data["nspd_zoning"] = _nspd_zoning
            _nspd_zoning["max_far"] = _regulation.get("max_far")
            _nspd_zoning["max_height_m"] = _regulation.get("max_height_m")
            _nspd_zoning["max_floors"] = _regulation.get("max_floors")
            _nspd_zoning["max_building_pct"] = _regulation.get("max_building_pct")
            _nspd_zoning["min_parcel_area_m2"] = _regulation.get("min_parcel_area_m2")
            _nspd_zoning["regulation_zone_index"] = _regulation.get("zone_index")
            _nspd_zoning["regulation_source"] = "ekb-geoportal-urbancard"
            # #1891: авторитетный список разрешённых ВРИ зоны → gate.is_residential_zone
            # принимает по нему МКД-решение (main_vri перебивает ^Ж regex/subcategory).
            # Применяется к ОБОИМ путям: synth (gap-зона) И dump-augment (даёт dump-зоне
            # авторитетный сигнал — чинит ИЖС-false-positive Ж-1/Ж-2 и Ц-2-false-negative).
            # main_vri из regulation dict — уже python list (zone_regulation _SELECT_SQL).
            _nspd_zoning["main_vri"] = _regulation.get("main_vri")
            logger.info(
                "zone regulation hit for %s: zone_index=%s max_far=%s synthesized=%s",
                cad_num,
                _regulation.get("zone_index"),
                _regulation.get("max_far"),
                _synthesized,
            )
        else:
            logger.debug("zone regulation miss for %s (no resolve)", cad_num)

    # 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
    parcel_meta: ParcelMeta | None = None
    try:
        with db.begin_nested():
            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:
        with db.begin_nested():
            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,
                           -- #1954: cost_registration_date — мёртвая колонка (0/42233 заполнено).
                           -- Репойнт на updated_at (последнее обновление записи, 42233/42233).
                           updated_at                           AS last_egrn_update_date,
                           -- #1954: land_record_area часто NULL (29424/42233); добираем
                           -- specified_area / declared_area, чтобы площадь не была «—».
                           COALESCE(
                               land_record_area, specified_area, declared_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:
        with db.begin_nested():
            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:
        with db.begin_nested():
            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 — ближайшие 3 станции метро к участку.
    # Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
    # скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
    # nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
    # нет" от "не реализовано". None только при ошибке запроса.
    metro_block: dict[str, Any] = {"nearest_top3": []}
    try:
        with db.begin_nested():
            metro_rows = (
                db.execute(
                    text("""
                    SELECT name,
                           ST_Distance(
                               m.geom::geography,
                               ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
                           ) AS dist_m
                      FROM osm_poi_ekb m
                     WHERE m.category = 'metro_stop'
                     ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
                     LIMIT 3
                """),
                    {"wkt": geom_wkt},
                )
                .mappings()
                .all()
            )
        metro_block = {
            "nearest_top3": [
                {
                    "name": mr["name"],
                    "distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
                }
                for mr in metro_rows
            ]
        }
    except Exception as e:
        logger.warning("metro_block query failed for %s: %s", cad_num, e)
        metro_block = {"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:
            with db.begin_nested():
                dp_row = (
                    db.execute(
                        text("""
                        -- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти
                        -- lot_id на физлот) → sample_size/вес медианы были по
                        -- пере-листингам. Дедупим INLINE через DISTINCT ON
                        -- (physflat-ключ, последний снапшот), district протолкнут В
                        -- CTE. НЕ через v_objective_lots_latest: view не несёт
                        -- district внутри → qual не проходит ниже DISTINCT ON →
                        -- сортировка ВСЕЙ таблицы 1.76M (~5.8 s на request-path
                        -- analyze_parcel). Inline → bitmap по району + sort (~1.7 s).
                        WITH latest AS (
                            SELECT DISTINCT ON (
                                project_name, corpus_name, section, floor, lot_number
                            )
                                price_per_m2_rub
                              FROM objective_lots
                             WHERE district = CAST(:dn AS text)
                             ORDER BY project_name, corpus_name, section, floor,
                                      lot_number, snapshot_date DESC, id DESC
                        )
                        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 latest
                         WHERE 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:
        with db.begin_nested():
            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:
        with db.begin_nested():
            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,
                    MAX(deal_date) AS max_period
                FROM district_deals
                """),
                    {"wkt": geom_wkt},
                )
                .mappings()
                .first()
            )
        # SF#1871 P2: честность staleness. Источник сделок (rosreestr) может встать —
        # тогда recent-окно (0-6 мес) пусто → recent_avg=NULL. Раньше market_trend
        # обнулялся в голый None, и фронт/скоринг не могли отличить «нет данных рядом»
        # от «источник устарел». Теперь явный status. (ETL-фикс — вне scope.)
        _raw_max_period = trend_row["max_period"] if trend_row else None
        # period_start_date — DATE; нормализуем на случай timestamp (.date()), чтобы
        # сравнение/форматирование не падало на datetime vs date.
        max_period: _dt.date | None
        if isinstance(_raw_max_period, _dt.datetime):
            max_period = _raw_max_period.date()
        elif isinstance(_raw_max_period, _dt.date):
            max_period = _raw_max_period
        else:
            max_period = None
        recent_n_val = int(trend_row["recent_n"]) if trend_row and trend_row["recent_n"] else 0
        # Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня.
        stale_cutoff = _dt.date.today() - _dt.timedelta(days=90)

        # #2178: ВТОРОЙ источник — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history
        # (ЖК в том же радиусе 3 км). Живёт, когда rosreestr встал (сделки stale).
        # Считаем всегда; ниже решаем, основной он или дополнительный к сделкам.
        # Своя savepoint — offer-запрос не должен ронять уже посчитанный сделки-тренд.
        offer_block: dict[str, Any] | None = None
        try:
            with db.begin_nested():
                # Предохранитель: на плотном центре ЕКБ холодный прогон доходит
                # до ~5.5 с (EXPLAIN 2026-07-02) — эндпоинт синхронный, дольше
                # 8 с ждать нельзя. Таймаут → QueryCanceled → откат savepoint →
                # offer_block=None (прежнее поведение). После запроса возвращаем
                # серверный дефолт (0, проверено на проде), т.к. RELEASE SAVEPOINT
                # сохранил бы SET LOCAL до конца внешней транзакции.
                db.execute(text("SET LOCAL statement_timeout = '8000'"))
                offer_trend = compute_offer_price_trend(
                    db, center_lat=centroid_lat, center_lon=centroid_lon
                )
                db.execute(text("SET LOCAL statement_timeout = 0"))
            if offer_trend.has_data:
                _offer_delta = offer_trend.delta_pct
                if _offer_delta is None:
                    _offer_label = "Динамика цен предложения (Объектив)"
                elif _offer_delta > 8:
                    _offer_label = "Цены предложения заметно растут"
                elif _offer_delta > 0:
                    _offer_label = "Цены предложения умеренно растут"
                elif _offer_delta > -5:
                    _offer_label = "Цены предложения стабильны"
                else:
                    _offer_label = "Цены предложения снижаются"
                offer_block = {
                    "source": "Объектив, цены предложения",
                    "label": _offer_label,
                    "radius_km": 3,
                    **offer_trend.as_dict(),
                }
        except Exception as e:
            logger.warning("offer_price_trend query failed for %s: %s", cad_num, e)
            offer_block = None

        if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
            # Happy path — есть данные в обоих окнах.
            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 = {
                "status": "ok",
                "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,
            }
            # #2178: Росреестр свежий → показываем ОБА блока (сделки + предложение).
            if offer_block is not None:
                market_trend["offer_trend"] = offer_block
        elif offer_block is not None:
            # #2178: сделки устарели/отсутствуют, НО есть живая динамика предложения —
            # она становится ОСНОВНЫМ содержимым карточки (status='offer_only').
            # as_of_date отдаём как дату последнего снапшота предложения, чтобы фронт
            # честно показал «по состоянию на …». Сохраняем максимум контекста сделок.
            _offer_as_of = offer_block["points"][-1]["date"] if offer_block["points"] else None
            _deals_stale = (
                recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
            )
            # Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
            # = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
            # называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
            # предложение как основной контент.
            _offer_only_label = (
                (
                    f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
                    "(поставка поквартальная); показываем динамику цен предложения"
                )
                if _deals_stale and max_period is not None
                else "Нет сделок рядом — показываем динамику цен предложения"
            )
            market_trend = {
                "status": "offer_only",
                "label": _offer_only_label,
                "deals_stale": _deals_stale,
                "recent_deals_count": recent_n_val,
                "deals_as_of_date": (max_period.isoformat() if max_period is not None else None),
                "as_of_date": _offer_as_of,
                "offer_trend": offer_block,
            }
        elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
            # Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
            # ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
            # называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
            # ТОЛЬКО когда и offer-данных нет (offer_block is None).
            market_trend = {
                "status": "source_stale",
                "as_of_date": max_period.isoformat(),
                "label": (
                    f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
                    "(поставка поквартальная); тренд по сделкам не показываем"
                ),
                "recent_deals_count": 0,
            }
        else:
            # Источник свежий (или вообще нет данных в радиусе), но по объекту 0 сделок
            # в recent-окне → честно «нет сделок», а не подмена под устаревание.
            market_trend = {
                "status": "no_deals",
                "label": "Нет сделок в радиусе за период",
                "recent_deals_count": 0,
            }
    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, obj_class, 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"],
                                # #1955: obj_class теперь различает строки с одинаковым
                                # area-label (Комфорт vs Типовой vs «не указан») — убирает
                                # визуальные дубли бакетов и фантом english 'Comfort'.
                                "obj_class": r["obj_class"],
                                "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-pre1b) Гео-радиусная цена — медиана price_per_m2_rub новостроек Objective в
    # радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
    # только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
    # проваливались в class_norm. У objective_lots нет geom — коорды берём через
    # complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py).
    geo_radius_price: dict[str, Any]
    # Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
    # медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
    # цену КОНКРЕТНОГО участка (price_is_calibrated=true). Это вводит в заблуждение —
    # пропускаем запрос, цена честно падает в district/class_norm.
    if not centroid_is_fallback:
        try:
            with db.begin_nested():
                grp_row = (
                    db.execute(
                        text("""
                            -- #1964 physflat-дедуп: objective_lots раздут ~2.91×
                            -- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
                            -- были по пере-листингам. Дедупим INLINE через DISTINCT ON
                            -- (physflat-ключ, последний снапшот), scope протолкнут В
                            -- CTE через complex_id ближних ЖК. НЕ через
                            -- v_objective_lots_latest: view материализует ВСЮ таблицу
                            -- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
                            -- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
                            -- geo-index по complexes → Nested Loop bitmap
                            -- objective_lots_complex_idx по ~186 ЖК → ~120 ms
                            -- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
                            -- цена объективна по физлоту (последний снапшот).
                            WITH nearby_cx AS (
                                SELECT c.id
                                  FROM complexes c
                                 WHERE c.latitude IS NOT NULL
                                   AND c.longitude IS NOT NULL
                                   AND ST_DWithin(
                                           ST_SetSRID(
                                               ST_MakePoint(c.longitude, c.latitude),
                                               4326
                                           )::geography,
                                           ST_SetSRID(
                                               ST_MakePoint(
                                                   CAST(:lon AS float),
                                                   CAST(:lat AS float)
                                               ), 4326
                                           )::geography,
                                           CAST(:radius_m AS float)
                                       )
                            ),
                            latest AS (
                                SELECT DISTINCT ON (
                                    ol.project_name, ol.corpus_name, ol.section,
                                    ol.floor, ol.lot_number
                                )
                                    ol.price_per_m2_rub,
                                    ol.complex_id
                                  FROM objective_lots ol
                                 WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
                                 ORDER BY ol.project_name, ol.corpus_name, ol.section,
                                          ol.floor, ol.lot_number,
                                          ol.snapshot_date DESC, ol.id DESC
                            )
                            SELECT
                                percentile_cont(0.5) WITHIN GROUP (
                                    ORDER BY price_per_m2_rub
                                )                                       AS median,
                                count(*)                                AS n,
                                count(DISTINCT complex_id)              AS n_complexes
                            FROM latest
                            WHERE price_per_m2_rub IS NOT NULL
                        """),
                        {
                            "lon": centroid_lon,
                            "lat": centroid_lat,
                            "radius_m": _GEO_PRICE_RADIUS_M,
                        },
                    )
                    .mappings()
                    .first()
                )
            _grp_n = int(grp_row["n"]) if grp_row and grp_row["n"] is not None else 0
            _grp_nc = (
                int(grp_row["n_complexes"]) if grp_row and grp_row["n_complexes"] is not None else 0
            )
            # Порог устойчивости медианы: n≥10 лотов И ≥2 ЖК (один ЖК не должен доминировать).
            if (
                grp_row
                and grp_row["median"] is not None
                and _grp_n >= _GEO_PRICE_MIN_LOTS
                and _grp_nc >= _GEO_PRICE_MIN_COMPLEXES
            ):
                geo_radius_price = {
                    "median": float(grp_row["median"]),
                    "lots_count": _grp_n,
                    "complexes_count": _grp_nc,
                    "radius_m": _GEO_PRICE_RADIUS_M,
                    "source": "objective_geo_radius",
                }
            else:
                geo_radius_price = {
                    "median": None,
                    "lots_count": _grp_n,
                    "complexes_count": _grp_nc,
                    "radius_m": _GEO_PRICE_RADIUS_M,
                    "source": "no_data",
                }
        except Exception as e:
            logger.warning("geo_radius_price query failed for %s: %s", cad_num, e)
            geo_radius_price = {
                "median": None,
                "lots_count": 0,
                "complexes_count": 0,
                "radius_m": _GEO_PRICE_RADIUS_M,
                "source": "no_data",
            }
    else:
        geo_radius_price = {
            "median": None,
            "lots_count": 0,
            "complexes_count": 0,
            "radius_m": _GEO_PRICE_RADIUS_M,
            "source": "no_data_centroid_fallback",
        }

    # 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-pre2b) #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (gisogd_permits,
    # ST_DWithin 500м). НОВЫЙ ключ payload — заменяет TODO-прокси quarter-prefix выше,
    # НЕ трогая старый ekburg-путь (recent_permits_in_quarter / permits_summary остаются).
    # Non-fatal: сбой → honest-ноль вместо падения всего analyze.
    permits_nearby_data: dict[str, Any] = {
        "radius_m": 500,
        "total_count": 0,
        "rs_count": 0,
        "rv_count": 0,
        "nearest_distance_m": None,
        "items": [],
        "items_truncated": False,
        "source": "gisogd66",
    }
    try:
        permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
    except Exception as e:
        logger.warning("gisogd permits_nearby 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
            )
            # OSRM road-distance (#39 A2) — flag-gated, default OFF. Тот же graceful
            # fallback, что и для OSM POI: сбой OSRM → straight-line, analyze не падает.
            if settings.use_osrm_distances and _custom_overlaps and not centroid_is_fallback:
                _apply_osrm_road_distances(
                    _custom_overlaps,
                    origin_lon=centroid_lon,
                    origin_lat=centroid_lat,
                    label=f"custom 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)

    # #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
    # (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
    # конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
    market_pulse: dict[str, Any] = _build_market_pulse(
        competitor_rows, int(_competitors_total_true), velocity_data
    )

    # #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
    # на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
    # только если у участка резолвится район; ошибка/нет демографии → None (не роняет
    # analyze). SAVEPOINT изолирует от outer-сессии (как success_recommendation выше).
    saturation_block: dict[str, Any] | None = None
    if district_row and district_row["district_name"]:
        try:
            with db.begin_nested():
                saturation_block = compute_district_saturation(db, district_row["district_name"])
        except Exception as e:
            logger.warning(
                "saturation block failed for %s (%s): %s",
                cad_num,
                district_row["district_name"],
                e,
            )

    # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner.
    # Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740)
    # пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное).
    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"],
    )

    # #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт
    # квартирографию — не удаляем рекомендацию (строгость = продуктовое решение),
    # а делаем противоречие явным через caveat-флаг. success_recommendation —
    # analyze-рекомендатор (топ-квартирография по district).
    if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
        success_recommendation["gate_caveat"] = (
            "Строительство МКД под вопросом (gate: Нельзя) — рекомендация носит условный характер"
        )

    # PR-4 (#1881): мост финмодели — синтез лёгкого ТЭП из buildability участка
    # (площадь + предельные параметры зоны НСПД/ПЗЗ) → полноценный DCF
    # (compute_financial). ОРИЕНТИРОВОЧНАЯ оценка по максимальной застройке, НЕ по
    # реальной концепции (caveat внутри результата). Hot-path-safe: любой сбой →
    # financial_estimate=None, analyze НЕ падает. Чистый сервис (без БД).
    financial_estimate: dict[str, Any] | None = None
    try:
        _fin_area_m2 = egrn_block.get("area_m2")
        if _fin_area_m2 is None:
            _fin_area_m2 = _polygon_suitability(geom_wkt).get("area_m2")
        _fin_cad_value = egrn_block.get("cadastral_value_rub")
        if _fin_cad_value is None and parcel_meta is not None:
            _fin_cad_value = parcel_meta.cad_cost
        # RANK 1: окно продаж DCF драйвится реальной рыночной абсорбцией района —
        # темпом выбытия ОДНОГО типового конкурента (project_absorption_sqm_per_month),
        # НЕ суммарной скоростью района. velocity_data посчитан выше (≈line 2858), до
        # этого моста — порядок не меняем. None (нет velocity / rosreestr-fallback) →
        # дефолт-30-мес график (schedule_is_default).
        _velocity_absorption = (
            velocity_data["project_absorption_sqm_per_month"] if velocity_data else None
        )
        financial_estimate = synthesize_parcel_financial(
            area_m2=_fin_area_m2,
            nspd_zoning=nspd_dump_data["nspd_zoning"],
            market_price=market_price,
            # Гео-радиусная медиана новостроек — приоритет МЕЖДУ квартальной MV и
            # district_reference: закрывает 5 не-name-match районов ЕКБ (≈line 2693).
            geo_radius_price_median=geo_radius_price.get("median"),
            district_price_median=district_price_block["district_price_per_m2_median"],
            cadastral_value_rub=_fin_cad_value,
            gate_verdict=gate_verdict,
            velocity_sqm_per_month=_velocity_absorption,
        )
    except Exception as e:
        logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)
        financial_estimate = None

    # #2181: оптимизатор программы — при ОТРИЦАТЕЛЬНОМ фин-вердикте (NPV<0 или ROI<0)
    # честно перебираем сетку (класс × этажность) той же compute_financial и отдаём топ-3
    # варианта + флаг any_viable (есть ли ВООБЩЕ плюс на этом участке). Позитивные отчёты
    # не засоряем (program_alternatives=None). Быстро (≈15 мс/12 комбо) → синхронно, без
    # Celery. Тот же «пояс безопасности», что у financial_estimate: любой сбой → None,
    # analyze НЕ падает. Единая откалиброванная цена во все комбо (DRY через
    # select_calibrated_price) — иначе business ложно выиграет за счёт норматива 210k.
    program_alternatives: dict[str, Any] | None = None
    try:
        _fin_negative = financial_estimate is not None and (
            (_fin_as_float(financial_estimate.get("npv_rub")) or 0.0) < 0
            or (_fin_as_float(financial_estimate.get("roi")) or 0.0) < 0
        )
        if _fin_negative:
            _opt_price, _opt_price_source = select_calibrated_price(
                market_price=market_price,
                geo_radius_price_median=geo_radius_price.get("median"),
                district_price_median=district_price_block["district_price_per_m2_median"],
            )
            _opt_zoning = nspd_dump_data["nspd_zoning"] or {}
            program_alternatives = optimize_program(
                area_m2=_fin_area_m2,
                max_far=_fin_as_float(_opt_zoning.get("max_far")),
                max_building_pct=_fin_as_float(_opt_zoning.get("max_building_pct")),
                max_floors_allowed=_fin_as_int(_opt_zoning.get("max_floors")),
                land_cost_rub=_fin_cad_value,
                market_price_per_sqm=_opt_price,
                price_source=_opt_price_source,
                velocity_sqm_per_month=_velocity_absorption,
            )
    except Exception:
        logger.exception("program_optimizer failed for %s", cad_num)
        program_alternatives = None

    # #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть
    # КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ
    # basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой
    # квартальной росреестровской ДКП-медиане (median_12m, для 66:41:0205010:287 = 64k
    # по 1 сделке). Раньше SQL делал COALESCE(median_12m, ekb_reference) → выигрывала
    # тонкая квартальная медиана.
    #
    # Basis-приоритет (newbuild-first, с устойчивыми fallback'ами):
    #   1. district_price_block.district_price_per_m2_median — Objective-лоты по ИМЕНИ
    #      района (objective_lots.district). NULL для 5/9 районов ЕКБ без name-match.
    #   2. geo_radius_price.median — Objective-лоты в 3 км радиусе вокруг центроида
    #      (n≥10 лотов, ≥2 ЖК). Закрывает name-match-пробел; для 66:41:0205010:287 ≈132k.
    #      Это ТОТ ЖЕ источник, что и market_pulse/financial_estimate → консистентно.
    #   3. ekb_districts.median_price_per_m2 — справочная DDU-медиана района (24 мес).
    #   4. mv_quarter_price_per_m2.median_12m — росреестровская квартальная медиана,
    #      ТОЛЬКО если deals_count ≥ _QUARTER_MEDIAN_MIN_DEALS (5). Тонкие кварталы
    #      (1 ДКП → 64k) сюда не проходят и медиана честно остаётся None.
    if district_row is not None:
        _newbuild_basis: float | None = None
        _basis_source = "none"
        _dpm = district_price_block.get("district_price_per_m2_median")
        _geo_m = geo_radius_price.get("median")
        _ekb_ref = district_row_raw["ekb_reference_median"] if district_row_raw else None
        _q_median = district_row_raw["quarter_median_12m"] if district_row_raw else None
        _q_deals = district_row_raw["quarter_deals_count"] if district_row_raw else None
        if _dpm is not None:
            _newbuild_basis = float(_dpm)
            _basis_source = "objective_district"
        elif _geo_m is not None:
            _newbuild_basis = float(_geo_m)
            _basis_source = "objective_geo_radius"
        elif _ekb_ref is not None:
            _newbuild_basis = float(_ekb_ref)
            _basis_source = "ekb_districts_reference"
        elif (
            _q_median is not None
            and _q_deals is not None
            and (int(_q_deals) >= _QUARTER_MEDIAN_MIN_DEALS)
        ):
            _newbuild_basis = float(_q_median)
            _basis_source = "quarter_rosreestr"
        district_row["median_price_per_m2"] = _newbuild_basis
        district_row["median_price_basis"] = _basis_source

    result_payload: dict[str, Any] = {
        "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,
        # #42: per-capita обеспеченность соцобъектами района (saturation) + влияние
        # на POI-score (score_multiplier). None если район не резолвится / нет демографии.
        "infra": {"saturation": saturation_block},
        "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": (
            "POI-скор (0–40, оценочный) — взвешенная сумма близких POI с учётом "
            "расстояния + бонус за центр. Не инвест-рейтинг: школы/сады/парки "
            "повышают, трамвайные пути снижают. Типичный город 15–30, >40 редко."
        ),
        # #1926 (остаток аудита #1871): машиночитаемый признак честности score.
        # score — это POI-density-эвристика по шкале 0..SCORE_MAX_REFERENCE, а НЕ
        # абсолютный инвест-рейтинг. Флаг + структурный объект позволяют фронту /
        # API-консьюмерам отличить эвристику от «настоящей» оценки явно (additive).
        "score_is_advisory": True,
        "score_advisory": {
            "kind": "poi_density_heuristic",
            "scale_min": 0,
            "scale_max": SCORE_MAX_REFERENCE,
        },
        "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",
        },
        # #999 (958-B4): competitors несут lat/lon (EPSG:4326) для Leaflet-слоёв.
        # Additive — все существующие поля сохранены через {**dict(c)}; lat/lon
        # округлены до 6 dp (float|None). latest_obj фильтрует latitude IS NOT NULL,
        # поэтому здесь координаты обычно заполнены, но _coord_round graceful к None.
        "competitors": [_competitor_with_coords(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,
        # Гео-радиусная медиана цены новостроек Objective вокруг центроида (transparency)
        "geo_radius_price": geo_radius_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,
        # #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (ГИСОГД-66, ST_DWithin 500м)
        "permits_nearby": permits_nearby_data,
        "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
        # (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
        "gate_verdict": gate_verdict,
        # PR-4 (#1881): мост финмодели — ОРИЕНТИРОВОЧНЫЙ DCF по максимальной
        # застройке зоны (синтез ТЭП из buildability). dict|None (None когда gate
        # против / нет чисел зоны / нет площади). caveat внутри. См. parcel_financial.
        "financial_estimate": financial_estimate,
        # #2181: при ОТРИЦАТЕЛЬНОМ фин-вердикте — топ-3 варианта программы (класс ×
        # этажность) по NPV + флаг any_viable (существует ли ВООБЩЕ плюс). dict|None
        # (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
        "program_alternatives": program_alternatives,
        # #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,
    }

    # #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
    # иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
    # (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
    # (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).

    # §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
    # jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
    # enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
    # ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
    # посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
    # передаётся в persist by-reference; если enqueue провалится после persist —
    # перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
    # снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
    result_payload["forecast"] = {"status": "pending", "horizon": horizon}

    # ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
    # функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
    # источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
    # сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
    if settings.enable_ird_analyze:
        try:
            # #1850 Fix 2: если zoning-merge блок выше уже резолвил регламент по тому же
            # centroid — передаём его готовым (_regulation, dict|None), ИРД-блок не делает
            # повторный резолв/DB-read. Если блок НЕ отработал (флаг off / не-ЕКБ / нет
            # centroid) — НЕ передаём kwarg, ИРД-блок резолвит сам (Fix 1 снимает network-цену).
            _ird_kwargs: dict[str, Any] = {}
            if _zone_regulation_resolved:
                _ird_kwargs["resolved_zone_regulation"] = _regulation
            result_payload["ird"] = build_ird_analyze_block(
                db, geom_wkt, centroid_lon, centroid_lat, cad_num, **_ird_kwargs
            )
        except Exception:
            logger.warning(
                "ird block failed for cad=%s — analyze response unaffected",
                cad_num,
                exc_info=True,
            )

    # РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ
    # (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41).
    # Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на
    # уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при
    # отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow").
    # Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим.
    if settings.enable_riasurt_gate:
        try:
            _riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num)
            if _riasurt_gate.get("applicable"):
                result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate
        except Exception:
            logger.warning(
                "riasurt gate failed for cad=%s — analyze response unaffected",
                cad_num,
                exc_info=True,
            )

    # Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
    # nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
    # (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
    # резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
    # сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
    # try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
    # добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
    try:
        _dev_attr = get_developer_attribution(db, cad_num)
        if _dev_attr is not None:
            result_payload["developer_attribution"] = _dev_attr.model_dump()
    except Exception:
        logger.warning(
            "developer_attribution block failed for cad=%s — analyze response unaffected",
            cad_num,
            exc_info=True,
        )

    # #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
    # forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
    # Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
    # форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
    # district денормализуем из result["district"]["district_name"] (для фильтрации
    # без JSON-разбора); confidence — отчётный уровень high/medium/low из
    # confidence_label (нормализуется под CHECK в repository). schema_version —
    # ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
    # SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
    _district_name = (
        result_payload["district"].get("district_name")
        if isinstance(result_payload.get("district"), dict)
        else None
    )
    persist_analysis_run(
        db,
        cad_num=cad_num,
        result=result_payload,
        params={
            "profile_id": profile_id,
            "profile_user_id": profile_user_id,
            "inline_weights": _inline_weights,
            "weights_source": _weights_source,
            "x_session_id": _session_id,
        },
        district=_district_name,
        confidence=confidence_info["label"],
        status="complete",
        schema_version=ANALYZE_SCHEMA_VERSION,
        created_by=x_authenticated_user,
    )

    # §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
    # единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
    # должен случиться только после того, как ран закоммичен: иначе фоновая таска
    # `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
    # ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
    # Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
    # frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
    # Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
    try:
        from app.workers.tasks.forecast import forecast_site_finder_report

        forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
    except Exception:
        # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
        # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
        # persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
        logger.warning(
            "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
            cad_num,
            horizon,
            exc_info=True,
        )
        result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}

    return result_payload


@router.get(
    "/{cad_num}/connection-points",
    response_model=ConnectionPointsResponse,
    summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
)
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"],
    )


@router.get(
    "/{cad_num}/utility-infrastructure",
    response_model=UtilityInfrastructureResponse,
    summary="Инженерные сети из OSM вблизи участка (#1746)",
)
def get_parcel_utility_infrastructure(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
) -> UtilityInfrastructureResponse:
    """Инженерные сети (ЛЭП/подстанции/трубопроводы/ЦТП/вышки) из OSM вблизи участка.

    No-B2B open-data источник: osm_utility_infrastructure_ekb (наполняется weekly-sync
    из Overpass). Возвращает фичи в радиусе от центроида участка (ST_DWithin) +
    summary с ближайшими расстояниями по видам сети.

    Query params:
        radius_m: радиус поиска, 50–2000 м (default 500).

    Если участок не найден в БД (нет geom) → 404.
    """
    try:
        data = get_nearby_utility_infrastructure(db, cad_num, radius_m)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    return UtilityInfrastructureResponse(
        features=data["features"],
        summary=data["summary"],
    )


@router.get(
    "/{cad_num}/connection-capacity",
    response_model=ConnectionCapacityResponse,
    summary="Точки подключения со свободной мощностью — электро + вода (Forgejo #2119)",
)
def get_parcel_connection_capacity(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=100, le=10000)] = 3000,
) -> ConnectionCapacityResponse:
    """Центры питания со свободной мощностью + резервы воды вблизи участка (ПТИЦА §3).

    Верифицированные open-data источники (наполняются weekly-sync, RUN-ON-PROD):
      - power_supply_centers: ЦП (ПС 35/110 кВ) Россети WFS + резервы xlsx.
      - water_supply_reserves: ЦСВ/ЦСК Водоканала ЕКБ (city-level, последний период).

    power_points — ЦП в радиусе от центроида участка (ST_DWithin geography).
    water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
    nearby_network_zones — позитив-разрез (#2119 C2/C3): сетевые охранные зоны cad_zouit
    в радиусе 300 м (где физически проходит сеть) — до 2 ближайших на net_kind,
    гуманизированы; пересекающие участок отдаются с distance_m=0. Радиус 300 м —
    независимый от radius_m ЦП.

    Query params:
        radius_m: радиус поиска ЦП, 100–10000 м (default 3000).

    Если участок не найден в БД (нет geom) → 404.
    """
    try:
        data = get_connection_capacity(db, cad_num, radius_m)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    return ConnectionCapacityResponse(
        power_points=data["power_points"],
        power_summary=data["power_summary"],
        water=data["water"],
        gas=data.get("gas"),
        gas_outlet_points=data.get("gas_outlet_points") or [],
        heat=data.get("heat"),
        nearby_network_zones=data.get("nearby_network_zones") or [],
    )


# ── neighbor-buildings (Forgejo #2180) ──────────────────────────────────────
# Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены «Размещение
# застройки» (§7 отчёта ПТИЦА). Отдаёт GeoJSON FeatureCollection упрощённых
# полигонов cad_buildings вокруг участка. Кэш не нужен — один GIST-запрос.
#
# Модуль-level `text(...)` (а не inline в хендлере) чтобы integration-EXPLAIN-
# gate мог проверить SQL против прод-PG (psycopg v3 `:x::type`-антипаттерн,
# несуществующие PostGIS-функции). ::geography приклеен к ЗАКРЫВАЮЩЕЙ скобке —
# это разрешённая форма (не `:bind::type`), bind-числа идут через CAST(:x AS float).
_NEIGHBOR_BUILDINGS_SQL = text("""
    WITH parcel AS (
        SELECT geom
          FROM cad_parcels_geom
         WHERE cad_num = CAST(:cad AS text)
           AND geom IS NOT NULL
         LIMIT 1
    )
    SELECT
        cb.cad_num,
        cb.floors,
        cb.building_name,
        cb.year_built,
        ST_AsGeoJSON(ST_SimplifyPreserveTopology(cb.geom, 0.000005)) AS geojson,
        ST_Distance(cb.geom::geography, (SELECT geom FROM parcel)::geography) AS distance_m
      FROM cad_buildings cb, parcel
     WHERE ST_DWithin(
               cb.geom::geography,
               parcel.geom::geography,
               CAST(:radius AS float)
           )
       AND NOT ST_Intersects(cb.geom, parcel.geom)
     ORDER BY distance_m ASC
     LIMIT 301
""")

# Высота этажа для грубой 3D-экструзии footprint'а (§7). Типовой МКД ≈ 3 м/этаж —
# достаточная точность для превью-сцены «Размещение застройки».
_NEIGHBOR_FLOOR_HEIGHT_M = 3.0
# Отдаём максимум 300 соседей (LIMIT 301 в SQL — «взяли на один больше» чтобы
# без COUNT(*) понять, обрезали ли выборку → truncated).
_NEIGHBOR_MAX_FEATURES = 300


@router.get(
    "/{cad_num}/neighbor-buildings",
    summary="Соседние здания (footprint'ы) для 3D-сцены §7 (Forgejo #2180)",
)
def get_parcel_neighbor_buildings(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=50, le=1000)] = 300,
) -> dict[str, Any]:
    """GeoJSON FeatureCollection footprint'ов соседних зданий вокруг участка.

    Для 3D-сцены «Размещение застройки» (§7 отчёта ПТИЦА): рисуем существующую
    застройку контекста, чтобы концепция вписывалась в окружение. Здания берём из
    `cad_buildings` в радиусе `radius_m` (ST_DWithin по geography). Здания,
    ПЕРЕСЕКАЮЩИЕ сам участок, исключаем — на его месте строим мы (их footprint'ы
    только мешали бы сцене).

    Геометрия упрощается (ST_SimplifyPreserveTopology, ~0.5 м) — фронту не нужна
    кадастровая точность контура для превью. properties каждой фичи:
    cad_num, floors (int|null), height_m (floors×3 если floors>0, иначе null),
    building_name (null если пусто), year_built (int|null), is_neighbor=true.

    Отдаём максимум 300 ближайших (ORDER BY расстоянию); `truncated=true` если в
    радиусе их больше. Участок не найден (нет geom) → 404.

    Query params:
        radius_m: радиус поиска, 50–1000 м (default 300).
    """
    rows = (
        db.execute(
            _NEIGHBOR_BUILDINGS_SQL,
            {"cad": cad_num, "radius": float(radius_m)},
        )
        .mappings()
        .all()
    )

    # Пустой результат может значить И «участка нет в БД», И «участок есть, но
    # соседей в радиусе нет» — надо различать (404 vs пустой 200). Отдельный
    # быстрый EXISTS-пробник участка: без него не отличить «нет geom» от «нет
    # соседей». Только когда rows пуст (в норме — 1 дешёвый запрос сверху).
    if not rows:
        parcel_exists = db.execute(
            text(
                "SELECT 1 FROM cad_parcels_geom "
                "WHERE cad_num = CAST(:cad AS text) AND geom IS NOT NULL LIMIT 1"
            ),
            {"cad": cad_num},
        ).first()
        if parcel_exists is None:
            raise HTTPException(
                status_code=404,
                detail=(
                    f"Геометрия участка {cad_num} не найдена в БД. "
                    "Запустите POST /analyze для дозагрузки."
                ),
            )

    truncated = len(rows) > _NEIGHBOR_MAX_FEATURES
    kept = rows[:_NEIGHBOR_MAX_FEATURES]

    features: list[dict[str, Any]] = []
    for r in kept:
        # geojson — строка из ST_AsGeoJSON; парсим в dict для валидного вложения
        # в FeatureCollection (иначе фронт получит geometry как escaped-строку).
        geom_raw = r.get("geojson")
        if not geom_raw:
            # Топологически битый polygon → ST_AsGeoJSON(NULL). Пропускаем: одна
            # плохая геометрия не должна ронять всю сцену.
            continue
        geometry = json.loads(geom_raw)

        floors = r.get("floors")
        floors_int = int(floors) if floors is not None and int(floors) > 0 else None
        year_built = r.get("year_built")
        building_name = (r.get("building_name") or "").strip() or None

        features.append(
            {
                "type": "Feature",
                "geometry": geometry,
                "properties": {
                    "cad_num": r.get("cad_num"),
                    "floors": floors_int,
                    "height_m": (
                        round(floors_int * _NEIGHBOR_FLOOR_HEIGHT_M, 1)
                        if floors_int is not None
                        else None
                    ),
                    "building_name": building_name,
                    "year_built": int(year_built) if year_built is not None else None,
                    "is_neighbor": True,
                },
            }
        )

    return {
        "type": "FeatureCollection",
        "features": features,
        "count": len(features),
        "radius_m": radius_m,
        "truncated": truncated,
    }


_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}",
        )

    # Получить координаты центроида из доступных геометрий участка.
    # `geom IS NOT NULL` во ВСЕХ ветках UNION (#1201): cad_parcels_geom — VIEW
    # над cad_parcels с nullable geom, 964 prod-строки. Без фильтра NULL-строка
    # ранней ветки скрывает валидную из поздней + ST_Centroid(NULL) → NULL → 500.
    # Зеркало паттерна analyze (parcels.py:1418-1438).
    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 AND geom IS NOT NULL
                UNION ALL
                SELECT geom FROM cad_buildings
                 WHERE cad_num = :c AND geom IS NOT NULL
                UNION ALL
                SELECT geom FROM cad_parcels_geom
                 WHERE cad_num = :c AND geom IS NOT NULL
              ) g
             LIMIT 1
        """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    # belt-and-suspenders: row может вернуться с NULL lat/lon только если в БД
    # есть строка с НЕ-NULL geom но битый ST_Centroid (топологически невалидный
    # polygon). Граница `not coord_row` truthy-проверка на RowMapping её бы НЕ
    # поймала (RowMapping с None-полями всё ещё truthy) → float(None) → 500.
    if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None:
        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)",
)
def get_poi_score(
    cad_num: str,
    db: Annotated[Session, Depends(get_db)],
    radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
    decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight",
) -> PoiScoreResponse:
    """Вернуть top-7 ближайших POI для участка.

    decay=straight (default, B6): weight = (1 / (distance_m + 100)) * category_weight.
    decay=routing (#41): время пешком (ORS /matrix) + per-category radius (минут) +
        per-category decay-кривая. За порогом доступности категории POI выбывает.
        ORS недоступен → graceful fallback на straight-line оценку времени.

    POI берутся из osm_poi_ekb в заданном радиусе (default 2000м), отсортированы
    по weight DESC.
    """
    # Получить координаты центроида участка из геометрических таблиц.
    # См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках
    # UNION (#1201): NULL-строка ранней ветки скрывает валидную из поздней +
    # ST_Centroid(NULL) → NULL → float(None) → 500. Зеркало analyze (parcels.py:1418-1438).
    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 AND geom IS NOT NULL
                    UNION ALL
                    SELECT geom FROM cad_buildings
                     WHERE cad_num = :c AND geom IS NOT NULL
                    UNION ALL
                    SELECT geom FROM cad_parcels_geom
                     WHERE cad_num = :c AND geom IS NOT NULL
                  ) g
                 LIMIT 1
            """),
            {"c": cad_num},
        )
        .mappings()
        .first()
    )

    # belt-and-suspenders: см. get_isochrones — RowMapping с None-полями truthy.
    if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None:
        raise HTTPException(
            status_code=404,
            detail=f"Геометрия для {cad_num} не найдена.",
        )

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

    if decay == "routing":
        return compute_poi_routing_decay(db, cad_num, lat, lon, radius_m=radius_m)
    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.
    """
    # sync get_competitors (несколько db.execute, competitors.py:518) мостится через
    # run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
    try:
        return await run_in_threadpool(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}/competitors-parking", response_model=CompetitorsParkingResponse)
def get_parcel_competitors_parking(
    cad_num: str,
    body: CompetitorsRequest,
    db: Annotated[Session, Depends(get_db)],
) -> CompetitorsParkingResponse:
    """parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze.

    Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через
    PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio.
    Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
    секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после
    отрисовки списка конкурентов.

    sync `def` (не async): premises_lookup изолирует НСПД-async через
    asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI
    исполняет sync-handler в threadpool, не блокируя event loop.
    """
    try:
        return get_competitors_parking(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-parking 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).
    """
    # sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
    # run_in_threadpool — иначе sync DB-IO блокирует event loop.
    try:
        return await run_in_threadpool(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 для тендера.
    """
    # sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
    # write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
    try:
        response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
        pdf_bytes = await run_in_threadpool(
            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


def _filter_competitors_by_dev(
    rows: list[dict[str, Any]], exclude_dev: str | None
) -> list[dict[str, Any]]:
    """Отфильтровать строки-конкуренты по имени застройщика (dev_name).

    `exclude_dev` — CSV нормализованных (casefold) имён застройщиков, которых
    нужно скрыть из блока «Конкуренты» PDF-снимка. Сравнение регистронезависимо.
    Пустой/None `exclude_dev` → список возвращается без изменений. Строки с
    пустым/None dev_name НЕ исключаются (исключаем только явные совпадения).
    """
    if not exclude_dev:
        return rows
    excluded = {s.strip().casefold() for s in exclude_dev.split(",") if s.strip()}
    if not excluded:
        return rows
    kept = [r for r in rows if (r.get("dev_name") or "").strip().casefold() not in excluded]
    dropped = len(rows) - len(kept)
    if dropped:
        logger.info(
            "snapshot_pdf: excluded %d competitors by dev_name filter %s",
            dropped,
            sorted(excluded),
        )
    return kept


@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)],
    exclude_dev: Annotated[
        str | None,
        Query(
            description=(
                "Скрыть конкурентов этих застройщиков (dev_name) из блока «Конкуренты». "
                "Несколько — через запятую. Регистронезависимо. Пример: exclude_dev=Практика"
            )
        ),
    ] = None,
) -> 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-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
    # district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
    # ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
    # traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
    # геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
    try:
        # 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

        # #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/
        # района). 422 — клиентский кейс «данные неполны», не серверная 500.
        if not geom_wkt:
            raise HTTPException(
                status_code=422,
                detail=(
                    f"Геометрия участка {cad_num} отсутствует в БД — "
                    "PDF-снимок недоступен. Запустите POST /analyze для дозагрузки."
                ),
            )

        # 3) POI в радиусе 1 км
        poi_rows: list[dict[str, Any]] = [
            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]] = [
            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()
        ]

        # 4b) Опционально скрыть конкурентов конкретных застройщиков (client-кейс:
        # убрать оффер девелопера X из отчёта для девелопера Y). Без exclude_dev —
        # список идентичен прежнему (zero behavior change).
        competitor_rows = _filter_competitors_by_dev(competitor_rows, exclude_dev)

        # 5) Получить district (через пересечение с ekb_districts)
        district: str | None = None
        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]
    except HTTPException:
        # 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500.
        raise
    except Exception as exc:
        logger.exception("snapshot data fetch failed for %s", cad_num)
        raise HTTPException(status_code=500, detail="Ошибка сбора данных для PDF-снимка") from exc

    # 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты,
    # шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739).
    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.exception("snapshot PDF render failed for %s", cad_num)
        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"'},
    )
