"""SQL queries for /api/v1/analytics endpoints.

One function per endpoint. All return plain dicts/lists ready for JSON.
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
"""

from __future__ import annotations

import logging
from decimal import Decimal
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)


def _f(value: Any) -> float | None:
    if value is None:
        return None
    if isinstance(value, Decimal):
        return float(value)
    return value


# UI шлёт английские классы (ClassLiteral = "Comfort"/"Comfort+"/"Business"/
# "Elite"), а domrf_kn_objects.obj_class и objective_corpus_room_month.class
# хранят русские названия из скрейпера (domrf_kn.py: "Элит"/"Бизнес"/"Комфорт"/
# "Стандарт"/"Типовой"). Без перевода точный матч obj_class = :cls в comparables/
# competitors/velocity/elasticity молча даёт ноль строк ("Comfort" != "Комфорт").
_CLASS_EN_TO_RU = {
    "Comfort": "Комфорт",
    "Comfort+": "Комфорт",
    "Business": "Бизнес",
    "Elite": "Элит",
}


def _class_to_db_vocab(target_class: str | None) -> str | None:
    """Перевод английского класса из UI в русский словарь БД (obj_class /
    objective class). Неизвестные значения возвращаем как есть (уже могут быть
    русскими, либо новый класс — пусть фильтр решает естественно)."""
    if target_class is None:
        return None
    return _CLASS_EN_TO_RU.get(target_class, target_class)


def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
    rows = (
        db.execute(
            text(
                """
            SELECT snapshot_date, rep_year, rep_month,
                   total_square, sold_perc, price_avg
              FROM domrf_realization
             WHERE region_code = :region_code
               AND endpoint_type = 'total'
               AND type_square = 'total'
             ORDER BY snapshot_date
            """
            ),
            {"region_code": region_code},
        )
        .mappings()
        .all()
    )
    return [
        {
            "snapshot_date": r["snapshot_date"].isoformat(),
            "rep_year": r["rep_year"],
            "rep_month": r["rep_month"],
            "total_square_th_sqm": _f(r["total_square"]),
            "sold_perc": _f(r["sold_perc"]),
            "price_avg": _f(r["price_avg"]),
        }
        for r in rows
    ]


def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
    """source: 'portfolio' (что строится) or 'deals' (реально покупают).

    Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью
    QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" /
    "3-к 60-80" / "80+ м²").

    Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к.
    API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25
    … FROM_100) маппируются на chart-бакеты по средней площади:
      FROM_0_TO_25  (~22 м²)               → "Студии 15-30"
      FROM_25_TO_35 (~31 м², мелкие однушки) → "1-к 30-45"
      FROM_35_TO_45 (~40 м²)               → "1-к 30-45"
      FROM_45_TO_55 (~50 м²)               → "2-к 45-60"
      FROM_55_TO_70 (~62 м²)               → "3-к 60-80"
      FROM_70_TO_85 (~77 м²)               → "3-к 60-80"
      FROM_85_TO_100 (~92 м²)              → "80+ м²"
      FROM_100      (100+ м²)              → "80+ м²"
    """
    if source == "portfolio":
        rows = (
            db.execute(
                text(
                    """
                WITH latest AS (
                    SELECT MAX(snapshot_date) AS snap
                      FROM domrf_flat_area_distribution
                     WHERE region_id = 0
                ),
                mapped AS (
                    SELECT
                        CASE area_bucket
                            WHEN 'FROM_0_TO_25'   THEN '1-Студия'
                            WHEN 'FROM_25_TO_35'  THEN '2-1-к'
                            WHEN 'FROM_35_TO_45'  THEN '2-1-к'
                            WHEN 'FROM_45_TO_55'  THEN '3-2-к'
                            WHEN 'FROM_55_TO_70'  THEN '4-3-к'
                            WHEN 'FROM_70_TO_85'  THEN '4-3-к'
                            WHEN 'FROM_85_TO_100' THEN '5-80+ м²'
                            WHEN 'FROM_100'       THEN '5-80+ м²'
                        END AS chart_bucket,
                        flat_count,
                        area_sqm
                      FROM domrf_flat_area_distribution
                     CROSS JOIN latest
                     WHERE region_id = 0
                       AND room_count_type = 'TOTAL'
                       AND snapshot_date = latest.snap
                )
                SELECT chart_bucket,
                       SUM(flat_count)::bigint AS flat_count,
                       SUM(area_sqm)           AS area_sqm
                  FROM mapped
                 WHERE chart_bucket IS NOT NULL
                 GROUP BY chart_bucket
                 ORDER BY chart_bucket
                """
                ),
                {},
            )
            .mappings()
            .all()
        )
        _pretty = {
            "1-Студия": "Студии 15-30",
            "2-1-к": "1-к 30-45",
            "3-2-к": "2-к 45-60",
            "4-3-к": "3-к 60-80",
            "5-80+ м²": "80+ м²",
        }
        total_flats = sum(r["flat_count"] or 0 for r in rows) or 1
        return [
            {
                "bucket": _pretty[r["chart_bucket"]],
                "flat_count": int(r["flat_count"] or 0),
                "area_sqm": _f(r["area_sqm"]),
                "percent": round((r["flat_count"] or 0) * 100 / total_flats, 1),
                "avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
            }
            for r in rows
        ]

    # deals: bucketize по area_per_unit = area / deal_count (rosreestr
    # с 2025Q1 публикует пакетные ДДУ одной строкой с суммарной area).
    # deal_count — это число квартир в строке; bucket по сырой area без
    # деления попадал в 80+ м² для большинства аггрегаций → перекошенный
    # «парадокс портфеля» (70% 80+ вместо реальных 5%).
    rows = (
        db.execute(
            text(
                """
            WITH per_unit AS (
                SELECT (area / deal_count) AS area_per_unit,
                       price_per_sqm,
                       deal_count
                  FROM rosreestr_deals
                 WHERE region_code = :region_id
                   AND doc_type = 'ДДУ'
                   AND realestate_type_code = '002001003000'
                   AND area > 0 AND deal_count > 0
                   AND (area / deal_count) BETWEEN 15 AND 200
                   AND price_per_sqm > 0
                   -- #1384: скользящее окно вместо захардкоженной даты-пола
                   -- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
                   -- доливки ETL новых report_months → перекос в сторону всё
                   -- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
                   AND period_start_date >= NOW() - make_interval(months => :months_window)
            ),
            bucketed AS (
                SELECT CASE
                         WHEN area_per_unit < 30 THEN '1-Студия'
                         WHEN area_per_unit < 45 THEN '2-1-к'
                         WHEN area_per_unit < 60 THEN '3-2-к'
                         WHEN area_per_unit < 80 THEN '4-3-к'
                         ELSE                        '5-80+ м²'
                       END AS bucket,
                       price_per_sqm,
                       deal_count
                  FROM per_unit
            )
            SELECT bucket,
                   SUM(deal_count)::bigint                         AS deals,
                   PERCENTILE_CONT(0.5) WITHIN GROUP
                       (ORDER BY price_per_sqm)                    AS median_price
              FROM bucketed
             GROUP BY bucket
             ORDER BY bucket
            """
            ),
            # 12 мес — размер окна, эквивалентный исходному '2025-07-01' на момент
            # написания (см. #1384); теперь окно скользит и не растёт со временем.
            {"region_id": region_id, "months_window": 12},
        )
        .mappings()
        .all()
    )
    pretty = {
        "1-Студия": "Студии 15-30",
        "2-1-к": "1-к 30-45",
        "3-2-к": "2-к 45-60",
        "4-3-к": "3-к 60-80",
        "5-80+ м²": "80+ м²",
    }
    total = sum(r["deals"] or 0 for r in rows) or 1
    return [
        {
            "bucket": pretty[r["bucket"]],
            "deals": int(r["deals"] or 0),
            "percent": round((r["deals"] or 0) * 100 / total, 1),
            "median_price": _f(r["median_price"]),
        }
        for r in rows
    ]


def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
    rows = (
        db.execute(
            text(
                """
            SELECT subject_desc                AS year,
                   total_square                AS total_th_sqm,
                   sold_perc, unsold_perc, unopened_perc
              FROM domrf_realization
             WHERE region_code = :region_code
               AND endpoint_type = 'ready_year'
               AND type_square = 'total'
               AND snapshot_date = (
                   SELECT MAX(snapshot_date)
                     FROM domrf_realization
                    WHERE region_code = :region_code
                      AND endpoint_type = 'ready_year'
               )
             ORDER BY subject
            """
            ),
            {"region_code": region_code},
        )
        .mappings()
        .all()
    )
    return [
        {
            "year": r["year"],
            "total_th_sqm": _f(r["total_th_sqm"]),
            "sold_perc": _f(r["sold_perc"]),
            "unsold_perc": _f(r["unsold_perc"]),
            "unopened_perc": _f(r["unopened_perc"]),
        }
        for r in rows
    ]


def districts(db: Session) -> list[dict[str, Any]]:
    rows = (
        db.execute(
            text(
                """
            SELECT d.district_name, d.zk_count, d.flat_count, d.area_m2,
                   d.median_price_per_m2, d.mean_price_per_m2,
                   COALESCE(cq.cad_quarter_count, 0) AS cad_quarter_count
              FROM ekb_districts d
              LEFT JOIN (
                  SELECT district_name,
                         COUNT(*) FILTER (WHERE cad_quarter IS NOT NULL) AS cad_quarter_count
                    FROM v_complex_full
                   WHERE district_name IS NOT NULL
                   GROUP BY district_name
              ) cq ON cq.district_name = d.district_name
             WHERE d.district_name <> 'не определён'
             ORDER BY d.zk_count DESC NULLS LAST
            """
            )
        )
        .mappings()
        .all()
    )
    return [
        {
            "district_name": r["district_name"],
            "zk_count": r["zk_count"],
            "flat_count": r["flat_count"],
            "area_m2": _f(r["area_m2"]),
            "median_price_per_m2": _f(r["median_price_per_m2"]),
            "mean_price_per_m2": _f(r["mean_price_per_m2"]),
            "cad_quarter_count": int(r["cad_quarter_count"]),
        }
        for r in rows
    ]


def yandex_listings(db: Session) -> dict[str, Any]:
    rows = (
        db.execute(
            text(
                """
            SELECT yid, name, developer, obj_class,
                   finished_obj, unfinished_obj,
                   price_from, price_to, address,
                   latitude, longitude, snapshot_date
              FROM yandex_realty_zk
             ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC
            """
            )
        )
        .mappings()
        .all()
    )
    items = [
        {
            "yid": r["yid"],
            "name": r["name"],
            "developer": r["developer"],
            "obj_class": r["obj_class"],
            "flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0),
            "price_from": _f(r["price_from"]),
            "price_to": _f(r["price_to"]),
            "address": r["address"],
            "lat": _f(r["latitude"]),
            "lon": _f(r["longitude"]),
        }
        for r in rows
    ]
    by_class: dict[str, int] = {}
    for it in items:
        by_class[it["obj_class"] or "—"] = by_class.get(it["obj_class"] or "—", 0) + 1
    return {
        "snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None,
        "total": len(items),
        "by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())],
        "items": items,
    }


def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]:
    """Top developers in Sverdl by sqm + Δ sold% over the available history.

    Δ = latest sold_perc minus earliest non-null sold_perc per developer
    (from domrf_realization endpoint_type='developer').
    """
    rows = (
        db.execute(
            text(
                """
            WITH dev_history AS (
                SELECT subject AS developer_id,
                       MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt,
                       MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt
                  FROM domrf_realization
                 WHERE region_code = :region_code
                   AND endpoint_type = 'developer'
                 GROUP BY subject
            ), first_last AS (
                SELECT h.developer_id,
                       (SELECT sold_perc FROM domrf_realization r
                         WHERE r.region_code = :region_code
                           AND r.endpoint_type = 'developer'
                           AND r.subject = h.developer_id
                           AND r.snapshot_date = h.first_dt
                           AND r.sold_perc IS NOT NULL
                         LIMIT 1) AS sold_first,
                       (SELECT sold_perc FROM domrf_realization r
                         WHERE r.region_code = :region_code
                           AND r.endpoint_type = 'developer'
                           AND r.subject = h.developer_id
                           AND r.snapshot_date = h.last_dt
                           AND r.sold_perc IS NOT NULL
                         LIMIT 1) AS sold_last,
                       h.first_dt, h.last_dt
                  FROM dev_history h
            )
            SELECT m.developer_id, m.developer_name,
                   m.jk_count, m.jk_flats_total,
                   m.sverdl_sqm, m.sverdl_sold_pct,
                   m.avg_area_sqm, m.pct_one, m.pct_three_plus,
                   fl.sold_first, fl.sold_last,
                   (fl.sold_last - fl.sold_first)              AS sold_delta_pp,
                   fl.first_dt, fl.last_dt,
                   (SELECT COUNT(*) FROM complexes c
                     WHERE c.developer_id = m.developer_id) AS complexes_count
              FROM v_developer_full_metrics m
              LEFT JOIN first_last fl ON fl.developer_id = m.developer_id
             WHERE m.sverdl_sqm IS NOT NULL
             ORDER BY m.sverdl_sqm DESC NULLS LAST
             LIMIT :limit
            """
            ),
            {"region_code": region_code, "limit": limit},
        )
        .mappings()
        .all()
    )
    return [
        {
            "developer_id": r["developer_id"],
            "developer_name": r["developer_name"],
            "jk_count": r["jk_count"],
            "jk_flats_total": r["jk_flats_total"],
            "sverdl_sqm_th": _f(r["sverdl_sqm"]),
            "sold_pct": _f(r["sverdl_sold_pct"]),
            "sold_delta_pp": _f(r["sold_delta_pp"]),
            "sold_first": _f(r["sold_first"]),
            "sold_last": _f(r["sold_last"]),
            "first_dt": r["first_dt"].isoformat() if r["first_dt"] else None,
            "last_dt": r["last_dt"].isoformat() if r["last_dt"] else None,
            "avg_area_sqm": _f(r["avg_area_sqm"]),
            "pct_one": _f(r["pct_one"]),
            "pct_three_plus": _f(r["pct_three_plus"]),
            "complexes_count": int(r["complexes_count"] or 0),
        }
        for r in rows
    ]


