"""§14.2 продуктовые скоры — детерминированная СОВЕТУЮЩАЯ карта пригодности продукта.

#985 (954-B, Site Finder v2 / «GG-форсайт» ТЗ §14.2), EPIC 10 «Скоринг продукта».
Это **СБОРОЧНЫЙ слой**: своей §9.x-математики НЕ пересобирает — берёт уже-смерженные
сервисы форсайта (#950…#984) и live-стека Site Finder и СВОДИТ их в 10 нормированных
скоров ∈ [0,1] (выше = лучше) + взвешенный overall + §16-подобную короткую причину на
каждый скор. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ (reuse сервисов).

10 СКОРОВ (ТЗ §14.2) и их backing-сервис (каждый ∈ [0,1], выше = лучше для девелопера):
  1. market_fit          ← deficit_index целевого сегмента (#980/#952 спрос↔предложение).
                           Выше дефицит (недосток) → выше fit. Remap [−1,+1]→[0,1].
  2. demand              ← market_metrics.unit_velocity (§9.2 абсорбция). Saturating.
  3. supply_risk         ← future_supply.index + market_metrics.overstock_index.
                           Выше давление/затоварка → НИЖЕ скор (ИНВЕРТИРОВАН).
  4. future_competition  ← competitors.relevance_weight density + кол-во конкурентов.
                           Больше/сильнее будущих конкурентов → НИЖЕ скор (ИНВЕРТИРОВАН).
  5. price_feasibility   ← affordability.monthly_payment_rub (§7.9 прокси нагрузки).
                           Меньше платёж → доступнее → выше скор. Saturating-инверс платежа.
  6. infra_fit           ← poi_score top-7 суммарный weight (Site Finder B6). Выше → выше.
  7. mortgage_sensitivity← rate_sensitivity.x_pct (§9.6). Чем отрицательнее (чувствительнее)
                           → НИЖЕ скор / флаг риска (ИНВЕРТИРОВАН по магнитуде).
  8. differentiation     ← USP-белое-пятно из recommendation (§10.5): число позитив-дефицит
                           USP-сегментов. Больше white-space → выше скор.
  9. commercial          ← recommendation коммерческий сигнал §10.4 (доля коммерции).
                           ДЕГРАДИРУЕТ в None, когда сигнал недоступен (НЕ 0-как-заглушка).
 10. confidence          ← META-скор: MIN/агрегат confidence вкладывающих сервисов +
                           доля доступных скоров (data-quality). Выше → надёжнее карта.

ИНВЕРТИРОВАННЫЕ (high-bad → low-score): supply_risk, future_competition,
mortgage_sensitivity. Остальные — high-good → high-score напрямую.

РЕНОРМАЛИЗАЦИЯ ВЕСОВ (критично, зеркало §9.5 macro_coefficient.renormalize_contributions):
overall = взвешенное среднее ТОЛЬКО по ДОСТУПНЫМ (value != None) скорам; веса доступных
делятся на свою сумму (renorm) → пропуск скора (тонкие данные) НЕ тянет overall к 0
искусственно. Все скоры None → overall=None честно. confidence-скор в overall НЕ
взвешивается весом-вкладом — он мета-скор о надёжности, а не «качество продукта», но он
ПРИСУТСТВУЕТ в карте как 10-й скор (его вес в _SCORE_WEIGHTS = 0.0, см. ниже).

ADVISORY (КРИТИЧНО — честность, зеркало demand_supply_forecast / recommendation):
карта наследует advisory-статус §9.x (движок не провалидирован до бэктеста #951).
Поэтому `advisory` ВСЕГДА True. Цифры — для explainability/прототипа, НЕ основание для
инвест-решения. Каждый backing-сервис уже advisory-capped; здесь cap не задираем.

Graceful-on-thin-data (дух всех §9.x-сервисов): любой тонкий вход / сбой backing-сервиса
→ соответствующий скор value=None + source='unavailable' (НИКОГДА 0-как-заглушка),
НИКОГДА не валит карту. Детерминированно (без рандома). numpy НЕ нужен.
"""

from __future__ import annotations

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

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.schemas.parcel import CompetitorsRequest
from app.services.forecasting.affordability import compute_affordability
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.recommendation import build_forecast_overlay
from app.services.forecasting.regression import compute_rate_regime_sensitivity
from app.services.forecasting.sales_series import SegmentSpec
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics
from app.services.site_finder.poi_score import compute_poi_weighted_top7

if TYPE_CHECKING:  # избегаем рантайм-зависимости (типы только для аннотаций)
    from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast

logger = logging.getLogger(__name__)

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

# ── Ключи 10 скоров (named — переименование не рассинхронит веса/карту молча) ──
_K_MARKET_FIT: str = "market_fit"
_K_DEMAND: str = "demand"
_K_SUPPLY_RISK: str = "supply_risk"
_K_FUTURE_COMPETITION: str = "future_competition"
_K_PRICE_FEASIBILITY: str = "price_feasibility"
_K_INFRA_FIT: str = "infra_fit"
_K_MORTGAGE_SENSITIVITY: str = "mortgage_sensitivity"
_K_DIFFERENTIATION: str = "differentiation"
_K_COMMERCIAL: str = "commercial"
_K_CONFIDENCE: str = "confidence"

