"""§15 движок ОТЧЁТНОЙ уверенности v2 — weakest-link + СТРУКТУРНАЯ причина.

#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.

ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x —
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
агрегирует + формулирует. Поэтому он ЮНИТ-ТЕСТИРУЕТСЯ БЕЗ БД.

ЛОГИКА (ТЗ §15):
  • Отчётная уверенность = WEAKEST-LINK (MIN ранга) вкладывающих факторов — самый
    тонкий сигнал честно роняет общий уровень (зеркало future_supply._min_confidence
    и product_scoring._min_confidence). Один 'low'-фактор → отчёт 'low'.
  • Причина — СТРУКТУРНАЯ: называет КОНКРЕТНЫЙ фактор(ы)-виновник с его ЧИСЛОМ
    (сделки / ЖК-аналоги / покрытие % / месяцы истории / шок-окно / advisory-cap),
    формат «Low потому что N сделок … / только M ЖК … / покрытие X%».
  • Весь форсайт-стек СОВЕТУЮЩИЙ (движок не провалидирован до бэктеста #951) →
    advisory-cap: при advisory=True уровень НИКОГДА не 'high' (потолок 'medium'),
    и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки.

ПОРОГИ (align с per-service gate'ами, которые читает движок):
  • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS:
    мало сделок → скоростные метрики статистически ненадёжны.
  • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low
    (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
  • domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
    docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
  • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
    ряд → связь rate↔sales / тренды не установлены.
  • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный
    разрыв → оценки смещены (НИКОГДА не 'high').

GRACEFUL (дух всего форсайт-стека): все входы None/пусто → 'low' + «недостаточно
данных» (тонкий анализ всё равно даёт честный вердикт, не crash). `as_dict()`
JSON-safe и ложится в слот ReportConfidence (#987): {level, rationale, factors}.
"""

from __future__ import annotations

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

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

# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
ComponentConfidence = Confidence | tuple[str, Confidence]

# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}

# ── Имена факторов (named — переименование не рассинхронит причину/тесты молча) ──
_F_DEAL_COUNT: str = "deal_count"
_F_ANALOG_COUNT: str = "analog_count"
_F_DOMRF_COVERAGE: str = "domrf_coverage"
_F_HISTORY_MONTHS: str = "history_months"
_F_CONFOUNDED_WINDOW: str = "confounded_window"
_F_ADVISORY_CAP: str = "advisory_cap"
_F_COMPONENT: str = "component"  # один вкладывающий per-service confidence

# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит
# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ →
# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ──────
_SERVICE_RU: dict[str, str] = {
    "market_metrics": "Рынок",
    "future_supply": "Будущее предложение",
    "product_scores": "Скоринг продукта",
    "special_indices": "Спец-индексы",
    "forecasts": "Прогноз спрос/предложение",
}
_SERVICE_RU_DEFAULT: str = "Компонент"

# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────

# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
# medium — рабочий минимум, low — статистически ненадёжно (зеркало духа
# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии).
_DEAL_COUNT_HIGH: int = 50
_DEAL_COUNT_LOW: int = 15

# analog_count: число ЖК-аналогов (= market_metrics.obj_count). ТОЧНАЯ копия
# _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2: 1 ЖК → low (ТЗ §15-пример «только 1 ЖК»).
_ANALOG_COUNT_HIGH: int = 3
_ANALOG_COUNT_LOW: int = 2  # < этого (т.е. ≤1 ЖК) → low

# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
_DOMRF_COVERAGE_HIGH: float = 0.6
_DOMRF_COVERAGE_LOW: float = 0.2

# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) /
# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены.
_HISTORY_MONTHS_HIGH: int = 24
_HISTORY_MONTHS_LOW: int = 12

# Потолок при advisory=True: весь стек советующий (не провалидирован) → НИКОГДА 'high'.
_ADVISORY_CEILING: Confidence = "medium"


