"""Market-metrics service — детерминированные рыночные метрики из данных Объектива.

#949 PR A (Site Finder v2 / GG-форсайт, EPIC «релевантность конкурентов +
рыночные метрики»). Это **измерительный слой** (ТЗ §9.2), который потребляют
forecasting-эпики (#950/#952) и relevance-модель (#949 PR B).

Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика.

Источники (см. `data/sql/68_schema_objective.sql`):
  - `objective_lots`           — per-flat текущее состояние (status, is_sold, area_pd,
                                 rooms_int, district, premise_kind, sales_start_date) +
                                 `contract_date` («Дата договора») — реальная дата сделки,
                                 заполнена у 100% проданных лотов. Используем её и для
                                 кумулятивного стока, и для продаж-в-окне (velocity /
                                 absorption / sell-through). `objective_lots_history`
                                 (weekly-снапшоты) НЕ используем: глубина ~17 дней не даёт
                                 корректного окна продаж (см. #949).
  - elasticity (price_sensitivity) — переиспользуем
    `analytics_queries._elasticity_coef` (objective_corpus_room_month, log-log регрессия).

Фильтрация по `district` / `obj_ids` (а НЕ по domrf↔objective маппингу): маппинг
покрывает ~2.5% объектов, тогда как `district` заполнен у большинства лотов. Это
обходит mapping-gap — главный риск проекта (sparse coverage).

Graceful-on-thin-data (КРИТИЧНО): любая метрика при отсутствии данных = `None`
(НЕ 0, НЕ crash), `confidence='low'`, результат всё равно возвращается. Каждый
helper защищён от деления на ноль и пустых выборок.
"""

from __future__ import annotations

import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.services.analytics_queries import _elasticity_coef
from app.services.forecast_request_cache import cached
from app.services.site_finder.district_resolver import resolve_objective_districts

logger = logging.getLogger(__name__)


def _market_metrics_key(
    db: Session,
    *,
    district: str | None = None,
    obj_ids: Sequence[int] | None = None,
    window_months: int = 6,
    premise_kind: str = "квартира",
    obj_class: str | None = None,
    room_bucket: str | None = None,
) -> tuple[Any, ...]:
    """Ключ кэша §22-форсайта для compute_market_metrics (#1129).

    Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
    hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
    посегментный дефицит). В форсайт-пути obj_ids всегда None → tuple() устойчив.
    `db` не в ключе (одна сессия на отчёт).
    """
    return (
        district,
        tuple(obj_ids) if obj_ids is not None else None,
        window_months,
        premise_kind,
        obj_class,
        room_bucket,
    )


Confidence = Literal["high", "medium", "low"]

# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
# месяцев и до сих пор не продан. ЕКБ-эмпирика: здоровый цикл поглощения ~ 12 мес.
_OVERSTOCK_MONTHS_THRESHOLD: int = 12

# Пороги уверенности по размеру выборки (зеркало духа ТЗ §15: мало лотов / 1 ЖК → low).
_CONF_HIGH_MIN_LOTS: int = 200
_CONF_HIGH_MIN_OBJ: int = 3
_CONF_MEDIUM_MIN_LOTS: int = 50
_CONF_MEDIUM_MIN_OBJ: int = 2

# Регион данных Объектива — ЕКБ (Свердловская обл.). Передаётся в elasticity-reuse,
# где параметр сохранён для обратной совместимости (objective покрывает только ЕКБ).
_EKB_REGION_CODE: int = 66


