"""Unit-тесты §15 движка отчётной уверенности (#990, 955-A4).

Чистые тесты — БЕЗ БД (движок PURE: берёт уже-посчитанные входы, ничего не считает
из §9.x и не ходит в БД):
  • _level_from_value / _factor_from_count — каждый порог-бэнд (high/medium/low) +
    RU-нота с РЕАЛЬНЫМ числом и оценочным словом; None → low + «нет данных».
  • _coverage_factor — покрытие domrf↔objective в % в ноте (структурный §15-пример).
  • _aggregate — weakest-link MIN (один low → общий low); пустой → low.
  • _cap — advisory-потолок опускает high → medium.
  • _build_rationale — называет тянущий-вниз фактор с его числом, «Low потому что N…»;
    advisory-cap проговаривается; пусто → «недостаточно данных».
  • compute_report_confidence — смешанные факторы → правильный level + структурная
    причина; advisory НИКОГДА не отдаёт high; all-None → low + «недостаточно данных»;
    as_dict() ложится в слот ReportConfidence (#987) и JSON-сериализуем.

Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
"""

from __future__ import annotations

import json
import os

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.forecasting.confidence_engine import (
    _ANALOG_COUNT_HIGH,
    _DEAL_COUNT_HIGH,
    _DEAL_COUNT_LOW,
    _HISTORY_MONTHS_HIGH,
    ConfidenceFactor,
    ReportConfidenceResult,
    _aggregate,
    _build_rationale,
    _cap,
    _coverage_factor,
    _factor_from_count,
    _history_factor,
    _level_from_value,
    compute_report_confidence,
)


def _factor(name: str, level: str) -> ConfidenceFactor:
    """Лёгкий фактор фиксированного уровня (для агрегатных/причинных тестов)."""
    return ConfidenceFactor(name=name, value=None, level=level, note=f"{name}:{level}")  # type: ignore[arg-type]


# ── _level_from_value — пороговые бэнды ────────────────────────────────────────


class TestLevelFromValue:
    def test_high_band(self) -> None:
        assert _level_from_value(50, high_at=50, low_below=15) == "high"
        assert _level_from_value(120, high_at=50, low_below=15) == "high"

    def test_medium_band(self) -> None:
        # Между low_below (вкл.) и high_at (искл.) → medium.
        assert _level_from_value(15, high_at=50, low_below=15) == "medium"
        assert _level_from_value(30, high_at=50, low_below=15) == "medium"

    def test_low_band(self) -> None:
        assert _level_from_value(14, high_at=50, low_below=15) == "low"
        assert _level_from_value(0, high_at=50, low_below=15) == "low"

    def test_none_is_low(self) -> None:
        # Нет сигнала ≠ хороший сигнал.
        assert _level_from_value(None, high_at=50, low_below=15) == "low"


# ── _factor_from_count — бэнды + RU-нота с числом ──────────────────────────────


class TestFactorFromCount:
    def test_low_count_note_has_number_and_word(self) -> None:
        f = _factor_from_count(
            "deal_count",
            7,
            high_at=_DEAL_COUNT_HIGH,
            low_below=_DEAL_COUNT_LOW,
            unit="сделок",
            suffix="за 6 мес",
        )
        assert f.level == "low"
        assert f.value == 7
        # Структурный §15-стиль: реальное число + контекст + оценка.
        assert "7 сделок" in f.note
        assert "за 6 мес" in f.note
        assert "мало" in f.note

    def test_medium_count_band(self) -> None:
        f = _factor_from_count(
            "deal_count", 20, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
        )
        assert f.level == "medium"
        assert "20 сделок" in f.note
        assert "умеренно" in f.note

    def test_high_count_band(self) -> None:
        f = _factor_from_count(
            "deal_count", 80, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
        )
        assert f.level == "high"
        assert "80 сделок" in f.note
        assert "достаточно" in f.note

    def test_one_analog_is_low_with_number(self) -> None:
        # ТЗ §15-пример «только 1 ЖК» — 1 аналог обязан давать low с числом в ноте.
        f = _factor_from_count(
            "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
        )
        assert f.level == "low"
        assert "1 ЖК-аналога" in f.note

    def test_none_count_low_no_data_note(self) -> None:
        f = _factor_from_count(
            "deal_count", None, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
        )
        assert f.level == "low"
        assert f.value is None
        assert "нет данных" in f.note

    def test_float_count_rendered_compactly(self) -> None:
        # Целое-как-float → без хвоста; дробное → 1 знак.
        f_int = _factor_from_count(
            "history_months", 12.0, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
        )
        assert "12 мес истории" in f_int.note  # не "12.0"
        f_frac = _factor_from_count(
            "history_months", 12.5, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
        )
        assert "12.5 мес истории" in f_frac.note


# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────


