"""Velocity-score — темп продаж конкурентов вокруг участка.

Per #34 D2: утилизация objective_corpus_room_month (еженедельно обновляемые данные).
Ранее использовался domrf_kn_sale_graph (последнее обновление 2026-01, устарел).
Главный demand-сигнал «продастся ли» — среднемесячный объём продаж
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
ЕКБ-медиане по данным Objective.

Fallback (SF#17): если Objective coverage <50% конкурентов в радиусе,
использует rosreestr_deals JOIN по cad_quarter участка (100% coverage по кварталам).

Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
            objective_complex_mapping (domrf_obj_id ↔ objective_complex_name),
            objective_corpus_room_month (project_name, deals_total_vol_m2,
                                         deals_total_count, report_month).
Fallback:   rosreestr_deals (quarter_cad_number, deal_count, period_start_date).

Linkage: domrf_kn_objects.obj_id
         → objective_complex_mapping.domrf_obj_id  (gated: is_reviewed/manual/score≥0.85)
         → objective_complex_mapping.objective_complex_name
         → objective_corpus_room_month.project_name

OBJ-2 (#307): маппинги фильтруются по confidence (_MAPPING_CONFIDENCE_GATE) —
unreviewed low-score auto-matches (#1331/#1333 backfill) исключаются как
false-positive risk.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, Literal

from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
# чем больше знаменатель нормализации, тем ниже velocity_score). Прежнее 4500.0 было
# завышено ~7.6× и латентно занижало score при срабатывании fallback.
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 750.0

# Порог: если доля конкурентов с Objective-маппингом < этого значения,
# пытаемся rosreestr_fallback.
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50

# OBJ-2 (#307): gate objective_complex_mapping by confidence перед использованием
# в velocity. Fuzzy-trgm backfill (#1331/#1333) добавил ~115 auto-matched строк с
# is_reviewed=false и низким match_score (вплоть до 0.50-0.625) — false-positive
# risk, который раздувал/искажал velocity конкурентов.
#
# Принимаем mapping только если:
#   - is_reviewed = TRUE          (человек подтвердил), ИЛИ
#   - match_method = 'manual'     (ручной маппинг, score обычно NULL), ИЛИ
#   - match_score >= 0.85         (AUTO_ACCEPT_THRESHOLD — high-confidence auto,
#                                  vault: fuzzy_trgm 0.85+ надёжен для auto-use).
#
# Строгий gate только на is_reviewed=true дал бы 2 строки из 303 → обнулил бы
# velocity-покрытие; 0.85-порог сохраняет 264/303 EKB-маппингов, отбрасывая 39
# низкоуверенных. Совпадает с AUTO_ACCEPT_THRESHOLD из objective_backfill.py.
_MAPPING_CONFIDENCE_GATE: str = (
    "(cm.is_reviewed = TRUE OR cm.match_method = 'manual' OR cm.match_score >= 0.85)"
)


@dataclass(frozen=True)
class VelocityResult:
    """Результат расчёта velocity-score для участка."""

    competitors_count: int
    monthly_velocity_sqm: float  # avg м²/мес по конкурентам в радиусе
    ekb_median_sqm: float  # benchmark ЕКБ для нормализации
    velocity_score: float  # 0..1 — отношение к benchmark
    confidence: Literal["high", "medium", "low"]
    months_observed: int  # фактически использованных месяцев
    period_start: str  # YYYY-MM
    period_end: str  # YYYY-MM
    sample_competitors: list[dict[str, Any]]  # top-5 для UI
    by_room_bucket: dict[str, dict[str, Any]]  # агрегат по комнатности
    # True если данные есть (objective или rosreestr_fallback).
    # False → нет данных ни из одного источника.
    velocity_data_available: bool = True
    # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу),
    # none (нет данных).
    velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective"
    # SF#1871 P2: честность покрытия / proxy-disclosure (additive — фронт/PDF не ломаются).
    # objective_coverage_pct — доля конкурентов с Objective-маппингом И данными (mapped_ratio×100).
    # 0.0 на early/none-путях, где покрытие неизвестно.
    objective_coverage_pct: float = 0.0
    # proxy_used — True когда объём фабриковался через count×45 (rosreestr_fallback путь),
    # а не брался из реального deals_total_vol_m2 Objective.
    proxy_used: bool = False
    # proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
    proxy_sqm_per_deal: float | None = None
    # Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где
    # deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy,
    # что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 —
    # медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа).
    ekb_median_proxy_fraction: float = 0.0
    # RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
    # м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
    # района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
    # драйвится окно продаж нового проекта в финмодели (он один продавец из многих).
    # None на rosreestr_fallback (квартальный count без per-project декомпозиции —
    # per-project absorption там ill-defined и НЕ должен драйвить график) и на
    # degenerate/zero путях.
    project_absorption_sqm_per_month: float | None = None
    # #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся
    # в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может
    # быть больше), а не честный total. Тот же silent-cap класс, что чинили в
    # utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь —
    # флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне
    # редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не
    # стоит — честная disclosure флагом дешевле.
    competitors_truncated: bool = False

    def as_dict(self) -> dict[str, Any]:
        return {
            "competitors_count": self.competitors_count,
            "monthly_velocity_sqm": round(self.monthly_velocity_sqm, 1),
            "ekb_median_sqm": round(self.ekb_median_sqm, 1),
            "velocity_score": round(self.velocity_score, 3),
            "confidence": self.confidence,
            "months_observed": self.months_observed,
            "period": {"start": self.period_start, "end": self.period_end},
            "sample_competitors": self.sample_competitors,
            "by_room_bucket": self.by_room_bucket,
            "velocity_data_available": self.velocity_data_available,
            "velocity_source": self.velocity_source,
            "objective_coverage_pct": self.objective_coverage_pct,
            "proxy_used": self.proxy_used,
            "proxy_sqm_per_deal": self.proxy_sqm_per_deal,
            "ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3),
            "project_absorption_sqm_per_month": (
                round(self.project_absorption_sqm_per_month, 1)
                if self.project_absorption_sqm_per_month is not None
                else None
            ),
            "competitors_truncated": self.competitors_truncated,
        }


def compute_velocity(
    db: Session,
    parcel_geom_wkt: str,
    radius_km: float = 3.0,
    obj_class: str | None = None,
    months_window: int = 6,
    cad_quarter: str | None = None,
) -> VelocityResult | None:
    """Вычислить velocity-score для участка.

    Алгоритм:
    1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
    2. Взять objective_corpus_room_month за последние months_window месяцев
       через objective_complex_mapping (domrf_obj_id → project_name).
    3. Если Objective coverage < 50% конкурентов → rosreestr_fallback:
       считаем сделки DDU/ДКП в cad_quarter участка за окно.
    4. Нормировать на ЕКБ-медиану → score 0..1.

    Параметры:
        cad_quarter: кадастровый квартал участка (первые 3 сегмента cad_num,
                     например "66:41:0702048"). Используется только для fallback.

    Возвращает None если parcel_geom_wkt невалиден или конкурентов нет.
    """
    # ── Step 1: конкуренты по lat/lon в радиусе ──────────────────────────────
    # DISTINCT ON (obj_id) ORDER BY snapshot_date DESC — latest snapshot only.
    # obj_class в domrf_kn_objects заполнен слабо (много NULL); фильтруем
    # только если явно передан. #38: при NULL реального класса используем
    # obj_class_fallback (yandex_match / price_inference) — реальный obj_class
    # в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
    class_filter = (
        "AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
    )
    # SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
    # db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
    # (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
    try:
        with db.begin_nested():
            comp_rows = (
                db.execute(
                    text(
                        f"""
                    WITH latest_obj AS (
                        SELECT DISTINCT ON (obj_id)
                               obj_id,
                               comm_name,
                               dev_name,
                               -- #38: эффективный класс — реальный, иначе fallback
                               COALESCE(obj_class, obj_class_fallback) AS obj_class,
                               latitude,
                               longitude,
                               district_name
                          FROM domrf_kn_objects
                         WHERE latitude IS NOT NULL
                           AND longitude IS NOT NULL
                           AND region_cd = 66
                           {class_filter}
                         ORDER BY obj_id, snapshot_date DESC NULLS LAST
                    )
                    SELECT
                        o.obj_id,
                        o.comm_name,
                        o.dev_name,
                        o.obj_class,
                        o.district_name,
                        ST_Distance(
                            ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                            ST_Centroid(ST_GeomFromText(:parcel_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(:parcel_wkt, 4326))::geography,
                               :radius_m
                           )
                     ORDER BY distance_m ASC
                     LIMIT 200
                    """
                    ),
                    {
                        "parcel_wkt": parcel_geom_wkt,
                        "radius_m": radius_km * 1000.0,
                        "obj_class": obj_class,
                    },
                )
                .mappings()
                .all()
            )
    except Exception:
        logger.exception("velocity: competitor query failed for wkt=%s", parcel_geom_wkt[:80])
        # SAVEPOINT auto-rollbacks через __exit__ context manager.
        # Outer tx остаётся clean — caller продолжает работать без cascade.
        return None

    if not comp_rows:
        return None

    obj_ids: list[int] = [int(r["obj_id"]) for r in comp_rows]
    competitor_meta: dict[int, dict[str, Any]] = {
        int(r["obj_id"]): {
            "name": r["comm_name"],
            "dev_name": r["dev_name"],
            "obj_class": r["obj_class"],
            "district_name": r["district_name"],
            "distance_m": round(float(r["distance_m"]), 0),
        }
        for r in comp_rows
    }

    # ── Step 2: objective_corpus_room_month за последние N месяцев ───────────
    # Linkage: domrf_obj_id → objective_complex_mapping → project_name →
    #          objective_corpus_room_month.
    # deals_total_vol_m2 = DDU + DKP м² за месяц (primary signal).
    # deals_total_count > 0 — фильтрует месяцы без сделок.
    # LEFT JOIN с objective_complex_mapping: конкуренты без маппинга не
    # выпадают — они включаются в список но с total_sqm=NULL (→ 0.0).
    # GROUP BY domrf_obj_id чтобы сохранить совместимость с caller (obj_id key).
    try:
        with db.begin_nested():
            sales_rows = (
                db.execute(
                    text(
                        f"""
                    WITH all_competitors AS (
                        SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
                    ),
                    mapped AS (
                        SELECT cm.domrf_obj_id AS obj_id,
                               cm.objective_complex_name
                          FROM objective_complex_mapping cm
                         WHERE cm.domrf_obj_id = ANY(:obj_ids)
                           AND {_MAPPING_CONFIDENCE_GATE}
                    )
                    SELECT
                        ac.obj_id,
                        SUM(COALESCE(crm.deals_total_vol_m2,
                                     crm.deals_total_count * 45.0)) AS total_sqm,
                        COUNT(DISTINCT crm.report_month)             AS months_with_data,
                        MIN(crm.report_month)                        AS period_start,
                        MAX(crm.report_month)                        AS period_end,
                        CASE WHEN m.obj_id IS NOT NULL THEN TRUE
                             ELSE FALSE END                          AS has_mapping
                      FROM all_competitors ac
                      LEFT JOIN mapped m ON m.obj_id = ac.obj_id
                      LEFT JOIN objective_corpus_room_month crm
                             ON crm.project_name = m.objective_complex_name
                            AND crm.report_month >= (
                                    CURRENT_DATE - CAST(:window_interval AS interval))
                            AND crm.deals_total_count > 0
                     GROUP BY ac.obj_id, m.obj_id
                    """
                    ),
                    {
                        "obj_ids": obj_ids,
                        "window_interval": f"{months_window} months",
                    },
                )
                .mappings()
                .all()
            )
    except Exception:
        logger.exception("velocity: objective sales query failed for obj_ids=%s", obj_ids[:5])
        # SAVEPOINT auto-rollback'нут — outer tx clean
        return None

    if not sales_rows:
        return None

    # ── Step 2a: проверка Objective coverage ─────────────────────────────────
    # Считаем: mapped_with_data — конкуренты с маппингом И реальными данными.
    # Если mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO → rosreestr_fallback.
    n_total_comps = len(obj_ids)
    mapped_with_data = [
        r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0
    ]
    mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0
    # SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
    coverage_pct = round(mapped_ratio * 100, 1)

    # Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля
    # строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback
    # (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated
    # константу, а не contaminated медиану, так что disclosure не нужен).
    _ekb_median_result = _get_ekb_median(db, months_window=months_window)
    if _ekb_median_result is not None:
        ekb_median, ekb_median_proxy_fraction = _ekb_median_result
    else:
        ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
        ekb_median_proxy_fraction = 0.0
    n_comps = len(comp_rows)
    # #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
    # уперлась в лимит, реальных конкурентов в радиусе может быть больше.
    n_comps_truncated = n_comps >= 200
    sample_no_data = sorted(
        [
            {
                "obj_id": oid,
                **competitor_meta[oid],
                "total_sqm_period": 0.0,
                "by_room_bucket": {},
            }
            for oid in obj_ids[:5]
            if oid in competitor_meta
        ],
        key=lambda x: x["distance_m"],  # type: ignore[index]
    )

    if mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO:
        logger.info(
            "velocity: objective coverage %.0f%% (<%d%%) for %d competitors;"
            " trying rosreestr_fallback cad_quarter=%s",
            mapped_ratio * 100,
            int(_OBJECTIVE_COVERAGE_MIN_RATIO * 100),
            n_total_comps,
            cad_quarter,
        )
        rr_result = _compute_rosreestr_fallback(
            db=db,
            cad_quarter=cad_quarter,
            months_window=months_window,
            n_comps=n_comps,
            ekb_median=ekb_median,
            sample_competitors=sample_no_data,
            objective_coverage_pct=coverage_pct,
            ekb_median_proxy_fraction=ekb_median_proxy_fraction,
            competitors_truncated=n_comps_truncated,
        )
        if rr_result is not None:
            return rr_result
        # Rosreestr тоже пуст — возвращаем none-state.
        logger.info("velocity: rosreestr_fallback also empty for cad_quarter=%s", cad_quarter)
        return VelocityResult(
            competitors_count=n_comps,
            monthly_velocity_sqm=0.0,
            ekb_median_sqm=ekb_median,
            velocity_score=0.0,
            confidence="low",
            months_observed=0,
            period_start="",
            period_end="",
            sample_competitors=sample_no_data,
            by_room_bucket={},
            velocity_data_available=False,
            velocity_source="none",
            objective_coverage_pct=coverage_pct,
            proxy_used=False,
            proxy_sqm_per_deal=None,
            ekb_median_proxy_fraction=ekb_median_proxy_fraction,
            project_absorption_sqm_per_month=None,  # degenerate path — ill-defined
            competitors_truncated=n_comps_truncated,
        )

    # ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
    # Тот же маппинг domrf_obj_id → project_name. Агрегируем по room_bucket
    # для отображения структуры спроса в UI.
    try:
        with db.begin_nested():
            bucket_rows = (
                db.execute(
                    text(
                        f"""
                    WITH mapped AS (
                        SELECT cm.domrf_obj_id AS obj_id,
                               cm.objective_complex_name
                          FROM objective_complex_mapping cm
                         WHERE cm.domrf_obj_id = ANY(:obj_ids)
                           AND {_MAPPING_CONFIDENCE_GATE}
                    )
                    SELECT
                        m.obj_id,
                        crm.room_bucket,
                        SUM(crm.deals_total_count) AS units_sold,
                        SUM(COALESCE(crm.deals_total_vol_m2,
                                     crm.deals_total_count * 45.0)) AS sqm_sold
                      FROM objective_corpus_room_month crm
                      JOIN mapped m ON m.objective_complex_name = crm.project_name
                     WHERE crm.report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
                       AND crm.deals_total_count > 0
                     GROUP BY m.obj_id, crm.room_bucket
                        """
                    ),
                    {
                        "obj_ids": obj_ids,
                        "window_interval": f"{months_window} months",
                    },
                )
                .mappings()
                .all()
            )
    except Exception:
        logger.warning("velocity: bucket breakdown query failed, continuing without it")
        bucket_rows = []

    # Агрегируем по room_bucket поверх всех конкурентов.
    by_bucket_agg: dict[str, dict[str, Any]] = {}
    per_comp_buckets: dict[int, dict[str, int]] = {}

    for row in bucket_rows:
        bucket = str(row["room_bucket"])
        oid = int(row["obj_id"])
        units = int(row["units_sold"] or 0)
        sqm = float(row["sqm_sold"] or 0.0)

        if bucket not in by_bucket_agg:
            by_bucket_agg[bucket] = {"units": 0, "sqm": 0.0, "complexes": set()}
        by_bucket_agg[bucket]["units"] += units
        by_bucket_agg[bucket]["sqm"] += sqm
        by_bucket_agg[bucket]["complexes"].add(oid)

        if oid not in per_comp_buckets:
            per_comp_buckets[oid] = {}
        per_comp_buckets[oid][bucket] = units

    by_room_bucket: dict[str, dict[str, Any]] = {
        bucket: {
            "units": data["units"],
            "sqm": round(data["sqm"], 0),
            "complexes_count": len(data["complexes"]),
        }
        for bucket, data in by_bucket_agg.items()
    }

    # Считаем только по строкам с маппингом — unmapped строки дают total_sqm=NULL.
    mapped_sales_rows = [r for r in sales_rows if bool(r["has_mapping"])]

    total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
    months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
    # Активные mapped-конкуренты: маппинг + реальные продажи в окне (months_with_data>0).
    # LEFT JOIN на crm даёт строку has_mapping=TRUE с total_sqm=NULL→0, months=0 для
    # замаппленных-но-непродающих ЖК — их НЕ считаем при делении/нормализации (#1354, #1382).
    active_sales_rows = [
        r
        for r in mapped_sales_rows
        if int(r["months_with_data"] or 0) > 0 and float(r["total_sqm"] or 0.0) > 0
    ]
    period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
    period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]]
    period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
    period_end = max(period_end_dates).strftime("%Y-%m") if period_end_dates else ""

    # Если mapped-конкурентов нет данных — partial coverage → fallback.
    if months_observed == 0 or total_sqm <= 0:
        logger.info(
            "velocity: %d competitors found, %d mapped, but no sales data in window;"
            " trying rosreestr_fallback",
            len(obj_ids),
            len(mapped_sales_rows),
        )
        rr_result = _compute_rosreestr_fallback(
            db=db,
            cad_quarter=cad_quarter,
            months_window=months_window,
            n_comps=n_comps,
            ekb_median=ekb_median,
            sample_competitors=sample_no_data,
            objective_coverage_pct=coverage_pct,
            ekb_median_proxy_fraction=ekb_median_proxy_fraction,
            competitors_truncated=n_comps_truncated,
        )
        if rr_result is not None:
            return rr_result
        sample_partial = sorted(
            [
                {
                    "obj_id": oid,
                    **competitor_meta[oid],
                    "total_sqm_period": 0.0,
                    "by_room_bucket": {},
                }
                for oid in obj_ids
                if oid in competitor_meta
            ],
            key=lambda x: x["total_sqm_period"],  # type: ignore[arg-type]
            reverse=True,
        )[:5]
        return VelocityResult(
            competitors_count=n_comps,
            monthly_velocity_sqm=0.0,
            ekb_median_sqm=ekb_median,
            velocity_score=0.0,
            confidence="low",
            months_observed=0,
            period_start="",
            period_end="",
            sample_competitors=sample_partial,
            by_room_bucket={},
            velocity_data_available=False,
            velocity_source="none",
            objective_coverage_pct=coverage_pct,
            proxy_used=False,
            proxy_sqm_per_deal=None,
            ekb_median_proxy_fraction=ekb_median_proxy_fraction,
            project_absorption_sqm_per_month=None,  # degenerate path — ill-defined
            competitors_truncated=n_comps_truncated,
        )

    # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
    # Делить суммарный объём на max(месяцев) нельзя: при разнородных окнах
    # (старые ЖК 6 мес, новые 1-2 мес) это размазывает объём новых по всему окну
    # и занижает совокупную месячную скорость района.
    monthly_velocity = sum(
        float(r["total_sqm"] or 0.0) / int(r["months_with_data"]) for r in active_sales_rows
    )

    # ── Step 3: нормализация → score 0..1 ────────────────────────────────────
    # Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
    # Если в радиусе продаётся N × ekb_median → рынок горячий.
    # Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
    # Cap 2×median = «насыщен». Итоговый score 0..1.
    # n_with_sales — только конкуренты с реальными продажами в окне (#1382).
    # mapped-но-непродающие ЖК (has_mapping=TRUE, total_sqm=0) исключены: иначе
    # знаменатель раздувается, а числитель их не учитывает → systematic занижение.
    n_with_sales = len(active_sales_rows)
    denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
    velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))

    # RANK 1: абсорбция ОДНОГО типового конкурента = суммарная скорость района /
    # число продающих ЖК. Драйвит окно продаж нового проекта в DCF (он один из
    # многих продавцов, а НЕ поглощает весь рынок). Только на objective-пути, где
    # есть per-project декомпозиция; иначе None.
    project_absorption: float | None = (
        monthly_velocity / max(n_with_sales, 1)
        if n_with_sales >= 1 and monthly_velocity > 0.0
        else None
    )

    # ── Step 4: confidence ───────────────────────────────────────────────────
    mapped_conf: Literal["high", "medium", "low"]
    if n_comps >= 10 and months_observed >= 5:
        mapped_conf = "high"
    elif n_comps >= 5 and months_observed >= 3:
        mapped_conf = "medium"
    else:
        mapped_conf = "low"

    # ── Step 5: top-5 конкурентов по объёму продаж ───────────────────────────
    sales_by_id: dict[int, float] = {
        int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in mapped_sales_rows
    }
    sample = sorted(
        [
            {
                "obj_id": oid,
                **competitor_meta[oid],
                "total_sqm_period": round(sales_by_id.get(oid, 0.0), 0),
                "by_room_bucket": per_comp_buckets.get(oid, {}),
            }
            for oid in obj_ids
            if oid in competitor_meta
        ],
        key=lambda x: x["total_sqm_period"],
        reverse=True,
    )[:5]

    return VelocityResult(
        competitors_count=n_comps,
        monthly_velocity_sqm=monthly_velocity,
        ekb_median_sqm=ekb_median,
        velocity_score=velocity_score,
        confidence=mapped_conf,
        months_observed=months_observed,
        period_start=period_start,
        period_end=period_end,
        sample_competitors=sample,
        by_room_bucket=by_room_bucket,
        velocity_data_available=True,
        velocity_source="objective",
        objective_coverage_pct=coverage_pct,
        proxy_used=False,
        proxy_sqm_per_deal=None,
        ekb_median_proxy_fraction=ekb_median_proxy_fraction,
        project_absorption_sqm_per_month=project_absorption,
        competitors_truncated=n_comps_truncated,
    )


def _compute_rosreestr_fallback(
    db: Session,
    cad_quarter: str | None,
    months_window: int,
    n_comps: int,
    ekb_median: float,
    sample_competitors: list[dict[str, Any]],
    objective_coverage_pct: float = 0.0,
    ekb_median_proxy_fraction: float = 0.0,
    competitors_truncated: bool = False,
) -> VelocityResult | None:
    """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.

    Считает суммарное число сделок DDU/ДКП в кадастровом квартале за окно months_window.
    Velocity = deal_count / months_window (сделок/мес). Нет разбивки по room_bucket
    (rosreestr не даёт комнатность).

    Возвращает None если cad_quarter не задан или данных нет.
    """
    if not cad_quarter:
        return None

    try:
        with db.begin_nested():
            row = (
                db.execute(
                    text(
                        """
                SELECT
                    SUM(deal_count) AS total_deals,
                    MIN(period_start_date) AS period_start,
                    MAX(period_start_date) AS period_end
                  FROM rosreestr_deals
                 WHERE quarter_cad_number = :cad_quarter
                   AND period_start_date >= (CURRENT_DATE - CAST(:window_interval AS interval))
                   AND doc_type IN ('ДДУ', 'ДКП')
                """
                    ),
                    {
                        "cad_quarter": cad_quarter,
                        "window_interval": f"{months_window} months",
                    },
                )
                .mappings()
                .first()
            )
    except Exception:
        logger.warning("velocity: rosreestr_fallback query failed for cad_quarter=%s", cad_quarter)
        return None

    if row is None or not row["total_deals"] or int(row["total_deals"]) == 0:
        return None

    total_deals = int(row["total_deals"])
    # Сделок/мес — грубый аналог velocity (без м², только count).
    # Умножаем на 45 м² (эмпирика) для совместимости с м²/мес единицами.
    avg_area_per_deal = 45.0  # м² — консервативная оценка для апартаментов ЕКБ
    monthly_velocity_sqm = (total_deals * avg_area_per_deal) / months_window

    # Нормализация относительно ekb_median (один ЖК × 2).
    velocity_score = min(1.0, max(0.0, monthly_velocity_sqm / (ekb_median * 2.0)))

    # Confidence — rosreestr данные менее детализированы, чем Objective.
    rr_confidence: Literal["high", "medium", "low"]
    if total_deals >= 50:
        rr_confidence = "medium"  # max medium для rosreestr — нет комнатности
    else:
        rr_confidence = "low"

    period_start_date = row["period_start"]
    period_end_date = row["period_end"]
    period_start = period_start_date.strftime("%Y-%m") if period_start_date else ""
    period_end = period_end_date.strftime("%Y-%m") if period_end_date else ""

    logger.info(
        "velocity: rosreestr_fallback success cad_quarter=%s"
        " total_deals=%d window=%dm velocity=%.1f sqm/mon",
        cad_quarter,
        total_deals,
        months_window,
        monthly_velocity_sqm,
    )

    return VelocityResult(
        competitors_count=n_comps,
        monthly_velocity_sqm=monthly_velocity_sqm,
        ekb_median_sqm=ekb_median,
        velocity_score=velocity_score,
        confidence=rr_confidence,
        months_observed=months_window,
        period_start=period_start,
        period_end=period_end,
        sample_competitors=sample_competitors,
        by_room_bucket={},  # rosreestr не даёт room_bucket
        velocity_data_available=True,
        velocity_source="rosreestr_fallback",
        objective_coverage_pct=objective_coverage_pct,
        proxy_used=True,  # объём = deal_count × avg_area_per_deal (фабрикуется)
        proxy_sqm_per_deal=avg_area_per_deal,
        # Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан
        # ДО вызова этого fallback) — даже здесь denominator может быть частично
        # contaminated count*45.0-строками.
        ekb_median_proxy_fraction=ekb_median_proxy_fraction,
        # RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
        # per-project absorption ill-defined → None, НЕ драйвит DCF-график.
        project_absorption_sqm_per_month=None,
        competitors_truncated=competitors_truncated,
    )


def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None:
    """ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.

    Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
    Ранее использовался domrf_kn_sale_graph с фильтром region_cd=66.
    objective_corpus_room_month не имеет region_cd — данные Objective'а
    покрывают primarily ЕКБ, что для baseline допустимо.

    Учитываются только ЖК с ≥3 месяцами данных за окно (стабильный сигнал).
    Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.

    Audit #2445 A4: report_month-строки с deals_total_count>0, но
    deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0) —
    тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только
    здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому
    нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе
    продающийся, но без м²-данных ЖК просто выпадает из бенчмарка — тоже
    искажение), но доля таких строк считается и возвращается вызывающему коду
    для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не
    проглатывается молча как чистые данные.

    Возвращает (median, proxy_row_fraction) либо None если данных нет.
    proxy_row_fraction — доля (project, room, month)-строк (среди вошедших в
    медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0,
    а не из реального deals_total_vol_m2. Считается на уровне строк
    objective_corpus_room_month (таблица хранит строку per project×room×month —
    см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне
    report_month, чтобы не менять исходный HAVING-гейт «≥3 месяца данных».
    """
    try:
        with db.begin_nested():
            row = (
                db.execute(
                    text(
                        """
                    WITH source_rows AS (
                        SELECT
                            project_name,
                            report_month,
                            COALESCE(deals_total_vol_m2,
                                     deals_total_count * 45.0) AS row_sqm,
                            (deals_total_vol_m2 IS NULL)        AS is_proxy_row
                          FROM objective_corpus_room_month
                         WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
                           AND deals_total_count > 0
                    ),
                    per_project AS (
                        SELECT
                            project_name,
                            SUM(row_sqm)                              AS total_sqm,
                            COUNT(DISTINCT report_month)               AS months_data,
                            COUNT(*)                                   AS rows_data,
                            COUNT(*) FILTER (WHERE is_proxy_row)       AS proxy_rows
                          FROM source_rows
                         GROUP BY project_name
                        HAVING COUNT(DISTINCT report_month) >= 3
                    ),
                    per_project_velocity AS (
                        SELECT total_sqm / months_data AS velocity,
                               rows_data,
                               proxy_rows
                          FROM per_project
                    )
                    SELECT
                        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median,
                        COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total,
                        COALESCE(SUM(rows_data), 0)  AS rows_total
                      FROM per_project_velocity
                    """
                    ),
                    {"window_interval": f"{months_window} months"},
                )
                .mappings()
                .first()
            )
    except Exception:
        logger.warning("velocity: ekb_median query failed, using fallback")
        # SAVEPOINT auto-rollback'нут
        return None

    if row is None or row["median"] is None:
        return None

    rows_total = int(row["rows_total"] or 0)
    proxy_rows_total = int(row["proxy_rows_total"] or 0)
    proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0
    return float(row["median"]), proxy_fraction