@dataclass(frozen=True)
class MarketMetrics:
    """Рыночные метрики ТЗ §9.2 для локации (район и/или набор obj_ids).

    Все метрики — детерминированные. Любая метрика = None при недостатке данных
    (никогда 0-как-заглушка и никогда исключение).
    """

    # ── Контекст выборки ──────────────────────────────────────────────────────
    district: str | None
    obj_count: int  # сколько отдельных ЖК (project_name) попало в выборку
    n_lots: int  # всего лотов (квартир) в выборке
    n_sold: int  # из них проданных
    n_available: int  # из них доступных (в продаже)
    window_months: int
    premise_kind: str
    confidence: Confidence

    # ── §9.2 named-метрики ────────────────────────────────────────────────────
    absorption_rate: float | None  # ед./мес ÷ доступные ед. (доля поглощения в мес)
    months_of_supply: float | None  # доступные ед. ÷ месячное поглощение (мес до распродажи)
    sell_through_pct: float | None  # проданные ÷ (проданные + доступные), %
    unit_velocity: float | None  # ед. продано в месяц (за window_months)
    area_velocity: float | None  # м² продано в месяц (за window_months)
    liquidity_index: dict[str, float] | None  # {rooms_bucket: индекс относит. скорости}
    overstock_index: float | None  # доля долго-экспонируемого непроданного стока
    demand_concentration: float | None  # Херфиндаль продаж по комнатности (0..1)
    price_sensitivity: float | None  # эластичность цена↔спрос (slope, обычно < 0)
    price_sensitivity_source: str | None  # 'regression' | 'fallback' | None
    # #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
    # "студия","1","2","3","4","5+"). None если нет данных в окне.
    velocity_by_room: dict[str, float] | None

    def as_dict(self) -> dict[str, Any]:
        return {
            "district": self.district,
            "obj_count": self.obj_count,
            "n_lots": self.n_lots,
            "n_sold": self.n_sold,
            "n_available": self.n_available,
            "window_months": self.window_months,
            "premise_kind": self.premise_kind,
            "confidence": self.confidence,
            "absorption_rate": _round_or_none(self.absorption_rate, 4),
            "months_of_supply": _round_or_none(self.months_of_supply, 1),
            "sell_through_pct": _round_or_none(self.sell_through_pct, 1),
            "unit_velocity": _round_or_none(self.unit_velocity, 2),
            "area_velocity": _round_or_none(self.area_velocity, 1),
            "liquidity_index": (
                {k: round(v, 3) for k, v in self.liquidity_index.items()}
                if self.liquidity_index is not None
                else None
            ),
            "overstock_index": _round_or_none(self.overstock_index, 3),
            "demand_concentration": _round_or_none(self.demand_concentration, 3),
            "price_sensitivity": _round_or_none(self.price_sensitivity, 4),
            "price_sensitivity_source": self.price_sensitivity_source,
            # velocity_by_room намеренно не сериализуется: это внутренний
            # pipeline-атрибут (потребляется recommendation.py напрямую).
        }


def _round_or_none(value: float | None, digits: int) -> float | None:
    return round(value, digits) if value is not None else None


# ──────────────────────────────────────────────────────────────────────────────
# Pure-арифметика метрик — без БД, полностью юнит-тестируемо.
# Каждая функция graceful: пустой/нулевой вход → None (не 0, не ZeroDivisionError).
# ──────────────────────────────────────────────────────────────────────────────


def _monthly_rate(count: float | int | None, months: int) -> float | None:
    """count за окно → count в месяц. None/нет окна → None."""
    if count is None or months <= 0:
        return None
    return float(count) / float(months)


def _absorption_rate(sold: int | None, available: int | None, months: int) -> float | None:
    """absorption_rate = (проданных в месяц) / доступных.

    Доля текущего стока, поглощаемая рынком за месяц. Если нет доступных лотов
    или нет окна — None (распродано / неизмеримо, НЕ 0).
    """
    monthly_sold = _monthly_rate(sold, months)
    if monthly_sold is None or not available or available <= 0:
        return None
    return monthly_sold / float(available)


def _months_of_supply(available: int | None, sold: int | None, months: int) -> float | None:
    """months_of_supply = доступные / (проданных в месяц).

    Сколько месяцев нужно, чтобы распродать текущий сток при текущем темпе.
    Нет продаж за окно (темп 0) → None (бесконечность неизмерима, НЕ 0).
    """
    monthly_sold = _monthly_rate(sold, months)
    if available is None or monthly_sold is None or monthly_sold <= 0:
        return None
    return float(available) / monthly_sold


