"""Supply-layers service — детерминированный расчёт 3-слойного склада предложения.

#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения», ТЗ §9.3).
Это **измерительный слой**: считает агрегаты текущего/скрытого/будущего предложения
по districts и ВОЗВРАЩАЕТ их dataclass'ами. В таблицу `supply_layers` (м.125) их
UPSERT-ит worker #950 Step 5 — ЭТОТ сервис НИЧЕГО не пишет (read-only compute).

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

Три слоя (ТЗ §9.3 / м.125):
  • layer1 (open)   — открытое предложение в текущей продаже. Источник: `objective_lots`
    (выставленные лоты). source='objective', confidence='high'.
  • layer2 (hidden) — скрытый запас застройщика, ЗАЯВЛЕННЫЙ в ПД но НЕ выставленный
    сейчас: per dom.рф-объект hidden = max(0, flat_count − free_flats). Обе колонки на
    ОДНОЙ строке `domrf_kn_objects` (free_flats — м.124, #950 PR A) → **mapping-free**:
    domrf↔objective маппинг покрывает ~2.5% и здесь НЕ нужен (это и есть весь смысл
    разблокировки free_flats). source='domrf_declared'.
  • layer3 (future) — будущее предложение, ещё НЕ на рынке: multiphase-проекты dom.рф
    (ready_dt далеко в будущем + почти нет выставленных квартир). source='domrf_multiphase',
    expected_online_date=ready_dt, confidence='low'.

Whitelisted значения (CHECK в м.125 — иначе worker'ский INSERT падает ГРОМКО):
  source ∈ {objective, domrf_declared, domrf_multiphase, graddoc_stub, insider_manual};
  confidence ∈ {high, medium, low}. Этот сервис эмитит только objective / domrf_declared /
  domrf_multiphase. Строки graddoc_stub / insider_manual сюда НЕ приходят — они
  живут в таблице независимо (поэтому м.125 — TABLE, не MV).

TODO(#956): источник 'graddoc_stub' (layer3) — граддок-NLP, LLM-фаза, отложена.
  insider_manual — ручной ввод. Оба вставляются ВНЕ этого сервиса (см. м.125 docstring).

Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пустые/тонкие данные →
пустой список строк (НЕ crash). Когда строка есть, но сигнал слабый → confidence='low'.

──────────────────────────────────────────────────────────────────────────────
Колонки, проверенные по реальной схеме (НЕ предполагались):
  domrf_kn_objects (43_anton_import + м.57 + м.113 + м.124):
    obj_id, snapshot_date, region_cd (66=ЕКБ), district_name (м.57, PostGIS spatial-join,
    NULL вне 8 районов ЕКБ), flat_count, free_flats (м.124, NULLABLE — старые снапшоты),
    obj_class, square_living, ready_dt, dev_group_name (м.113), site_status.
  objective_lots (м.68): objective_lot_id, project_name, district, premise_kind, class
    (obj_class, lowercase), is_sold, status, contract_date, area_pd, snapshot_date.

Известные ограничения (degraded-gracefully):
  • L2 fallback: у `domrf_kn_objects` НЕТ колонки `sold` (проверено: 43_anton_import,
    м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
    бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ — чище, чем
    выдуманный sold-proxy) → coverage снижается → confidence падает с medium до low.
    Тот же exclusion — И при flat_count IS NULL (#2445-A3: заполняется независимо от
    free_flats, м.124) — иначе GREATEST(0, COALESCE(flat_count,0) − free_flats) молча
    давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
  • L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
    spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь —
    БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
    Объекты с district_name IS NULL (вне 8 районов ЕКБ / без координат) в district-grain
    L2 НЕ попадают (documented gap).
  • Per-ЖК L2 (complex_id grain) НЕ эмитим: это потребовало бы domrf↔objective маппинг
    (2.5% покрытие) — district-агрегат и есть deliverable (ТЗ §9.3).
"""

from __future__ import annotations

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

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.services.site_finder.district_resolver import resolve_objective_districts

logger = logging.getLogger(__name__)

Confidence = Literal["high", "medium", "low"]
Source = Literal["objective", "domrf_declared", "domrf_multiphase"]

# Регион данных (ЕКБ / Свердловская обл.). district_name на domrf_kn_objects заполнен
# spatial-join'ом только для region_cd=66 (м.57) — фильтруем по нему явно.
_EKB_REGION_CODE: int = 66

# L2: доля объектов района с заполненным free_flats, выше которой доверяем 'medium'.
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
_L2_MEDIUM_MIN_COVERAGE: float = 0.6

# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
#   L2 (hidden)  : ready_dt IS NULL  OR  ready_dt <= now + L2_NEAR_HORIZON   [now, now+12mo]
#   L3 (future)  : ready_dt IS NOT NULL  AND  ready_dt > now + L3_FUTURE_HORIZON  (now+12mo, ∞)
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
_L2_NEAR_HORIZON_MONTHS: int = 12  # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
# почти-на-рынке предложение (ловится L1/L2), не «future».
_L3_FUTURE_HORIZON_MONTHS: int = 12  # L3 берёт объекты с ready_dt > сейчас+12мес

# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
_L3_MAX_FREE_FLATS_NOT_SELLING: int = 5


@dataclass(frozen=True)
class SupplyLayerRow:
    """Одна строка склада предложения — зеркало колонок таблицы `supply_layers` (м.125).

    Grain: district_name (primary) + optional obj_class. complex_id здесь всегда None
    (per-ЖК grain — это L3-stubs/reviewed-L2, вне этого сервиса). expected_online_date
    заполнен только для layer3 (когда предложение выйдет на рынок); для L1/L2 — None.

    source/confidence — ТОЛЬКО whitelisted значения (CHECK м.125). units_estimate /
    area_estimate — NULLABLE (None при отсутствии дешёвого сигнала, НЕ 0-как-заглушка).
    """

    layer: Literal[1, 2, 3]
    district_name: str | None
    complex_id: int | None
    obj_class: str | None
    # L3-grain (#970, м.128): бакет future-supply по группе застройщика. ЧАСТЬ логического
    # ключа uq_supply_layers_logical → разные dev_group'ы района дают разные ключи и больше
    # не схлопываются (~95.6% потерь L3 до фикса). NULL для L1/L2 (у них dev_group'а нет).
    dev_group_name: str | None
    units_estimate: int | None
    area_estimate: float | None
    expected_online_date: date | None
    source: Source
    confidence: Confidence
    method: str
    snapshot_date: date | None  # None → worker подставит DEFAULT CURRENT_DATE на write

    def as_dict(self) -> dict[str, Any]:
        return {
            "layer": self.layer,
            "district_name": self.district_name,
            "complex_id": self.complex_id,
            "obj_class": self.obj_class,
            "dev_group_name": self.dev_group_name,
            "units_estimate": self.units_estimate,
            "area_estimate": _round_or_none(self.area_estimate, 1),
            "expected_online_date": self.expected_online_date,
            "source": self.source,
            "confidence": self.confidence,
            "method": self.method,
            "snapshot_date": self.snapshot_date,
        }


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


# ──────────────────────────────────────────────────────────────────────────────
# Pure-арифметика — без БД, полностью юнит-тестируемо. Каждая функция graceful.
# ──────────────────────────────────────────────────────────────────────────────


def _hidden_units(flat_count: int | None, free_flats: int | None) -> int | None:
    """L2 per-объект hidden = max(0, flat_count − free_flats). Clamp ≥ 0 ВСЕГДА.

    None при отсутствии любого из слагаемых — такой объект нельзя посчитать и он
    ИСКЛЮЧАЕТСЯ из L2-агрегата (free_flats IS NULL → нет сигнала, sold-колонки нет).
    Никогда не эмитим отрицательное hidden (free_flats > flat_count из-за рассинхрона
    снапшотов/перепланировки — клампим в 0, не в негатив).
    """
    if flat_count is None or free_flats is None:
        return None
    return max(0, int(flat_count) - int(free_flats))


def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Confidence:
    """L2 confidence по покрытию free_flats (ТЗ §9.3 + м.124 fallback-note).

    'medium' когда free_flats заполнен у БОЛЬШИНСТВА объектов района (надёжный hidden);
    'low' когда заполнен у меньшинства (много исключённых NULL-объектов → недооценка
    скрытого запаса) или объектов нет вовсе. НИКОГДА 'high' — L2 это оценка по ПД, не
    факт продаж (high зарезервирован за L1/наблюдаемым).
    """
    if n_objects_total <= 0 or n_objects_with_free_flats <= 0:
        return "low"
    coverage = n_objects_with_free_flats / n_objects_total
    return "medium" if coverage >= _L2_MEDIUM_MIN_COVERAGE else "low"


# ──────────────────────────────────────────────────────────────────────────────
# SQL — все bind-параметры через CAST(:x AS type) (psycopg v3; НИКОГДА :x::type).
# ──────────────────────────────────────────────────────────────────────────────