class TestCoverageFactor:
    def test_low_coverage_percent_in_note(self) -> None:
        # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
        f = _coverage_factor(0.025)
        assert f.level == "low"
        assert f.value == 0.025
        assert "2.5%" in f.note
        # #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
        assert "domrf" not in f.note
        assert "будущ" in f.note  # говорит про будущее предложение/проекты

    def test_high_coverage(self) -> None:
        f = _coverage_factor(0.75)
        assert f.level == "high"
        assert "75.0%" in f.note

    def test_none_coverage_low(self) -> None:
        f = _coverage_factor(None)
        assert f.level == "low"
        assert "неизвестн" in f.note
        assert "domrf" not in f.note

    def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
        # BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
        # До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —
        # инфляция confidence в exactly near-zero кейсе, который §15 призван флагать.
        f = _coverage_factor(0.008)
        assert f.level == "low"
        assert "0.8%" in f.note


# ── _history_factor — глубина истории + «на что влияет» + связь с 6.2 (#1963) ──


class TestHistoryFactor:
    def test_short_history_low_with_impact_and_scenario_link(self) -> None:
        f = _history_factor(6)
        assert f.level == "low"
        assert f.value == 6
        assert "6 мес истории" in f.note
        # Говорит на что влияет (ставка/тренды) и связывает с 6.2 (сценарии).
        assert "ставк" in f.note
        assert "6.2" in f.note

    def test_long_history_high(self) -> None:
        f = _history_factor(36)
        assert f.level == "high"
        assert "36 мес истории" in f.note

    def test_none_history_low(self) -> None:
        f = _history_factor(None)
        assert f.level == "low"
        assert f.value is None
        assert "6.2" in f.note


# ── _aggregate — weakest-link MIN ──────────────────────────────────────────────


class TestAggregate:
    def test_one_low_drags_overall_to_low(self) -> None:
        factors = [_factor("a", "high"), _factor("b", "medium"), _factor("c", "low")]
        assert _aggregate(factors) == "low"

    def test_all_high_stays_high(self) -> None:
        assert _aggregate([_factor("a", "high"), _factor("b", "high")]) == "high"

    def test_medium_floor_when_no_low(self) -> None:
        assert _aggregate([_factor("a", "high"), _factor("b", "medium")]) == "medium"

    def test_empty_is_low(self) -> None:
        # Нет ни одного сигнала → low (graceful).
        assert _aggregate([]) == "low"


# ── _cap — advisory-потолок ────────────────────────────────────────────────────


class TestCap:
    def test_high_capped_to_medium(self) -> None:
        assert _cap("high", "medium") == "medium"

    def test_low_unchanged_below_ceiling(self) -> None:
        assert _cap("low", "medium") == "low"

    def test_medium_unchanged_at_ceiling(self) -> None:
        assert _cap("medium", "medium") == "medium"


# ── _build_rationale — структурная §15-причина ─────────────────────────────────


class TestBuildRationale:
    def test_low_names_dragging_factor_with_number(self) -> None:
        # «Low потому что 7 сделок …» — называет виновника с его числом.
        deal = _factor_from_count(
            "deal_count",
            7,
            high_at=_DEAL_COUNT_HIGH,
            low_below=_DEAL_COUNT_LOW,
            unit="сделок",
            suffix="за 6 мес",
        )
        good = _factor("history_months", "high")
        text = _build_rationale("low", [deal, good], advisory_capped=False)
        assert text.startswith("Low потому что")
        assert "7 сделок" in text
        # Не-тянущий (high) фактор НЕ называется.
        assert "history_months" not in text

    def test_low_lists_multiple_dragging_factors(self) -> None:
        deal = _factor_from_count(
            "deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
        )
        analog = _factor_from_count(
            "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
        )
        cov = _coverage_factor(0.025)
        text = _build_rationale("low", [deal, analog, cov], advisory_capped=False)
        assert "7 сделок" in text
        assert "1 ЖК-аналога" in text
        assert "2.5%" in text
        assert " / " in text  # перечисление виновников

    def test_advisory_cap_spoken_in_rationale(self) -> None:
        # Уровень упёрся в advisory-потолок (не данные) → причина это проговаривает.
        text = _build_rationale("medium", [_factor("x", "medium")], advisory_capped=True)
        assert "советующий" in text
        assert "medium" in text

    def test_empty_factors_insufficient(self) -> None:
        assert "недостаточно данных" in _build_rationale("low", [], advisory_capped=False)


# ── compute_report_confidence — интеграция (PURE, без БД) ──────────────────────