def _sell_through_pct(sold: int | None, available: int | None) -> float | None:
    """sell_through_pct = sold / (sold + available) * 100.

    Доля реализованного от всего выведенного на рынок. Пустая выборка → None.
    """
    if sold is None or available is None:
        return None
    denom = sold + available
    if denom <= 0:
        return None
    return float(sold) / float(denom) * 100.0


def _liquidity_index(sold_by_room: Mapping[str, int]) -> dict[str, float] | None:
    """liquidity_index per комнатность — относительная скорость продаж.

    Нормируем долю продаж бакета на среднюю долю (1/n_buckets): индекс 1.0 =
    «продаётся со средней по выборке скоростью», >1 = быстрее, <1 = медленнее.
    Нет продаж ни в одном бакете → None.
    """
    buckets = {k: int(v) for k, v in sold_by_room.items() if v is not None}
    total = sum(buckets.values())
    n = len(buckets)
    if total <= 0 or n == 0:
        return None
    fair_share = 1.0 / n
    return {bucket: (cnt / total) / fair_share for bucket, cnt in buckets.items()}


def _overstock_index(n_long_unsold: int | None, n_available: int | None) -> float | None:
    """overstock_index = долго-экспонируемые непроданные / все доступные.

    Доля «зависшего» стока (в продаже > N месяцев без сделки). Нет доступных
    лотов → None (неизмеримо, НЕ 0).
    """
    if n_long_unsold is None or not n_available or n_available <= 0:
        return None
    return float(n_long_unsold) / float(n_available)


def _demand_concentration(sold_by_room: Mapping[str, int]) -> float | None:
    """demand_concentration — индекс Херфиндаля (HHI) долей продаж по комнатности.

    Sum( share_i^2 ) ∈ (0..1]: 1.0 = весь спрос в одном формате, → 0 =
    равномерно размазан. Нет продаж → None.
    """
    counts = [int(v) for v in sold_by_room.values() if v is not None and v > 0]
    total = sum(counts)
    if total <= 0:
        return None
    return sum((c / total) ** 2 for c in counts)


def _confidence(n_lots: int, obj_count: int, n_sold: int) -> Confidence:
    """Уверенность по размеру выборки (ТЗ §15 spirit).

    'low' если мало лотов / 1 ЖК / нет проданной истории — тогда метрики
    скорости/поглощения статистически ненадёжны.
    """
    if n_sold <= 0:
        return "low"
    if n_lots >= _CONF_HIGH_MIN_LOTS and obj_count >= _CONF_HIGH_MIN_OBJ:
        return "high"
    if n_lots >= _CONF_MEDIUM_MIN_LOTS and obj_count >= _CONF_MEDIUM_MIN_OBJ:
        return "medium"
    return "low"


def _room_bucket(rooms_int: int | None) -> str:
    """Нормализуем rooms_int (objective: 0=студия) в стабильный bucket-ключ."""
    if rooms_int is None:
        return "unknown"
    if rooms_int <= 0:
        return "студия"
    if rooms_int >= 5:
        return "5+"
    return str(rooms_int)


# ──────────────────────────────────────────────────────────────────────────────
# SQL aggregation
# ──────────────────────────────────────────────────────────────────────────────