@dataclass(frozen=True)
class ConfidenceFactor:
    """Один вклад в отчётную уверенность — фактор-сигнал с числом, уровнем и RU-нотой.

    `name` — машинное имя (_F_*); `value` — сырое число/флаг, которым фактор оценён
    (число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
    его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
    («7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
    `label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
    под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой
    «Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
    """

    name: str
    value: Any  # число/доля/флаг, обосновавшие level (или None)
    level: Confidence
    note: str  # RU, с реальным числом
    label: str = ""  # RU-имя фактора (для component — имя сервиса, #1737); опционально

    def as_dict(self) -> dict[str, Any]:
        # `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
        # потребители без поля `label` продолжают читать name/value/level/note).
        out: dict[str, Any] = {
            "name": self.name,
            "value": self.value,
            "level": self.level,
            "note": self.note,
        }
        if self.label:
            out["label"] = self.label
        return out


@dataclass(frozen=True)
class ReportConfidenceResult:
    """§15 результат движка отчётной уверенности — уровень + СТРУКТУРНАЯ причина.

    `level` — weakest-link MIN факторов, capped в 'medium' при advisory. `rationale` —
    RU-предложение «Low потому что …», НАЗЫВАЮЩЕЕ тянущие вниз факторы с их числами.
    `factors` — все учтённые ConfidenceFactor (explainability §16). `advisory_capped` —
    True, если потолок advisory реально понизил уровень (был бы 'high'). `as_dict()`
    ложится в слот ReportConfidence #987: {level, rationale, factors}.
    """

    level: Confidence
    rationale: str
    factors: list[ConfidenceFactor] = field(default_factory=list)
    advisory_capped: bool = False

    def as_dict(self) -> dict[str, Any]:
        """JSON-safe вид под слот ReportConfidence (#987): {level, rationale, factors}.

        `factors` — плоский dict {name: {value, level, note}} (стабильно, удобно
        экспортёрам/чату; advisory_capped дублируем внутрь для полноты explainability).
        Повторяющиеся имена (несколько `component`) суффиксуем `_2/_3…`, чтобы ни один
        фактор не потерялся в плоском dict (ключи иначе бы перезаписали друг друга).
        """
        factors: dict[str, Any] = {}
        for f in self.factors:
            key = f.name
            if key in factors:
                i = 2
                while f"{key}_{i}" in factors:
                    i += 1
                key = f"{key}_{i}"
            entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note}
            # RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда
            # задано — плоский dict остаётся обратно-совместимым по форме.
            if f.label:
                entry["label"] = f.label
            factors[key] = entry
        factors["advisory_capped"] = self.advisory_capped
        return {
            "level": self.level,
            "rationale": self.rationale,
            "factors": factors,
        }


# ──────────────────────────────────────────────────────────────────────────────
# Pure-хелперы — без БД, полностью юнит-тестируемо. Каждый graceful: None/пусто →
# безопасная деградация ('low' / нейтраль), НИКОГДА не бросает.
# ──────────────────────────────────────────────────────────────────────────────


def _level_from_value(value: float | None, *, high_at: float, low_below: float) -> Confidence:
    """Сырое число → high/medium/low по двум именованным порогам. PURE.

    value ≥ high_at → 'high'; value < low_below → 'low'; между → 'medium'. None →
    'low' (нет сигнала ≠ хороший сигнал). Монотонно (выше число → не хуже уровень).
    """
    if value is None:
        return "low"
    if value >= high_at:
        return "high"
    if value < low_below:
        return "low"
    return "medium"