# Канонический порядок 10 скоров (детерминизм карты / тестов).
_SCORE_KEYS: tuple[str, ...] = (
    _K_MARKET_FIT,
    _K_DEMAND,
    _K_SUPPLY_RISK,
    _K_FUTURE_COMPETITION,
    _K_PRICE_FEASIBILITY,
    _K_INFRA_FIT,
    _K_MORTGAGE_SENSITIVITY,
    _K_DIFFERENTIATION,
    _K_COMMERCIAL,
    _K_CONFIDENCE,
)

# ── Веса overall (СУММА = 1.0) — экспертная оценка вклада в «пригодность продукта».
# market_fit (баланс спрос↔предложение) — доминирующий сигнал §9.7 «что строить».
# Инвертированные риск-скоры (supply_risk / future_competition / mortgage_sensitivity)
# несут существенную, но меньшую массу. confidence — МЕТА-скор (надёжность, не качество
# продукта) → вес 0.0 в overall, но он ПРИСУТСТВУЕТ как 10-й скор карты. Веса заданы в
# ПОЛНОЙ схеме; renorm делит на сумму ДОСТУПНЫХ (см. _weighted_overall). Эвристика
# (НЕ фит) — уточняется бэктестом #951; карта advisory до валидации. Tunable.
_SCORE_WEIGHTS: dict[str, float] = {
    _K_MARKET_FIT: 0.22,
    _K_DEMAND: 0.16,
    _K_SUPPLY_RISK: 0.13,
    _K_FUTURE_COMPETITION: 0.12,
    _K_PRICE_FEASIBILITY: 0.11,
    _K_INFRA_FIT: 0.09,
    _K_MORTGAGE_SENSITIVITY: 0.07,
    _K_DIFFERENTIATION: 0.06,
    _K_COMMERCIAL: 0.04,
    _K_CONFIDENCE: 0.0,  # МЕТА-скор: в overall не вкладывается весом (надёжность ≠ качество)
}

# ── Named-нормировочные константы (каждая — задокументированная шкала) ─────────

# market_fit: deficit_index ∈ [−1,+1] (−1 затоварка / +1 сильный дефицит) → [0,1]
# линейным remap (di+1)/2. 0.5 = баланс. Прямая (не насыщающая) — индекс УЖЕ насыщен
# в §9.8 (_deficit_index лог-clamp), повторно не жмём.
_MARKET_FIT_MIDPOINT: float = 0.5  # значение скора при deficit_index = 0 (баланс)

# demand: unit_velocity (ед./мес) → saturating v/(v+half). При v = _DEMAND_HALF_VELOCITY
# скор = 0.5. 8 ед./мес ≈ «здоровый» месячный темп одного ЖК масс-рынка ЕКБ (зеркало
# духа market_metrics абсорбции); ниже → продукт медленнее поглощается → ниже скор.
_DEMAND_HALF_VELOCITY: float = 8.0

# supply_risk (ИНВЕРТИРОВАН): берём СРЕДНЕЕ доступных из {future_supply.index,
# overstock_index} (оба уже ∈ [0,1], выше = хуже) → риск r ∈ [0,1]; скор = 1 − r.
# Оба None → None. Среднее (а не max) — оба канала равноправно описывают избыток
# предложения (грядущее давление + зависший сток). Прямая инверсия (1−r) — каналы
# уже нормированы.
# (без доп. константы — оба входа уже в [0,1])

# future_competition (ИНВЕРТИРОВАН): плотность конкурентов = mean(top-N relevance_weight)
# масштабируем кол-вом найденных через count/(count+half). density ∈ [0,1];
# скор = 1 − density. _COMP_HALF_COUNT = «при стольких релевантных конкурентах
# масштаб-множитель = 0.5». 5 ≈ умеренно плотный рынок в радиусе 1 км. Нет конкурентов
# → density=0 → скор 1.0 (чистое поле — хорошо). Пустой ответ (нет геометрии) → None.
_COMP_HALF_COUNT: float = 5.0
_COMP_TOP_N: int = 5  # сколько верхних по relevance_weight усредняем (зеркало §9.8 top-N)

# price_feasibility: monthly_payment_rub (₽/мес, прокси нагрузки §7.9) → saturating
# ИНВЕРС: скор = half/(half+payment), т.е. меньше платёж → выше скор. При payment =
# _PRICE_HALF_PAYMENT_RUB скор = 0.5. 60 000 ₽/мес ≈ ориентир «комфортного» аннуитета
# масс-рынка ЕКБ на эталонную квартиру (НЕ привязка к доходу — дохода в данных нет,
# #946; это ОТНОСИТЕЛЬНАЯ шкала тяжести платежа). Платёж None → None.
_PRICE_HALF_PAYMENT_RUB: float = 60_000.0

# infra_fit: Σ top-7 POI weight (poi_score формула 1/(d+100)·cat_weight) → saturating
# w/(w+half). При сумме = _INFRA_HALF_WEIGHT скор = 0.5. 0.06 ≈ умеренно обеспеченная
# инфраструктурой локация (несколько близких POI среднего веса); калибровано под
# масштаб poi_score weight (метро на ~400 м даёт ~0.012, школа рядом ~0.01). Нет POI /
# нет координат участка → None (НЕ 0 — «нет данных» ≠ «нет инфраструктуры рядом»).
_INFRA_HALF_WEIGHT: float = 0.06