# Текущий сток per-flat. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
# точный per-flat дедуп.
#
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
# (класс/комнатность/площадь не меняются между снапшотами).
#
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
_STOCK_SQL = text(
    """
    WITH lots AS (
        SELECT DISTINCT ON (
            ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
        )
            ol.project_name,
            ol.rooms_int,
            ol.area_pd,
            ol.sales_start_date,
            (
                ol.is_sold IS TRUE
                OR ol.contract_date IS NOT NULL
                OR LOWER(COALESCE(ol.status, '')) = 'продан'
            ) AS sold_now
          FROM objective_lots ol
         WHERE ol.premise_kind = :premise_kind
           AND (
                CAST(:has_district AS boolean) IS FALSE
                OR ol.district = ANY(CAST(:districts AS text[]))
               )
           AND (
                CAST(:has_obj_ids AS boolean) IS FALSE
                OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
               )
           AND (
                CAST(:cls AS text) IS NULL
                OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
               )
           AND (
                CAST(:room_bucket AS text) IS NULL
                OR (CASE
                        WHEN ol.area_pd >= CAST(:large_area AS numeric)
                            THEN CAST(:b_large AS text)
                        WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text)
                        WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text)
                        WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text)
                        WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text)
                        WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text)
                        ELSE CAST(:b_large AS text)
                    END) = CAST(:room_bucket AS text)
               )
         ORDER BY
            ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
            ol.snapshot_date DESC, ol.id DESC
    )
    SELECT
        COUNT(*)                                              AS n_lots,
        COUNT(*) FILTER (WHERE sold_now)                      AS n_sold,
        COUNT(*) FILTER (WHERE NOT sold_now)                  AS n_available,
        COUNT(DISTINCT project_name)                          AS obj_count,
        COUNT(*) FILTER (
            WHERE NOT sold_now
              AND sales_start_date IS NOT NULL
              AND sales_start_date
                  <= CURRENT_DATE - CAST(:overstock_interval AS interval)
        )                                                     AS n_long_unsold
      FROM lots
    """
)

# Продажи за окно — напрямую из objective_lots по contract_date («Дата договора»).
# «Продано в окне» = лот с contract_date внутри окна. contract_date — реальная дата
# сделки и заполнен у 100% проданных лотов, поэтому источник надёжен и не требует
# истории. (Раньше считали через objective_lots_history-снапшоты, но history глубиной
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
#
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
# предложению → дефицит вырождается в −1.0). Дедупим в CTE `latest`, окно по
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
# area_pd берём из последнего снапшота физлота.
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
# {bucket, total}.
_SALES_WINDOW_SQL = text(
    """
    WITH latest AS (
        SELECT DISTINCT ON (
            ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
        )
            ol.rooms_int,
            ol.area_pd,
            ol.contract_date,
            ol.class
          FROM objective_lots ol
         WHERE ol.premise_kind = :premise_kind
           AND (
                CAST(:has_district AS boolean) IS FALSE
                OR ol.district = ANY(CAST(:districts AS text[]))
               )
           AND (
                CAST(:has_obj_ids AS boolean) IS FALSE
                OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
               )
         ORDER BY
            ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
            ol.snapshot_date DESC, ol.id DESC
    )
    SELECT
        COUNT(*)                          AS units_sold_window,
        COALESCE(SUM(area_pd), 0)         AS area_sold_window,
        rooms_int,
        -- #1214: ROLLUP grand-total и NULL-группа дают rooms_int IS NULL обе.
        -- Различаем через GROUPING(): =1 для grand-total, =0 для NULL-комнатной
        -- группы. Без этого один проданный лот с rooms_int IS NULL даёт ДВЕ
        -- строки rooms_int IS NULL (NULL-группа + итог), и MixedAggregate-план
        -- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
        -- счётом → unit_velocity/absorption занижены, MoS завышен.
        GROUPING(rooms_int)               AS is_total
      FROM latest
     WHERE contract_date IS NOT NULL
       AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
       AND (
            CAST(:cls AS text) IS NULL
            OR LOWER(class) = LOWER(CAST(:cls AS text))
           )
       AND (
            CAST(:room_bucket AS text) IS NULL
            OR (CASE
                    WHEN area_pd >= CAST(:large_area AS numeric)
                        THEN CAST(:b_large AS text)
                    WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
                    WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
                    WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
                    WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
                    WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
                    ELSE CAST(:b_large AS text)
                END) = CAST(:room_bucket AS text)
           )
     GROUP BY ROLLUP (rooms_int)
    """
)