def _factor_from_count(
    name: str,
    count: float | None,
    *,
    high_at: float,
    low_below: float,
    unit: str,
    suffix: str = "",
) -> ConfidenceFactor:
    """Сырой счётчик → ConfidenceFactor (уровень по порогам + RU-нота с числом). PURE.

    Маппит `count` в high/medium/low через _level_from_value(high_at/low_below) и
    собирает RU-ноту с РЕАЛЬНЫМ числом и оценкой («7 сделок за 6 мес — мало» /
    «3 ЖК-аналога — достаточно»). `unit` — что считаем («сделок» / «ЖК-аналога»);
    `suffix` — хвост контекста («за 6 мес»). count None → low + нота «нет данных».

    Args:
        name: машинное имя фактора (_F_*).
        count: сырое число (или None).
        high_at: порог 'high' (count ≥ → high).
        low_below: порог 'low' (count < → low).
        unit: единица в RU-ноте (родительный падеж: «сделок», «ЖК-аналога»).
        suffix: необязательный хвост ноты (например «за 6 мес»).

    Returns:
        ConfidenceFactor с level/value/note.
    """
    level = _level_from_value(count, high_at=high_at, low_below=low_below)
    tail = f" {suffix}" if suffix else ""
    if count is None:
        note = f"нет данных о количестве ({unit})"
    else:
        shown = int(count) if float(count).is_integer() else round(float(count), 1)
        note = f"{shown} {unit}{tail} — {_QUALITY_WORD[level]}"
    return ConfidenceFactor(name=name, value=count, level=level, note=note)


# Оценочное слово на уровень (для RU-ноты фактора). Зеркало духа per-service фраз.
_QUALITY_WORD: dict[Confidence, str] = {
    "high": "достаточно",
    "medium": "умеренно",
    "low": "мало",
}


def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
    """domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.

    Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
    (структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low.
    """
    level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
    if coverage is None:
        note = (
            "Доля будущих проектов с известными планировками и площадями неизвестна — "
            "оценка будущего предложения и конкуренции менее надёжна"
        )
    else:
        pct = round(float(coverage) * 100.0, 1)
        note = (
            f"Известные планировки и площади есть у {pct}% будущих проектов "
            f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
            "будущего предложения и конкуренции"
        )
    return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)


def _history_factor(history_months: int | None) -> ConfidenceFactor:
    """Глубина ряда (мес) → ConfidenceFactor с «на что влияет» + связью с 6.2. PURE.

    Короткий ряд не даёт оценить тренды и чувствительность к ставке (§9.6) — а это
    та же причина, по которой сценарии (6.2) схлопываются в один. Нота называет
    реальное число месяцев И что от этого зависит. None → low (нет сигнала). PURE.
    """
    level = _level_from_value(
        history_months, high_at=_HISTORY_MONTHS_HIGH, low_below=_HISTORY_MONTHS_LOW
    )
    if history_months is None:
        note = (
            "Длина истории продаж неизвестна — тренды и чувствительность к ставке "
            "оценить нельзя (см. сценарии в 6.2)"
        )
    else:
        note = (
            f"{int(history_months)} мес истории ({_QUALITY_WORD[level]}) — на коротком "
            "ряде тренды и чувствительность спроса к ставке оцениваются хуже "
            "(поэтому в 6.2 может остаться один сценарий вместо трёх)"
        )
    return ConfidenceFactor(
        name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
    )


def _confounded_factor(confounded: bool) -> ConfidenceFactor:
    """Шок-окно → ConfidenceFactor. PURE.

    confounded=True (ряд пересекает структурный разрыв, PR2) → 'low' (оценки смещены,
    НИКОГДА не 'high'). False → 'high' (окно чистое — фактор не тянет вниз).
    """
    if confounded:
        return ConfidenceFactor(
            name=_F_CONFOUNDED_WINDOW,
            value=True,
            level="low",
            note="окно ряда пересекает шок-период (оценки смещены)",
        )
    return ConfidenceFactor(
        name=_F_CONFOUNDED_WINDOW,
        value=False,
        level="high",
        note="окно ряда вне шок-периодов",
    )


def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor:
    """Один вкладывающий per-service confidence → ConfidenceFactor. PURE.

    Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
    нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` —
    машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя
    («Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в
    `label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»).
    Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется).
    """
    ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT)
    return ConfidenceFactor(
        name=_F_COMPONENT,
        value=None,
        level=level,
        note=f"{ru_name}: {level}",
        label=ru_name,
    )