# ── Layer1: открытое предложение (objective_lots), per район × класс ───────────
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
#
# #1964: дедуп до ФИЗИЧЕСКОГО лота. Сырая objective_lots раздута ~2.91× (мульти
# objective_lot_id на один физлот через пере-листинги) → units_available по району
# был завышен ~3-4.6× (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт
# переоценивал открытое предложение L1.
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view селектит ol.* и
# его DISTINCT ON — оптимизационный барьер: WHERE снаружи (premise_kind/district) НЕ
# проталкивается ниже DISTINCT ON → планировщик материализует ВСЮ таблицу (1.76M,
# Parallel Seq Scan width≈891 + external merge sort 76-101 МБ/воркер) ПЕРЕД фильтром
# (~6.0 s, прод-EXPLAIN). Inline протолкивает СТАБИЛЬНЫЕ фильтры (premise_kind, district
# — НЕ меняются между снапшотами физлота: премайз-кайнд 0 mixed-групп на проде, район ~4
# transient на 605k) В WHERE ДО DISTINCT ON → bitmap по objective_lots_district_class_idx
# + узкий sort (~5 МБ/воркер) ~0.47 s district-scoped (≈13× быстрее; EKB-wide остаётся
# seq-scan, но width≈99 вместо 891 → ~4.6 s). Эквивалентность view-vs-inline доказана
# бит-в-бит на проде (все district×class пары, 0 расхождений) — зеркало parcels.py.
# sold_now (is_sold/contract_date/status='продан') — МЕНЯЮЩИЕСЯ атрибуты → считаем
# ПОСЛЕ дедупа (на последнем снапшоте), как делал view.
_L1_OPEN_SQL = text(
    """
    WITH lots AS (
        SELECT DISTINCT ON (
            project_name, corpus_name, section, floor, lot_number
        )
            district,
            class AS obj_class,
            area_pd,
            (
                is_sold IS TRUE
                OR contract_date IS NOT NULL
                OR LOWER(COALESCE(status, '')) = 'продан'
            ) AS sold_now
          FROM objective_lots
         WHERE premise_kind = CAST(:premise_kind AS text)
           AND (
                CAST(:has_district AS boolean) IS FALSE
                OR district = ANY(CAST(:districts AS text[]))
               )
         ORDER BY project_name, corpus_name, section, floor,
                  lot_number, snapshot_date DESC, id DESC
    )
    SELECT
        district,
        obj_class,
        COUNT(*) FILTER (WHERE NOT sold_now)                       AS units_available,
        COALESCE(SUM(area_pd) FILTER (WHERE NOT sold_now), 0)      AS area_available
      FROM lots
     WHERE district IS NOT NULL
     GROUP BY district, obj_class
     HAVING COUNT(*) FILTER (WHERE NOT sold_now) > 0
    """
)

# ── Layer2: скрытый запас (domrf_kn_objects ТОЛЬКО — mapping-free), per район ───
# Берём свежий снапшот на obj_id (DISTINCT ON), затем агрегируем Σ max(0, flat_count −
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
# на этой же таблице.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
_L2_HIDDEN_SQL = text(
    """
    WITH latest AS (
        SELECT DISTINCT ON (o.obj_id)
            o.obj_id,
            o.district_name,
            o.flat_count,
            o.free_flats,
            o.ready_dt
          FROM domrf_kn_objects o
         WHERE o.region_cd = :region_cd
           AND o.district_name IS NOT NULL
           AND (
                CAST(:district AS text) IS NULL
                OR o.district_name = CAST(:district AS text)
               )
         ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
    )
    SELECT
        district_name,
        COUNT(*)                                              AS n_objects_total,
        COUNT(*) FILTER (
            WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
        )                                                      AS n_with_free_flats,
        COALESCE(SUM(
            GREATEST(0, flat_count - free_flats)
        ) FILTER (
            WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
        ), 0)                                                  AS hidden_units
      FROM latest
     WHERE ready_dt IS NULL
        OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
     GROUP BY district_name
    """
)