@cached(_market_metrics_key, label="compute_market_metrics")
def compute_market_metrics(
    db: Session,
    *,
    district: str | None = None,
    obj_ids: Sequence[int] | None = None,
    window_months: int = 6,
    premise_kind: str = "квартира",
    obj_class: str | None = None,
    room_bucket: str | None = None,
) -> MarketMetrics:
    """Вычислить рыночные метрики ТЗ §9.2 для локации.

    Фильтрация по `district` и/или `obj_ids` (объединяются по AND, если оба
    заданы). Если ни один не задан — считается по всей выборке premise_kind
    (имеет смысл для ЕКБ-wide baseline).

    #1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
    матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр —
    "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
    `sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода)
    сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
    сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
    what_to_build делят один темп → дефицит вырождается в −1.0). None по обеим осям
    → прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
    последнего снапшота на физлот → counts (а значит и confidence) считаются на
    дедуплицированном наборе.

    Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
    None, confidence='low'. Никогда не бросает на отсутствии данных.
    """
    # #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
    # _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
    # Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
    # → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
    from app.services.forecasting.sales_series import (
        _LARGE_AREA_THRESHOLD_M2,
        ROOM_AREA_BUCKET_1K,
        ROOM_AREA_BUCKET_2K,
        ROOM_AREA_BUCKET_3K,
        ROOM_AREA_BUCKET_LARGE,
        ROOM_AREA_BUCKET_STUDIO,
        ROOM_AREA_BUCKET_UNKNOWN,
    )

    obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
    has_obj_ids = bool(obj_id_list)
    # Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
    # реально фильтруется objective_lots. None → EKB-wide (без district-фильтра); это
    # лечит баг «админ-имя → 0 строк → пустой прогноз» (см. district_resolver).
    micros = resolve_objective_districts(db, district)
    has_district = micros is not None
    params: dict[str, Any] = {
        "premise_kind": premise_kind,
        # has_district=False → district-ветка фильтра отключена (EKB-wide).
        "has_district": has_district,
        # ANY(NULL::text[]) валиден; пустой список когда фильтра нет (has_district=False).
        "districts": micros if micros is not None else [],
        "has_obj_ids": has_obj_ids,
        # ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
        "obj_ids": obj_id_list,
        "overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
        # #1959 сегмент-фильтры (None → ось не сужается).
        "cls": obj_class,
        "room_bucket": room_bucket,
        # #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
        # / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
        # перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
        "large_area": _LARGE_AREA_THRESHOLD_M2,
        "b_studio": ROOM_AREA_BUCKET_STUDIO,
        "b_1k": ROOM_AREA_BUCKET_1K,
        "b_2k": ROOM_AREA_BUCKET_2K,
        "b_3k": ROOM_AREA_BUCKET_3K,
        "b_large": ROOM_AREA_BUCKET_LARGE,
        "b_unknown": ROOM_AREA_BUCKET_UNKNOWN,
    }

    # ── Текущий сток ──────────────────────────────────────────────────────────
    stock = _query_stock(db, params)
    n_lots = stock["n_lots"]
    n_sold_total = stock["n_sold"]
    n_available = stock["n_available"]
    obj_count = stock["obj_count"]
    n_long_unsold = stock["n_long_unsold"]

    # ── Продажи за окно (для velocity / absorption / liquidity / concentration) ─
    window_params = {**params, "window_interval": f"{window_months} months"}
    units_sold_window, area_sold_window, sold_by_room = _query_sales_window(db, window_params)

    # ── Pure-метрики ──────────────────────────────────────────────────────────
    # n_lots == 0 → выборка пуста, мерить нечего: velocity/absorption = None
    # (НЕ 0 — иначе «нет данных» не отличить от «честно продали 0»). При n_lots>0
    # и нуле продаж в окне velocity=0.0 — это валидное измерение «0 ед./мес».
    has_sample = n_lots > 0
    units_window: int | None = units_sold_window if has_sample else None
    area_window: float | None = area_sold_window if has_sample else None
    absorption = _absorption_rate(units_window, n_available, window_months)
    mos = _months_of_supply(n_available, units_window, window_months)
    sell_through = _sell_through_pct(n_sold_total, n_available)
    unit_velocity = _monthly_rate(units_window, window_months)
    area_velocity = _monthly_rate(area_window, window_months)
    liquidity = _liquidity_index(sold_by_room)
    overstock = _overstock_index(n_long_unsold, n_available)
    demand_conc = _demand_concentration(sold_by_room)
    # #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
    # _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
    # смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
    # window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
    vel_by_room: dict[str, float] | None = (
        {bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
        if has_sample and sold_by_room and window_months > 0
        else None
    )

    # ── price_sensitivity — reuse analytics_queries._elasticity_coef ───────────
    price_sensitivity, price_sensitivity_source = _price_sensitivity(
        db, district=district, window_months=window_months
    )

    confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)

    logger.info(
        "market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
        "n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
        district,
        micros,
        obj_class,
        room_bucket,
        len(obj_id_list),
        n_lots,
        n_sold_total,
        n_available,
        obj_count,
        units_sold_window,
        confidence,
    )

    return MarketMetrics(
        district=district,
        obj_count=obj_count,
        n_lots=n_lots,
        n_sold=n_sold_total,
        n_available=n_available,
        window_months=window_months,
        premise_kind=premise_kind,
        confidence=confidence,
        absorption_rate=absorption,
        months_of_supply=mos,
        sell_through_pct=sell_through,
        unit_velocity=unit_velocity,
        area_velocity=area_velocity,
        liquidity_index=liquidity,
        overstock_index=overstock,
        demand_concentration=demand_conc,
        price_sensitivity=price_sensitivity,
        price_sensitivity_source=price_sensitivity_source,
        velocity_by_room=vel_by_room,
    )