# mortgage_sensitivity (ИНВЕРТИРОВАН по магнитуде): x_pct = % изменения продаж на +1 п.п.
# ставки (NEGATIVE при чувствительном сегменте, §9.6). Риск = min(1, |x_pct|/max);
# скор = 1 − риск. _MORTG_MAX_X_PCT = «падение продаж на 25% за +1 п.п. — уже полный
# риск-флаг» (экстремально rate-чувствительный сегмент). x_pct None → None. Знак x_pct≥0
# (продажи якобы растут при росте ставки — не доверяем, §9.6) трактуем как нет риска
# (риск 0 → скор 1.0): мы НЕ штрафуем за «нечувствительность».
_MORTG_MAX_X_PCT: float = 25.0

# differentiation: число позитив-дефицит USP-сегментов (§10.5) из overlay →
# n/_DIFF_TARGET_USP, clamp [0,1]. 3 USP-пункта (= _USP_TOP_K в recommendation) =
# полный white-space сигнал (скор 1.0). 0 USP → 0.0 (нет белых пятен — слабая
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
_DIFF_TARGET_USP: float = 3.0

# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
# как прокси силы спроса на коммерцию.

# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
# монотонная шкала (зеркало рангов §9.x). Доля доступных = (кол-во value!=None из 9
# продуктовых скоров) / 9. Финал = mean(label-score, availability-fraction).
_CONF_LABEL_VALUE: dict[Confidence, float] = {"high": 1.0, "medium": 0.6, "low": 0.25}

# Радиус поиска конкурентов (км) — дефолт CompetitorsRequest (прямые соседи). Явная
# константа для детерминизма вызова (зеркало demand_supply_forecast._COMPETITORS_RADIUS_KM).
_COMPETITORS_RADIUS_KM: float = 1.0

# Радиус POI (м) для infra_fit — дефолт poi_score (2 км пешей доступности). Явно.
_POI_RADIUS_M: int = 2000

# Горизонт по умолчанию (мес) — зеркало продуктового «на горизонте» (#982/#983 default 12).
_DEFAULT_HORIZON_MONTHS: int = 12

# Порядок уверенности для MIN-агрегации (хуже = ниже). Зеркало §9.x-сервисов.
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}

# SQL центроида участка (lat/lon для poi_score) — reuse семантики competitors
# (cad_parcels_geom → fallback cad_quarters_geom). Параметризован, без f-string.
_PARCEL_CENTROID_SQL = text("""
    SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat
    FROM (
        SELECT ST_Centroid(geom) AS pt
          FROM cad_parcels_geom
         WHERE cad_num = :cad_num AND geom IS NOT NULL
        UNION ALL
        SELECT ST_Centroid(geom) AS pt
          FROM cad_quarters_geom
         WHERE cad_number = :quarter AND geom IS NOT NULL
    ) sub
    LIMIT 1
""")


@dataclass(frozen=True)
class ProductScore:
    """Один из 10 §14.2 продуктовых скоров (считается на лету, детерминированно).

    `value` ∈ [0,1] (выше = лучше для девелопера) ИЛИ None, когда backing-данные тонкие
    (НИКОГДА 0-как-заглушка). `source` = имя backing-сервиса или 'unavailable'.
    `reason` — короткая RU §16-подобная причина. Инвертированные скоры (supply_risk /
    future_competition / mortgage_sensitivity) уже приведены к «выше = лучше».
    """

    key: str  # один из _SCORE_KEYS
    value: float | None  # ∈ [0,1] или None (тонкие данные)
    confidence: Confidence
    reason: str  # короткая RU §16-причина
    source: str  # backing-сервис или 'unavailable'

    def as_dict(self) -> dict[str, Any]:
        return {
            "key": self.key,
            "value": _round_or_none(self.value, 3),
            "confidence": self.confidence,
            "reason": self.reason,
            "source": self.source,
        }