def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None:
    row = (
        db.execute(
            text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"),
            {"dev": developer_id},
        )
        .mappings()
        .first()
    )
    if not row:
        return None
    return {
        "developer_id": row["developer_id"],
        "developer_name": row["developer_name"],
        "jk_count": row["jk_count"],
        "jk_flats_total": row["jk_flats_total"],
        "jk_sqm_total": _f(row["jk_sqm_total"]),
        "jk_ekb": row["jk_ekb"],
        "jk_completed": row["jk_completed"],
        "jk_in_progress": row["jk_in_progress"],
        "jk_escrow": row["jk_escrow"],
        "agg_flats_total": row["agg_flats_total"],
        "agg_one_room": row["agg_one_room"],
        "agg_two_room": row["agg_two_room"],
        "agg_three_room": row["agg_three_room"],
        "agg_four_plus": row["agg_four_plus"],
        "pct_one": _f(row["pct_one"]),
        "pct_three_plus": _f(row["pct_three_plus"]),
        "avg_area_sqm": _f(row["avg_area_sqm"]),
        "sverdl_sqm_th": _f(row["sverdl_sqm"]),
        "sverdl_sold_pct": _f(row["sverdl_sold_pct"]),
        "sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]),
        "sverdl_price_avg": _f(row["sverdl_price_avg"]),
    }


def developer_history(
    db: Session,
    developer_ids: list[str],
    region_code: int = 66,
) -> list[dict[str, Any]]:
    """Per-month sold_perc for one or more developers in the region."""
    rows = (
        db.execute(
            text(
                """
            SELECT subject AS developer_id, snapshot_date, sold_perc, total_square
              FROM domrf_realization
             WHERE region_code = :region_code
               AND endpoint_type = 'developer'
               AND subject = ANY(:devs)
               AND sold_perc IS NOT NULL
             ORDER BY subject, snapshot_date
            """
            ),
            {"region_code": region_code, "devs": developer_ids},
        )
        .mappings()
        .all()
    )
    return [
        {
            "developer_id": r["developer_id"],
            "snapshot_date": r["snapshot_date"].isoformat(),
            "sold_perc": _f(r["sold_perc"]),
            "total_th_sqm": _f(r["total_square"]),
        }
        for r in rows
    ]


def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
    # #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
    # snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
    # ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
    # latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
    # recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
    # (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
    # region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
    # SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
    # дедупа (obj_id, snapshot_date), не с ready_dt.
    rows = (
        db.execute(
            text(
                """
            SELECT obj_id, comm_name, addr, region_cd, flat_count,
                   square_living, ready_dt, obj_class, escrow,
                   problem_flag, latitude, longitude, is_ekb
              FROM (
                  SELECT DISTINCT ON (obj_id)
                         obj_id, comm_name, addr, region_cd, flat_count,
                         square_living, ready_dt, obj_class, escrow,
                         problem_flag, latitude, longitude, is_ekb
                    FROM domrf_kn_objects
                   WHERE dev_id = :dev
                   ORDER BY obj_id, snapshot_date DESC NULLS LAST
              ) latest
             ORDER BY ready_dt DESC NULLS LAST
            """
            ),
            {"dev": developer_id},
        )
        .mappings()
        .all()
    )
    return [
        {
            "obj_id": r["obj_id"],
            "comm_name": r["comm_name"],
            "addr": r["addr"],
            "region_cd": r["region_cd"],
            "flat_count": r["flat_count"],
            "square_living": _f(r["square_living"]),
            "ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
            "obj_class": r["obj_class"],
            "escrow": r["escrow"],
            "problem_flag": r["problem_flag"],
            "lat": _f(r["latitude"]),
            "lon": _f(r["longitude"]),
            "is_ekb": r["is_ekb"],
        }
        for r in rows
    ]