def _aggregate(factors: list[ConfidenceFactor]) -> Confidence:
    """Weakest-link агрегат: MIN ранга факторов (худший тянет вниз). PURE.

    Зеркало future_supply._min_confidence / product_scoring._min_confidence: один
    'low'-фактор → общий 'low'. Пустой список → 'low' (нет ни одного сигнала).
    """
    ranks = [_CONFIDENCE_RANK[f.level] for f in factors]
    if not ranks:
        return "low"
    return _RANK_TO_CONFIDENCE[min(ranks)]


def _cap(level: Confidence, ceiling: Confidence) -> Confidence:
    """Опустить level до потолка ceiling (MIN ранга). PURE.

    advisory-cap: при ceiling='medium' любое 'high' становится 'medium'. Зеркало
    advisory-духа recommendation/product_scoring (confidence ≤ medium в advisory).
    """
    return _RANK_TO_CONFIDENCE[min(_CONFIDENCE_RANK[level], _CONFIDENCE_RANK[ceiling])]


def _dragging_factors(factors: list[ConfidenceFactor], level: Confidence) -> list[ConfidenceFactor]:
    """Факторы, чей уровень == итоговому (т.е. ИМЕННО они тянут вниз). PURE.

    Для 'low'-итога — все low-факторы; для 'medium' — все medium-факторы. Это
    «виновники» структурной причины §15. Component-факторы (без своего числа)
    отодвигаем в конец — приоритет адресным счётчикам с числами в ноте.
    """
    same = [f for f in factors if f.level == level]
    same.sort(key=lambda f: f.name == _F_COMPONENT)  # named-счётчики раньше component
    return same


def _build_rationale(
    level: Confidence,
    factors: list[ConfidenceFactor],
    *,
    advisory_capped: bool,
) -> str:
    """Структурная §15-причина: «Low потому что <факторы с числами>». PURE.

    Берёт тянущие-вниз факторы (_dragging_factors) и НАЗЫВАЕТ их ноты (с реальными
    числами) после «<Level> потому что …». Если уровень понижен именно advisory-cap'ом
    (был бы 'high') — проговаривает, что движок советующий. Пустые/нейтральные факторы
    → «недостаточно данных» (graceful).

    Args:
        level: итоговый (уже capped) уровень.
        factors: все учтённые факторы.
        advisory_capped: True, если потолок advisory реально понизил уровень.

    Returns:
        RU-предложение-причина.
    """
    label = _LEVEL_LABEL[level]
    drag = _dragging_factors(factors, level)
    notes = [f.note for f in drag]

    if advisory_capped and level == _ADVISORY_CEILING:
        # Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
        # _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
        # иначе advisory-cap-сообщение дублируется (частый all-high случай).
        other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
        base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
        if other:
            base += "; также " + _join_notes(other)
        return base + "."

    if not notes:
        return f"{label}: недостаточно данных для оценки уверенности."

    return f"{label} потому что {_join_notes(notes)}."


# RU-метка уровня для причины (заглавная — начало предложения).
_LEVEL_LABEL: dict[Confidence, str] = {"high": "High", "medium": "Medium", "low": "Low"}


def _join_notes(notes: list[str], *, limit: int = 3) -> str:
    """Склеить ноты факторов в RU-перечисление через « / » (не более limit). PURE.

    Ограничиваем limit (по умолчанию 3), чтобы причина оставалась фразой, а не
    простынёй; хвост сворачиваем в «… и др.». Дубли убираем, порядок сохраняем.
    """
    seen: list[str] = []
    for n in notes:
        if n not in seen:
            seen.append(n)
    head = seen[:limit]
    joined = " / ".join(head)
    if len(seen) > limit:
        # Без точки в маркере — её добавит вызывающий концом предложения (не «др..»).
        joined += " и др"
    return joined


# ──────────────────────────────────────────────────────────────────────────────
# Публичный API — PURE-сборка (входы уже посчитаны сборщиком #988, без БД).
# ──────────────────────────────────────────────────────────────────────────────