# ── Layer3: будущее предложение (domrf_kn_objects multiphase) ───────────────────
# Свежий снапшот на obj_id; «future» = ready_dt дальше горизонта И выставлено ≤ порога
# квартир (free_flats мал/0 → проект объявлен, но ещё не открыт). Группируем по
# (район, dev_group_name) — поздние очереди застройщика. expected_online_date = MIN
# ready_dt группы (ближайший ввод). flat_count Σ как оценка будущего объёма.
_L3_FUTURE_SQL = text(
    """
    WITH latest AS (
        -- #1212: сначала ВСЕГДА берём последний снапшот на obj_id
        -- (DISTINCT ON + ORDER BY snapshot_date DESC). Внутри CTE — только
        -- стабильные фильтры (region_cd / админ-район). Volatile-фильтры
        -- (ready_dt, free_flats) применяются ПОСЛЕ DISTINCT ON во внешнем
        -- WHERE. Иначе WHERE применяется ДО DISTINCT ON и берётся «последний
        -- снапшот, прошедший фильтр»: проект, когда-то бывший «объявлен,
        -- не продаётся», остаётся в L3 и после открытия продаж (свежий
        -- снапшот с большим free_flats отфильтрован, взят старый) →
        -- двойной счёт с L1/L2, stale flat_count/ready_dt.
        SELECT DISTINCT ON (o.obj_id)
            o.obj_id,
            o.district_name,
            o.dev_group_name,
            o.obj_class,
            o.flat_count,
            o.square_living,
            o.free_flats,
            o.ready_dt
          FROM domrf_kn_objects o
         WHERE o.region_cd = :region_cd
           AND o.district_name IS NOT NULL
           AND (
                CAST(:district AS text) IS NULL
                OR o.district_name = CAST(:district AS text)
               )
         ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
    )
    SELECT
        district_name,
        dev_group_name,
        COUNT(*)                              AS n_objects,
        COALESCE(SUM(flat_count), 0)          AS units_future,
        COALESCE(SUM(square_living), 0)       AS area_future,
        MIN(ready_dt)                         AS expected_online_date
      FROM latest
     WHERE ready_dt IS NOT NULL
       AND ready_dt > CURRENT_DATE + CAST(:horizon AS interval)
       AND COALESCE(free_flats, 0) <= :max_free_flats
     GROUP BY district_name, dev_group_name
    """
)


def compute_layer1_open(
    db: Session,
    *,
    district: str | None = None,
    premise_kind: str = "квартира",
    snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
    """Layer1 (open): открытое предложение в продаже, per район × класс.

    Источник — `objective_lots` (выставленные лоты), переиспользует district-stock
    паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
    на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает.

    district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро
    (objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None →
    EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по
    domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join).
    """
    micros = resolve_objective_districts(db, district)
    params: dict[str, Any] = {
        "premise_kind": premise_kind,
        "has_district": micros is not None,
        "districts": micros if micros is not None else [],
    }
    rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1)

    out: list[SupplyLayerRow] = []
    for r in rows:
        units = int(r["units_available"] or 0)
        if units <= 0:
            continue
        area = float(r["area_available"] or 0.0)
        out.append(
            SupplyLayerRow(
                layer=1,
                district_name=r["district"],
                complex_id=None,
                obj_class=r["obj_class"],
                dev_group_name=None,  # L1 (open) — у района-агрегата dev_group'а нет
                units_estimate=units,
                area_estimate=area if area > 0 else None,
                expected_online_date=None,
                source="objective",
                confidence="high",
                method="objective_lots: available (not sold) lots per district×class",
                snapshot_date=snapshot_date,
            )
        )
    logger.info(
        "supply_layers L1(open): district=%s premise=%s rows=%d",
        district,
        premise_kind,
        len(out),
    )
    return out


def compute_layer2_hidden(
    db: Session,
    *,
    district: str | None = None,
    snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
    """Layer2 (hidden): скрытый запас застройщика, per район (mapping-free).

    Per dom.рф-объект hidden = max(0, flat_count − free_flats); Σ к району. ОБЕ колонки
    на одной строке domrf_kn_objects → НЕ нужен domrf↔objective маппинг (см. docstring
    модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
    (#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
    free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
    объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают coverage
    → confidence medium→low.

    source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
    units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает.
    """
    params: dict[str, Any] = {
        "region_cd": _EKB_REGION_CODE,
        "district": district,
        "near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
    }
    rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)

    out: list[SupplyLayerRow] = []
    for r in rows:
        n_total = int(r["n_objects_total"] or 0)
        # n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
        # (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
        # Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
        # "покрыто и вычислимо", не только "free_flats заполнен".
        n_with = int(r["n_with_free_flats"] or 0)
        # hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся.
        hidden = max(0, int(r["hidden_units"] or 0))
        if n_with <= 0:
            # Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
            # известны) → нечего объявлять скрытым (а не «0»).
            continue
        confidence = _l2_confidence(n_total, n_with)
        out.append(
            SupplyLayerRow(
                layer=2,
                district_name=r["district_name"],
                complex_id=None,
                obj_class=None,
                dev_group_name=None,  # L2 (hidden) — district-агрегат, dev_group'а нет
                units_estimate=hidden,
                area_estimate=None,  # площадь скрытого недёшево/ненадёжно вывести — None
                expected_online_date=None,
                source="domrf_declared",
                confidence=confidence,
                method=(
                    f"domrf_kn_objects: Σ max(0, flat_count − free_flats); "
                    f"coverage {n_with}/{n_total} objects with flat_count & free_flats known"
                ),
                snapshot_date=snapshot_date,
            )
        )
    logger.info(
        "supply_layers L2(hidden): district=%s rows=%d (mapping-free, domrf_declared)",
        district,
        len(out),
    )
    return out