def prinzip_district_distribution(
    db: Session, developer_id: str = "6208_0"
) -> list[dict[str, Any]]:
    """Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons.

    Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district
    через простой COUNT — но в таблице нет геометрии районов. Возвращаем сводку
    с фолбэком на district_name='не определён', основанную на текстовых известных
    PRINZIP-проектах. Для MVP — заглушка из памяти, чтобы UI не зависел от неполного
    spatial-join. TODO: добавить geometry в ekb_districts.
    """
    # Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping.
    known: list[dict[str, Any]] = [
        {"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7},
        {"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6},
        {"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9},
        {"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7},
        {"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7},
        {"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0},
        {"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0},
        {"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0},
    ]
    return known


def prinzip_insights() -> dict[str, Any]:
    """Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph)."""
    return {
        "headline": (
            "PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), "
            "но портфель смещён в сегмент инвесторских студий-однушек, "
            "тогда как рынок голосует деньгами за семейные 60-90 м² "
            "и премиум 80+."
        ),
        "key_gaps": [
            {
                "label": "Средний метраж",
                "prinzip": 38.1,
                "market": 49.0,
                "brusnika": 60.0,
                "forum": 61.0,
                "unit": "м²",
            },
            {
                "label": "Доля 1-к",
                "prinzip": 75.4,
                "market": 52.0,
                "brusnika": 47.0,
                "forum": 44.3,
                "unit": "%",
            },
            {
                "label": "Доля 3-к+",
                "prinzip": 5.4,
                "market": 13.0,
                "brusnika": 18.1,
                "forum": 21.5,
                "unit": "%",
            },
            {
                "label": "sold% Свердл",
                "prinzip": 48.0,
                "market": 29.0,
                "brusnika": 47.0,
                "forum": 54.0,
                "unit": "%",
            },
        ],
        "priorities": [
            {
                "rank": 1,
                "title": "Семейные 60-90 м² (3-к)",
                "why": (
                    "Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). "
                    "Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок "
                    "при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — "
                    "выше текущих 6.15 М CRM."
                ),
            },
            {
                "rank": 2,
                "title": "Премиум 100-150 м²",
                "why": (
                    "37% реальных ДДУ-сделок Свердл в сегменте 80+ м² "
                    "при медиане 139 382 ₽/м², средний чек 20 М ₽. "
                    "Премиум кад.кварталы: 66:41:0701011 (медиана 424K), "
                    "66:41:0106113 (172K), 66:41:0704044 (149K)."
                ),
            },
        ],
        "where_to_build": [
            {
                "district": "Академический",
                "why": (
                    "330 ЖК / 82К квартир — самый большой кластер ЕКБ, "
                    "PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей."
                ),
            },
            {
                "district": "Верх-Исетский (расширение)",
                "why": (
                    "Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², "
                    "ниша бизнес 80-130 м²."
                ),
            },
            {
                "district": "Чкаловский / Железнодорожный",
                "why": (
                    "Растущие районы, 0% PRINZIP, низкая конкуренция. "
                    "Тест 60-80 м² без премиума."
                ),
            },
        ],
        "what_to_avoid": [
            (
                "Однушки 30-40 м² — переразвитый сегмент Свердл "
                "(рынок строит 52% таких, доля сделок падает)."
            ),
            (
                "Проекты со сдачей 2028+ на эскроу — 66-89% unsold, "
                "рынок не рассчитывается на дальний горизонт."
            ),
        ],
        "benchmarks": [
            {
                "name": "Брусника",
                "model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."),
            },
            {
                "name": "Холдинг Форум-групп",
                "model": (
                    "113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
                ),
            },
        ],
    }


# ── Per-object drill-in (поверх extras-таблиц из 51_schema_kn_extras.sql) ────


def object_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
    """Базовая инфа объекта из domrf_kn_objects (последний snapshot).

    Также возвращает buildings_count из v_complex_buildings (0 если зданий нет).
    """
    row = (
        db.execute(
            text(
                """
            SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
                   o.dev_id, o.dev_name, o.floor_min, o.floor_max, o.flat_count,
                   o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
                   o.latitude, o.longitude, o.obj_status, o.snapshot_date,
                   COALESCE(cb.buildings_count, 0) AS buildings_count
              FROM domrf_kn_objects o
              LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
             WHERE o.obj_id = :obj
             ORDER BY o.snapshot_date DESC
             LIMIT 1
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .first()
    )
    if not row:
        return None
    return {
        "obj_id": row["obj_id"],
        "hobj_id": row["hobj_id"],
        "comm_name": row["comm_name"],
        "addr": row["addr"],
        "short_addr": row["short_addr"],
        "region_cd": row["region_cd"],
        "dev_id": row["dev_id"],
        "dev_name": row["dev_name"],
        "floor_min": row["floor_min"],
        "floor_max": row["floor_max"],
        "flat_count": row["flat_count"],
        "square_living": _f(row["square_living"]),
        "ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
        "site_status": row["site_status"],
        "escrow": row["escrow"],
        "obj_class": row["obj_class"],
        "latitude": _f(row["latitude"]),
        "longitude": _f(row["longitude"]),
        "obj_status": row["obj_status"],
        "snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
        "buildings_count": int(row["buildings_count"]),
    }


def object_sale_graph(
    db: Session, obj_id: int, type_filter: str | None = None
) -> list[dict[str, Any]]:
    """Time-series продаж per-ЖК. Latest snapshot.

    NOTE: намеренно оставлен на domrf_kn_sale_graph — это внутренний
    per-object detail view для PRINZIP-аналитики (/api/v1/analytics/object/*).
    Миграция на objective_corpus_room_month требует отдельного pr: там другая
    гранулярность (corpus × room_bucket), а не obj_id.
    Данные stale (newest 2026-01) — приемлемо для исторического графика.
    """
    where_type = ""
    params: dict[str, Any] = {"obj": obj_id}
    if type_filter:
        where_type = "AND type = :type_filter"
        params["type_filter"] = type_filter
    rows = (
        db.execute(
            text(
                f"""
            SELECT obj_id, report_month, type, realised, contracted,
                   area_sq, price_avg, snapshot_date
              FROM domrf_kn_sale_graph
             WHERE obj_id = :obj
               {where_type}
               AND snapshot_date = (
                   SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph
                    WHERE obj_id = :obj {where_type}
               )
             ORDER BY type, report_month
            """
            ),
            params,
        )
        .mappings()
        .all()
    )
    return [
        {
            "report_month": r["report_month"].isoformat() if r["report_month"] else None,
            "type": r["type"],
            "realised": r["realised"],
            "contracted": r["contracted"],
            "area_sq": _f(r["area_sq"]),
            "price_avg": _f(r["price_avg"]),
        }
        for r in rows
    ]


def object_sales_agg(db: Session, obj_id: int) -> list[dict[str, Any]]:
    """3 строки текущих агрегатов: apartments / nonliv / parking."""
    rows = (
        db.execute(
            text(
                """
            SELECT type, name, total, realised, perc, snapshot_date
              FROM domrf_kn_sales_agg
             WHERE obj_id = :obj
               AND snapshot_date = (
                   SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id = :obj
               )
             ORDER BY CASE type
                        WHEN 'apartments' THEN 1
                        WHEN 'nonliv' THEN 2
                        ELSE 3
                      END
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .all()
    )
    return [
        {
            "type": r["type"],
            "name": r["name"],
            "total": r["total"],
            "realised": r["realised"],
            "perc": _f(r["perc"]),
        }
        for r in rows
    ]


def object_infrastructure(
    db: Session,
    obj_id: int,
    category: str | None = None,
    max_distance: int = 5000,
) -> list[dict[str, Any]]:
    """POI вокруг ЖК с фильтром по категории и радиусу."""
    where_cat = "AND poi_category = :cat" if category else ""
    params: dict[str, Any] = {"obj": obj_id, "dist": max_distance}
    if category:
        params["cat"] = category
    rows = (
        db.execute(
            text(
                f"""
            SELECT poi_name, poi_subtitle, poi_category, poi_address,
                   poi_lat, poi_lon, distance_m
              FROM domrf_kn_infrastructure
             WHERE obj_id = :obj
               AND distance_m <= :dist
               {where_cat}
               AND snapshot_date = (
                   SELECT MAX(snapshot_date) FROM domrf_kn_infrastructure WHERE obj_id = :obj
               )
             ORDER BY distance_m ASC
            """
            ),
            params,
        )
        .mappings()
        .all()
    )
    return [
        {
            "poi_name": r["poi_name"],
            "poi_subtitle": r["poi_subtitle"],
            "poi_category": r["poi_category"],
            "poi_address": r["poi_address"],
            "lat": _f(r["poi_lat"]),
            "lon": _f(r["poi_lon"]),
            "distance_m": _f(r["distance_m"]),
        }
        for r in rows
    ]


def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str, Any]]:
    """Фото-метаданные, последние сверху."""
    rows = (
        db.execute(
            text(
                """
            SELECT obj_file_id, ord_num, photo_url, photo_dttm, period_dt,
                   size_bytes, photo_name, ready_desc, build_type, hidden, local_path
              FROM domrf_kn_photos
             WHERE obj_id = :obj AND COALESCE(hidden, FALSE) = FALSE
             ORDER BY period_dt DESC NULLS LAST, ord_num DESC NULLS LAST
             LIMIT :lim
            """
            ),
            {"obj": obj_id, "lim": limit},
        )
        .mappings()
        .all()
    )
    return [
        {
            "obj_file_id": r["obj_file_id"],
            "ord_num": r["ord_num"],
            "photo_url": r["photo_url"],
            # Always serve thumbs through our backend — cached WebP, no upstream
            # latency, no Next.js dev-mode optimizer cold-hit cost.
            "thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb",
            "full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full",
            "photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
            "period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
            "size_bytes": r["size_bytes"],
            "photo_name": r["photo_name"],
            "ready_desc": r["ready_desc"],
            "build_type": r["build_type"],
            "local_path": r["local_path"],
        }
        for r in rows
    ]


def object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
    """Extended object detail — adds all Wave A+B columns (22begh).

    Returns the same base fields as object_detail() PLUS the 30 new columns
    from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns
    that haven't been scraped yet return NULL.
    """
    row = (
        db.execute(
            text(
                """
            SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
                   o.dev_id, o.dev_name, o.dev_group_name,
                   o.floor_min, o.floor_max, o.flat_count,
                   o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
                   o.latitude, o.longitude, o.obj_status, o.snapshot_date,
                   o.energy_eff, o.wall_type,
                   -- Building specs (22e)
                   o.first_floor_type, o.section_count,
                   o.elevators_passenger_count, o.elevators_cargo_count,
                   o.parking_total_slots, o.guest_parking_inside_count,
                   o.guest_parking_outside_count, o.ceiling_height_m,
                   -- Apartment summary (22e)
                   o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2,
                   -- Yard (22e)
                   o.playground_kids_count, o.playground_sport_count,
                   o.has_bike_paths, o.trash_areas_count,
                   -- OVZ (22e)
                   o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift,
                   -- Catalog/UI (22e)
                   o.flat_area_min, o.flat_area_max,
                   o.price_min_rub, o.price_max_rub,
                   o.price_per_m2_min, o.price_per_m2_max,
                   o.parking_provision_pct, o.project_published_at,
                   o.project_declaration_num,
                   -- Metro & scores (22e/22h)
                   o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3,
                   o.domrf_score_location, o.domrf_score_transport,
                   o.domrf_score_infrastructure,
                   COALESCE(cb.buildings_count, 0) AS buildings_count
              FROM domrf_kn_objects o
              LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
             WHERE o.obj_id = :obj
             ORDER BY o.snapshot_date DESC
             LIMIT 1
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .first()
    )
    if not row:
        return None

    metro_top3 = row["metro_top3"]

    return {
        "obj_id": row["obj_id"],
        "hobj_id": row["hobj_id"],
        "comm_name": row["comm_name"],
        "addr": row["addr"],
        "short_addr": row["short_addr"],
        "region_cd": row["region_cd"],
        "dev_id": row["dev_id"],
        "dev_name": row["dev_name"],
        "dev_group_name": row["dev_group_name"],
        "floor_min": row["floor_min"],
        "floor_max": row["floor_max"],
        "flat_count": row["flat_count"],
        "square_living": _f(row["square_living"]),
        "ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
        "site_status": row["site_status"],
        "escrow": row["escrow"],
        "obj_class": row["obj_class"],
        "latitude": _f(row["latitude"]),
        "longitude": _f(row["longitude"]),
        "obj_status": row["obj_status"],
        "snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
        "energy_eff": row["energy_eff"],
        "wall_type": row["wall_type"],
        # Building specs
        "first_floor_type": row["first_floor_type"],
        "section_count": row["section_count"],
        "elevators_passenger_count": row["elevators_passenger_count"],
        "elevators_cargo_count": row["elevators_cargo_count"],
        "parking_total_slots": row["parking_total_slots"],
        "guest_parking_inside_count": row["guest_parking_inside_count"],
        "guest_parking_outside_count": row["guest_parking_outside_count"],
        "ceiling_height_m": _f(row["ceiling_height_m"]),
        # Apartment summary
        "finishing_variants_count": row["finishing_variants_count"],
        "has_free_planning": row["has_free_planning"],
        "avg_flat_area_m2": _f(row["avg_flat_area_m2"]),
        # Yard
        "playground_kids_count": row["playground_kids_count"],
        "playground_sport_count": row["playground_sport_count"],
        "has_bike_paths": row["has_bike_paths"],
        "trash_areas_count": row["trash_areas_count"],
        # OVZ
        "has_ramp": row["has_ramp"],
        "has_low_platforms": row["has_low_platforms"],
        "has_wheelchair_lift": row["has_wheelchair_lift"],
        # Catalog/UI
        "flat_area_min": _f(row["flat_area_min"]),
        "flat_area_max": _f(row["flat_area_max"]),
        "price_min_rub": row["price_min_rub"],
        "price_max_rub": row["price_max_rub"],
        "price_per_m2_min": _f(row["price_per_m2_min"]),
        "price_per_m2_max": _f(row["price_per_m2_max"]),
        "parking_provision_pct": _f(row["parking_provision_pct"]),
        "project_published_at": (
            row["project_published_at"].isoformat() if row["project_published_at"] else None
        ),
        "project_declaration_num": row["project_declaration_num"],
        # Metro & scores
        "metro_nearest_name": row["metro_nearest_name"],
        "metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"],
        "metro_top3": metro_top3,  # already jsonb → dict/list from psycopg3
        "domrf_score_location": row["domrf_score_location"],
        "domrf_score_transport": row["domrf_score_transport"],
        "domrf_score_infrastructure": row["domrf_score_infrastructure"],
        "buildings_count": int(row["buildings_count"]),
    }


def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]:
    """Per-rooms aggregation из domrf_kn_flats для объекта.

    Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL).
    Возвращает count total, count 'free', min/max area, min/max price.
    """
    rows = (
        db.execute(
            text(
                """
            WITH latest AS (
                SELECT MAX(snapshot_date) AS snap
                  FROM domrf_kn_flats
                 WHERE obj_id = :obj
            )
            SELECT
                CASE
                    WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
                        OR LOWER(f.flat_type) LIKE '%nonliv%' THEN 'Нежилые'
                    WHEN f.rooms = 0 THEN 'Студия'
                    WHEN f.rooms = 1 THEN '1-комн.'
                    WHEN f.rooms = 2 THEN '2-комн.'
                    WHEN f.rooms = 3 THEN '3-комн.'
                    ELSE (f.rooms::text || '-комн.')
                END                                         AS room_label,
                -- #1358: sort_key должен совпадать с логическим room_label, а
                -- не с сырым rooms. Нежилой юнит с flat_type LIKE '%нежил%' но
                -- ненулевым rooms (напр. коммерция rooms=2) иначе уходил в
                -- отдельную группу sort_key=2, дробя бакет 'Нежилые' на дубли.
                CASE
                    WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
                        OR LOWER(f.flat_type) LIKE '%nonliv%' THEN -1
                    ELSE f.rooms
                END                                         AS sort_key,
                COUNT(*)                                    AS total_count,
                COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
                                    OR LOWER(f.status) LIKE '%свобод%')
                                                            AS free_count,
                MIN(f.total_area)                           AS area_min,
                MAX(f.total_area)                           AS area_max,
                MIN(f.price_rub) FILTER (WHERE f.price_rub > 0)
                                                            AS price_min,
                MAX(f.price_rub) FILTER (WHERE f.price_rub > 0)
                                                            AS price_max
              FROM domrf_kn_flats f
             CROSS JOIN latest l
             WHERE f.obj_id = :obj
               AND f.snapshot_date = l.snap
             GROUP BY room_label, sort_key
             ORDER BY sort_key
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .all()
    )
    return [
        {
            "room_label": r["room_label"],
            "total_count": r["total_count"],
            "free_count": r["free_count"],
            "area_min": _f(r["area_min"]),
            "area_max": _f(r["area_max"]),
            "price_min": r["price_min"],
            "price_max": r["price_max"],
        }
        for r in rows
    ]


def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]:
    """6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f)."""
    rows = (
        db.execute(
            text(
                """
            SELECT check_type, passed, checked_at
              FROM domrf_obj_checks
             WHERE obj_id = :obj
             ORDER BY check_type
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .all()
    )
    return [
        {
            "check_type": r["check_type"],
            "passed": r["passed"],
            "checked_at": r["checked_at"].isoformat() if r["checked_at"] else None,
        }
        for r in rows
    ]


def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]:
    """PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at."""
    rows = (
        db.execute(
            text(
                """
            SELECT doc_type, doc_num, posted_at, file_url, size_bytes
              FROM domrf_kn_documents
             WHERE obj_id = :obj
             ORDER BY doc_type, posted_at DESC NULLS LAST
            """
            ),
            {"obj": obj_id},
        )
        .mappings()
        .all()
    )
    return [
        {
            "doc_type": r["doc_type"],
            "doc_num": r["doc_num"],
            "posted_at": r["posted_at"].isoformat() if r["posted_at"] else None,
            "file_url": r["file_url"],
            "size_bytes": r["size_bytes"],
        }
        for r in rows
    ]


def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
    """Воронка по месяцам из materialized view."""
    rows = (
        db.execute(
            text(
                """
            SELECT month, source, leads, engaged, converted, conv_pct
              FROM prinzip_funnel_monthly
             WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
             ORDER BY month DESC, leads DESC
            """
            ),
            {"months": months},
        )
        .mappings()
        .all()
    )
    return [
        {
            "month": r["month"].isoformat() if r["month"] else None,
            "source": r["source"],
            "leads": r["leads"],
            "engaged": r["engaged"],
            "converted": r["converted"],
            "conv_pct": _f(r["conv_pct"]),
        }
        for r in rows
    ]


def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, Any]]:
    """Агрегаты по source за последние N месяцев."""
    rows = (
        db.execute(
            text(
                """
            SELECT source,
                   SUM(leads) AS leads,
                   SUM(engaged) AS engaged,
                   SUM(converted) AS converted,
                   ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
              FROM prinzip_funnel_monthly
             WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
             GROUP BY source
             ORDER BY leads DESC
            """
            ),
            {"months": months},
        )
        .mappings()
        .all()
    )
    return [
        {
            "source": r["source"],
            "leads": int(r["leads"] or 0),
            "engaged": int(r["engaged"] or 0),
            "converted": int(r["converted"] or 0),
            "conv_pct": _f(r["conv_pct"]),
        }
        for r in rows
    ]


def prinzip_funnel_by_object(db: Session) -> list[dict[str, Any]]:
    """Conversion per ЖК."""
    rows = (
        db.execute(
            text(
                """
            SELECT obj_id, comm_name, leads_count, deals_count, conv_pct,
                   total_revenue, avg_deal_price
              FROM prinzip_funnel_by_object
             ORDER BY total_revenue DESC NULLS LAST
            """
            ),
        )
        .mappings()
        .all()
    )
    return [
        {
            "obj_id": r["obj_id"],
            "comm_name": r["comm_name"],
            "leads_count": r["leads_count"],
            "deals_count": r["deals_count"],
            "conv_pct": _f(r["conv_pct"]),
            "total_revenue": _f(r["total_revenue"]),
            "avg_deal_price": _f(r["avg_deal_price"]),
        }
        for r in rows
    ]


def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]:
    """Список 28 PRINZIP-ЖК с агрегатами + apartments-velocity sparkline data.

    NOTE: sparkline (velocity CTE) намеренно оставлен на domrf_kn_sale_graph.
    objective_corpus_room_month не имеет obj_id — требует JOIN через
    objective_complex_mapping по project_name. Это отдельная задача рефакторинга
    admin-view. Данные stale (newest 2026-01), sparkline визуально OK для тренда.
    """
    rows = (
        db.execute(
            text(
                """
            WITH agg AS (
                SELECT obj_id, total, realised, perc
                  FROM domrf_kn_sales_agg
                 WHERE type = 'apartments'
                   AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg)
            ),
            velocity AS (
                SELECT obj_id,
                       ARRAY_AGG(realised ORDER BY report_month) AS sparkline_realised,
                       ARRAY_AGG(report_month::text ORDER BY report_month) AS months
                  FROM domrf_kn_sale_graph
                 WHERE type = 'apartments'
                   AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph)
                 GROUP BY obj_id
            )
            SELECT o.obj_id, o.comm_name, o.addr, o.flat_count, o.square_living, o.ready_dt,
                   o.site_status,
                   a.total, a.realised, a.perc,
                   v.sparkline_realised, v.months
              FROM domrf_kn_objects o
              LEFT JOIN agg a ON a.obj_id = o.obj_id
              LEFT JOIN velocity v ON v.obj_id = o.obj_id
             WHERE o.dev_id = '6208_0'
               AND o.snapshot_date = (
                   SELECT MAX(snapshot_date) FROM domrf_kn_objects WHERE dev_id = '6208_0'
               )
             ORDER BY a.total DESC NULLS LAST
            """
            ),
        )
        .mappings()
        .all()
    )
    return [
        {
            "obj_id": r["obj_id"],
            "comm_name": r["comm_name"],
            "addr": r["addr"],
            "flat_count": r["flat_count"],
            "square_living": _f(r["square_living"]),
            "ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
            "site_status": r["site_status"],
            "total": r["total"],
            "realised": r["realised"],
            "perc": _f(r["perc"]),
            "sparkline_realised": (
                list(r["sparkline_realised"]) if r["sparkline_realised"] else []
            ),
            "months": list(r["months"]) if r["months"] else [],
        }
        for r in rows
    ]


# ── Rule-based recommender (Уровень 1) ────────────────────────────────────────

# Pretty-name map shared with quartirography_deals(). Keep IDs sortable so
# bucket ordering is deterministic in the response.
_BUCKET_PRETTY: dict[str, str] = {
    "1-Студия": "Студии 15-30",
    "2-1-к": "1-к 30-45",
    "3-2-к": "2-к 45-60",
    "4-3-к": "3-к 60-80",
    "5-80+ м²": "80+ м²",
}


_BUCKET_SQL = text(
    """
    -- ВАЖНО: rosreestr агрегирует пакетные ДДУ-сделки в одну строку.
    -- Например, 5 квартир по 40 м² одного покупателя → row с
    -- area=200, deal_count=5. Если bucket'ить по сырой area, такая
    -- запись попадает в «80+ м²» хотя реально это 5 квартир «1-к».
    -- Поэтому:
    --   * area_per_unit = area / deal_count (площадь одной квартиры)
    --   * COUNT через SUM(deal_count) — реальное число единиц жилья
    --   * Медианы взвешиваем по deal_count (PERCENTILE_DISC по разворачиванию
    --     не PostgreSQL-friendly; используем PERCENTILE_CONT — приближение,
    --     для редких outliers с deal_count >>1 расхождение <2%)
    WITH per_unit AS (
        SELECT (area / deal_count)               AS area_per_unit,
               price_per_sqm,
               deal_count
          FROM rosreestr_deals
         WHERE region_code = :rc
           AND doc_type = 'ДДУ'
           -- realestate_type_code 002001003000 = квартиры (жилые помещения).
           -- 001 = земельные участки, 002 = нежилые помещения.
           AND realestate_type_code = '002001003000'
           AND area > 10
           -- ВНИМАНИЕ: с 2025Q1 rosreestr резко увеличил агрегацию строк
           -- (1 row = 30+ сделок, area = SUM по пакету). Фильтр по сырой
           -- area отрезает 95% свежих данных. Используем только per-unit
           -- фильтр (15..200 м² — реалистичный диапазон одной квартиры).
           AND deal_count > 0
           AND (area / deal_count) BETWEEN 15 AND 200
           AND price_per_sqm BETWEEN 30000 AND 1000000
           AND period_start_date >= NOW() - make_interval(months => :months_window)
    ),
    bucketed AS (
        SELECT CASE
                 WHEN area_per_unit < 30 THEN '1-Студия'
                 WHEN area_per_unit < 45 THEN '2-1-к'
                 WHEN area_per_unit < 60 THEN '3-2-к'
                 WHEN area_per_unit < 80 THEN '4-3-к'
                 ELSE                        '5-80+ м²'
               END                               AS bucket,
               area_per_unit,
               price_per_sqm,
               deal_count
          FROM per_unit
    )
    SELECT bucket,
           SUM(deal_count)::bigint                                  AS deals,
           SUM(area_per_unit * deal_count) / SUM(deal_count)        AS area_avg,
           PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_per_unit) AS area_median,
           PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqm) AS price_median,
           PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p25,
           PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqm) AS price_p75
      FROM bucketed
     GROUP BY bucket
     ORDER BY bucket
    """
)


def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]:
    return list(
        db.execute(
            _BUCKET_SQL,
            {"rc": region_code, "months_window": months_window},
        )
        .mappings()
        .all()
    )


# Industry-default elasticity used when sale_graph regression is not reliable
# (n<30 or R²<0.1). Negative because higher price ⇒ slower sales.
FALLBACK_ELASTICITY = -1.5


def _velocity_baseline(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
) -> dict[str, Any]:
    """Median monthly sales velocity (apartments/month per ЖК) from
    objective_corpus_room_month for objects in the same район+class over last 24 mo.

    Migrated from domrf_kn_sale_graph (stale since 2026-01) to
    objective_corpus_room_month (updated weekly via Objective API).
    objective_corpus_room_month.district matches domrf_kn_objects.district_name.
    class filter uses 'class' column (Комфорт/Бизнес/Стандарт).

    Returns dict {realised_per_month_median, realised_per_month_avg,
    objects_count, observations}. All-None means no data → caller falls back.
    """
    # Objective class naming: "Комфорт", "Бизнес", "Стандарт" — capitalised.
    # domrf obj_class may differ in case; apply ILIKE for robustness.
    where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
    params: dict[str, Any] = {"dn": district_name}
    if target_class:
        params["cls"] = target_class
    # region_code not used — objective_corpus_room_month covers only EKB (region 66).
    # district filter is sufficient for locality. If district returns no rows,
    # caller falls back to rosreestr_fallback path (unchanged behaviour).
    _ = region_code  # retained in signature for backward compat
    row = (
        db.execute(
            text(
                f"""
                WITH per_project_month AS (
                    SELECT project_name,
                           report_month,
                           SUM(deals_total_count) AS month_units
                      FROM objective_corpus_room_month crm
                     WHERE crm.district = :dn
                       {where_class}
                       AND crm.deals_total_count > 0
                       AND crm.report_month >= NOW() - INTERVAL '24 months'
                     GROUP BY project_name, report_month
                )
                SELECT
                    AVG(month_units)                                           AS avg_pm,
                    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units)   AS median_pm,
                    COUNT(DISTINCT project_name)                               AS objects,
                    COUNT(*)                                                   AS observations
                  FROM per_project_month
                """
            ),
            params,
        )
        .mappings()
        .first()
    )
    if not row:
        return {
            "realised_per_month_avg": None,
            "realised_per_month_median": None,
            "objects_count": 0,
            "observations": 0,
        }
    return {
        "realised_per_month_avg": _f(row["avg_pm"]),
        "realised_per_month_median": _f(row["median_pm"]),
        "objects_count": int(row["objects"] or 0),
        "observations": int(row["observations"] or 0),
    }


def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]:
    """Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок
    (конкуренты много продали, новый проект имеет место). <20% = свежий
    (много инвентаря на продажу, сложнее пробиться).

    Возвращает (median_pct, n_objects). None если <5 ЖК с perc.
    """
    # #1221: domrf_kn_objects/domrf_kn_sales_agg хранят ~3 snapshot'а на obj_id
    # (UNIQUE(obj_id,[type,]snapshot_date), weekly UPSERT, ретенции нет). Без
    # latest-фильтра один ЖК входил в медиану N раз → sold_median тянется старыми
    # perc (sold% растёт во времени → sat_factor занижен), гейт n>=5 проходил уже
    # при 2 ЖК, sat_n завышен ~×N. Фильтруем оба JOIN-партнёра MAX(snapshot_date)
    # (зеркало #1210/#1212, ср. строки 1200/1208 и latest_obj-CTE стр. ~2780
    # «иначе comparables дублируются»). COUNT(DISTINCT a.obj_id) — defensive:
    # после latest-snapshot пары (obj_id, type='apartments') уникальны, но явный
    # DISTINCT исключает регрессию, если кто-то ослабит UNIQUE-constraint.
    row = (
        db.execute(
            text(
                """
                SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median,
                       COUNT(DISTINCT a.obj_id) AS n
                  FROM domrf_kn_sales_agg a
                  JOIN domrf_kn_objects o
                    ON o.obj_id = a.obj_id
                   AND o.snapshot_date = a.snapshot_date
                 WHERE a.type = 'apartments'
                   AND a.perc IS NOT NULL
                   AND o.district_name = :dn
                   AND o.site_status = 'Строящиеся'
                   AND a.snapshot_date = (
                       SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg
                   )
                   AND o.snapshot_date = (
                       SELECT MAX(snapshot_date) FROM domrf_kn_objects
                   )
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if not row or (row["n"] or 0) < 5:
        return None, int(row["n"] or 0) if row else 0
    return _f(row["sold_median"]), int(row["n"])


def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]:
    """Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 —
    остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+.

    Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
    objective_corpus_room_month (обновляется еженедельно).
    deals_total_count заменяет realised (DDU + DKP всего по корпусу).

    Возвращает (ratio, recent_units, prior_units). None если данных мало.
    """
    row = (
        db.execute(
            text(
                """
                -- #1203: rolling 6mo/6mo окна относительно NOW(), а не
                -- захардкоженных дат 2025. С хардкодом recent-окно росло
                -- безгранично (ETL еженедельно доливает report_month) → на
                -- июнь 2026 recent ≈ 11-12мес vs prior=6мес → ratio ≈ 2.0
                -- → trend_factor навсегда упирался в кап 2.0 → bucket_velocity
                -- ×2 → months_to_sellout занижен ×2 в живом recommend_mix.
                SELECT
                    SUM(crm.deals_total_count)
                        FILTER (WHERE crm.report_month >= NOW() - INTERVAL '6 months')
                        AS recent,
                    SUM(crm.deals_total_count)
                        FILTER (WHERE crm.report_month >= NOW() - INTERVAL '12 months'
                                AND crm.report_month <  NOW() - INTERVAL '6 months')
                        AS prior
                  FROM objective_corpus_room_month crm
                 WHERE crm.district = :dn
                   AND crm.deals_total_count > 0
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    recent = int(row["recent"] or 0) if row else 0
    prior = int(row["prior"] or 0) if row else 0
    if prior > 0 and recent > 0:
        return recent / prior, recent, prior
    return None, recent, prior


_POI_WEIGHTS = {
    "Транспорт": 1.5,
    "Метро": 2.0,
    "Образование": 1.2,
    "Медицина": 1.3,
    "Спорт": 1.0,
    "Продукты": 0.8,
    "Развлечения": 0.7,
    "Новостройки": 0.0,  # сами ЖК — не используем как amenity
}


def _district_poi_score(db: Session, *, district_name: str) -> float | None:
    """Среднее по ЖК района: weighted POI count в радиусе 1000м.
    Используем категории-веса (метро/медицина важнее, новостройки игнор).

    Возвращает None если в районе <3 ЖК с POI.
    """
    weights_sql = " ".join(
        [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
    )
    row = (
        db.execute(
            text(
                f"""
                WITH per_obj AS (
                    SELECT i.obj_id,
                           SUM(CASE {weights_sql} ELSE 0.5 END)
                               FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
                               AS weighted_poi
                      FROM domrf_kn_infrastructure i
                      JOIN domrf_kn_objects o
                        ON o.obj_id = i.obj_id
                       AND o.snapshot_date = i.snapshot_date
                     WHERE o.district_name = :dn
                     GROUP BY i.obj_id
                )
                SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n
                  FROM per_obj
                 WHERE weighted_poi > 0
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if not row or (row["n"] or 0) < 3:
        return None
    return _f(row["avg_score"])


def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None:
    """Средний POI score по всему ЕКБ — для нормировки district_poi_score."""
    weights_sql = " ".join(
        [f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
    )
    row = (
        db.execute(
            text(
                f"""
                WITH per_obj AS (
                    SELECT i.obj_id,
                           SUM(CASE {weights_sql} ELSE 0.5 END)
                               FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
                               AS weighted_poi
                      FROM domrf_kn_infrastructure i
                      JOIN domrf_kn_objects o
                        ON o.obj_id = i.obj_id
                       AND o.snapshot_date = i.snapshot_date
                     WHERE o.region_cd = :rc
                       AND o.district_name IS NOT NULL
                     GROUP BY i.obj_id
                )
                SELECT AVG(weighted_poi) AS avg_score
                  FROM per_obj
                 WHERE weighted_poi > 0
                """
            ),
            {"rc": region_code},
        )
        .mappings()
        .first()
    )
    return _f(row["avg_score"]) if row else None


def _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, Any]:
    """Медианная кадастровая стоимость ₽/м² жилых строений в районе через
    spatial-join cad_buildings → ekb_districts_geom. Возвращает None полей,
    если в районе нет cad_buildings со cost_value.

    Используется как cross-check для market price из rosreestr_deals:
    cadastre_vs_market_pct > +50% (рынок сильно дороже кадастра, переоценка)
    или < -30% (рынок дешевле кадастра, аномалия) → warning badge на UI.
    """
    row = (
        db.execute(
            text(
                """
                WITH district_geom AS (
                    SELECT geom
                      FROM ekb_districts_geom
                     WHERE district_name = :dn
                     LIMIT 1
                ),
                buildings_in AS (
                    SELECT
                        cb.cost_value / NULLIF(cb.area, 0) AS price_per_m2
                      FROM cad_buildings cb
                      JOIN district_geom dg
                        ON ST_Intersects(dg.geom, cb.geom)
                     WHERE cb.cost_value IS NOT NULL
                       AND cb.area IS NOT NULL
                       AND cb.area >= 100
                       -- floors INTEGER (Rosreestr ETL приводит к int); NULL = unknown.
                       -- Считаем МКД если floors ≥3 или purpose содержит «многокв».
                       AND (cb.floors >= 3 OR cb.purpose ILIKE '%многокв%')
                       AND (cb.cost_value / NULLIF(cb.area, 0))
                           BETWEEN 5000 AND 500000
                )
                SELECT
                    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2)
                        AS median_per_m2,
                    COUNT(*)::bigint AS n
                  FROM buildings_in
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if not row or row["n"] == 0:
        return {"median_per_m2": None, "buildings_n": 0}
    return {
        "median_per_m2": _f(row["median_per_m2"]),
        "buildings_n": int(row["n"]),
    }


def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
    """Последняя средневзвешенная ставка ИЖК из cbr_mortgage_series.

    ВАЖНО: возвращаем СРЕДНЕВЗВЕШЕННУЮ С льготами (семейная/IT/ДВ-ипотека) —
    это ~7-8%. РЫНОЧНАЯ ставка без льгот в БД отсутствует (она ~20% по
    публикациям ЦБ Янв 2026, но в наших cbr_mortgage_series этой серии нет).

    Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов
    на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим
    'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'.

    #236: period — ТЕКСТ вида 'Февраль 2026', поэтому `ORDER BY period DESC`
    сортировал лексикографически по названию месяца ('Январь' > 'Февраль',
    т.к. «Я» > «Ф») и возвращал устаревшую точку. Сортируем по разобранной
    дате (год DESC, затем порядковый номер месяца DESC). Regex-guard
    `'^[А-Яа-я]+ [0-9]{4}$'` отсекает мусорные строки, где в period лежит
    число (доля ИЖС вида '5.57'), иначе split_part(...)::int упал бы.
    """
    row = (
        db.execute(
            text(
                """
                SELECT value, period
                  FROM cbr_mortgage_series
                 WHERE title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%'
                   AND title ILIKE '%в рублях, %'
                   AND value IS NOT NULL
                   AND value BETWEEN 1 AND 30  -- защита от мусорных
                   AND period ~ '^[А-Яа-я]+ [0-9]{4}$'  -- 'Месяц YYYY', иначе skip
                 ORDER BY
                   split_part(period, ' ', 2)::int DESC,  -- год
                   CASE split_part(period, ' ', 1)        -- порядковый № месяца
                     WHEN 'Январь' THEN 1 WHEN 'Февраль' THEN 2 WHEN 'Март' THEN 3
                     WHEN 'Апрель' THEN 4 WHEN 'Май' THEN 5 WHEN 'Июнь' THEN 6
                     WHEN 'Июль' THEN 7 WHEN 'Август' THEN 8 WHEN 'Сентябрь' THEN 9
                     WHEN 'Октябрь' THEN 10 WHEN 'Ноябрь' THEN 11 WHEN 'Декабрь' THEN 12
                   END DESC
                 LIMIT 1
                """
            )
        )
        .mappings()
        .first()
    )
    if not row:
        return None, None
    return _f(row["value"]), row["period"]


def _recommend_data_last_updated(db: Session, *, region_code: int) -> str | None:
    """#237: дата актуальности данных recommend — MAX(snapshot_date) по основному
    источнику (domrf_kn_objects, ЖК региона). ISO 'YYYY-MM-DD' для UI-индикатора
    свежести; None если в регионе нет объектов с непустым snapshot_date.
    """
    val = db.execute(
        text(
            """
            SELECT MAX(snapshot_date)
              FROM domrf_kn_objects
             WHERE region_cd = :rc
               AND snapshot_date IS NOT NULL
            """
        ),
        {"rc": region_code},
    ).scalar()
    if val is None:
        return None
    return val.isoformat()


def _active_competitors_count(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
) -> tuple[int, str]:
    """N активно строящихся ЖК для нормировки velocity. Каскадный fallback:
    1) (район + класс) — самый узкий
    2) (район) без класса — если первый дал <2
    3) весь регион — если второй дал <2
    Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""

    def _q(where_extras: str, params: dict[str, Any]) -> int:
        # #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
        # (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
        # distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
        # COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
        # (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
        # (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
        #
        # ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
        # а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
        # фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
        # variance across snapshots (authoritative, coordinator 2026-07-08):
        #   dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
        #   site_status=11 (ВОЛАТИЛЬНЫ).
        # → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
        #   как partition-scope). {where_extras} (district_name/obj_class) и
        #   site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
        #   Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
        #   фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
        #   его активным Комфортом, хотя его актуальный класс уже Бизнес.
        n = db.execute(
            text(
                f"""
                WITH latest AS (
                    SELECT DISTINCT ON (obj_id)
                           obj_id, site_status, district_name,
                           obj_class, obj_class_fallback
                      FROM domrf_kn_objects
                     WHERE region_cd = :rc
                     ORDER BY obj_id, snapshot_date DESC NULLS LAST
                )
                SELECT COUNT(*) FROM latest
                 WHERE site_status = 'Строящиеся'
                   {where_extras}
                """
            ),
            params,
        ).scalar()
        return int(n or 0)

    # Tier 1: район + класс (через PostGIS-полигоны district_name)
    # #38: реальный obj_class в приоритете, иначе obj_class_fallback.
    if target_class:
        n = _q(
            "AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
            {"rc": region_code, "dn": district_name, "cls": target_class},
        )
        if n >= 2:
            return n, "district+class"

    # Tier 2: район (без класса — могут быть ЖК где obj_class NULL)
    n = _q(
        "AND district_name = :dn",
        {"rc": region_code, "dn": district_name},
    )
    if n >= 2:
        return n, "district"

    # Tier 3: весь регион (когда район по сути не покрыт скрапером)
    n = _q("", {"rc": region_code})
    if n >= 1:
        return n, "region"

    return 1, "fallback_singleton"


def _velocity_baseline_per_bucket(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
) -> dict[str, float] | None:
    """Per-bucket median velocity (units/month per ЖК) из objective_corpus_room_month.

    Группирует по room_bucket → для каждого бакета вычисляет median(month_units)
    по проектам района/класса за последние 24 месяца.

    Маппинг room_bucket → _BUCKET_PRETTY ключи:
        студия/studio/0 → '1-Студия'
        1               → '2-1-к'
        2               → '3-2-к'
        3               → '4-3-к'
        4/5+            → '5-80+ м²'

    Возвращает dict {bucket_id → median velocity} только для бакетов с данными,
    или None если нет данных совсем (caller переходит на rosreestr-fallback).
    _ region_code retained for backward compat; objective data covers EKB only.
    """
    _ = region_code
    where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
    params: dict[str, Any] = {"dn": district_name}
    if target_class:
        params["cls"] = target_class

    rows = (
        db.execute(
            text(
                f"""
                WITH bucket_mapped AS (
                    SELECT
                        CASE
                            WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
                                THEN '1-Студия'
                            WHEN crm.room_bucket = '1' THEN '2-1-к'
                            WHEN crm.room_bucket = '2' THEN '3-2-к'
                            WHEN crm.room_bucket = '3' THEN '4-3-к'
                            WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
                            ELSE NULL
                        END AS bucket_id,
                        crm.project_name,
                        crm.report_month,
                        crm.deals_total_count
                      FROM objective_corpus_room_month crm
                     WHERE crm.district = :dn
                       {where_class}
                       AND crm.deals_total_count > 0
                       AND crm.report_month >= NOW() - INTERVAL '24 months'
                ),
                per_project_bucket_month AS (
                    SELECT bucket_id,
                           project_name,
                           report_month,
                           SUM(deals_total_count) AS month_units
                      FROM bucket_mapped
                     WHERE bucket_id IS NOT NULL
                     GROUP BY bucket_id, project_name, report_month
                )
                SELECT
                    bucket_id,
                    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm,
                    COUNT(DISTINCT project_name)                              AS objects,
                    COUNT(*)                                                  AS observations
                  FROM per_project_bucket_month
                 GROUP BY bucket_id
                """
            ),
            params,
        )
        .mappings()
        .all()
    )

    if not rows:
        return None

    result: dict[str, float] = {}
    for r in rows:
        v = _f(r["median_pm"])
        if v is not None and int(r["observations"] or 0) >= 3:
            result[r["bucket_id"]] = v

    return result if result else None


def _elasticity_coef(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
    elasticity_window_months: int = 24,
    districts: list[str] | None = None,
) -> dict[str, Any]:
    """Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
    objective_corpus_room_month observations for the same район+class.
    Returns elasticity (slope), R², n.
    Falls back to FALLBACK_ELASTICITY if data thin or regression weak.

    Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
    objective_corpus_room_month (обновляется еженедельно).
    Маппинг: realised → deals_total_count,
             price_avg → deals_total_avg_price_thousand_rub_per_m2.
    LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
    не зависит от единиц (аддитивный сдвиг в LN пространстве).

    District filter (#1211):
      • ``districts is None`` → legacy: фильтр ``crm.district = :dn`` по
        ``district_name`` (back-compat для caller'ов с admin-vocab источником —
        напр. ``analytics_queries`` использует ``domrf_kn_objects.district_name``;
        отдельный bug class).
      • ``districts=[]`` → EKB-wide (без district-фильтра). Используется когда
        резолвер вернул None (вход = 'не определён' / нет чистых алиасов).
      • ``districts=[m1, m2, ...]`` → ``crm.district = ANY(CAST(:districts AS text[]))``.
        ``district_name`` тогда используется только для logging.

      ``objective_corpus_room_month.district`` — МИКРО-вокабуляр ЕКБ (тот же, что
      ``objective_lots.district``: 'Втузгородок', 'ЖБИ', …). Передача сюда
      admin-имени ('Кировский') даёт 0 точек → всегда FALLBACK (silent-correctness
      bug #1211). Резолв admin→micros — обязанность вызывающего.
    """
    where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
    params: dict[str, Any] = {
        "dn": district_name,
        "ew": elasticity_window_months,
    }
    if target_class:
        params["cls"] = target_class
    _ = region_code  # retained for backward compat; objective data covers EKB only

    if districts is None:
        # Legacy path: один district_name (admin-vocab caller'ы остаются как были).
        where_district = "AND crm.district = :dn"
    elif districts:
        # Резолвнутый набор МИКРО-районов — bind через CAST(:x AS text[]).
        where_district = "AND crm.district = ANY(CAST(:districts AS text[]))"
        params["districts"] = list(districts)
    else:
        # Пустой список — EKB-wide fallback (резолвер вернул None → нечего сужать).
        where_district = ""

    row = (
        db.execute(
            text(
                f"""
                WITH pts AS (
                    SELECT
                        LN(crm.deals_total_count::float8)                          AS y,
                        LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8)  AS x
                      FROM objective_corpus_room_month crm
                     WHERE crm.deals_total_count > 0
                       {where_district}
                       {where_class}
                       AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
                       AND crm.report_month >= NOW() - make_interval(months => :ew)
                )
                SELECT
                    regr_slope(y, x) AS slope,
                    regr_r2(y, x)    AS r2,
                    COUNT(*)         AS n
                  FROM pts
                """
            ),
            params,
        )
        .mappings()
        .first()
    )
    n = int(row["n"]) if row and row["n"] is not None else 0
    slope = _f(row["slope"]) if row else None
    r2 = _f(row["r2"]) if row else None
    if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
        return {
            "elasticity": round(slope, 4),
            "r2": round(r2, 4),
            "n": n,
            "source": "regression",
        }
    return {
        "elasticity": FALLBACK_ELASTICITY,
        "r2": r2 or 0.0,
        "n": n,
        "source": "fallback",
    }


def _elasticity_per_bucket_coef(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
    fallback: dict[str, Any],
    elasticity_window_months: int = 24,
) -> dict[str, dict[str, Any]]:
    """Per-bucket эластичность (Tier 3): группируем objective_corpus_room_month
    по room_bucket — регрессия log-log для каждой группы.
    Студии vs 80+ м² реагируют на цену по-разному.

    Мигрировано с domrf_kn_sale_graph + domrf_kn_flats (stale 2026-01) на
    objective_corpus_room_month (обновляется еженедельно).
    objective_corpus_room_month уже содержит room_bucket напрямую — нет
    необходимости в MODE-агрегации domrf_kn_flats.

    Маппинг room_bucket → _BUCKET_PRETTY ключи:
      'студия' → '1-Студия'
      '1'      → '2-1-к'
      '2'      → '3-2-к'
      '3'      → '4-3-к'
      '4'/'5+' → '5-80+ м²'

    Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е
    меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём
    общую эластичность из `fallback` со source='fallback_global'.
    """
    where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
    params: dict[str, Any] = {
        "dn": district_name,
        "ew": elasticity_window_months,
    }
    if target_class:
        params["cls"] = target_class
    _ = region_code  # retained for backward compat; objective data covers EKB only
    rows = (
        db.execute(
            text(
                f"""
                WITH pts AS (
                    SELECT
                        CASE
                            WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
                                THEN '1-Студия'
                            WHEN crm.room_bucket = '1' THEN '2-1-к'
                            WHEN crm.room_bucket = '2' THEN '3-2-к'
                            WHEN crm.room_bucket = '3' THEN '4-3-к'
                            WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
                            ELSE NULL
                        END AS bucket,
                        LN(crm.deals_total_count::float8)                          AS y,
                        LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8)  AS x
                      FROM objective_corpus_room_month crm
                     WHERE crm.district = :dn
                       {where_class}
                       AND crm.deals_total_count > 0
                       AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
                       AND crm.report_month >= NOW() - make_interval(months => :ew)
                )
                SELECT bucket,
                       regr_slope(y, x)  AS slope,
                       regr_r2(y, x)     AS r2,
                       COUNT(*)::bigint  AS n
                  FROM pts
                 WHERE bucket IS NOT NULL
                 GROUP BY bucket
                """
            ),
            params,
        )
        .mappings()
        .all()
    )

    out: dict[str, dict[str, Any]] = {}
    fallback_e = float(fallback["elasticity"])
    by_bucket = {r["bucket"]: r for r in rows}
    for bucket_id, bucket_pretty in _BUCKET_PRETTY.items():
        r = by_bucket.get(bucket_id)
        n_b = int(r["n"]) if r and r["n"] is not None else 0
        slope = _f(r["slope"]) if r else None
        r2 = _f(r["r2"]) if r else None
        if n_b >= 30 and slope is not None and r2 is not None and r2 >= 0.05 and slope < 0:
            out[bucket_pretty] = {
                "elasticity": round(slope, 4),
                "r2": round(r2, 4),
                "n": n_b,
                "source": "regression",
            }
        else:
            out[bucket_pretty] = {
                "elasticity": fallback_e,
                "r2": round(r2, 4) if r2 is not None else 0.0,
                "n": n_b,
                "source": "fallback_global",
            }
    return out


def _noise_penalty_factor(db: Session, district_name: str | None) -> tuple[float, dict]:
    """Penalty к ценам исходя из плотности шумных объектов в районе.

    Returns: (factor in [0.90, 1.0], breakdown dict).
    Чем больше магистралей/жд/промзон — тем ниже factor (max -10%).
    """
    if not district_name:
        return 1.0, {}
    row = (
        db.execute(
            text(
                """
                WITH district_noise AS (
                    SELECT n.source_type, n.road_class, COUNT(*) AS n
                      FROM osm_noise_sources_ekb n
                      JOIN ekb_districts d ON ST_Intersects(n.geom, d.geom)
                     WHERE d.district_name = :dn
                     GROUP BY 1, 2
                )
                SELECT COALESCE(SUM(n), 0) AS total_sources,
                       COALESCE(SUM(CASE WHEN source_type = 'railway' THEN n END), 0) AS railway_n,
                       COALESCE(SUM(CASE WHEN source_type = 'industrial' THEN n END), 0)
                           AS industrial_n,
                       COALESCE(
                           SUM(CASE WHEN road_class IN ('motorway', 'trunk') THEN n END), 0
                       ) AS magistral_n
                  FROM district_noise
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if not row or not row["total_sources"]:
        return 1.0, {"district": district_name, "noise_sources": 0}
    score = (
        float(row["magistral_n"]) * 0.05
        + float(row["railway_n"]) * 0.02
        + float(row["industrial_n"]) * 0.03
    )
    penalty = min(0.10, max(0.0, score / 100))
    factor = 1.0 - penalty
    return round(factor, 4), {
        "district": district_name,
        "magistral_n": int(row["magistral_n"]),
        "railway_n": int(row["railway_n"]),
        "industrial_n": int(row["industrial_n"]),
        "total_sources": int(row["total_sources"]),
        "penalty_pct": round(penalty * 100, 1),
    }


def _competitors_two_dim(
    db: Session,
    *,
    region_code: int,
    district_name: str,
    target_class: str | None,
) -> tuple[int, int, float, str]:
    """Двумерный подсчёт активных конкурентов:
    - radius_n: ЖК в радиусе 3км от центроида района
    - district_only_n: ЖК в районе, но вне 3км радиуса
    - total_weighted = radius_n * 1.0 + district_only_n * 0.6

    Returns (radius_n, district_only_n, total_weighted, scope).
    Если district_name не найден в ekb_districts — падает в старый
    _active_competitors_count с total_weighted = float(competitors).
    """
    # Получаем центроид района для radius-фильтра
    centroid_row = (
        db.execute(
            text(
                """
                SELECT ST_AsText(ST_Centroid(geom)) AS centroid_wkt
                  FROM ekb_districts
                 WHERE district_name = :dn
                 LIMIT 1
                """
            ),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if not centroid_row or not centroid_row["centroid_wkt"]:
        # Fallback: используем старый одномерный счётчик
        n, scope = _active_competitors_count(
            db, region_code=region_code, district_name=district_name, target_class=target_class
        )
        return 0, n, float(n), scope

    # #38: реальный obj_class в приоритете, иначе obj_class_fallback.
    class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
    params: dict[str, Any] = {
        "rc": region_code,
        "dn": district_name,
        "centroid": centroid_row["centroid_wkt"],
    }
    if target_class:
        params["cls"] = target_class

    row = (
        db.execute(
            text(
                f"""
                WITH latest AS (
                    -- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
                    -- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
                    -- предикаты (site_status=11, district_name=1, obj_class=29 per
                    -- прод-замер) — НЕ внутри DISTINCT ON, иначе взяли бы «последний
                    -- снапшот, прошедший фильтр», а не истинно последний (ЖК,
                    -- сменивший класс/район/статус, засчитался бы по устаревшему
                    -- снапшоту). Зеркалит _q() в _active_competitors_count выше.
                    SELECT DISTINCT ON (obj_id)
                           obj_id, latitude, longitude, district_name,
                           site_status, obj_class, obj_class_fallback
                      FROM domrf_kn_objects
                     WHERE region_cd = :rc
                     ORDER BY obj_id, snapshot_date DESC NULLS LAST
                ),
                active AS (
                    -- Волатильные фильтры на истинно-последней строке.
                    SELECT obj_id, latitude, longitude, district_name
                      FROM latest
                     WHERE site_status = 'Строящиеся'
                       AND district_name = :dn
                       {class_filter}
                ),
                centroid AS (
                    SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
                )
                SELECT
                    COUNT(*) FILTER (
                        WHERE ST_DWithin(
                            ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
                            c.pt,
                            3000
                        )
                    ) AS radius_n,
                    COUNT(*) FILTER (
                        WHERE NOT ST_DWithin(
                            ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
                            c.pt,
                            3000
                        )
                    ) AS district_only_n
                  FROM active a, centroid c
                """
            ),
            params,
        )
        .mappings()
        .first()
    )
    radius_n = int(row["radius_n"] or 0) if row else 0
    district_only_n = int(row["district_only_n"] or 0) if row else 0
    total_weighted = radius_n * 1.0 + district_only_n * 0.6
    if total_weighted < 1.0:
        # Нет конкурентов в районе — fallback к старому счётчику (регион)
        n, scope = _active_competitors_count(
            db, region_code=region_code, district_name=district_name, target_class=target_class
        )
        return 0, n, float(max(n, 1)), scope
    return radius_n, district_only_n, max(total_weighted, 1.0), "district_2d"


def _bucket_success_ranking(
    db: Session, district_name: str | None, target_class: str | None
) -> list[dict]:
    """Рейтинг bucket'ов по success_score из v_bucket_success_score.

    Возвращает список dict {bucket, success_score, n_deals, velocity_z,
    price_z, area_z}, sorted DESC by success_score. Пустой список если
    данных нет или district_name не передан.

    target_class ДОЛЖЕН быть в словаре БД (русский) — caller переводит через
    _class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт':
    после миграции 172 view хранит только канонические русские классы
    ('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский
    'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' —
    самый массовый класс ЕКБ (723 объекта) → разумный baseline для
    «класс не указан в запросе». NULL-class строки ('не указан', бывшие
    obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации
    квартирографии лучше показать реальный массовый класс, чем агрегат «не
    указан»; явный запрос с target_class='не указан' их при этом достанет.
    """
    if not district_name:
        return []
    rows = (
        db.execute(
            text(
                """
                SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
                  FROM v_bucket_success_score
                 WHERE district_name = :dn
                   AND obj_class = COALESCE(:cls, 'Комфорт')
                 ORDER BY success_score DESC
                """
            ),
            {"dn": district_name, "cls": target_class},
        )
        .mappings()
        .all()
    )
    return [
        {
            "bucket": r["bucket"],
            "success_score": float(r["success_score"]) if r["success_score"] is not None else 0.0,
            "n_deals": int(r["n_deals"] or 0),
            "velocity_z": float(r["velocity_z"]) if r["velocity_z"] is not None else 0.0,
            "price_z": float(r["price_z"]) if r["price_z"] is not None else 0.0,
            "area_z": float(r["area_z"]) if r["area_z"] is not None else 0.0,
        }
        for r in rows
    ]


def recommend_mix(
    db: Session,
    *,
    district_name: str,
    area_total_m2: float | None = None,
    target_class: str | None = None,
    months_window: int = 24,
    region_code: int = 66,
    price_factor: float = 1.0,
    target_months: int | None = None,
    horizon_months: int | None = None,
    cad_num: str | None = None,
) -> dict[str, Any]:
    """Rule-based квартирография recommender v3.1-v3.4.

    City-wide bucket distribution from rosreestr_deals (последние N месяцев),
    скорректированная на район (через ekb_districts.median_price_per_m2) и
    класс (через yandex_realty_zk price-агрегаты per-class).

    v3.1: noise penalty (-10% max) по osm_noise_sources_ekb
    v3.2: hard-cap comparables по boundaries района
    v3.3: hard-cap 24 мес + elasticity_window_months = 24
    v3.4: success-driven mix из v_bucket_success_score
    """
    warnings: list[str] = []

    # #24 Hard-cap: данные старше 24 мес нерелевантны (ставки ЦБ, ипотека менялись)
    if months_window > 24:
        logger.warning("recommend_mix: months_window=%d > 24, capped to 24", months_window)
        months_window = 24
    elasticity_window_months = 24  # синхронизировано с share_window (issue #24)

    # 1) District lookup
    district_row = (
        db.execute(
            text(
                """
                SELECT district_name, zk_count, flat_count,
                       median_price_per_m2, mean_price_per_m2
                  FROM ekb_districts
                 WHERE district_name ILIKE :dn
                   AND district_name <> 'не определён'
                 LIMIT 1
                """
            ),
            {"dn": district_name.strip()},
        )
        .mappings()
        .first()
    )
    if not district_row:
        return {
            "scope": {"district": district_name, "error": "district unknown"},
            "buckets": [],
            "summary": {
                "total_revenue_rub": None,
                "weighted_avg_price_per_m2": None,
                "warnings": [f"Район '{district_name}' не найден в ekb_districts"],
            },
            "comparables": [],
        }

    district_median = _f(district_row["median_price_per_m2"])
    if district_median is None:
        warnings.append(
            f"В ekb_districts нет median_price_per_m2 для '{district_row['district_name']}',"
            " коэффициент района = 1.0"
        )

    # 2) City-wide median baseline
    city_median = _f(
        db.execute(
            text(
                """
                SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY median_price_per_m2)
                  FROM ekb_districts
                 WHERE median_price_per_m2 IS NOT NULL
                """
            )
        ).scalar()
    )

    district_factor = (
        district_median / city_median
        if (district_median and city_median and city_median > 0)
        else 1.0
    )

    # 3) Class multiplier через yandex_realty_zk + Comfort как BASELINE (×1.0).
    # Раньше делили class_avg/overall_avg где overall = смесь по 12 rows
    # → числа абсурдные (Elite ×1.22, Comfort+ ×0.66 < Comfort).
    # Теперь: ratio(class) = class_price_avg / comfort_price_avg.
    # Реалистичные индустриальные значения: Comfort=1.0, Comfort+=1.02,
    # Business=1.86, Elite=4.27 (на основе текущих 12 rows yandex_realty_zk).
    # yandex_realty_class_prices игнорируем — midpoint бессмыслен (ширина
    # диапазонов класса искажает result).
    # UI шлёт 'Comfort'/'Comfort+'/'Business'/'Elite' → realty_zk: 'COMFORT'/
    # 'COMFORT_PLUS'/'BUSINESS'/'ELITE'.
    class_multiplier = 1.0
    class_multiplier_source: str | None = None
    if target_class:
        zk_norm = {
            "Comfort": "COMFORT",
            "Comfort+": "COMFORT_PLUS",
            "Business": "BUSINESS",
            "Elite": "ELITE",
        }.get(target_class)
        if zk_norm:
            r = (
                db.execute(
                    text(
                        """
                    SELECT
                        AVG(price_from) FILTER (WHERE obj_class = :cls)     AS class_avg,
                        AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg
                      FROM yandex_realty_zk
                     WHERE price_from IS NOT NULL AND price_from > 0
                    """
                    ),
                    {"cls": zk_norm},
                )
                .mappings()
                .first()
            )
            cavg = _f(r["class_avg"]) if r else None
            comfort_avg = _f(r["comfort_avg"]) if r else None
            if cavg and comfort_avg and comfort_avg > 0:
                class_multiplier = cavg / comfort_avg
                class_multiplier_source = "realty_zk_vs_comfort"
            else:
                warnings.append(
                    f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'"
                    " — коэффициент класса = 1.0"
                )

    # 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
    # Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории,
    # 24 мес даёт устойчивые per-bucket медианы для большинства региональных
    # ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес
    # (мах доступного окна), это последний rampe перед deferring к global.
    bucket_rows = _bucket_distribution(db, region_code, months_window)
    effective_window = months_window
    if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
        bucket_rows_27 = _bucket_distribution(db, region_code, 27)
        if bucket_rows_27:
            bucket_rows = bucket_rows_27
            effective_window = 27
            warnings.append(
                f"Окно расширено до 27 мес: при {months_window} мес хотя бы один"
                " бакет имел <30 сделок — оценка была бы шумной"
            )
    total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1

    # 5) Build buckets with adjusted prices + optional allocation
    buckets: list[dict[str, Any]] = []
    weighted_num = 0.0  # Σ area_avg × share × price
    weighted_den = 0.0  # Σ area_avg × share
    total_revenue = 0.0
    have_revenue = False
    for r in bucket_rows:
        bid = r["bucket"]
        deals = int(r["deals"] or 0)
        share = round(deals * 100 / total_deals, 1)
        area_avg = _f(r["area_avg"]) or 0.0
        area_med = _f(r["area_median"]) or 0.0
        p_med_city = _f(r["price_median"]) or 0.0
        p25_city = _f(r["price_p25"]) or 0.0
        p75_city = _f(r["price_p75"]) or 0.0

        adj = district_factor * class_multiplier
        p_med = round(p_med_city * adj, 2)
        p25 = round(p25_city * adj, 2)
        p75 = round(p75_city * adj, 2)

        units_planned: int | None = None
        revenue_planned: float | None = None
        if area_total_m2 and area_avg > 0:
            allocated = area_total_m2 * (share / 100.0)
            units_planned = max(1, round(allocated / area_avg))
            revenue_planned = round(units_planned * area_avg * p_med, 2)
            total_revenue += revenue_planned
            have_revenue = True

        weighted_num += area_avg * share * p_med
        weighted_den += area_avg * share

        if deals < 30:
            warnings.append(
                f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
                f" за {effective_window} мес — оценка с большой погрешностью"
            )

        buckets.append(
            {
                "bucket": _BUCKET_PRETTY.get(bid, bid),
                "share_pct": share,
                "deal_count": deals,
                "area_avg_m2": round(area_avg, 1),
                "area_median_m2": round(area_med, 1),
                "price_median_per_m2": p_med,
                "price_p25_per_m2": p25,
                "price_p75_per_m2": p75,
                "units_planned": units_planned,
                "revenue_planned_rub": revenue_planned,
                # #1359: сырое area_avg сохраняем, чтобы при success-boost
                # перераспределить units/revenue под новые share_pct. Удаляется
                # из ответа перед возвратом.
                "_area_avg_raw": area_avg,
            }
        )

    weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None

    # 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
    # Both are required for the live "цена↔темп" calculator on the frontend.
    # Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
    # баг скрейпера). Если в районе нет ни одного ЖК ИМЕННО запрошенного класса —
    # игнорируем target_class фильтр на уровне velocity/elasticity/comparable
    # запросов, иначе obj_pool пустой и всё падает в fallback.
    # #1375: БД хранит русские названия классов, UI шлёт английские — переводим
    # перед матчем obj_class = :cls, иначе точный матч молча даёт ноль строк
    # ("Comfort" != "Комфорт") и comparables/competitors тихо пустеют.
    target_class_db = _class_to_db_vocab(target_class)
    has_class_data = bool(
        db.execute(
            text(
                """
                SELECT 1 FROM domrf_kn_objects
                 WHERE region_cd = :rc
                   AND district_name = :dn
                   -- #38: реальный класс ИЛИ fallback (yandex_match/price_inf)
                   AND COALESCE(obj_class, obj_class_fallback) IS NOT NULL
                   AND (
                        CAST(:cls AS TEXT) IS NULL
                        OR COALESCE(obj_class, obj_class_fallback) = :cls
                   )
                 LIMIT 1
                """
            ),
            {"rc": region_code, "dn": district_row["district_name"], "cls": target_class_db},
        ).scalar()
    )
    target_class_for_geo = target_class_db if has_class_data else None
    if target_class and not has_class_data:
        warnings.append(
            f"Нет ЖК класса '{target_class}' среди размеченных в районе"
            f" {district_row['district_name']} — фильтр по классу игнорируется в"
            " velocity/comparable (но class_multiplier из yandex_realty_zk"
            " применяется к ценам)."
        )
    vel = _velocity_baseline(
        db,
        region_code=region_code,
        district_name=district_row["district_name"],
        target_class=target_class_for_geo,
    )
    sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
    # velocity_source label: "objective" when data available, "rosreestr_fallback" otherwise.
    # Value key kept as "sale_graph" in output for frontend backward-compat (no breaking change).
    # After fix #574: per-bucket objective data (_velocity_baseline_per_bucket) is used even
    # when aggregate sale_graph_vel_pm is None. velocity_source reflects aggregate source;
    # per-bucket source is tracked in bucket["velocity_source"] added below.
    velocity_source = "objective" if sale_graph_vel_pm is not None else "rosreestr_fallback"

    elast = _elasticity_coef(
        db,
        region_code=region_code,
        district_name=district_row["district_name"],
        target_class=target_class_for_geo,
        elasticity_window_months=elasticity_window_months,
    )
    elasticity = elast["elasticity"]
    if elast["source"] == "fallback":
        warnings.append(
            f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
            f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
            " (недостаточно для регрессии)."
        )

    # Tier 3: per-bucket эластичность. Регрессия sale_graph по
    # «доминирующему bucket» каждого ЖК. Если для bucket'а данных мало —
    # подставляем общую elasticity. Малые сегменты (1-2 студии в районе)
    # таким образом не выкидываются — используем общую кривую.
    elast_per_bucket = _elasticity_per_bucket_coef(
        db,
        region_code=region_code,
        district_name=district_row["district_name"],
        target_class=target_class_for_geo,
        fallback=elast,
        elasticity_window_months=elasticity_window_months,
    )

    # 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n.
    # total_weighted используется как divisor в rosreestr-fallback.
    competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = (
        _competitors_two_dim(
            db,
            region_code=region_code,
            district_name=district_row["district_name"],
            target_class=target_class_for_geo,
        )
    )
    # Обратная совместимость: одномерный счётчик для warnings
    competitors = round(competitors_weighted)
    if competitors_scope == "fallback_singleton":
        warnings.append(
            f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
            f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
        )
    elif competitors_scope not in ("district+class", "district_2d"):
        scope_label = {
            "district": f"районе {district_row['district_name']} (без класса)",
            "region": f"регионе {region_code} (вне района)",
        }.get(competitors_scope, competitors_scope)
        warnings.append(
            f"Конкурентов класса '{target_class or '*'}' в районе мало —"
            f" нормировка по {competitors} ЖК в {scope_label}."
        )

    # 5b-2) Per-bucket market velocity (fix #574: per-bucket formula, realistic срок).
    #
    # BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем
    #   bucket_v = market_vel_pm × share/100 → all buckets scale proportionally →
    #   aggregate velocity ≡ market_vel_pm независимо от mix (slider static bug).
    #   При competitors_district≈1 получаем 159 кв/мес (темп РЫНКА, не проекта).
    #
    # FIX: per-bucket velocity вычисляется независимо для каждого бакета:
    #   objective path: _velocity_baseline_per_bucket → median per ЖК per room_bucket
    #   rosreestr fallback: bucket_deals / months / n_comp (district+class competitors)
    #
    # Это позволяет mix-слайдерам реально менять aggregate KPI, т.к. velocities
    # студий, 1к и т.д. теперь независимые константы, не производные от share.

    # Objective path: per-bucket velocities из objective_corpus_room_month
    obj_per_bucket = _velocity_baseline_per_bucket(
        db,
        region_code=region_code,
        district_name=district_row["district_name"],
        target_class=target_class_for_geo,
    )

    # n_comp — district+class конкуренты (_active_competitors_count каскад,
    # уже вычислено выше как competitors_weighted). Делим темп РЫНКА района/класса
    # на число активных ЖК этого района/класса → среднерыночный темп одного проекта.
    # НЕ region-wide (~442 ЖК) — это давало срок 379-1180 мес (нереалистично).
    n_comp = max(round(competitors_weighted), 1)  # district+class competitors, NOT region-wide

    # Aggregate market_vel_pm (сохраняем для scope/output, не для bucket расчётов)
    if sale_graph_vel_pm is not None:
        market_vel_pm = sale_graph_vel_pm
    else:
        # Rosreestr fallback aggregate: district+class deals / months / n_comp =
        # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
        market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
        warnings.append(
            f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
            f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
        )

    # 5b-2.5) Per-bucket market velocity (fix #574).
    #
    # Objective path: используем per-bucket velocities из objective_corpus_room_month.
    #   Для бакетов без данных в objective — fallback к rosreestr per-bucket.
    # Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors).
    #   КРИТИЧНО: bucket_deal_counts — это MARKET deal counts (независимы от share_pct
    #   пользователя), поэтому per-bucket velocities — независимые константы →
    #   mix-слайдеры реально влияют на aggregate KPI (static-mix bug fix).
    bucket_deal_counts = {r["bucket"]: int(r["deals"] or 0) for r in bucket_rows}

    bucket_market_velocities: dict[str, float] = {}
    for b in buckets:
        bkey = b["bucket"]
        bkt_id = next((k for k, v in _BUCKET_PRETTY.items() if v == bkey), bkey)
        # Objective per-bucket (preferred): median units/month per ЖК в этом бакете
        if obj_per_bucket and bkt_id in obj_per_bucket:
            bucket_market_velocities[bkey] = obj_per_bucket[bkt_id]
        else:
            # Rosreestr fallback per-bucket: market bucket_deals / months / n_comp.
            # n_comp — district+class competitors (НЕ region-wide ~442), иначе срок
            # выходит 379-1180 мес. bucket_deal_counts — market (не user share).
            raw_deals = bucket_deal_counts.get(bkt_id, 0)
            bucket_market_velocities[bkey] = raw_deals / max(effective_window, 1) / n_comp

    # 5b-2.5) Дополнительные district-specific signals (Tier 2):
    #   sat_factor   — насколько зрелый рынок района (median sold% активных
    #                  ЖК). >50% = зрелый, новый проект имеет место, +bonus.
    #                  <20% = свежий, много инвентаря, -penalty.
    #   trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы
    #                  экстремум не разрушал расчёты.
    #   poi_factor   — weighted POI density района / city avg. ±5% на цены.
    sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"])
    sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0

    trend_ratio, trend_recent, trend_prior = _district_velocity_trend(
        db, district_name=district_row["district_name"]
    )
    trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0

    poi_score = _district_poi_score(db, district_name=district_row["district_name"])
    city_avg_poi = _city_avg_poi_score(db, region_code=region_code)

    # Cadastre cross-check: медианная кадастровая стоимость ₽/м² района через
    # cad_buildings → ekb_districts spatial-join. Аномалии (рынок vs кадастр)
    # выводятся как warning-цена в RecommendVelocityPanel.
    cadastre = _district_cadastre_baseline(db, district_name=district_row["district_name"])
    poi_factor = (
        1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05
        if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0)
        else 1.0
    )

    mortgage_rate, mortgage_period = _current_mortgage_rate(db)

    # #237: индикатор актуальности данных — MAX(snapshot_date) основного источника
    data_last_updated = _recommend_data_last_updated(db, region_code=region_code)

    # #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
    noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])

    # #25 Success-driven ranking из v_bucket_success_score.
    # #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский
    # target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" !=
    # "Комфорт"), success-boost тихо деградирует в [].
    success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db)

    # 5b-3) Per-bucket project velocity at price_factor=1.0:
    #   bucket_market_v   = per-bucket velocity из objective или rosreestr/N_active_region.
    #                       После fix #574: каждый бакет имеет независимую скорость
    #                       (не производную от share) → mix-слайдеры реально меняют KPI.
    #   project_velocity  = bucket_market_v × sat_factor × trend_factor
    #                       sat — зрелый рынок ускоряет; trend — текущая
    #                       динамика (горит/остывает).
    #   adjusted          = project_velocity × price_factor^elasticity
    #   months_to_sellout = units_planned / adjusted
    # Цены корректируются на poi_factor (развитость района = премиум)
    # и noise_penalty (шумное окружение = дисконт).
    pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
    macro_velocity_mult = sat_factor * trend_factor
    # Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт
    combined_price_factor = poi_factor * noise_penalty
    total_units = 0
    for b in buckets:
        bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
        bkt_id_for_src = next(
            (k for k, v in _BUCKET_PRETTY.items() if v == b["bucket"]), b["bucket"]
        )
        b["velocity_source"] = (
            "objective_per_bucket"
            if (obj_per_bucket and bkt_id_for_src in obj_per_bucket)
            else "rosreestr_fallback"
        )
        bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4)
        b["velocity_per_month"] = bucket_velocity
        # Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty).
        be = elast_per_bucket.get(b["bucket"]) or {}
        bucket_elasticity = float(be.get("elasticity", elasticity))
        bucket_pf_pow = price_factor**bucket_elasticity if price_factor > 0 else 1.0
        b["elasticity"] = bucket_elasticity
        b["elasticity_r2"] = be.get("r2", 0.0)
        b["elasticity_n"] = be.get("n", 0)
        b["elasticity_source"] = be.get("source", "fallback_global")
        # POI-корректировка + noise penalty на цены (ВСЕ p25/median/p75)
        b["price_median_per_m2"] = round(b["price_median_per_m2"] * combined_price_factor, 2)
        b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * combined_price_factor, 2)
        b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * combined_price_factor, 2)
        b["is_top_success"] = False
        if b["units_planned"] and bucket_velocity > 0:
            # Revenue тоже пересчитываем после combined-correction (linear scale).
            if b["revenue_planned_rub"] is not None:
                b["revenue_planned_rub"] = round(
                    b["revenue_planned_rub"] * combined_price_factor, 2
                )
            adjusted_velocity = bucket_velocity * bucket_pf_pow
            b["months_to_sellout"] = (
                round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
            )
            total_units += b["units_planned"]
        else:
            b["months_to_sellout"] = None
    # Итог revenue + weighted_avg_price после POI-correction + noise penalty.
    if have_revenue:
        total_revenue *= combined_price_factor
    if weighted_avg_price is not None:
        weighted_avg_price = round(weighted_avg_price * combined_price_factor, 2)

    # #25 Success-driven mix: поднимаем долю top-success bucket'а на 10%,
    # пропорционально уменьшаем остальные. Условие: success_score > 0 AND n_deals >= 30.
    if success_ranking:
        top = next(
            (r for r in success_ranking if r["success_score"] > 0 and r["n_deals"] >= 30),
            None,
        )
        if top:
            top_bucket_name = top["bucket"]
            # Найти bucket в списке по имени
            top_b = next((b for b in buckets if b["bucket"] == top_bucket_name), None)
            if top_b is not None:
                boost = top_b["share_pct"] * 0.10  # +10%
                top_b["share_pct"] = round(top_b["share_pct"] + boost, 1)
                top_b["is_top_success"] = True
                # Пропорционально уменьшаем остальные чтобы sum = 100
                other_sum = sum(b["share_pct"] for b in buckets if b["bucket"] != top_bucket_name)
                if other_sum > 0:
                    scale = (100.0 - top_b["share_pct"]) / other_sum
                    for b in buckets:
                        if b["bucket"] != top_bucket_name:
                            b["share_pct"] = round(b["share_pct"] * scale, 1)

                # #1576: success-boost изменил share_pct → средневзвешенная цена
                # должна пересчитаться под новые доли, иначе weighted_avg_price
                # остаётся от ДО-boost микса (пробел в фиксе #1359, который
                # обновлял только units/revenue). price_median_per_m2 уже включает
                # combined_price_factor (line 2743), поэтому домножать НЕ нужно —
                # это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
                # независимо от area_total_m2, поэтому вне ветки area_total_m2.
                wnum = sum(
                    b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
                    for b in buckets
                    if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
                )
                wden = sum(
                    b["_area_avg_raw"] * b["share_pct"]
                    for b in buckets
                    if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
                )
                weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None

                # #1359: success-boost изменил share_pct → перераспределяем
                # units/revenue/months_to_sellout и агрегаты под новые доли,
                # иначе share_pct рассогласуется с units_planned/revenue/sellout
                # (UI показывал бы 40% доли при units от старых 30%).
                if area_total_m2:
                    total_units = 0
                    total_revenue = 0.0
                    for b in buckets:
                        area_avg = b["_area_avg_raw"]
                        if area_avg and area_avg > 0:
                            allocated = area_total_m2 * (b["share_pct"] / 100.0)
                            units = max(1, round(allocated / area_avg))
                            b["units_planned"] = units
                            # price_median_per_m2 уже с combined_price_factor —
                            # revenue считаем по скорректированной цене.
                            revenue = round(units * area_avg * b["price_median_per_m2"], 2)
                            b["revenue_planned_rub"] = revenue
                            total_revenue += revenue
                            bucket_velocity = b["velocity_per_month"] or 0.0
                            be_val = b.get("elasticity")
                            bpf = (
                                price_factor**be_val
                                if (be_val is not None and price_factor > 0)
                                else 1.0
                            )
                            adj_vel = bucket_velocity * bpf
                            b["months_to_sellout"] = (
                                round(units / adj_vel, 1) if adj_vel > 0 else None
                            )
                            total_units += units
                    # total_revenue/weighted_avg_price уже включают
                    # combined_price_factor (через per-bucket price_median_per_m2),
                    # повторно домножать НЕ нужно.
                    have_revenue = total_revenue > 0

    # 5c) Inverse mode: target_months → required price_factor.
    # Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
    # → агрегатная только когда нужна одна цифра). При smooth-buckets разница
    # с глобальной невелика, но если bucket-mix сильно перекошен в одну сторону —
    # weighted-эластичность точнее отражает поведение портфеля.
    required_price_factor: float | None = None
    weighted_elasticity = elasticity
    if total_units > 0:
        weighted_elasticity = (
            sum(
                (b.get("elasticity") or elasticity) * (b.get("units_planned") or 0) for b in buckets
            )
            / total_units
        )
    if target_months and total_units > 0:
        base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
        if base_total_velocity > 0 and weighted_elasticity != 0:
            required_velocity = total_units / target_months
            ratio = required_velocity / base_total_velocity
            try:
                required_price_factor = round(ratio ** (1.0 / weighted_elasticity), 4)
            except Exception:
                required_price_factor = None
            if required_price_factor and required_price_factor < 0.7:
                warnings.append(
                    f"Целевой срок {target_months} мес требует скидки"
                    f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
                    " сдвиг ассортимента в сторону ликвидных бакетов."
                )

    # 5d) Liquidity score (0-100): % units sold within 24 months.
    liquidity_24mo: float | None = None
    if total_units > 0:
        sold_24mo = 0.0
        for b in buckets:
            mts = b["months_to_sellout"]
            up = b["units_planned"] or 0
            if up <= 0 or mts is None or mts <= 0:
                continue
            frac = min(1.0, 24.0 / mts)
            sold_24mo += frac * up
        liquidity_24mo = round(sold_24mo / total_units * 100, 1)

    # 5e) Aggregate KPIs. Total months_to_sellout считаем как сумму
    # bucket-уровневых adjusted velocities (каждая со своим pf_pow по своей
    # эластичности) — иначе перекос в bucket-mix искажает агрегат.
    months_to_sellout_total: float | None = None
    base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
    adjusted_total_v = 0.0
    for b in buckets:
        v = b.get("velocity_per_month") or 0
        be = b.get("elasticity")
        bpf = price_factor**be if (be is not None and price_factor > 0) else pf_pow
        adjusted_total_v += v * bpf
    if total_units > 0 and adjusted_total_v > 0:
        months_to_sellout_total = round(total_units / adjusted_total_v, 1)
    avg_ticket = (
        round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None
    )

    # 6) Comparable ЖК — same district (parsed from addr) and class.
    # #22 Hard-cap по границам: фильтруем по ST_Within чтобы исключить ЖК
    # у границы района, формально в domrf по district_name, но реально за
    # пределами полигона (координаты из v_complex_full). ЖК без координат
    # (latitude/longitude NULL) — пропускаем через LEFT JOIN + фильтр.
    cmp_rows = (
        db.execute(
            text(
                """
                WITH latest_agg AS (
                    SELECT obj_id, MAX(snapshot_date) AS snap
                      FROM domrf_kn_sales_agg
                     WHERE type = 'apartments'
                     GROUP BY obj_id
                ),
                vcf_dedup AS (
                    -- один ряд на canonical_name: берём с наибольшим cad_buildings_n
                    SELECT DISTINCT ON (lower(canonical_name))
                           lower(canonical_name) AS name_key,
                           cad_quarter,
                           latitude,
                           longitude,
                           cad_buildings_n
                      FROM v_complex_full
                     ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST
                ),
                district_geom AS (
                    SELECT geom FROM ekb_districts WHERE district_name = :dn LIMIT 1
                ),
                latest_obj AS (
                    -- domrf_kn_objects содержит ~3 snapshot'а на obj_id;
                    -- берём только самый свежий, иначе comparables дублируются.
                    -- #38: класс-фильтр по COALESCE(obj_class, obj_class_fallback);
                    -- eff_class отдаём наружу как эффективный класс.
                    SELECT DISTINCT ON (obj_id) *,
                           COALESCE(obj_class, obj_class_fallback) AS eff_class
                      FROM domrf_kn_objects
                     WHERE region_cd = :rc
                       AND district_name = :dn
                       AND (
                            CAST(:cls AS TEXT) IS NULL
                            OR COALESCE(obj_class, obj_class_fallback) = :cls
                       )
                     ORDER BY obj_id, snapshot_date DESC NULLS LAST
                )
                SELECT o.obj_id, o.comm_name, o.dev_name, o.eff_class AS obj_class, o.flat_count,
                       a.perc AS sold_perc,
                       c.cad_quarter,
                       c.latitude  AS lat,
                       c.longitude AS lon,
                       c.cad_buildings_n AS buildings_n
                  FROM latest_obj o
                  LEFT JOIN latest_agg la ON la.obj_id = o.obj_id
                  LEFT JOIN domrf_kn_sales_agg a
                         ON a.obj_id = la.obj_id
                        AND a.snapshot_date = la.snap
                        AND a.type = 'apartments'
                  LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name)
                 WHERE (
                     -- hard-cap по границам района: только если координаты известны И
                     -- точка внутри полигона. Без координат — включаем (нет данных для отсева)
                     c.latitude IS NULL
                     OR c.longitude IS NULL
                     OR ST_Within(
                         ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326),
                         (SELECT geom FROM district_geom)
                     )
                 )
                 ORDER BY o.flat_count DESC NULLS LAST
                 LIMIT 5
                """
            ),
            {
                "rc": region_code,
                "dn": district_row["district_name"],
                "cls": target_class_for_geo,
            },
        )
        .mappings()
        .all()
    )

    # 7) Headline для CEO — одна строка с тремя главными цифрами
    headline_parts: list[str] = []
    if have_revenue:
        headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
    if months_to_sellout_total:
        headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес")
    if avg_ticket:
        headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
    if base_total_v > 0:
        # Tempo = sum bucket-adjusted velocities (каждая со своим pf_pow по своей
        # эластичности). Это согласовано с months_to_sellout_total выше.
        tempo = adjusted_total_v if adjusted_total_v > 0 else base_total_v * pf_pow
        headline_parts.append(
            f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес"
        )
    if liquidity_24mo is not None:
        headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
    headline = " · ".join(headline_parts) if headline_parts else None

    # #1359: убираем служебное поле перед возвратом (и до передачи в overlay).
    for b in buckets:
        b.pop("_area_avg_raw", None)

    result: dict[str, Any] = {
        "scope": {
            "district": district_row["district_name"],
            "district_zk_count": district_row["zk_count"],
            "district_median_price_per_m2": district_median,
            "district_factor": round(district_factor, 4),
            "class_multiplier": round(class_multiplier, 4),
            "class_multiplier_source": class_multiplier_source,
            "target_class": target_class,
            "months_window": months_window,
            "effective_window_months": effective_window,
            "region_code": region_code,
            "total_deals": total_deals if bucket_rows else 0,
            "market_velocity_per_month": (
                round(market_vel_pm, 3) if market_vel_pm is not None else None
            ),
            "velocity_source": velocity_source,
            # fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback
            "n_competitors": n_comp,
            "velocity_observations": vel["observations"],
            "velocity_objects": vel["objects_count"],
            "competitors_count": competitors,
            "competitors_scope": competitors_scope,
            "competitors_radius_n": competitors_radius_n,
            "competitors_district_only_n": competitors_district_only_n,
            "saturation_median": sat_median,
            "saturation_n": sat_n,
            "sat_factor": round(sat_factor, 4),
            "velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None),
            "trend_recent_units": trend_recent,
            "trend_prior_units": trend_prior,
            "trend_factor": round(trend_factor, 4),
            "poi_score": round(poi_score, 1) if poi_score is not None else None,
            "poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None),
            "poi_factor": round(poi_factor, 4),
            "mortgage_rate_pct": mortgage_rate,
            "mortgage_rate_period": mortgage_period,
            # #237: дата актуальности данных recommend (ISO 'YYYY-MM-DD' | null)
            "last_updated": data_last_updated,
            "elasticity": elasticity,
            "elasticity_r2": elast["r2"],
            "elasticity_n": elast["n"],
            "elasticity_source": elast["source"],
            "elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
            "elasticity_per_bucket": elast_per_bucket,
            # Окна источников данных — для прозрачности и UI-tooltip.
            # share_window_months — окно по rosreestr_deals для bucket-shares
            #   и market velocity (input months_window, может расшириться до 27).
            # elasticity_window_months — синхронизировано с share_window (issue #24).
            "share_window_months": effective_window,
            "elasticity_window_months": elasticity_window_months,
            # Noise penalty (issue #22)
            "noise_penalty": noise_penalty,
            "noise_breakdown": noise_breakdown,
            # Success ranking (issue #25)
            "success_ranking": success_ranking,
            "cadastre_median_per_m2": (
                round(cadastre["median_per_m2"], 0)
                if cadastre["median_per_m2"] is not None
                else None
            ),
            "cadastre_buildings_n": cadastre["buildings_n"],
            "cadastre_vs_market_pct": (
                round(
                    (district_median - cadastre["median_per_m2"])
                    / cadastre["median_per_m2"]
                    * 100.0,
                    1,
                )
                if (cadastre["median_per_m2"] and cadastre["median_per_m2"] > 0 and district_median)
                else None
            ),
            "price_factor_applied": round(price_factor, 4),
            "required_price_factor": required_price_factor,
            "target_months": target_months,
            "data_caveat": (
                "MVP: bucket-распределение город-wide (регион 66). Район влияет"
                " только на ценовой коэффициент. v2 добавит per-district demand"
                " при заведении PostGIS-полигонов."
            ),
        },
        "buckets": buckets,
        "summary": {
            "total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
            "weighted_avg_price_per_m2": weighted_avg_price,
            "total_units_planned": total_units if total_units > 0 else None,
            "months_to_sellout_total": months_to_sellout_total,
            "avg_ticket_rub": avg_ticket,
            "liquidity_score_24mo": liquidity_24mo,
            "headline": headline,
            "warnings": warnings,
        },
        "comparables": [
            {
                "obj_id": r["obj_id"],
                "comm_name": r["comm_name"],
                "dev_name": r["dev_name"],
                "obj_class": r["obj_class"],
                "flat_count": r["flat_count"],
                "sold_perc": _f(r["sold_perc"]),
                "cad_quarter": r["cad_quarter"],
                "lat": _f(r["lat"]),
                "lon": _f(r["lon"]),
                "buildings_n": r["buildings_n"],
            }
            for r in cmp_rows
        ],
    }

    # #982 (953-A) ADDITIVE OPT-IN: horizon-aware forecast-overlay. Живой микс выше
    # БАЙТ-в-БАЙТ не тронут — overlay добавляется ТОЛЬКО при заданном horizon_months
    # и ТОЛЬКО под scope["forecast"] (открытый dict). КРИТИЧНО (live-endpoint safety):
    # overlay СОВЕТУЮЩИЙ и НИКОГДА не должен уронить живой ответ — вся ветка в
    # try/except, на сбое кладём {error, advisory} и всё равно возвращаем микс.
    # Локальный импорт — избегаем потенциального import-cycle (analytics_queries ↔
    # forecasting) и не утяжеляем top-level живого analytics-стека advisory-движком.
    if horizon_months is not None:
        try:
            from app.services.forecasting.recommendation import build_forecast_overlay

            result["scope"]["forecast"] = build_forecast_overlay(
                db,
                district=district_row["district_name"],
                cad_num=cad_num,
                horizon_months=horizon_months,
                target_class=target_class,
                market_buckets=buckets,
            )
        except Exception as e:
            logger.exception(
                "recommend_mix: forecast-overlay failed (district=%s cad_num=%s horizon=%s) "
                "— живой микс возвращён без overlay",
                district_row["district_name"],
                cad_num,
                horizon_months,
            )
            result["scope"]["forecast"] = {"error": str(e), "advisory": True}

    return result


# ── Cadastral buildings per complex ──────────────────────────────────────────


def complex_buildings(db: Session, obj_id: int) -> list[dict[str, Any]]:
    """Список зданий из cad_buildings для данного ЖК.

    Возвращает [] если ни одного здания не найдено.
    """
    rows = (
        db.execute(
            text(
                """
            SELECT cad_num, floors, area, purpose, building_name,
                   readable_address, ST_AsGeoJSON(geom) AS geom_geojson
              FROM cad_buildings
             WHERE complex_id = :obj_id
             ORDER BY cad_num
            """
            ),
            {"obj_id": obj_id},
        )
        .mappings()
        .all()
    )
    import json as _json

    result: list[dict[str, Any]] = []
    for r in rows:
        geom_raw = r["geom_geojson"]
        geom: dict[str, Any] | None = None
        if geom_raw:
            try:
                geom = _json.loads(geom_raw)
            except (ValueError, TypeError):
                geom = None
        result.append(
            {
                "cad_num": r["cad_num"],
                "floors": r["floors"],
                "area": _f(r["area"]),
                "purpose": r["purpose"],
                "building_name": r["building_name"],
                "readable_address": r["readable_address"],
                "geom_geojson": geom,
            }
        )
    return result