class TestComputeReportConfidence:
    def test_mixed_factors_weakest_link_low(self) -> None:
        # Сильная история, но 7 сделок и 1 ЖК → weakest-link low; причина структурная.
        res = compute_report_confidence(
            component_confidences=["medium", "high"],
            deal_count=7,
            analog_count=1,
            domrf_coverage=0.4,
            history_months=36,
            confounded=False,
            advisory=True,
        )
        assert isinstance(res, ReportConfidenceResult)
        assert res.level == "low"
        assert res.rationale.startswith("Low потому что")
        assert "7 сделок" in res.rationale
        assert "1 ЖК-аналога" in res.rationale

    def test_advisory_never_high(self) -> None:
        # Все сигналы отличные, но advisory=True → НИКОГДА не high (потолок medium).
        res = compute_report_confidence(
            component_confidences=["high", "high"],
            deal_count=200,
            analog_count=10,
            domrf_coverage=0.9,
            history_months=48,
            confounded=False,
            advisory=True,
        )
        assert res.level == "medium"
        assert res.advisory_capped is True
        assert "советующий" in res.rationale

    def test_non_advisory_allows_high(self) -> None:
        # advisory=False (гипотетически провалидированный стек) → high достижим.
        res = compute_report_confidence(
            component_confidences=["high"],
            deal_count=200,
            analog_count=10,
            domrf_coverage=0.9,
            history_months=48,
            confounded=False,
            advisory=False,
        )
        assert res.level == "high"
        assert res.advisory_capped is False

    def test_confounded_window_drags_down(self) -> None:
        # Шок-окно → low даже при прочих хороших сигналах; названо в причине.
        res = compute_report_confidence(
            component_confidences=["high"],
            deal_count=200,
            analog_count=10,
            domrf_coverage=0.9,
            history_months=48,
            confounded=True,
            advisory=True,
        )
        assert res.level == "low"
        assert "шок" in res.rationale

    def test_all_none_is_low_insufficient(self) -> None:
        # Граничный graceful: ни одного входа → low + «недостаточно данных».
        res = compute_report_confidence()
        assert res.level == "low"
        assert "недостаточно данных" in res.rationale
        assert res.factors == []
        assert res.advisory_capped is False

    def test_clean_inputs_medium_under_advisory(self) -> None:
        # Рабочие (medium-бэнд) счётчики без low-факторов → medium под advisory-cap.
        res = compute_report_confidence(
            component_confidences=["medium"],
            deal_count=20,
            analog_count=2,
            domrf_coverage=0.4,
            history_months=18,
            advisory=True,
        )
        assert res.level == "medium"
        # Здесь потолок не понижал (raw уже medium) → не помечаем capped.
        assert res.advisory_capped is False

    def test_as_dict_fits_report_confidence_slot(self) -> None:
        # as_dict() ложится в слот ReportConfidence (#987): {level, rationale, factors}.
        res = compute_report_confidence(deal_count=7, analog_count=1, advisory=True)
        d = res.as_dict()
        assert set(d.keys()) == {"level", "rationale", "factors"}
        assert d["level"] == "low"
        assert isinstance(d["rationale"], str)
        assert isinstance(d["factors"], dict)
        # Факторы по именам + флаг advisory_capped внутри.
        assert "deal_count" in d["factors"]
        assert d["factors"]["deal_count"]["value"] == 7
        assert "advisory_capped" in d["factors"]
        # Полностью JSON-сериализуем (контракт для экспортёров/чата).
        assert json.loads(json.dumps(d, ensure_ascii=False)) == d

    def test_deal_count_note_carries_window_months(self) -> None:
        # #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
        res = compute_report_confidence(
            deal_count=7,
            deal_count_months=6,
            advisory=False,
        )
        dc_factor = next(f for f in res.factors if f.name == "deal_count")
        assert "за 6 мес" in dc_factor.note
        assert "7 сделок" in dc_factor.note
        # Структурная причина тоже содержит период (через ноту фактора-виновника).
        assert "за 6 мес" in res.rationale

    def test_deal_count_note_without_window_has_no_period(self) -> None:
        # deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
        res = compute_report_confidence(deal_count=7, advisory=False)
        dc_factor = next(f for f in res.factors if f.name == "deal_count")
        assert "7 сделок" in dc_factor.note
        assert "за" not in dc_factor.note

    def test_ignores_garbage_component_confidence(self) -> None:
        # Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
        res = compute_report_confidence(
            component_confidences=["high", "bogus"],  # type: ignore[list-item]
            deal_count=200,
            analog_count=10,
            domrf_coverage=0.9,
            history_months=48,
            advisory=False,
        )
        assert res.level == "high"

    def test_as_dict_keeps_all_component_factors(self) -> None:
        # Несколько component-факторов не теряются в плоском dict (суффиксуются).
        res = compute_report_confidence(
            component_confidences=["high", "medium", "low"], advisory=False
        )
        d = res.as_dict()
        component_keys = [k for k in d["factors"] if k.startswith("component")]
        assert len(component_keys) == 3  # ни один не перезаписан
        assert json.loads(json.dumps(d, ensure_ascii=False)) == d