def compute_layer3_future(
    db: Session,
    *,
    district: str | None = None,
    snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
    """Layer3 (future): будущее предложение ещё НЕ на рынке (domrf multiphase).

    Детерминированный скелет: dom.рф-объекты с ready_dt дальше горизонта И почти без
    выставленных квартир (объявлены, но не открыты), сгруппированные по (район,
    dev_group_name) как поздние очереди застройщика. source='domrf_multiphase',
    expected_online_date=MIN(ready_dt), confidence='low'.

    Граддок-stubs (#956, LLM) и insider_manual сюда НЕ приходят — живут в таблице
    независимо (см. docstring модуля). Пустые данные → []. Не бросает.
    """
    params: dict[str, Any] = {
        "region_cd": _EKB_REGION_CODE,
        "district": district,
        "horizon": f"{_L3_FUTURE_HORIZON_MONTHS} months",
        "max_free_flats": _L3_MAX_FREE_FLATS_NOT_SELLING,
    }
    rows = _safe_rows(db, _L3_FUTURE_SQL, params, layer=3)

    out: list[SupplyLayerRow] = []
    for r in rows:
        units = int(r["units_future"] or 0)
        area = float(r["area_future"] or 0.0)
        dev_group = r["dev_group_name"]
        out.append(
            SupplyLayerRow(
                layer=3,
                district_name=r["district_name"],
                complex_id=None,
                obj_class=None,
                # #970: dev_group_name теперь КЛЮЧ-колонка (uq_supply_layers_logical),
                # не только текст в method → dev_group'ы района не схлопываются на UPSERT.
                dev_group_name=dev_group,
                units_estimate=units if units > 0 else None,
                area_estimate=area if area > 0 else None,
                expected_online_date=r["expected_online_date"],
                source="domrf_multiphase",
                confidence="low",
                method=(
                    "domrf_kn_objects: ready_dt beyond horizon & ~no listed flats; "
                    f"grouped by dev_group_name={dev_group!r}"
                ),
                snapshot_date=snapshot_date,
            )
        )
    logger.info(
        "supply_layers L3(future): district=%s rows=%d (domrf_multiphase)",
        district,
        len(out),
    )
    return out


def compute_all_layers(
    db: Session,
    *,
    district: str | None = None,
    premise_kind: str = "квартира",
    snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
    """Удобный объединённый расчёт L1+L2+L3 для района (для worker'а #950 Step 5).

    Каждый слой graceful-независим: сбой/пустота одного не валит остальные (компонуем
    из самих compute_*, которые уже не бросают). Возвращает плоский список строк всех
    трёх слоёв (worker UPSERT-ит их по uq_supply_layers_logical).
    """
    rows: list[SupplyLayerRow] = []
    rows.extend(
        compute_layer1_open(
            db, district=district, premise_kind=premise_kind, snapshot_date=snapshot_date
        )
    )
    rows.extend(compute_layer2_hidden(db, district=district, snapshot_date=snapshot_date))
    rows.extend(compute_layer3_future(db, district=district, snapshot_date=snapshot_date))
    return rows


def _safe_rows(
    db: Session,
    sql: Any,
    params: Mapping[str, Any],
    *,
    layer: int,
) -> Sequence[Mapping[str, Any]]:
    """Выполнить read-query, graceful: на ошибке логируем и возвращаем [] (не бросаем).

    Зеркало market_metrics._query_* — тонкие данные/сбой БД не должны валить расчёт
    остальных слоёв или worker'а целиком.

    SAVEPOINT (``db.begin_nested()``): ``compute_all_layers`` вызывает эту функцию 3×
    (L1/L2/L3) на ОДНОЙ Session — без savepoint сбой L1 отравил бы транзакцию и L2/L3
    молча получили бы [] тоже (плюс любой db.execute ПОСЛЕ compute_all_layers на этом же
    db, включая caller'а — worker owns SessionLocal(), но при вызове из shared
    request-scoped db, напр. forecasting/orchestrator.build_site_finder_report,
    отравился бы весь оставшийся запрос). Голый db.rollback() здесь НЕЛЬЗЯ — см.
    velocity.py:170.
    """
    try:
        with db.begin_nested():
            return db.execute(sql, dict(params)).mappings().all()
    except Exception:
        # L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
        district_dbg = params.get("district", params.get("districts"))
        logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
        return []