@dataclass(frozen=True)
class ProductScoreCard:
    """§14.2 карта из 10 продуктовых скоров для сегмента/горизонта (считается на лету).

    Все 10 скоров присутствуют в `scores` (тонкие → value=None + source='unavailable').
    `overall` — взвешенное среднее ТОЛЬКО по доступным скорам с РЕНОРМАЛИЗАЦИЕЙ весов
    над доступными (зеркало §9.5): пропуск скора НЕ тянет overall к 0; все скоры None →
    overall=None. `advisory` ВСЕГДА True (наследует advisory-cap §9.x). `confidence` —
    МИН-агрегат вкладывающих + data-quality. Карта — explainability, НЕ инвест-решение.
    """

    segment: dict[str, str | None]
    horizon_months: int
    scores: dict[str, ProductScore]  # все 10 (по _SCORE_KEYS)
    overall: float | None  # взвешенное среднее доступных (renorm над доступными)
    advisory: bool  # ВСЕГДА True
    confidence: Confidence

    def as_dict(self) -> dict[str, Any]:
        return {
            "segment": dict(self.segment),
            "horizon_months": self.horizon_months,
            "scores": {k: self.scores[k].as_dict() for k in _SCORE_KEYS if k in self.scores},
            "overall": _round_or_none(self.overall, 3),
            "advisory": self.advisory,
            "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


def _clamp01(value: float) -> float:
    """clamp в [0,1]. PURE."""
    return max(0.0, min(1.0, value))


def _saturating(value: float | None, half: float) -> float | None:
    """Насыщение неотрицательной величины → [0,1): v/(v+half). PURE.

    При value = half → 0.5; монотонно неубывающее; стремится к 1.0 при value→∞.
    value None / half ≤ 0 → None (нет осмысленной шкалы — не выдумываем). value < 0
    клампим к 0 (величины-входы неотрицательны по смыслу).
    """
    if value is None or half <= 0:
        return None
    v = max(0.0, value)
    return v / (v + half)


def _unavailable(key: str, reason: str) -> ProductScore:
    """Скор-заглушка для тонких данных: value=None, source='unavailable', conf='low'. PURE."""
    return ProductScore(key=key, value=None, confidence="low", reason=reason, source="unavailable")


# ──────────────────────────────────────────────────────────────────────────────
# Pure-скореры — по одному на скор. Каждый: backing-выход → (value|None, conf, reason).
# None-вход → None + 'unavailable'-причина (НИКОГДА 0). Инверсия где помечено.
# PURE, без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────


def _score_market_fit(
    deficit_index: float | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """market_fit ← deficit_index ∈ [−1,+1] → [0,1] линейным remap (di+1)/2. PURE.

    Выше дефицит (недосток предложения) → выше fit. 0.5 = баланс. deficit_index None
    (тонкое/неизмеримое предложение) → None. Прямой (не инвертирован).
    """
    if deficit_index is None:
        return None, "low", "Дефицит предложения по сегменту неизмерим (тонкие данные §9.8)."
    value = _clamp01((deficit_index + 1.0) / 2.0)
    if deficit_index > 0:
        reason = f"Дефицит предложения (индекс {round(deficit_index, 2)}) — рынок недонасыщен."
    elif deficit_index < 0:
        reason = f"Затоварка (индекс {round(deficit_index, 2)}) — предложения больше спроса."
    else:
        reason = "Баланс спроса и предложения по сегменту."
    return value, confidence, reason


def _score_demand(
    unit_velocity: float | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """demand ← unit_velocity (ед./мес, §9.2) → saturating v/(v+half). PURE.

    Выше темп поглощения → выше скор. None → None (нет выборки ≠ нулевой спрос).
    """
    value = _saturating(unit_velocity, _DEMAND_HALF_VELOCITY)
    if value is None:
        return None, "low", "Темп продаж по локации неизмерим (нет выборки §9.2)."
    reason = f"Темп поглощения ~{round(unit_velocity or 0.0, 1)} ед./мес по локации."
    return value, confidence, reason


def _score_supply_risk(
    future_pressure_index: float | None,
    overstock_index: float | None,
    confidence: Confidence,
) -> tuple[float | None, Confidence, str]:
    """supply_risk (ИНВЕРТИРОВАН) ← mean(future_supply.index, overstock_index) → 1−риск. PURE.

    Оба входа ∈ [0,1], выше = хуже (больше грядущего давления / зависшего стока).
    Риск = среднее доступных; скор = 1 − риск (high-bad → low-score). Оба None → None.
    """
    parts = [p for p in (future_pressure_index, overstock_index) if p is not None]
    if not parts:
        return None, "low", "Давление будущего предложения и затоварка неизмеримы (§9.3)."
    risk = _clamp01(sum(parts) / len(parts))
    value = 1.0 - risk
    reason = (
        f"Риск избытка предложения (давление+затоварка) ~{round(risk, 2)} — "
        f"{'низкий' if risk < 0.5 else 'повышенный'}."
    )
    return value, confidence, reason


def _score_future_competition(
    relevance_weights: list[float] | None,
    n_competitors: int | None,
    confidence: Confidence,
) -> tuple[float | None, Confidence, str]:
    """future_competition (ИНВЕРТИРОВАН) ← плотность конкурентов → 1−density. PURE.

    density = mean(top-N relevance_weight) × count/(count+half). Больше/сильнее будущих
    конкурентов → выше density → НИЖЕ скор. relevance_weights None (нет геометрии/сбой)
    → None. Пустой список (нет конкурентов) → density 0 → скор 1.0 (чистое поле).
    """
    if relevance_weights is None or n_competitors is None:
        return None, "low", "Будущие конкуренты не определены (нет геометрии участка §9.7)."
    count = max(0, n_competitors)
    if count == 0 or not relevance_weights:
        return 1.0, confidence, "Релевантных будущих конкурентов в радиусе не найдено."
    top = sorted(relevance_weights, reverse=True)[:_COMP_TOP_N]
    mean_weight = _clamp01(sum(top) / len(top))
    count_scale = count / (count + _COMP_HALF_COUNT)
    density = _clamp01(mean_weight * count_scale)
    value = 1.0 - density
    reason = (
        f"{count} релевантных будущих конкурентов (плотность ~{round(density, 2)}) — "
        f"конкуренция {'умеренная' if density < 0.5 else 'высокая'}."
    )
    return value, confidence, reason


def _score_price_feasibility(
    monthly_payment_rub: float | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """price_feasibility ← monthly_payment_rub (₽/мес, §7.9 прокси) → saturating ИНВЕРС. PURE.

    Меньше платёж → доступнее → ВЫШЕ скор: скор = half/(half+payment). None → None.
    Шкала ОТНОСИТЕЛЬНАЯ (дохода в данных нет, #946) — тяжесть платежа, не payment/income.
    """
    if monthly_payment_rub is None or monthly_payment_rub < 0:
        return None, "low", "Платёж по ипотеке неизмерим (нет цены сегмента §7.9)."
    value = _PRICE_HALF_PAYMENT_RUB / (_PRICE_HALF_PAYMENT_RUB + monthly_payment_rub)
    reason = (
        f"Прокси платежа ~{round(monthly_payment_rub / 1000.0)} тыс.₽/мес "
        f"(рыночная ставка key_rate + спред, §7.9 — доступность относительная)."
    )
    return value, confidence, reason


def _score_infra_fit(
    poi_weight_sum: float | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """infra_fit ← Σ top-7 POI weight (poi_score B6) → saturating w/(w+half). PURE.

    Выше суммарный вес близких POI → выше скор. None (нет координат участка / нет POI)
    → None (нет данных ≠ нет инфраструктуры). Прямой (не инвертирован).
    """
    value = _saturating(poi_weight_sum, _INFRA_HALF_WEIGHT)
    if value is None:
        return None, "low", "Инфраструктура (POI) вокруг участка не определена."
    reason = f"Сумма весов близких POI ~{round(poi_weight_sum or 0.0, 3)} (метро/школы/ТЦ B6)."
    return value, confidence, reason


def _score_mortgage_sensitivity(
    x_pct: float | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """mortgage_sensitivity (ИНВЕРТИРОВАН по магнитуде) ← x_pct (§9.6) → 1−риск. PURE.

    x_pct = % изменения продаж на +1 п.п. ставки (NEGATIVE = чувствителен). Риск =
    min(1, |x_pct|/max) ТОЛЬКО для x_pct<0; скор = 1 − риск (чувствительнее → НИЖЕ).
    x_pct ≥ 0 (не доверяем «растёт при росте ставки», §9.6) → риск 0 → скор 1.0
    (не штрафуем за нечувствительность). None → None.
    """
    if x_pct is None:
        return None, "low", "Чувствительность к ключевой ставке не определена (§9.6)."
    if x_pct >= 0:
        return 1.0, confidence, "Сегмент малочувствителен к ключевой ставке (§9.6)."
    risk = _clamp01(abs(x_pct) / _MORTG_MAX_X_PCT)
    value = 1.0 - risk
    reason = (
        f"Продажи −{round(abs(x_pct), 1)}% на +1 п.п. ставки (§9.6) — "
        f"чувствительность {'умеренная' if risk < 0.5 else 'высокая'}."
    )
    return value, confidence, reason


def _score_differentiation(
    n_positive_usp: int | None, confidence: Confidence
) -> tuple[float | None, Confidence, str]:
    """differentiation ← число позитив-дефицит USP-сегментов (§10.5) → n/target. PURE.

    Больше white-space (недообеспеченных ниш) → выше скор. n None (overlay сбой/
    недоступен) → None. n=0 → 0.0 (нет белых пятен — слабая дифференциация). Прямой.
    """
    if n_positive_usp is None:
        return None, "low", "USP-ниши (§10.5) не определены (overlay недоступен)."
    value = _clamp01(n_positive_usp / _DIFF_TARGET_USP)
    white_space = "есть" if n_positive_usp else "не выражен"
    reason = f"{n_positive_usp} дефицит-ниш (USP, §10.5) — white-space {white_space}."
    return value, confidence, reason


def _score_commercial(
    commercial: dict[str, Any] | None,
) -> tuple[float | None, Confidence, str]:
    """commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE.

    overlay.commercial.available=True → commercial_sell_through_pct/100 (темп распродажи
    нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
    → None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
    """
    if not commercial or not commercial.get("available"):
        return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
    sell_through_pct = commercial.get("commercial_sell_through_pct")
    if sell_through_pct is None:
        return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
    raw_conf = commercial.get("confidence", "low")
    conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
    value = _clamp01(float(sell_through_pct) / 100.0)
    reason = (
        f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
        "ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
    )
    return value, conf, reason


def _score_confidence(
    contributing: list[Confidence],
    n_available: int,
    n_product_scores: int,
) -> tuple[float | None, Confidence, str]:
    """confidence (МЕТА) ← mean(label-score вкладывающих, доля доступных скоров). PURE.

    label-score: high=1.0/medium=0.6/low=0.25 (усредняем по вкладывающим сервисам).
    availability = n_available / n_product_scores (сколько из 9 продуктовых скоров не
    None). Финал value = mean(mean-label, availability). Возвращаемая confidence-метка =
    MIN вкладывающих (худший тянет вниз). Пусто/всё-None → ('low'-value по availability).
    """
    label_scores = [_CONF_LABEL_VALUE[c] for c in contributing]
    mean_label = sum(label_scores) / len(label_scores) if label_scores else 0.0
    availability = (n_available / n_product_scores) if n_product_scores > 0 else 0.0
    value = _clamp01((mean_label + availability) / 2.0)
    label = _min_confidence(contributing)
    reason = (
        f"Сводная надёжность: {n_available}/{n_product_scores} скоров доступно, "
        f"вкладывающие сервисы ~{label}."
    )
    return value, label, reason


# ──────────────────────────────────────────────────────────────────────────────
# Pure-агрегаты карты — confidence-min, взвешенный overall с renorm над доступными.
# ──────────────────────────────────────────────────────────────────────────────


def _min_confidence(values: list[Confidence]) -> Confidence:
    """Итоговая метка = MIN компонентных (худшая тянет вниз). Пусто → 'low'. PURE."""
    ranks = [_CONFIDENCE_RANK[v] for v in values]
    if not ranks:
        return "low"
    return _RANK_TO_CONFIDENCE[min(ranks)]


def _weighted_overall(scores: dict[str, ProductScore], weights: dict[str, float]) -> float | None:
    """Взвешенное среднее ДОСТУПНЫХ скоров с РЕНОРМАЛИЗАЦИЕЙ весов над доступными. PURE.

    Зеркало §9.5 renormalize_contributions: доступные (value!=None И вес>0) делят свой
    вес на СУММУ доступных весов → пропуск скора НЕ тянет overall к 0 искусственно.
    Все скоры None (или нулевые веса) → None честно. Скоры с весом 0 (confidence-мета)
    в overall НЕ участвуют. value уже ∈ [0,1], поэтому overall ∈ [0,1]. PURE.

    Args:
        scores: ключ → ProductScore (value может быть None).
        weights: ключ → вес (полная схема; renorm над доступными).

    Returns:
        Взвешенное среднее ∈ [0,1] или None (нет доступных взвешенных скоров).
    """
    available_weight = 0.0
    weighted_sum = 0.0
    for key, weight in weights.items():
        if weight <= 0:
            continue
        score = scores.get(key)
        if score is None or score.value is None:
            continue
        available_weight += weight
        weighted_sum += weight * score.value
    if available_weight <= 0:
        return None
    return _clamp01(weighted_sum / available_weight)


# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-скореры выше тестируются без него.
# ──────────────────────────────────────────────────────────────────────────────


def compute_score_card(
    db: Session,
    *,
    spec: SegmentSpec,
    district: str | None,
    cad_num: str,
    horizon_months: int = _DEFAULT_HORIZON_MONTHS,
) -> ProductScoreCard:
    """Собрать §14.2 карту из 10 продуктовых скоров для сегмента/участка/горизонта.

    ADVISORY (наследует advisory-статус §9.x, см. module docstring) — НЕ для production-
    решений. СБОРКА, НЕ пересчёт §9.x: вызывает уже-смерженные сервисы ПО ОДНОМУ разу:
      • #980/#952 compute_demand_supply_forecast → deficit_index (market_fit).
      • §9.2 compute_market_metrics → unit_velocity (demand) + overstock_index (supply_risk).
      • §9.3 compute_future_supply_pressure → index (supply_risk).
      • §9.7 get_competitors → relevance_weight density (future_competition).
      • §7.9 compute_affordability → monthly_payment_rub (price_feasibility).
      • poi_score compute_poi_weighted_top7 → Σ weight (infra_fit; нужен центроид участка).
      • §9.6 compute_rate_sensitivity → x_pct (mortgage_sensitivity).
      • §10.x build_forecast_overlay → USP §10.5 (differentiation) + commercial §10.4.
    Затем кормит pure-скореры, ренормализует веса над доступными → overall, считает
    мета-confidence. advisory ВСЕГДА True.

    Graceful (КРИТИЧНО): любой backing-сервис тонкий/бросает → СВОЙ скор value=None +
    source='unavailable', карта ВСЁ РАВНО возвращается (никогда не crash). Каждый вызов
    обёрнут так, что сбой одного сервиса не роняет остальные скоры. Детерминированно.

    Args:
        db: SQLAlchemy sync Session.
        spec: целевой сегмент рынка (любой subset осей).
        district: район для §9.2/§9.3 (None → ЕКБ-wide).
        cad_num: кадастровый номер участка (вход §9.7 + центроид для POI).
        horizon_months: горизонт прогноза (мес; по умолчанию _DEFAULT_HORIZON_MONTHS).

    Returns:
        ProductScoreCard — всегда 10 скоров (тонкие → None+unavailable), overall
        (renorm над доступными), advisory=True, мета-confidence.
    """
    segment = spec.as_dict()
    scores: dict[str, ProductScore] = {}

    # ── market_fit + demand + supply_risk (часть) ← демандо-сапплай + метрики ───
    forecast = _safe_call(
        "demand_supply_forecast",
        lambda: _forecast_at_horizon(
            db, spec=spec, district=district, cad_num=cad_num, horizon_months=horizon_months
        ),
    )
    metrics = _safe_call(
        "market_metrics",
        lambda: compute_market_metrics(db, district=district),
    )
    fsp = _safe_call(
        "future_supply",
        lambda: compute_future_supply_pressure(
            db, district=district, horizon_months=horizon_months
        ),
    )

    deficit_index = forecast.deficit_index if forecast is not None else None
    market_conf = forecast.confidence if forecast is not None else "low"
    scores[_K_MARKET_FIT] = _build(
        "market_fit",
        _K_MARKET_FIT,
        "demand_supply_forecast",
        _score_market_fit(deficit_index, market_conf),
    )

    unit_velocity = metrics.unit_velocity if metrics is not None else None
    demand_conf = metrics.confidence if metrics is not None else "low"
    scores[_K_DEMAND] = _build(
        "demand", _K_DEMAND, "market_metrics", _score_demand(unit_velocity, demand_conf)
    )

    overstock = metrics.overstock_index if metrics is not None else None
    pressure_index = fsp.index if fsp is not None else None
    supply_conf = _min_confidence(
        [
            c
            for c in (
                fsp.confidence if fsp is not None else None,
                metrics.confidence if metrics is not None else None,
            )
            if c is not None
        ]
    )
    scores[_K_SUPPLY_RISK] = _build(
        "supply_risk",
        _K_SUPPLY_RISK,
        "future_supply+market_metrics",
        _score_supply_risk(pressure_index, overstock, supply_conf),
    )

    # ── future_competition ← конкуренты участка (relevance_weight density) ──────
    weights, n_comp = _competitor_signal(db, cad_num=cad_num, horizon_months=horizon_months)
    scores[_K_FUTURE_COMPETITION] = _build(
        "future_competition",
        _K_FUTURE_COMPETITION,
        "competitors",
        _score_future_competition(weights, n_comp, "medium"),
    )

    # ── price_feasibility ← affordability (§7.9 платёж) ────────────────────────
    afford = _safe_call("affordability", lambda: compute_affordability(db, spec=spec))
    payment = afford.monthly_payment_rub if afford is not None else None
    afford_conf: Confidence = afford.confidence if afford is not None else "low"
    scores[_K_PRICE_FEASIBILITY] = _build(
        "price_feasibility",
        _K_PRICE_FEASIBILITY,
        "affordability",
        _score_price_feasibility(payment, afford_conf),
    )

    # ── infra_fit ← poi_score top-7 Σ weight (нужен центроид) ──────────────────
    poi_sum = _poi_weight_sum(db, cad_num=cad_num)
    scores[_K_INFRA_FIT] = _build(
        "infra_fit", _K_INFRA_FIT, "poi_score", _score_infra_fit(poi_sum, "medium")
    )

    # ── mortgage_sensitivity ← §9.6 x_pct (Almon-ADL estimator, #978) ──────────
    sensitivity = _safe_call(
        "rate_sensitivity", lambda: compute_rate_regime_sensitivity(db, spec=spec)
    )
    x_pct = sensitivity.x_pct if sensitivity is not None else None
    sens_conf: Confidence = sensitivity.confidence if sensitivity is not None else "low"
    scores[_K_MORTGAGE_SENSITIVITY] = _build(
        "mortgage_sensitivity",
        _K_MORTGAGE_SENSITIVITY,
        "rate_sensitivity",
        _score_mortgage_sensitivity(x_pct, sens_conf),
    )

    # ── differentiation + commercial ← overlay (§10.5 USP / §10.4 commercial) ──
    overlay = _safe_call(
        "recommendation_overlay",
        lambda: build_forecast_overlay(
            db,
            district=district,
            cad_num=cad_num,
            horizon_months=horizon_months,
            target_class=spec.obj_class,
        ),
    )
    n_usp = _count_positive_usp(overlay)
    overlay_conf = _overlay_confidence(overlay)
    scores[_K_DIFFERENTIATION] = _build(
        "differentiation",
        _K_DIFFERENTIATION,
        "recommendation",
        _score_differentiation(n_usp, overlay_conf),
    )
    commercial = overlay.get("commercial") if isinstance(overlay, dict) else None
    scores[_K_COMMERCIAL] = _build(
        "commercial", _K_COMMERCIAL, "recommendation", _score_commercial(commercial)
    )

    # ── confidence (МЕТА) ← MIN вкладывающих + data-quality ────────────────────
    n_product_scores = len(_SCORE_KEYS) - 1  # все, кроме самого confidence
    n_available = sum(1 for k in _SCORE_KEYS if k != _K_CONFIDENCE and scores[k].value is not None)
    contributing = [
        scores[k].confidence
        for k in _SCORE_KEYS
        if k != _K_CONFIDENCE and scores[k].value is not None
    ]
    scores[_K_CONFIDENCE] = _build(
        "confidence",
        _K_CONFIDENCE,
        "aggregate",
        _score_confidence(contributing, n_available, n_product_scores),
    )

    # ── overall (renorm над доступными) + карта-confidence ─────────────────────
    overall = _weighted_overall(scores, _SCORE_WEIGHTS)
    card_confidence = _min_confidence(contributing) if contributing else "low"

    logger.info(
        "product_scoring: segment=%s h=%d overall=%s available=%d/%d confidence=%s (ADVISORY)",
        segment,
        horizon_months,
        _round_or_none(overall, 3),
        n_available,
        n_product_scores,
        card_confidence,
    )

    return ProductScoreCard(
        segment=segment,
        horizon_months=horizon_months,
        scores=scores,
        overall=overall,
        advisory=True,
        confidence=card_confidence,
    )


# ──────────────────────────────────────────────────────────────────────────────
# Тонкие orchestrator-хелперы (graceful-обёртки над backing-сервисами).
# ──────────────────────────────────────────────────────────────────────────────


def _build(
    name: str, key: str, source: str, scored: tuple[float | None, Confidence, str]
) -> ProductScore:
    """Свести вывод pure-скорера в ProductScore. value None → source='unavailable'.

    name — человекочитаемое имя скорера (для лога/симметрии), key — канон-ключ карты.
    """
    value, confidence, reason = scored
    resolved_source = source if value is not None else "unavailable"
    return ProductScore(
        key=key, value=value, confidence=confidence, reason=reason, source=resolved_source
    )


def _safe_call(label: str, fn: Any) -> Any:
    """Вызвать backing-сервис graceful: сбой → None + logger.exception (не crash карты).

    Любой backing-сервис может бросить (тонкие данные / нет геометрии / сбой SQL) — мы
    логируем и возвращаем None, чтобы соответствующий скор стал None+unavailable, а
    остальная карта собралась. Намеренно ловим широкий Exception (изоляция одного
    сервиса от карты) с обязательным logger.exception — НЕ молчаливое глотание.
    """
    try:
        return fn()
    except Exception:
        logger.exception("product_scoring: backing service %s failed → score unavailable", label)
        return None


def _forecast_at_horizon(
    db: Session,
    *,
    spec: SegmentSpec,
    district: str | None,
    cad_num: str,
    horizon_months: int,
) -> DemandSupplyForecast | None:
    """Прогон #980 на ОДНОМ горизонте → DemandSupplyForecast (или None при пустом). Thin."""
    forecasts = compute_demand_supply_forecast(
        db, spec=spec, district=district, cad_num=cad_num, horizons=[horizon_months]
    )
    return forecasts[0] if forecasts else None


def _competitor_signal(
    db: Session, *, cad_num: str, horizon_months: int
) -> tuple[list[float] | None, int | None]:
    """§9.7 relevance_weight'ы будущих конкурентов + их число. Graceful → (None, None).

    Нет геометрии участка / сбой → (None, None) → future_competition становится
    None+unavailable. Пустой список конкурентов → ([], 0) → скор 1.0 (чистое поле).
    """
    try:
        response = get_competitors(
            db,
            cad_num,
            CompetitorsRequest(horizon_months=horizon_months, radius_km=_COMPETITORS_RADIUS_KM),
        )
    except Exception:
        logger.exception(
            "product_scoring: competitors lookup failed (cad_num=%s) → future_competition n/a",
            cad_num,
        )
        return None, None
    weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
    # #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
    # частичных данных (None-веса) count > len(weights) → density завышена, future_competition
    # занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
    return weights, len(weights)


def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
    """Σ top-7 POI weight вокруг участка (poi_score B6). Graceful → None.

    Сначала тянем центроид участка (cad_parcels_geom → fallback cad_quarters_geom), затем
    compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
    """
    try:
        with db.begin_nested():
            coords = (
                db.execute(
                    _PARCEL_CENTROID_SQL,
                    {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
                )
                .mappings()
                .first()
            )
    except Exception:
        logger.exception(
            "product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
        )
        return None
    if not coords or coords.get("lat") is None or coords.get("lon") is None:
        return None
    try:
        with db.begin_nested():
            response = compute_poi_weighted_top7(
                db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
            )
    except Exception:
        logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
        return None
    if not response.top_poi:
        return None
    return sum(item.weight for item in response.top_poi)


def _quarter_from_cad(cad_num: str) -> str:
    """Кадастровый квартал из номера участка (отрезаем последний сегмент). PURE.

    '66:41:0303161:123' → '66:41:0303161'. Уже квартал (3 сегмента) / мусор → как есть
    (centroid-SQL тогда просто не найдёт строку → None, graceful).
    """
    parts = cad_num.split(":")
    if len(parts) >= 4:
        return ":".join(parts[:3])
    return cad_num


def _count_positive_usp(overlay: Any) -> int | None:
    """Число позитив-дефицит USP-сегментов (§10.5) из overlay. Graceful → None.

    overlay None / не dict → None (overlay недоступен). Список usp есть (в т.ч. пустой)
    → считаем элементы с deficit_index > 0. Пустой usp при валидном overlay → 0.
    """
    if not isinstance(overlay, dict):
        return None
    usp = overlay.get("usp")
    if usp is None:
        return None
    count = 0
    for item in usp:
        di = item.get("deficit_index") if isinstance(item, dict) else None
        if di is not None and di > 0:
            count += 1
    return count


def _overlay_confidence(overlay: Any) -> Confidence:
    """MAX confidence среди ranked_segments overlay (зеркало recommendation._best_confidence).

    Overlay недоступен/пуст → 'low'. Сигнал дифференциации не слабее сильнейшей ячейки.
    """
    if not isinstance(overlay, dict):
        return "low"
    ranked = overlay.get("ranked_segments") or []
    ranks = [
        _CONFIDENCE_RANK[c]
        for seg in ranked
        if isinstance(seg, dict) and (c := seg.get("confidence")) in _CONFIDENCE_RANK
    ]
    return _RANK_TO_CONFIDENCE[max(ranks)] if ranks else "low"