def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
    """Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
    try:
        # SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
        # / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
        # velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
        # остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
        # на этой же сессии.
        with db.begin_nested():
            row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
    except Exception:
        logger.exception(
            "market_metrics: stock query failed (districts=%s)", params.get("districts")
        )
        row = None
    if row is None:
        return {
            "n_lots": 0,
            "n_sold": 0,
            "n_available": 0,
            "obj_count": 0,
            "n_long_unsold": 0,
        }
    return {
        "n_lots": int(row["n_lots"] or 0),
        "n_sold": int(row["n_sold"] or 0),
        "n_available": int(row["n_available"] or 0),
        "obj_count": int(row["obj_count"] or 0),
        "n_long_unsold": int(row["n_long_unsold"] or 0),
    }


def _query_sales_window(
    db: Session, params: Mapping[str, Any]
) -> tuple[int, float, dict[str, int]]:
    """Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).

    GROUP BY ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
      • is_total=1 → grand-total (units/area за все комнаты);
      • is_total=0 и rooms_int IS NULL → разбивка для лотов БЕЗ rooms — кладём
        в by_room['unknown'] (а не путаем с total);
      • is_total=0 и rooms_int не NULL → разбивка по конкретной комнатности.
    by_room аккумулирует через += чтобы при будущих доп.NULL-вариантах не
    затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
    """
    try:
        # SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
        # сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
        # цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
        # is aborted" вместо честного graceful-fallback.
        with db.begin_nested():
            rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
    except Exception:
        logger.exception(
            "market_metrics: sales-window query failed (districts=%s)", params.get("districts")
        )
        rows = []

    units_total = 0
    area_total = 0.0
    by_room: dict[str, int] = {}
    for r in rows:
        cnt = int(r["units_sold_window"] or 0)
        area = float(r["area_sold_window"] or 0.0)
        if int(r["is_total"]) == 1:
            # ROLLUP grand-total — единственная строка с GROUPING=1.
            units_total = cnt
            area_total = area
        elif r["rooms_int"] is None:
            # Лоты с rooms_int IS NULL (ETL пишет NULL для «неопределённого типа»)
            # — отдельный бакет, не путаем с total.
            by_room["unknown"] = by_room.get("unknown", 0) + cnt
        else:
            bucket = _room_bucket(int(r["rooms_int"]))
            by_room[bucket] = by_room.get(bucket, 0) + cnt
    return units_total, area_total, by_room


def _price_sensitivity(
    db: Session, *, district: str | None, window_months: int
) -> tuple[float | None, str | None]:
    """Эластичность цена↔спрос — reuse analytics_queries._elasticity_coef.

    Требует district (регрессия по району). Без district → None (нечего фитить).
    elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории,
    поэтому минимум 24 мес (как в recommend_mix).

    #1211 fix: District-вход — admin-имя ЕКБ (из /analyze), но
    ``objective_corpus_room_month.district`` — МИКРО-вокабуляр (как и
    ``objective_lots.district``). Сырое admin-имя в фильтр давало 0 точек
    регрессии → всегда FALLBACK_ELASTICITY (silent-correctness). Резолвим
    admin → набор микро через ``resolve_objective_districts`` и передаём
    списком в ``_elasticity_coef(..., districts=...)``. Резолвер None
    (нет чистых алиасов / 'не определён') → пустой список → EKB-wide
    регрессия (лучше, чем 0 точек: получаем агрегированный сигнал).
    """
    if not district:
        return None, None
    micros = resolve_objective_districts(db, district)
    elasticity_window = max(window_months, 24)
    try:
        # SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
        # / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
        # оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
        # SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
        with db.begin_nested():
            elast = _elasticity_coef(
                db,
                region_code=_EKB_REGION_CODE,
                district_name=district,
                target_class=None,
                elasticity_window_months=elasticity_window,
                districts=micros if micros is not None else [],
            )
    except Exception:
        logger.exception(
            "market_metrics: elasticity reuse failed (district=%s micros=%s)",
            district,
            micros,
        )
        return None, None
    return float(elast["elasticity"]), str(elast["source"])


# ──────────────────────────────────────────────────────────────────────────────
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
#
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
# карточку, когда сделки устарели.
#
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
# 131_district_vocab_map.sql):
#   radius (ST_DWithin по domrf_kn_objects)
#     → domrf_obj_id
#     → objective_complex_mapping.domrf_obj_id → objective_complex_name
#     → objective_lots.project_name → objective_lot_id-набор
#     → objective_lots_history (per-snapshot цены этих лотов).
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
#
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
# иначе задвоенный день весит ×2 и смещает медиану.
#
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
#
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты.
_OFFER_TREND_SQL = text(
    """
    WITH radius_obj AS (
        SELECT DISTINCT ON (o.obj_id) o.obj_id
          FROM domrf_kn_objects o
         WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
           AND ST_DWithin(
                   ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
                   ST_SetSRID(
                       ST_MakePoint(
                           CAST(:center_lon AS float), CAST(:center_lat AS float)
                       ),
                       4326
                   )::geography,
                   CAST(:radius_m AS float)
               )
         ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
    ),
    radius_projects AS (
        SELECT DISTINCT cm.objective_complex_name AS project_name
          FROM objective_complex_mapping cm
         WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
    ),
    radius_lots AS (
        SELECT DISTINCT ol.objective_lot_id
          FROM objective_lots ol
          JOIN radius_projects rp ON rp.project_name = ol.project_name
         WHERE ol.premise_kind = CAST(:premise_kind AS text)
    ),
    hist AS (
        -- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
        SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
               h.snapshot_date,
               h.price_per_m2_rub
          FROM objective_lots_history h
          JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
         WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
                                      AND CAST(:price_max AS numeric)
           AND (h.is_sold IS NOT TRUE)
           AND LOWER(COALESCE(h.status, '')) <> 'продан'
         ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
    )
    SELECT
        snapshot_date                                                 AS snapshot_date,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub)  AS median_price_m2,
        COUNT(*)                                                      AS lots_count,
        (SELECT COUNT(*) FROM radius_projects)                        AS complexes_covered
      FROM hist
     GROUP BY snapshot_date
     ORDER BY snapshot_date
    """
)

# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
# ⚠ Значения зеркалированы ЛИТЕРАЛАМИ в partial-предикате покрывающего индекса
# data/sql/186_objective_lots_history_offer_trend_covering_idx.sql — при изменении
# констант индекс надо пересоздать, иначе offer-trend молча теряет Index-Only Scan.
_OFFER_PRICE_MIN: int = 10000
_OFFER_PRICE_MAX: int = 1000000

# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
_OFFER_TREND_RADIUS_M: int = 3000


@dataclass(frozen=True)
class OfferPriceTrend:
    """Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).

    Источник — objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
    Всегда конструируется через ``from_points``; пустой набор точек → None-поля,
    ``points=[]`` (никогда не бросает на отсутствии данных).
    """

    points: list[dict[str, Any]]  # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
    delta_pct: float | None  # (последняя медиана − первая) / первая × 100
    lots_latest: int | None  # lots_count последнего снапшота
    complexes_covered: int  # сколько ЖК (project_name) попало в радиус-выборку

    def as_dict(self) -> dict[str, Any]:
        return {
            "points": self.points,
            "delta_pct": _round_or_none(self.delta_pct, 1),
            "lots_latest": self.lots_latest,
            "complexes_covered": self.complexes_covered,
        }

    @property
    def has_data(self) -> bool:
        """True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
        return len(self.points) > 0