def compute_report_confidence(
    *,
    component_confidences: Sequence[ComponentConfidence] | None = None,
    deal_count: int | None = None,
    deal_count_months: int | None = None,
    analog_count: int | None = None,
    domrf_coverage: float | None = None,
    history_months: int | None = None,
    confounded: bool = False,
    advisory: bool = True,
) -> ReportConfidenceResult:
    """§15 свести входы в ОДНУ отчётную уверенность + структурную причину. PURE.

    СОВЕТУЮЩИЙ агрегатор: НЕ ходит в БД и НЕ зовёт сервисы — берёт уже-посчитанные
    счётчики качества данных + per-service confidence (их кормит сборщик #988),
    строит факторы, агрегирует weakest-link (MIN), при advisory опускает потолок до
    'medium' и собирает причину «Low потому что …» с реальными числами виновников.

    Шаги:
      1. Из сырых счётчиков (deal_count / analog_count / domrf_coverage /
         history_months / confounded) → ConfidenceFactor'ы с RU-нотами (с числами).
      2. Свернуть component_confidences под-сервисов в факторы (как есть).
      3. level = weakest-link MIN всех факторов (худший тянет вниз).
      4. advisory=True → cap level в 'medium' (НИКОГДА не 'high'); пометить
         advisory_capped, если потолок реально понизил.
      5. Структурная §15-причина, называющая тянущие-вниз факторы с числами.

    Graceful: все входы None/пусто → 'low' + «недостаточно данных». Никогда не бросает.

    Args:
        component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
            нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень
            'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
            имя сервиса проносится сквозь стек в RU-метку фактора).
        deal_count: число сделок за окно (None → нет данных, тянет в low).
        deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
            в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
        analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
        domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
        history_months: глубина ряда (мес).
        confounded: True, если окно ряда пересекает шок-период (PR2).
        advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).

    Returns:
        ReportConfidenceResult (level / rationale / factors / advisory_capped);
        `as_dict()` ложится в слот ReportConfidence отчёта #987.
    """
    factors: list[ConfidenceFactor] = []

    # ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
    if deal_count is not None:
        _deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
        factors.append(
            _factor_from_count(
                _F_DEAL_COUNT,
                deal_count,
                high_at=_DEAL_COUNT_HIGH,
                low_below=_DEAL_COUNT_LOW,
                unit="сделок",
                suffix=_deal_suffix,
            )
        )
    if analog_count is not None:
        factors.append(
            _factor_from_count(
                _F_ANALOG_COUNT,
                analog_count,
                high_at=_ANALOG_COUNT_HIGH,
                low_below=_ANALOG_COUNT_LOW,
                unit="ЖК-аналога",
            )
        )
    if domrf_coverage is not None:
        factors.append(_coverage_factor(domrf_coverage))
    if history_months is not None:
        factors.append(_history_factor(history_months))
    # Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
    # искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
    if confounded:
        factors.append(_confounded_factor(True))

    # ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
    # Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
    # (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
    # отбрасываем (whitelist), как и раньше.
    for c in component_confidences or []:
        if isinstance(c, tuple):
            service, level = c
        else:
            service, level = None, c
        if level in _CONFIDENCE_RANK:
            factors.append(_component_factor(level, service))

    # ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
    raw_level = _aggregate(factors)

    # ── 4. advisory-cap (НИКОГДА не 'high', пока стек советующий) ──────────────
    if advisory:
        capped_level = _cap(raw_level, _ADVISORY_CEILING)
        advisory_capped = capped_level != raw_level
        if advisory_capped:
            # Зафиксировать причину-cap как явный фактор (explainability §16).
            factors.append(
                ConfidenceFactor(
                    name=_F_ADVISORY_CAP,
                    value=True,
                    level=_ADVISORY_CEILING,
                    note="прогноз советующий — уровень ограничен «medium»",
                )
            )
        level = capped_level
    else:
        level = raw_level
        advisory_capped = False

    # ── 5. структурная §15-причина ────────────────────────────────────────────
    rationale = _build_rationale(level, factors, advisory_capped=advisory_capped)

    return ReportConfidenceResult(
        level=level,
        rationale=rationale,
        factors=factors,
        advisory_capped=advisory_capped,
    )
