"""Monthly ряд продаж (sold units + area + avg price) по сегменту — Y-ось §9.6.

#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.6), EPIC 7 «Чувствительность к ключевой
ставке», sub-PR 1 (#951c). Это **data-independent фундамент** Y-оси (зависимая
переменная) для регрессии §9.6 «продажи ↔ ключевая ставка» (later PR3). Парный к
PR2 (macro_series.py — X-ось/regressor). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM.

Что строим: для SegmentSpec (любой subset class/room_bucket/district/price_bucket)
— непрерывный MONTHLY ряд (units, area_m2, avg_price_per_m2) из ОДНОГО из двух
источников Объектива, в frozen-dataclass SalesSeries.

Два источника (см. data/sql/68_schema_objective.sql), у каждого своя зона
авторитетности:
  • Source A `objective_corpus_room_month` (monthly aggregate, long-формат
    месяц×корпус×room_bucket). Колонки сделок: deals_total_count /
    deals_total_vol_m2 / deals_total_avg_price_thousand_rub_per_m2. class —
    Title-case ("Комфорт"). АВТОРИТЕТЕН для агрегатов класс/район (полные
    помесячные сделки ДДУ+ДКП, не зависит от текущего листинга).
  • Source B `objective_lots` (per-lot, даты сделок). COUNT(*) + SUM(area_pd) +
    взвешенная цена, GROUP BY date_trunc('month', registration_date). class —
    lowercase ("комфорт") → нормализуем регистр при матчинге. АВТОРИТЕТЕН для
    тонкой сегментации room×area×price (Source A не несёт price-bucket / точную
    площадь лота). КАВЕАТ ВЫЖИВАЕМОСТИ: objective_lots — это последний UPSERT-
    снапшот per-lot; видны лишь лоты, до сих пор присутствующие в выгрузке. Лот,
    проданный и затем выпавший из листинга, в исторических месяцах недоучтён →
    Source B занижает старые месяцы (survivorship bias). Для агрегатов класс/
    район поэтому предпочтителен Source A; Source B — когда нужна price/area-
    сегментация, которую A не даёт.

Соглашение по дате: каждый месяц ряда — ПЕРВОЕ число (YYYY-MM-01). Source B
нормализует registration_date через date_trunc('month', …); Source A's
report_month уже 1-е число. Переиспользуем _month_start/_shift_months/_month_grid
из macro_series (PR2) — не дублируем сеточную логику.

КРИТИЧНО (units=0 vs None): месяц без сделок → units=0 (НАСТОЯЩИЙ ноль: «честно
продали 0» — это данные, а не пропуск; downstream-регрессии нужен непрерывный
ряд счётчиков). Площадь/цена в таком месяце → None (среднее по пустому множеству
не определено — НЕ 0). Это зеркалит дух market_metrics: «нет данных ≠ 0».

Graceful-on-thin-data: пустая таблица / сбой БД → ряд по сетке месяцев с
units=0, area/price=None, confidence='low' (НЕ crash).

psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) —
никогда :x::type (парсер psycopg3 даёт SyntaxError на :name::type).
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal

from sqlalchemy import text
from sqlalchemy.orm import Session

# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
# macro_series, хоть и не в __all__ пакета.
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
from app.services.site_finder.district_resolver import resolve_objective_districts

logger = logging.getLogger(__name__)

SalesSource = Literal["corpus_room_month", "objective_lots"]
Confidence = Literal["high", "medium", "low"]

# ── Named-константы ───────────────────────────────────────────────────────────

# Глубина ряда по умолчанию (месяцев назад). 48 мес ≈ 4 года — зеркалит
# _DEFAULT_MONTHS_BACK макро-ряда (PR2): регрессия §9.6 join-ит sales↔macro
# месяц-в-месяц, окна должны совпадать по длине.
_DEFAULT_MONTHS_BACK: int = 48

# Пороги confidence по числу НЕнулевых месяцев (месяцев с реальными сделками).
# Регрессия §9.6 (sales ~ key_rate + лаги) требует достаточной вариации Y:
#   • ≥24 ненулевых мес → 'high'   (≥2 года сделок — устойчивый фит лагов)
#   • ≥12 ненулевых мес → 'medium' (≥1 год — фит возможен, но шумнее)
#   • иначе              → 'low'    (тонкий сегмент — регрессии не доверять)
# Считаем именно НЕнулевые: длинный хвост zero-месяцев (мёртвый сегмент) не
# добавляет информации регрессии, поэтому в порог не идёт.
_CONF_HIGH_MIN_NONZERO_MONTHS: int = 24
_CONF_MEDIUM_MIN_NONZERO_MONTHS: int = 12

# ── EKB price-bands (₽/м²) ────────────────────────────────────────────────────
# Фиксированные пороги цены за м² (рубли) для price_bucket_of. Источник — рынок
# новостроек ЕКБ 2024-2025 (objective_lots.price_per_m2_rub): здоровое тело
# распределения ~110-220 тыс ₽/м². Берём 4 band'а квартильного духа с КРУГЛЫМИ
# порогами (стабильнее «плавающих» квантилей — детерминированный bucket, не
# зависящий от выборки):
#   эконом  : < 120 000  ₽/м²  (нижний сегмент / окраины)
#   комфорт : 120k–160k  ₽/м²  (массовое тело рынка)
#   бизнес  : 160k–220k  ₽/м²  (верх-средний)
#   премиум : ≥ 220 000  ₽/м²  (премиальные ЖК / центр)
# Границы ВКЛючительны слева (lo ≤ x < hi); подобрано под §9.6 price-сегментацию,
# а НЕ под маркетинговый класс ЖК (потому ключи нейтральные, не путать с `class`).
_PRICE_BAND_ECONOMY_MAX: float = 120_000.0
_PRICE_BAND_COMFORT_MAX: float = 160_000.0
_PRICE_BAND_BUSINESS_MAX: float = 220_000.0

PRICE_BUCKET_ECONOMY: str = "эконом"
PRICE_BUCKET_COMFORT: str = "комфорт"
PRICE_BUCKET_BUSINESS: str = "бизнес"
PRICE_BUCKET_PREMIUM: str = "премиум"
PRICE_BUCKET_UNKNOWN: str = "unknown"

# room×area bucket-ключи — зеркало analytics_queries._BUCKET_PRETTY (§9.6 ждёт
# «Z m²»-подписи). rooms_int — primary axis (mirror _elasticity_per_bucket_coef:
# 0/студия→Студии, 1→1-к, 2→2-к, 3→3-к, 4|5+→80+). area_pd — вторичная страховка
# для пограничных случаев (большие 1-2-к по площади уезжают в 80+).
ROOM_AREA_BUCKET_STUDIO: str = "Студии 15-30"
ROOM_AREA_BUCKET_1K: str = "1-к 30-45"
ROOM_AREA_BUCKET_2K: str = "2-к 45-60"
ROOM_AREA_BUCKET_3K: str = "3-к 60-80"
ROOM_AREA_BUCKET_LARGE: str = "80+ м²"
ROOM_AREA_BUCKET_UNKNOWN: str = "unknown"

# Площадь (м²), с которой лот считается «80+» независимо от комнатности —
# зеркалит верхний bucket _BUCKET_PRETTY ('5-80+ м²' / area_per_unit >= 80).
_LARGE_AREA_THRESHOLD_M2: float = 80.0


@dataclass(frozen=True)
class SegmentSpec:
    """Спецификация сегмента — любой subset осей; None = агрегировать по оси.

    obj_class передаётся в «человеческом» регистре; SQL нормализует регистр под
    источник (Source A Title-case / Source B lowercase), поэтому здесь регистр
    не важен. price_bucket — ключ из price_bucket_of (только Source B; для
    Source A игнорируется — у месячного агрегата нет per-lot цены).

    ВАЖНО (room_bucket source-specific!): семантика зависит от source:
      • Source A (corpus_room_month): СЫРОЙ Objective room_bucket —
        "студия" | "1" | "2" | "3" | "4" | "5+".
      • Source B (objective_lots): метка из room_area_bucket_of —
        напр. "2-к 45-60", "80+ м²".
    Передача A-ключа в Source B (или наоборот) → тихий пустой ряд (0 строк).
    Для агрегатов класс/район бери Source A; для room×area×price — Source B.
    """

    obj_class: str | None = None
    room_bucket: str | None = None
    district: str | None = None
    price_bucket: str | None = None

    def as_dict(self) -> dict[str, str | None]:
        return {
            "obj_class": self.obj_class,
            "room_bucket": self.room_bucket,
            "district": self.district,
            "price_bucket": self.price_bucket,
        }


@dataclass(frozen=True)
class SalesSeries:
    """Monthly ряд продаж по сегменту (ТЗ §9.6, Y-ось регрессии).

    Все списки выровнены по индексу months (одной длины). units[i] — НАСТОЯЩИЙ
    счётчик (0 = честный ноль продаж, не пропуск). area_m2[i]/avg_price_per_m2[i]
    — None в месяцах без сделок (среднее по пустому не определено).
    """

    months: list[date]
    units: list[int]
    area_m2: list[float | None]
    avg_price_per_m2: list[float | None]
    n_months: int
    source: SalesSource
    segment: dict[str, str | None]
    confidence: Confidence

    def as_dict(self) -> dict[str, Any]:
        return {
            "months": [m.isoformat() for m in self.months],
            "units": list(self.units),
            "area_m2": [_round_or_none(a, 1) for a in self.area_m2],
            "avg_price_per_m2": [_round_or_none(p, 0) for p in self.avg_price_per_m2],
            "n_months": self.n_months,
            "source": self.source,
            "segment": dict(self.segment),
            "confidence": self.confidence,
        }


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


# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────


def price_bucket_of(price_per_m2: float | int | None) -> str:
    """Отнести цену за м² (₽) к фиксированному EKB price-band'у.

    Детерминированные круглые пороги (_PRICE_BAND_*): эконом < 120k ≤ комфорт <
    160k ≤ бизнес < 220k ≤ премиум. None / непозитивная цена → 'unknown'
    (нет осмысленного band'а — не подмешиваем в реальные сегменты). PURE.

    Args:
        price_per_m2: цена за квадратный метр в рублях.

    Returns:
        Один из PRICE_BUCKET_* ключей.
    """
    if price_per_m2 is None:
        return PRICE_BUCKET_UNKNOWN
    p = float(price_per_m2)
    if p <= 0:
        return PRICE_BUCKET_UNKNOWN
    if p < _PRICE_BAND_ECONOMY_MAX:
        return PRICE_BUCKET_ECONOMY
    if p < _PRICE_BAND_COMFORT_MAX:
        return PRICE_BUCKET_COMFORT
    if p < _PRICE_BAND_BUSINESS_MAX:
        return PRICE_BUCKET_BUSINESS
    return PRICE_BUCKET_PREMIUM


def room_area_bucket_of(rooms_int: int | None, area_pd: float | int | None) -> str:
    """Отнести лот к room×area bucket'у (зеркало _BUCKET_PRETTY / §9.6 «Z m²»).

    Primary axis — rooms_int (mirror _elasticity_per_bucket_coef): 0/студия→Студии,
    1→1-к, 2→2-к, 3→3-к, 4+→80+. Вторичная страховка: лот площадью ≥ 80 м²
    уезжает в верхний bucket '80+ м²' независимо от комнатности (так же, как
    _BUCKET_PRETTY бакетит area_per_unit ≥ 80 в '5-80+ м²'). rooms_int=None И
    area=None → 'unknown'; если комнат нет, но площадь известна — решаем по площади
    (≥80 → большой, иначе unknown: без комнатности тонкий формат не определить).
    PURE.

    Args:
        rooms_int: число комнат (objective: 0=студия), либо None.
        area_pd: площадь лота, м² (для верхней границы), либо None.

    Returns:
        Один из ROOM_AREA_BUCKET_* ключей.
    """
    area = float(area_pd) if area_pd is not None else None
    if area is not None and area >= _LARGE_AREA_THRESHOLD_M2:
        return ROOM_AREA_BUCKET_LARGE
    if rooms_int is None:
        return ROOM_AREA_BUCKET_UNKNOWN
    if rooms_int <= 0:
        return ROOM_AREA_BUCKET_STUDIO
    if rooms_int == 1:
        return ROOM_AREA_BUCKET_1K
    if rooms_int == 2:
        return ROOM_AREA_BUCKET_2K
    if rooms_int == 3:
        return ROOM_AREA_BUCKET_3K
    return ROOM_AREA_BUCKET_LARGE


def log_diff(series: list[float | int | None]) -> list[float | None]:
    """Δln ряда для регрессии §9.6: out[t] = ln(x_t) − ln(x_{t-1}).

    Логарифм-разность стационаризует ряд продаж (рост в %). Правила None:
      • out[0] = None ВСЕГДА (нет предыдущей точки).
      • если x_t или x_{t-1} равны None или ≤ 0 → out[t] = None (ln(0)/ln(neg)
        = −inf/undefined; 0 продаж — валидное наблюдение для уровня, но не для
        Δln, поэтому помечаем пропуском, а не −inf, чтобы не отравить регрессию).
    Длина выхода = длине входа. PURE.

    Args:
        series: ряд значений (обычно units или area), None/0 допустимы.

    Returns:
        Список Δln той же длины; [0] и любые undefined-точки = None.
    """
    out: list[float | None] = []
    for i, cur in enumerate(series):
        if i == 0:
            out.append(None)
            continue
        prev = series[i - 1]
        if cur is None or prev is None:
            out.append(None)
            continue
        cur_f = float(cur)
        prev_f = float(prev)
        if cur_f <= 0 or prev_f <= 0:
            out.append(None)
            continue
        out.append(math.log(cur_f) - math.log(prev_f))
    return out


def fill_month_grid(
    by_month: dict[date, tuple[int, float | None, float | None]],
    grid: list[date],
) -> tuple[list[int], list[float | None], list[float | None]]:
    """Разложить разрежённые помесячные точки на непрерывную сетку.

    Каждая точка by_month[month] = (units, area_m2, avg_price). Месяц сетки,
    которого нет в by_month → (0, None, None): units=0 — НАСТОЯЩИЙ ноль («продали
    0»), area/price=None (нет сделок → среднего нет). Ключи нормализуются к 1-му
    числу (страховка). PURE, не мутирует вход.

    Args:
        by_month: {month1st: (units, area_m2, avg_price_per_m2)}.
        grid: непрерывный ASC-список 1-х чисел месяцев (из _month_grid).

    Returns:
        (units, area_m2, avg_price_per_m2) — три списка длины len(grid).
    """
    normalised = {_month_start(m): v for m, v in by_month.items()}
    units: list[int] = []
    area: list[float | None] = []
    price: list[float | None] = []
    for month in grid:
        point = normalised.get(month)
        if point is None:
            units.append(0)
            area.append(None)
            price.append(None)
        else:
            u, a, p = point
            units.append(int(u))
            area.append(a)
            price.append(p)
    return units, area, price


def _confidence(units: list[int]) -> Confidence:
    """Confidence по числу НЕнулевых месяцев (см. _CONF_* пороги)."""
    nonzero = sum(1 for u in units if u > 0)
    if nonzero >= _CONF_HIGH_MIN_NONZERO_MONTHS:
        return "high"
    if nonzero >= _CONF_MEDIUM_MIN_NONZERO_MONTHS:
        return "medium"
    return "low"


# ──────────────────────────────────────────────────────────────────────────────
# SQL — Source A (objective_corpus_room_month, monthly aggregate)
# ──────────────────────────────────────────────────────────────────────────────

# Source A: суммируем помесячные сделки (ДДУ+ДКП = deals_total_*) по report_month.
# Авторитетен для агрегатов класс/район. Фильтры spec опциональны через
# CAST(:x AS text) IS NULL OR col = :x. class — Title-case в этой таблице, но
# матчим через LOWER(...)=LOWER(...) (регистронезависимо — устойчиво к вариациям
# регистра spec). Площадь — SUM(deals_total_vol_m2); цена — count-weighted AVG
# уже усреднённой колонки (sql.md: AVG по pre-aggregated строкам неверен →
# SUM(avg*cnt)/NULLIF(SUM(cnt),0)). Цена в таблице в тыс.₽/м² → ×1000 к ₽/м²
# (единый масштаб с Source B price_per_m2_rub).
_SOURCE_A_SQL = text(
    """
    SELECT
        crm.report_month                                   AS month,
        SUM(crm.deals_total_count)                         AS units,
        SUM(crm.deals_total_vol_m2)                        AS area_m2,
        SUM(
            crm.deals_total_avg_price_thousand_rub_per_m2
            * crm.deals_total_count
        ) / NULLIF(SUM(crm.deals_total_count), 0) * 1000.0 AS avg_price_per_m2
      FROM objective_corpus_room_month crm
     WHERE crm.report_month >= CAST(:since AS date)
       AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text)))
       AND (
            CAST(:has_district AS boolean) IS FALSE
            OR crm.district = ANY(CAST(:districts AS text[]))
           )
       AND (
            CAST(:room_bucket AS text) IS NULL
            OR crm.room_bucket = CAST(:room_bucket AS text)
           )
     GROUP BY crm.report_month
    """
)


# ──────────────────────────────────────────────────────────────────────────────
# SQL — Source B (objective_lots, per-lot deal dates)
# ──────────────────────────────────────────────────────────────────────────────

# Source B: COUNT(*) проданных лотов по месяцу РЕГИСТРАЦИИ сделки
# (date_trunc('month', registration_date) — есть индекс objective_lots_reg_idx).
# Авторитетен для room×area×price-сегментации (несёт area_pd / price_per_m2_rub
# per-lot, чего Source A не даёт). class — lowercase в этой таблице → матчим
# LOWER(...)=LOWER(...). room×area bucket и price bucket считаем В SQL теми же
# порогами, что pure-helpers (держим логику зеркальной — пороги ниже = константы
# _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*; при правке менять в ОБОИХ местах).
# Цена — средневзвешенная по площади: SUM(price*area)/NULLIF(SUM(area),0)
# (корректнее простого AVG по лотам разной площади).
# КАВЕАТ ВЫЖИВАЕМОСТИ задокументирован в module docstring.
_SOURCE_B_SQL = text(
    """
    WITH sold AS (
        SELECT
            CAST(date_trunc('month', ol.registration_date) AS date) AS month,
            ol.area_pd                                               AS area_pd,
            ol.price_per_m2_rub                                      AS price_per_m2,
            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 AS room_area_bucket,
            CASE
                WHEN ol.price_per_m2_rub IS NULL
                    OR ol.price_per_m2_rub <= 0 THEN CAST(:p_unknown AS text)
                WHEN ol.price_per_m2_rub < CAST(:p_economy_max AS numeric)
                    THEN CAST(:p_economy AS text)
                WHEN ol.price_per_m2_rub < CAST(:p_comfort_max AS numeric)
                    THEN CAST(:p_comfort AS text)
                WHEN ol.price_per_m2_rub < CAST(:p_business_max AS numeric)
                    THEN CAST(:p_business AS text)
                ELSE CAST(:p_premium AS text)
            END AS price_bucket
          FROM objective_lots ol
         WHERE ol.premise_kind = CAST(:premise_kind AS text)
           AND ol.registration_date IS NOT NULL
           AND ol.registration_date >= CAST(:since AS date)
           AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
           AND (
                CAST(:has_district AS boolean) IS FALSE
                OR ol.district = ANY(CAST(:districts AS text[]))
               )
    )
    SELECT
        month,
        COUNT(*)                                          AS units,
        SUM(area_pd)                                      AS area_m2,
        SUM(price_per_m2 * area_pd)
            / NULLIF(SUM(area_pd), 0)                     AS avg_price_per_m2
      FROM sold
     WHERE (CAST(:room_bucket AS text) IS NULL OR room_area_bucket = CAST(:room_bucket AS text))
       AND (CAST(:price_bucket AS text) IS NULL OR price_bucket = CAST(:price_bucket AS text))
     GROUP BY month
    """
)

# premise_kind по умолчанию для Source B (жилые квартиры — единственный сегмент,
# по которому считаем продажи §9.6; зеркалит market_metrics default).
_DEFAULT_PREMISE_KIND: str = "квартира"


def _sales_series_key(
    db: Session,
    *,
    spec: SegmentSpec,
    source: SalesSource,
    months_back: int = _DEFAULT_MONTHS_BACK,
    premise_kind: str = _DEFAULT_PREMISE_KIND,
) -> tuple[Any, ...]:
    """Ключ кэша §22-форсайта для build_sales_series (#1129).

    SegmentSpec — frozen-dataclass (hashable напрямую); source/premise — строки;
    + окно. `db` не в ключе (одна сессия на отчёт).
    """
    return (spec, source, months_back, premise_kind)


@cached(_sales_series_key, label="build_sales_series")
def build_sales_series(
    db: Session,
    *,
    spec: SegmentSpec,
    source: SalesSource,
    months_back: int = _DEFAULT_MONTHS_BACK,
    premise_kind: str = _DEFAULT_PREMISE_KIND,
) -> SalesSeries:
    """Собрать monthly ряд продаж по сегменту из выбранного источника.

    Ряд строится по НЕПРЕРЫВНОЙ сетке месяцев [start .. текущий] (а не только по
    месяцам со сделками): месяц без продаж → units=0 (настоящий ноль), area/price
    = None. Это даёт регрессии §9.6 регулярный шаг по времени.

    Source выбирает зону авторитетности (см. module docstring):
      • 'corpus_room_month' — агрегаты класс/район (полные сделки ДДУ+ДКП).
      • 'objective_lots'     — тонкая room×area×price-сегментация (но survivorship
        bias на старых месяцах — каведат в module docstring).

    Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0,
    area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) — только
    если сетка пуста (months_back < 0).

    Args:
        db: SQLAlchemy sync Session.
        spec: сегмент (любой subset осей; None-поля = агрегировать по оси).
        source: 'corpus_room_month' | 'objective_lots'.
        months_back: глубина ряда (по умолчанию _DEFAULT_MONTHS_BACK).
        premise_kind: тип помещения для Source B (по умолчанию 'квартира').

    Returns:
        SalesSeries (всегда; [] months только при пустой сетке).
    """
    today = date.today()
    start = _shift_months(today, -max(0, months_back))
    grid = _month_grid(start, _month_start(today))

    segment = spec.as_dict()
    if not grid:
        return SalesSeries(
            months=[],
            units=[],
            area_m2=[],
            avg_price_per_m2=[],
            n_months=0,
            source=source,
            segment=segment,
            confidence="low",
        )

    if source == "corpus_room_month":
        by_month = _query_source_a(db, spec=spec, since=start)
    else:
        by_month = _query_source_b(db, spec=spec, since=start, premise_kind=premise_kind)

    units, area_m2, avg_price = fill_month_grid(by_month, grid)
    confidence = _confidence(units)

    logger.info(
        "build_sales_series: source=%s months=%d nonzero=%d "
        "class=%s room=%s district=%s price=%s confidence=%s",
        source,
        len(grid),
        sum(1 for u in units if u > 0),
        spec.obj_class,
        spec.room_bucket,
        spec.district,
        spec.price_bucket,
        confidence,
    )

    return SalesSeries(
        months=grid,
        units=units,
        area_m2=area_m2,
        avg_price_per_m2=avg_price,
        n_months=len(grid),
        source=source,
        segment=segment,
        confidence=confidence,
    )


def _query_source_a(
    db: Session, *, spec: SegmentSpec, since: date
) -> dict[date, tuple[int, float | None, float | None]]:
    """Source A (corpus_room_month) → {month1st: (units, area, avg_price)}.

    Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
    игнорируется (агрегат не несёт per-lot цены) — фиксируется логом.

    SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
    переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
    `db.execute` оставляет транзакцию Postgres aborted — все ПОСЛЕДУЮЩИЕ запросы на
    той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
    SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
    """
    if spec.price_bucket is not None:
        logger.info(
            "build_sales_series: price_bucket=%s ignored for source=corpus_room_month "
            "(monthly aggregate carries no per-lot price)",
            spec.price_bucket,
        )
    # district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро —
    # тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide.
    micros = resolve_objective_districts(db, spec.district)
    params = {
        "since": since,
        "cls": spec.obj_class,
        "has_district": micros is not None,
        "districts": micros if micros is not None else [],
        "room_bucket": spec.room_bucket,
    }
    try:
        with db.begin_nested():
            rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
    except Exception:
        logger.exception("build_sales_series: source A query failed")
        return {}
    return _rows_to_by_month(rows)


def _query_source_b(
    db: Session, *, spec: SegmentSpec, since: date, premise_kind: str
) -> dict[date, tuple[int, float | None, float | None]]:
    """Source B (objective_lots) → {month1st: (units, area, avg_price)}.

    Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
    (зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
    правилом и в БД, и в Python.

    SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
    §22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
    """
    # district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
    # None → EKB-wide (без district-фильтра).
    micros = resolve_objective_districts(db, spec.district)
    params = {
        "since": since,
        "premise_kind": premise_kind,
        "cls": spec.obj_class,
        "has_district": micros is not None,
        "districts": micros if micros is not None else [],
        "room_bucket": spec.room_bucket,
        "price_bucket": spec.price_bucket,
        # bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*).
        "large_area": _LARGE_AREA_THRESHOLD_M2,
        "p_economy_max": _PRICE_BAND_ECONOMY_MAX,
        "p_comfort_max": _PRICE_BAND_COMFORT_MAX,
        "p_business_max": _PRICE_BAND_BUSINESS_MAX,
        # bucket-метки (зеркало ROOM_AREA_BUCKET_* / PRICE_BUCKET_*).
        "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,
        "p_economy": PRICE_BUCKET_ECONOMY,
        "p_comfort": PRICE_BUCKET_COMFORT,
        "p_business": PRICE_BUCKET_BUSINESS,
        "p_premium": PRICE_BUCKET_PREMIUM,
        "p_unknown": PRICE_BUCKET_UNKNOWN,
    }
    try:
        with db.begin_nested():
            rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
    except Exception:
        logger.exception("build_sales_series: source B query failed")
        return {}
    return _rows_to_by_month(rows)


def _rows_to_by_month(
    rows: list[Any],
) -> dict[date, tuple[int, float | None, float | None]]:
    """Свести строки (month, units, area_m2, avg_price_per_m2) в {month1st: tuple}.

    Площадь/цена → None если NULL (нет сделок с известным значением). units
    приводим к int (≥0). Строки с month IS NULL пропускаем (защита от мусора).
    """
    out: dict[date, tuple[int, float | None, float | None]] = {}
    for r in rows:
        month = r["month"]
        if month is None:
            continue
        units = int(r["units"] or 0)
        area = float(r["area_m2"]) if r["area_m2"] is not None else None
        price = float(r["avg_price_per_m2"]) if r["avg_price_per_m2"] is not None else None
        out[_month_start(month)] = (units, area, price)
    return out