def compute_offer_price_trend(
    db: Session,
    *,
    center_lat: float,
    center_lon: float,
    radius_m: int = _OFFER_TREND_RADIUS_M,
    premise_kind: str = "квартира",
) -> OfferPriceTrend:
    """Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).

    ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
    радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects →
    objective_complex_mapping → objective_lots. По их лотам считается per-snapshot
    медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
    вменяемости цены).

    Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
    complexes_covered=0). Никогда не бросает на отсутствии данных — на ошибке SQL
    логирует и возвращает пустой результат (graceful, зеркало _query_stock).
    """
    params: dict[str, Any] = {
        "center_lat": float(center_lat),
        "center_lon": float(center_lon),
        "radius_m": float(radius_m),
        "premise_kind": premise_kind,
        "price_min": _OFFER_PRICE_MIN,
        "price_max": _OFFER_PRICE_MAX,
    }
    try:
        # SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
        # 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
        # транзакция остаётся aborted и следующий db.execute падает "current transaction
        # is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
        with db.begin_nested():
            rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
    except Exception as exc:
        # Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
        # (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.
        # WARNING, не exception: ERROR-уровень поднимал ложные GlitchTip-события.
        logger.warning(
            "market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s): %s",
            center_lat,
            center_lon,
            radius_m,
            exc,
        )
        rows = []

    points: list[dict[str, Any]] = []
    complexes_covered = 0
    for r in rows:
        # complexes_covered — константа по всем строкам (scalar-subquery), берём
        # из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
        complexes_covered = int(r["complexes_covered"] or 0)
        median = r["median_price_m2"]
        if median is None:
            continue
        snap = r["snapshot_date"]
        # snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
        # date или datetime отдал драйвер.
        date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
        points.append(
            {
                "date": date_str,
                "median_price_m2": round(float(median)),
                "lots_count": int(r["lots_count"] or 0),
            }
        )

    delta_pct: float | None = None
    lots_latest: int | None = None
    if points:
        first_median = points[0]["median_price_m2"]
        last_median = points[-1]["median_price_m2"]
        lots_latest = points[-1]["lots_count"]
        if first_median and first_median > 0:
            delta_pct = (last_median - first_median) / first_median * 100.0

    logger.info(
        "offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
        "lots_latest=%s delta_pct=%s",
        center_lat,
        center_lon,
        radius_m,
        len(points),
        lots_latest,
        _round_or_none(delta_pct, 1),
    )

    return OfferPriceTrend(
        points=points,
        delta_pct=delta_pct,
        lots_latest=lots_latest,
        complexes_covered=complexes_covered,
    )
