"""Юниты каскадного матчинга план↔факт в report.py (чистая логика, без API).

Каскад по контрагентам (см. report.match_cascade): A) partnerId, B) фамилия из описания,
C) точная сумма («Неразнесенное списание» — джокер), D) агрегаты по сумме фактов статьи.
Каждый рубль факта тратится один раз; сумма плана в A/B не критерий (план — оценка).
Помимо базовых правил проверяем реальный кейс 20.07.2026, на котором провалился матчинг по сумме.
"""
import datetime
import matching
import report


def plan(date, value, catId, factMonth="07.2026", cat="статья", desc="", partnerId=None):
    return {"date": date, "value": float(value), "catId": catId, "cat": cat,
            "factMonth": factMonth, "desc": desc, "partnerId": partnerId, "matched": False}


def fact(date, value, catId, cat="статья", desc="", partnerId=None, partner_name="", factMonth="07.2026"):
    return {"date": date, "value": float(value), "catId": catId, "cat": cat,
            "desc": desc, "partnerId": partnerId, "partner_name": partner_name, "factMonth": factMonth}


TODAY = datetime.date(2026, 7, 22)
MM = "07.2026"
NEXT_FIRST = datetime.date(2026, 8, 3)   # 01.08.2026 суббота → перенос на пн 03.08

ADMIN = 1543725          # Зарплаты администраторов
VZNOS = 1287211          # Взносы в фонды
KREDIT = 1544225         # % по кредитам
IVENT = 999001           # Расходы на внутренние ивенты
UNALLOC_CAT = "Неразнесенное списание"


# ── Фаза C: точная сумма + статья (совместимо со старым поведением по категории) ──

def test_paid_before_date_is_matched_and_excluded():
    # планка на 25.07 оплачена досрочно 20.07 → матчится по точной сумме+статье, хотя дата впереди
    ops = [plan("25.07.2026", 35_000, 111)]
    cls = report.classify_unpaid(ops, [fact("20.07.2026", 35_000, 111)], TODAY, MM, NEXT_FIRST)
    assert ops[0]["matched"] is True
    assert cls["rest"] == [] and cls["due_window"] == [] and cls["next2w"] == []


def test_future_unpaid_stays_in_rest_not_overdue():
    ops = [plan("28.07.2026", 40_000, 111)]
    cls = report.classify_unpaid(ops, [], TODAY, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False
    assert cls["rest"] == ops
    assert cls["overdue"] == []            # дата ещё не прошла
    assert cls["fut"] == ops               # попадает в «ближайшую выплату»


def test_overdue_unpaid_in_rest_and_overdue():
    ops = [plan("15.07.2026", 50_000, 222)]
    cls = report.classify_unpaid(ops, [], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == ops
    assert cls["overdue"] == ops
    assert ops[0] in cls["due_window"]
    assert cls["fut"] == []
    assert ops[0]["due"] == 50_000


def test_overdue_matched_disappears():
    ops = [plan("15.07.2026", 50_000, 222)]
    cls = report.classify_unpaid(ops, [fact("16.07.2026", 50_000, 222)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == [] and cls["overdue"] == []


# ── допуск суммы (фаза C) ──

def test_tolerance_1pct_matches():
    ops = [plan("18.07.2026", 100_000, 5)]           # 1% = 1000 ₽
    cls = report.classify_unpaid(ops, [fact("18.07.2026", 100_900, 5)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == []


def test_tolerance_exceeded_no_match():
    # два плана одной статьи → фаза C2 (уникальная пара) НЕ применяется, решает только допуск суммы
    ops = [plan("18.07.2026", 100_000, 5), plan("18.07.2026", 200_000, 5)]
    cls = report.classify_unpaid(ops, [fact("18.07.2026", 102_000, 5)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == ops                        # 2000 > max(1000,50) → не матч, C2 не спасает


def test_tolerance_50rub_floor_for_small():
    ops = [plan("18.07.2026", 2_000, 5)]
    cls = report.classify_unpaid(ops, [fact("18.07.2026", 2_040, 5)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == []


def test_wrong_category_no_match():
    # чужая статья и не джокер → фаза C не матчит
    ops = [plan("18.07.2026", 50_000, 111)]
    cls = report.classify_unpaid(ops, [fact("18.07.2026", 50_000, 999)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == ops


# ── окно дат ──

def test_fact_older_than_14_days_no_match():
    ops = [plan("25.07.2026", 30_000, 7)]
    cls = report.classify_unpaid(ops, [fact("05.07.2026", 30_000, 7)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == ops


def test_fact_within_14_days_matches():
    ops = [plan("25.07.2026", 30_000, 7)]
    cls = report.classify_unpaid(ops, [fact("12.07.2026", 30_000, 7)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == []


def test_future_fact_not_matched():
    ops = [plan("28.07.2026", 30_000, 7)]
    cls = report.classify_unpaid(ops, [fact("27.07.2026", 30_000, 7)], TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == ops


# ── one-to-one ──

def test_one_to_one_two_plans_one_fact():
    ops = [plan("10.07.2026", 20_000, 42), plan("14.07.2026", 20_000, 42)]
    cls = report.classify_unpaid(ops, [fact("12.07.2026", 20_000, 42)], TODAY, MM, NEXT_FIRST)
    assert sum(1 for o in ops if o["matched"]) == 1
    assert len(cls["rest"]) == 1


def test_one_to_one_two_facts_two_plans():
    ops = [plan("10.07.2026", 20_000, 42), plan("14.07.2026", 20_000, 42)]
    facts = [fact("10.07.2026", 20_000, 42), fact("14.07.2026", 20_000, 42)]
    cls = report.classify_unpaid(ops, facts, TODAY, MM, NEXT_FIRST)
    assert cls["rest"] == []


# ── календарь next2w ──

def test_next2w_excludes_paid():
    paid = plan("24.07.2026", 15_000, 1)
    due = plan("26.07.2026", 18_000, 2)
    cls = report.classify_unpaid([paid, due], [fact("21.07.2026", 15_000, 1)], TODAY, MM, NEXT_FIRST)
    assert cls["next2w"] == [due]


def test_next2w_horizon_excludes_far():
    near = plan("28.07.2026", 10_000, 1)
    far = plan("10.08.2026", 10_000, 1, factMonth="08.2026")
    cls = report.classify_unpaid([near, far], [], TODAY, MM, NEXT_FIRST)
    assert cls["next2w"] == [near]


def test_matching_is_deterministic():
    for _ in range(5):
        ops = [plan("10.07.2026", 20_000, 42), plan("14.07.2026", 20_000, 42)]
        cls = report.classify_unpaid(ops, [fact("12.07.2026", 20_000, 42)], TODAY, MM, NEXT_FIRST)
        assert ops[0]["matched"] is True and ops[1]["matched"] is False


# ── Фаза B: матчинг по фамилии (сумма не критерий) ──

def test_surname_match_underpaid():
    # «Мухина доп.» 15 000 ↔ факт «Выплата Мухина Наталия Сергеевна» 12 000: матч по фамилии,
    # план — оценка → matched, но оплачено меньше → в underpaid, НЕ в overdue
    p = plan("20.07.2026", 15_000, ADMIN, desc="Мухина доп. — 50% (20-е)")
    f = fact("20.07.2026", 12_000, ADMIN, desc="Выплата Мухина Наталия Сергеевна 20.07.2026",
             partner_name="Мухина Наталия Сергеевна")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 12_000
    assert cls["overdue"] == [] and cls["rest"] == []
    assert cls["underpaid"] == [p]


def test_surname_match_overpaid_leftover_free():
    # «Поляков» 11 000 ↔ факт «Выплата Поляков…» 32 000: матч, взято 11 000, остаток 21 000 свободен
    p = plan("20.07.2026", 11_000, ADMIN, desc="Поляков — 50% (20-е)")
    f = fact("20.07.2026", 32_000, ADMIN, desc="Выплата Поляков Михаил Викторович 20.07.2026",
             partner_name="Поляков Михаил Викторович")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 11_000
    assert p not in cls["underpaid"]      # заплатили не меньше плана
    assert cls["overdue"] == []


def test_surname_does_not_false_match_generic_word():
    # «Оплата Оле за видео» — ведущее слово «Оплата» (не фамилия) → по фамилии не матчится,
    # по сумме тоже (нет факта) → остаётся просроченным
    p = plan("20.07.2026", 9_575, IVENT, desc="Оплата Оле за видео с концерта 30.05")
    other = fact("20.07.2026", 500, 5, desc="Оплата по счету № 12")   # слово «Оплата» не должно ловить
    cls = report.classify_unpaid([p], [other], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is False
    assert cls["overdue"] == [p] and p["due"] == 9_575


def test_phase_b_matches_only_partner_name_not_description():
    # Реальный промах: две планки статьи «внутренние ивенты» (Оля, Анлок) «гасились» фактами репбаз,
    # т.к. фаза B цеплялась за слова «оле»/«аренды» в СВОБОДНОМ ТЕКСТЕ. Теперь B матчит только по имени
    # контрагента (partner_name чужой) → ни одна не матчится; C2 не фирит (две планки в статье).
    p1 = plan("20.07.2026", 9_575, IVENT, desc="Оле — за видео с концерта")
    p2 = plan("20.07.2026", 15_000, IVENT, desc="Оплата аренды Анлок")
    facts = [
        fact("08.07.2026", 7_100, IVENT, desc="оплата репбазы для репетиции крыши. Оле помогала",
             partner_name="ООО Репбаза"),
        fact("06.07.2026", 7_100, IVENT, desc="Оплата аренды реп. базы к концерту в ДЕПО",
             partner_name="ИП Студия"),
    ]
    cls = report.classify_unpaid([p1, p2], facts, TODAY, MM, NEXT_FIRST)
    assert p1["matched"] is False and p2["matched"] is False
    assert p1 in cls["overdue"] and p2 in cls["overdue"]
    assert p1["due"] == 9_575 and p2["due"] == 15_000


def test_phase_b_matches_when_name_in_partner_name():
    # тот же ключ, но теперь фамилия в ИМЕНИ КОНТРАГЕНТА → матч (зарплатные факты так и выглядят)
    p = plan("20.07.2026", 15_000, ADMIN, desc="Мухина доп.")
    f = fact("20.07.2026", 15_000, ADMIN, desc="перевод", partner_name="Мухина Наталия Сергеевна")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 15_000


def test_partial_below_50pct_goes_to_overdue_not_underpaid():
    # партнёр заплатил 4 000 из планки 50 000 (8%) — матч по partnerId есть, но покрытие <50%:
    # гард честности → планка в «Просрочено» с пометкой частичной оплаты, НЕ в «оплачено меньше плана»
    p = plan("20.07.2026", 50_000, ADMIN, desc="Кто-то", partnerId=777)
    f = fact("20.07.2026", 4_000, ADMIN, partnerId=777, desc="аванс")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 4_000     # факт сматчился
    assert p in cls["overdue"] and p["due"] == 46_000      # но долг висит
    assert p not in cls["underpaid"]                       # и это НЕ «оплачено меньше плана»


def test_partial_at_least_50pct_is_underpaid():
    # покрытие 60% (≥50%) → «оплачено меньше плана», не «Просрочено»
    p = plan("20.07.2026", 50_000, ADMIN, desc="Кто-то", partnerId=777)
    f = fact("20.07.2026", 30_000, ADMIN, partnerId=777, desc="выплата")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p in cls["underpaid"] and p not in cls["overdue"]
    assert cls["rest"] == []


# ── Фаза C: джокер «Неразнесенное списание» ──

def test_joker_category_matches_any():
    # «Саша — взнос» 13 920, статья Взносы; факт 13 920 в статье-помойке → джокер гасит по сумме
    p = plan("20.07.2026", 13_920, VZNOS, desc="Саша — взнос (20-е)")
    f = fact("20.07.2026", 13_920, 12345, cat=UNALLOC_CAT,
             desc="Для зачисления на счет Харитонова Александра")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and cls["overdue"] == []


def test_joker_still_needs_sum_match():
    # джокер гасит любую статью, но сумма всё равно должна совпасть в пределах допуска
    p = plan("20.07.2026", 80_000, KREDIT, desc="Кредит 80к (счёт, «флористика»)")
    f = fact("20.07.2026", 50_000, 12345, cat=UNALLOC_CAT, desc="Счет на оплату № 999")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is False


# ── Фаза C2: единственная планка статьи (сумма не критерий) ──

def test_c2_unique_plan_in_category_matches_any_sum():
    # Реальный промах: «% по займу» 85 000, факт 82 192 той же статьи (разница 3,3% > допуска C).
    # В статье одна непогашенная планка → C2 гасит независимо от суммы, «оплачено 82 192 из 85 000».
    p = plan("25.07.2026", 85_000, 1544226, desc="% по займу")
    f = fact("21.07.2026", 82_192, 1544226, desc="Оплата процентов по займу под 20% годовых")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 82_192 and p["due"] == 2_808
    assert cls["overdue"] == [] and cls["rest"] == []
    assert cls["underpaid"] == [p]                    # «оплачено X из Y»


def test_c2_two_plans_in_category_does_not_fire():
    # две непогашенные планки одной статьи → неоднозначность, C2 НЕ срабатывает
    p1 = plan("25.07.2026", 85_000, 1544226, desc="% по займу")
    p2 = plan("25.07.2026", 70_000, 1544226, desc="% по кредиту")
    cls = report.classify_unpaid([p1, p2], [fact("21.07.2026", 82_192, 1544226, desc="проценты")],
                                 TODAY, MM, NEXT_FIRST)
    assert p1["matched"] is False and p2["matched"] is False
    assert p1 in cls["rest"] and p2 in cls["rest"]


def test_c2_overpay_splits_fact_leftover_to_aggregate():
    # C2 гасит единственную планку 50 000 фактом 65 000 (130% — в пределах саните), берёт 50 000,
    # остаток 15 000 факта доступен дальше — агрегат той же статьи догашивается им (дробление факта).
    single = plan("20.07.2026", 50_000, 7, desc="Подрядчик Иванов разовый")
    agg = plan("20.07.2026", 120_000, 7, desc="Администраторы — 50% (20-е)")
    f = fact("20.07.2026", 65_000, 7, desc="Выплата Иванов Иван")
    cls = report.classify_unpaid([single, agg], [f], TODAY, MM, NEXT_FIRST)
    assert single["matched"] is True and single["paid"] == 50_000
    assert agg["paid"] == 15_000 and agg["matched"] is False and agg["due"] == 105_000


def test_c2_overpay_beyond_150pct_does_not_fire():
    # факт 80 000 для планки 50 000 (160%) — санити по сумме не пускает, C2 не срабатывает
    p = plan("20.07.2026", 50_000, 7, desc="Подрядчик разовый")
    cls = report.classify_unpaid([p], [fact("20.07.2026", 80_000, 7)], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is False and p in cls["overdue"]


def test_c2_tiny_fact_below_50pct_does_not_fire():
    # реальный промах: планка «Саша — взнос» 13 920, единственный факт статьи 80 ₽ (0,6%) → НЕ гасим,
    # планка целиком в «Просрочено», а не «оплачено 80 из 13 920»
    p = plan("20.07.2026", 13_920, VZNOS, desc="Саша — взнос (20-е)")
    f = fact("06.07.2026", 80, VZNOS, desc="Взносы на обязательное страхование")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is False and p["paid"] == 0
    assert p in cls["overdue"] and p not in cls["underpaid"]
    assert p["due"] == 13_920


def test_c2_ignores_joker_category():
    # факт лежит в статье-помойке (другой catId) → C2 (та же статья) его не берёт
    p = plan("20.07.2026", 85_000, 1544226, desc="% по займу")
    f = fact("18.07.2026", 82_192, 999, cat=UNALLOC_CAT, desc="Неразнесённое")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is False and p in cls["overdue"]


# ── Фаза A: партнёр (partnerId), сумма не критерий ──

def test_partnerid_match_sum_not_criterion():
    p = plan("20.07.2026", 15_000, ADMIN, desc="Мухина доп.", partnerId=5818360)
    f = fact("20.07.2026", 12_000, ADMIN, partnerId=5818360, desc="что угодно")
    cls = report.classify_unpaid([p], [f], TODAY, MM, NEXT_FIRST)
    assert p["matched"] is True and p["paid"] == 12_000
    assert cls["underpaid"] == [p]


# ── Фаза D: агрегат с частичным покрытием + дробление факта ──

def test_aggregate_partial_with_fact_splitting():
    # Реальный кейс 20.07: агрегат «Администраторы — 50%» 120 000 гасится ЕЩЁ НЕ использованными
    # фактами статьи «Зарплаты администраторов»: Тарасова 32 + Никитина 28 + остаток Полякова 21 = 81 000.
    agg = plan("20.07.2026", 120_000, ADMIN, desc="Администраторы — 50% (20-е)")
    polyakov = plan("20.07.2026", 11_000, ADMIN, desc="Поляков — 50% (20-е)")
    f_pol = fact("20.07.2026", 32_000, ADMIN, desc="Выплата Поляков Михаил Викторович",
                 partner_name="Поляков Михаил Викторович")
    f_tar = fact("20.07.2026", 32_000, ADMIN, desc="Выплата Тарасова Евгения Алексеевна",
                 partner_name="Тарасова Евгения Алексеевна")
    f_nik = fact("20.07.2026", 28_000, ADMIN, desc="Выплата Никитина Дарья Евгеньевна",
                 partner_name="Никитина Дарья Евгеньевна")
    cls = report.classify_unpaid([agg, polyakov], [f_pol, f_tar, f_nik], TODAY, MM, NEXT_FIRST)
    assert polyakov["matched"] is True and polyakov["paid"] == 11_000
    assert agg["matched"] is False                     # частичное покрытие
    assert agg["paid"] == 81_000 and agg["due"] == 39_000
    assert agg in cls["overdue"]


# ── Полный прогон реального кейса 20.07.2026 ──

def test_real_scenario_20_july():
    plans = [
        plan("20.07.2026", 15_000, ADMIN, desc="Мухина доп. — 50% (20-е)"),
        plan("20.07.2026", 11_000, ADMIN, desc="Поляков — 50% (20-е)"),
        plan("20.07.2026", 13_920, VZNOS, desc="Саша — взнос (20-е)"),
        plan("20.07.2026", 120_000, ADMIN, desc="Администраторы — 50% (20-е)"),
        plan("20.07.2026", 9_575, IVENT, desc="Оплата Оле за видео с концерта 30.05"),
        plan("20.07.2026", 15_000, IVENT, desc="Оплата 50% аренды Анлок на 22.08"),
        plan("20.07.2026", 80_000, KREDIT, desc="Кредит 80к (счёт, «флористика») — план"),
    ]
    facts = [
        fact("20.07.2026", 12_000, ADMIN, desc="Выплата Мухина Наталия Сергеевна 20.07.2026",
             partner_name="Мухина Наталия Сергеевна"),
        fact("20.07.2026", 32_000, ADMIN, desc="Выплата Поляков Михаил Викторович 20.07.2026",
             partner_name="Поляков Михаил Викторович"),
        fact("20.07.2026", 32_000, ADMIN, desc="Выплата Тарасова Евгения Алексеевна 20.07.2026",
             partner_name="Тарасова Евгения Алексеевна"),
        fact("20.07.2026", 28_000, ADMIN, desc="Выплата Никитина Дарья Евгеньевна 20.07.2026",
             partner_name="Никитина Дарья Евгеньевна"),
        fact("20.07.2026", 13_920, 12345, cat=UNALLOC_CAT, desc="Для зачисления на счет Харитонова Александра"),
        fact("20.07.2026", 80_000, 12345, cat=UNALLOC_CAT, desc="Счет на оплату № 746 от 1 июля 2026"),
        fact("20.07.2026", 8_000, 12345, cat=UNALLOC_CAT, desc="Харитонов А. за 2 смены админом"),
    ]
    cls = report.classify_unpaid(plans, facts, TODAY, MM, NEXT_FIRST)
    by_desc = {p["desc"][:6]: p for p in plans}
    assert by_desc["Мухина"]["matched"] and by_desc["Мухина"]["paid"] == 12_000
    assert by_desc["Поляко"]["matched"] and by_desc["Поляко"]["paid"] == 11_000
    assert by_desc["Саша —"]["matched"]                        # джокер по сумме
    assert by_desc["Кредит"]["matched"]                        # джокер по сумме
    agg = by_desc["Админи"]
    assert not agg["matched"] and agg["paid"] == 81_000 and agg["due"] == 39_000
    # осталось просрочено: Оля, Анлок, остаток агрегата
    overdue_descs = sorted(o["desc"][:6] for o in cls["overdue"])
    assert overdue_descs == sorted(["Оплата", "Оплата", "Админи"])
    total_overdue = sum(o["due"] for o in cls["overdue"])
    assert total_overdue == 9_575 + 15_000 + 39_000            # 63 575
    # неоплаченные Оля/Анлок целиком
    olya = [o for o in cls["overdue"] if o["desc"].startswith("Оплата Оле")][0]
    anlok = [o for o in cls["overdue"] if "Анлок" in o["desc"]][0]
    assert olya["due"] == 9_575 and anlok["due"] == 15_000
    # underpaid — только Мухина (12 из 15)
    assert [o["desc"][:6] for o in cls["underpaid"]] == ["Мухина"]


def test_unallocated_facts_listed():
    # блок «не разнесено» собирается в compute() из фактов месяца статьи-помойки —
    # проверяем фильтр отдельно (та же логика: factMonth==mm и «неразнесен» в статье)
    facts = [
        fact("20.07.2026", 13_920, 1, cat=UNALLOC_CAT, desc="Для зачисления на счет Харитонова"),
        fact("20.07.2026", 80_000, 1, cat=UNALLOC_CAT, desc="Счет на оплату № 746"),
        fact("15.06.2026", 5_000, 1, cat=UNALLOC_CAT, desc="Прошлый месяц", factMonth="06.2026"),
        fact("20.07.2026", 1_000, 2, cat="Сервисы", desc="норм статья"),
    ]
    unalloc = sorted([f for f in facts if f.get("factMonth") == MM and "неразнесен" in report._norm(f.get("cat"))],
                     key=lambda f: report.parse_dmy(f["date"]))
    assert len(unalloc) == 2
    assert sum(f["value"] for f in unalloc) == 93_920


# ── Фаза C: одной суммы мало, если описания называют РАЗНЫХ людей ──

def test_step_c_rejects_conflicting_names():
    """Реальный кейс 27.07.2026: планку «Саше Русманову за МК ЩУКА» гасил факт
    «Сергею звукорежиссёру за хор в ПГ» — те же 5 000 в статье b2b-ивентов,
    но это два разных человека и два разных события."""
    ops = [plan("27.07.2026", 5_000, IVENT, desc="Оплата Саше Русманову за МК по кринжу для команды ЩУКА"),
           plan("27.07.2026", 10_000, IVENT, desc="Оплата Ксюше Хохловой за хор в ПГ 17.07")]
    facts = [fact("20.07.2026", 5_000, IVENT, desc="Оплата Сергею звукорежисееру за хор в ПГ")]
    report.classify_unpaid(ops, facts, datetime.date(2026, 7, 27), MM, NEXT_FIRST)
    assert ops[0]["matched"] is False
    assert ops[0]["due"] == 5_000


def test_step_c_allows_same_name_in_other_case():
    """Склонение — тот же человек: «Полине» и «Полина» матчиться должны."""
    ops = [plan("27.07.2026", 10_640, IVENT, desc="Оплата Полине фотографу за концерт 25.07")]
    facts = [fact("26.07.2026", 10_640, IVENT, desc="Полина Соколова, фотосъёмка концерта")]
    report.classify_unpaid(ops, facts, datetime.date(2026, 7, 27), MM, NEXT_FIRST)
    assert ops[0]["matched"] is True


def test_step_c_allows_name_mentioned_anywhere_in_fact():
    """Имя из планки есть в тексте факта, пусть и не первым словом — это тот же платёж."""
    ops = [plan("27.07.2026", 15_000, IVENT, desc="Оплата Кокореву (гитара) за ДЕПО 11.07")]
    facts = [fact("26.07.2026", 15_000, IVENT, desc="Перевод по номеру телефона, получатель Кокореву Ю.")]
    report.classify_unpaid(ops, facts, datetime.date(2026, 7, 27), MM, NEXT_FIRST)
    assert ops[0]["matched"] is True


def test_step_c_unaffected_when_descriptions_are_impersonal():
    """Безымянные описания («Типография», «Счёт №…») конфликта не дают — матч как раньше."""
    ops = [plan("25.07.2026", 5_000, 111, desc="Типография")]
    facts = [fact("20.07.2026", 5_000, 111, desc="Платеж по счету № 4411032663, печать плакатов")]
    report.classify_unpaid(ops, facts, TODAY, MM, NEXT_FIRST)
    assert ops[0]["matched"] is True


# ── Фаза A2: контрагент из колонки «Контрагент» таблицы Оли ──
# В «Назначении» Оля пишет人 по-разному («Вове (бас)», «Саше Б»), а колонка «Контрагент»
# держит полное ФИО — это самый точный ключ к человеку. Планка несёт его в поле 'kontr'.

TODAY29 = datetime.date(2026, 7, 29)


def test_kontr_column_matches_when_naznach_has_no_surname():
    """«Оплата Вове (бас)» фамилии не содержит — узнаём человека по колонке «Контрагент»."""
    ops = [plan("27.07.2026", 15_000, IVENT, desc="Оплата Вове (бас) за ДЕПО 11.07")]
    ops[0]["kontr"] = "СЗ Барышников Владимир"
    facts = [fact("27.07.2026", 15_000, IVENT, partner_name="БАРЫШНИКОВ ВЛАДИМИР СЕРГЕЕВИЧ")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is True and ops[0]["due"] == 0


def test_kontr_does_not_match_another_person():
    """Козлову Олегу деньги через Рабочие руки не уходили — платёж Соловьевой его не гасит."""
    ops = [plan("27.07.2026", 10_000, IVENT, desc="Оплата Олегу (кахон) за хор в ПГ")]
    ops[0]["kontr"] = "СЗ КОЗЛОВ ОЛЕГ ВЛАДИМИРОВИЧ"
    facts = [fact("27.07.2026", 10_000, IVENT, partner_name="Соловьева Алиса Сергеевна",
                  desc="Выплата Соловьева Алиса Сергеевна 27.07.2026 (Рабочие руки, по акту)")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False and ops[0]["due"] == 10_000


def test_kontr_needs_surname_and_first_name():
    """Одного совпавшего слова мало — иначе «Александр» склеит Русманова с Булгаковым."""
    ops = [plan("27.07.2026", 5_000, IVENT, desc="Оплата Саше Русманову за МК")]
    ops[0]["kontr"] = "СЗ Русманов Александр"
    facts = [fact("27.07.2026", 5_000, IVENT, partner_name="Булгаков Александр Николаевич")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False


def test_kontr_picks_fact_with_equal_amount_first():
    """У Кокорева в один день две выплаты — планка на 10 000 не должна дробить факт на 15 000."""
    ops = [plan("27.07.2026", 10_000, IVENT, desc="Оплата Кокореву (гитара) за хор в ПГ"),
           plan("27.07.2026", 15_000, IVENT, desc="Оплата Кокореву (гитара) за ДЕПО 11.07")]
    for o in ops:
        o["kontr"] = "СЗ Кокорев Юрий Сергеевич"
    facts = [fact("27.07.2026", 15_000, IVENT, partner_name="Кокорев Юрий Сергеевич"),
             fact("27.07.2026", 10_000, IVENT, partner_name="Кокорев Юрий Сергеевич")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert all(o["matched"] and o["due"] == 0 for o in ops)


def test_kontr_does_not_pay_plan_from_another_category():
    """У человека бывает и ЗП, и гонорар за съёмку: планка события не гасится зарплатой."""
    ops = [plan("03.08.2026", 15_000, IVENT, factMonth="08.2026",
                desc="Оплата Юре Кокореву за cover session 25.07")]
    ops[0]["kontr"] = "СЗ Кокорев Юрий Сергеевич"
    facts = [fact("25.07.2026", 8_100, ADMIN, partner_name="Кокорев Юрий Сергеевич",
                  desc="ЗП педагога")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False and ops[0]["due"] == 15_000


def test_kontr_requires_surname_not_just_first_name_and_patronymic():
    """Кокорев Юрий Сергеевич и Шапошников Юрий Сергеевич — разные люди: фамилия решает."""
    ops = [plan("03.08.2026", 15_000, IVENT, factMonth="08.2026",
                desc="Оплата Юре Кокореву за cover session 25.07")]
    ops[0]["kontr"] = "СЗ Кокорев Юрий Сергеевич"
    facts = [fact("27.07.2026", 10_640, IVENT, partner_name="Шапошников Юрий Сергеевич",
                  desc="Оплата Полине фотографу за концерт 25.07")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False and ops[0]["due"] == 15_000


# ── планка с ФИО гасится ТОЛЬКО по связке контрагент + сумма + статья ──

def test_person_plan_not_paid_by_different_amount():
    """Тот человек и та статья, но сумма другая — это другой платёж, планку не гасим."""
    ops = [plan("27.07.2026", 15_000, IVENT, desc="Оплата Вове (бас) за ДЕПО 11.07")]
    ops[0]["kontr"] = "СЗ Барышников Владимир"
    facts = [fact("27.07.2026", 12_000, IVENT, partner_name="БАРЫШНИКОВ ВЛАДИМИР СЕРГЕЕВИЧ")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False and ops[0]["due"] == 15_000


def test_person_plan_not_paid_by_anonymous_fact():
    """Факт без контрагента не гасит именную планку, даже если сумма и статья совпали."""
    ops = [plan("27.07.2026", 15_000, IVENT, desc="Оплата Вове (бас) за ДЕПО 11.07")]
    ops[0]["kontr"] = "СЗ Барышников Владимир"
    facts = [fact("27.07.2026", 15_000, IVENT, desc="Списание по счёту")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False and ops[0]["due"] == 15_000


def test_person_plan_ignores_unique_category_shortcut():
    """Единственная планка в статье (шаг C2) — тоже не повод погасить её чужим платежом."""
    ops = [plan("27.07.2026", 15_000, KREDIT, desc="Оплата Вове (бас) за ДЕПО 11.07")]
    ops[0]["kontr"] = "СЗ Барышников Владимир"
    facts = [fact("27.07.2026", 14_000, KREDIT, partner_name="Иванов Сергей Петрович")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is False


def test_person_plan_matches_on_full_triple():
    """Контрагент, сумма и статья совпали — гасим."""
    ops = [plan("27.07.2026", 15_000, IVENT, desc="Оплата Вове (бас) за ДЕПО 11.07")]
    ops[0]["kontr"] = "СЗ Барышников Владимир"
    facts = [fact("27.07.2026", 15_000, IVENT, partner_name="БАРЫШНИКОВ ВЛАДИМИР СЕРГЕЕВИЧ")]
    report.classify_unpaid(ops, facts, TODAY29, MM, NEXT_FIRST)
    assert ops[0]["matched"] is True and ops[0]["due"] == 0


def test_same_person_handles_reversed_name_order():
    """У Оли встречается «Имя Фамилия» — «СЗ Ксения Хохлова» это та же Хохлова Ксения."""
    assert matching._same_person("СЗ Ксения Хохлова", "Хохлова Ксения Игоревна") is True


def test_same_person_rejects_two_companies():
    """«ООО» — не фамилия: два разных юрлица не должны склеиваться."""
    assert matching._same_person('ООО "ОНЛАЙН ТРЕЙД"', 'ООО "ТРЕЙДСЕРВИС"') is False


def test_same_person_understands_diminutive_names():
    """Оля пишет живым языком: «Харитонов Саша» — это Харитонов Александр."""
    assert matching._same_person("Харитонов Саша", "Харитонов Александр Игоревич") is True
    assert matching._same_person("СЗ Ционов Серёжа", "ЦИОНОВ СЕРГЕЙ ВЯЧЕСЛАВОВИЧ") is True


def test_diminutive_does_not_glue_different_surnames():
    """Уменьшительное имя не отменяет требования к фамилии."""
    assert matching._same_person("Булгаков Саша", "Русманов Александр Сергеевич") is False


def test_name_and_patronymic_of_same_root_count_once():
    """«Александра» и «Александровна» — одна основа: нельзя засчитывать их как два совпадения,
    иначе клиентка «Александра Романовна Б.» становится «Монастыршиной Александрой»."""
    assert matching._same_person("ИП Монастыршина Александра Александровна",
                                 "Александра Романовна Б.") is False


def test_client_card_with_initial_surname_never_matches():
    """Карточки клиентов Марса анонимны («Александр Николаевич Б.») — фамилии в них нет,
    сверять с ФИО из таблицы Оли нечего."""
    assert matching._same_person("СЗ Булгаков Александр",
                                 "Александр Николаевич Б.") is False


def test_anonymous_card_with_only_initial_never_matches():
    """«АЛЕКСАНДРА АЛЕКСАНДРОВНА Т.» — карточка клиента без фамилии: сверять не с чем,
    даже когда имя и отчество совпали с реальным контрагентом."""
    assert matching._same_person("ИП Монастыршина Александра Александровна",
                                 "АЛЕКСАНДРА АЛЕКСАНДРОВНА Т.") is False
    assert matching._same_person("Харитонов Саша", "Харитонов Александр Игоревич") is True


def test_surname_compared_by_longer_stem():
    """Четырёх букв фамилии мало: «Королева» и «Коротаев» — разные люди."""
    assert matching._same_person("ИП КОРОЛЕВА ВАРВАРА МИХАЙЛОВНА",
                                 "Коротаев Аркадий Михайлович") is False


def test_patronymic_plus_similar_name_is_not_a_person_match():
    """Имя одного похоже на фамилию другого, отчества однокоренные — но фамилии разные."""
    assert matching._same_person("ЦИОНОВ СЕРГЕЙ ВЯЧЕСЛАВОВИЧ",
                                 "Сергеева Юлия Вячеславовна") is False
    assert matching._same_person("ИП НАУМОВ ИВАН СЕРГЕЕВИЧ",
                                 "ИВАНОВ СЕРГЕЙ АЛЕКСАНДРОВИЧ") is False


def test_typo_in_surname_still_same_person():
    """«Чернышева» и «Чернышова» — одна Анастасия Сергеевна, опечатка в карточке."""
    assert matching._same_person("Чернышева Анастасия Сергеевна",
                                 "Чернышова Анастасия Сергеевна") is True


def test_reversed_order_still_matches_after_surname_tightening():
    assert matching._same_person("Сабина Климович", "Климович Сабина") is True


# ── скользящее окно 14 дней вместо прыгающего «к 1-му» ──

def test_window_does_not_jump_at_month_boundary():
    """Раньше 31.07 окно было 4 дня, а 01.08 — 32 дня: отчёт за сутки менял систему
    координат и «нужно заплатить» прыгало с 715 000 до 4 276 862."""
    ops = [plan("03.08.2026", 700_000, 1543716, factMonth="08.2026", cat="Зарплаты педагогов"),
           plan("17.08.2026", 700_000, 1543716, factMonth="08.2026", cat="Зарплаты педагогов")]
    d1, d2 = datetime.date(2026, 7, 31), datetime.date(2026, 8, 1)
    r1 = matching.classify_unpaid([dict(o) for o in ops], [], d1, "07.2026",
                                  d1 + datetime.timedelta(days=14))
    r2 = matching.classify_unpaid([dict(o) for o in ops], [], d2, "08.2026",
                                  d2 + datetime.timedelta(days=14))
    assert len(r1["due_window"]) == 1     # окно 31.07–14.08 видит только 03.08
    assert len(r2["due_window"]) == 1     # окно 01.08–15.08 тоже одну


def test_after_window_shows_next_payments():
    """Окно режет по живому: за границей 15.08 сразу ЗП педагогов 17.08 — покажем."""
    ops = [plan("17.08.2026", 700_000, 1543716, factMonth="08.2026", cat="Зарплаты педагогов")]
    today = datetime.date(2026, 8, 1)
    r = matching.classify_unpaid(ops, [], today, "08.2026", today + datetime.timedelta(days=14))
    assert r["due_window"] == []
    assert len(r["after_window"]) == 1
    assert r["after_window"][0]["date"] == "17.08.2026"


def test_after_window_only_nearest_date():
    """За границей показываем только ближайший день, а не весь хвост месяца."""
    ops = [plan("17.08.2026", 700_000, 1543716, factMonth="08.2026", cat="Зарплаты педагогов"),
           plan("17.08.2026", 70_000, 1544225, factMonth="08.2026", cat="% по кредитам"),
           plan("25.08.2026", 40_000, 1543727, factMonth="08.2026", cat="Аутсорс")]
    today = datetime.date(2026, 8, 1)
    r = matching.classify_unpaid(ops, [], today, "08.2026", today + datetime.timedelta(days=14))
    assert len(r["after_window"]) == 2
    assert {o["date"] for o in r["after_window"]} == {"17.08.2026"}


# ── транзитный счёт «Рабочие руки»: деньги уже ушли со счетов ──

def test_platform_covers_teacher_payments():
    """На площадке 598 887 под выплаты педагогам. Эти деньги уже переведены, и
    требовать их второй раз из свободного остатка нельзя."""
    due = [{"date": "03.08.2026", "due": 700_000.0, "cat": "Зарплаты педагогов"},
           {"date": "14.08.2026", "due": 1_146_000.0, "cat": "Аренда и содержание здания"}]
    covered, need_live = report.covered_by_platform(due, 598_887.0)
    assert covered == 598_887.0                                  # гасит ЗП, но не аренду
    assert need_live == 700_000.0 + 1_146_000.0 - 598_887.0


def test_platform_covers_no_more_than_payable():
    """Если на площадке больше, чем предстоит выплатить людям — лишнее не засчитываем:
    аренду с площадки не заплатишь."""
    due = [{"date": "03.08.2026", "due": 100_000.0, "cat": "Зарплаты педагогов"},
           {"date": "14.08.2026", "due": 50_000.0, "cat": "Сервисы"}]
    covered, need_live = report.covered_by_platform(due, 598_887.0)
    assert covered == 100_000.0
    assert need_live == 50_000.0


def test_platform_zero_when_empty():
    due = [{"date": "03.08.2026", "due": 700_000.0, "cat": "Зарплаты педагогов"}]
    covered, need_live = report.covered_by_platform(due, 0.0)
    assert covered == 0.0 and need_live == 700_000.0


def test_platform_below_threshold_is_ignored():
    """Порог управляет и арифметикой: мелкий остаток не гасит ничего, поэтому «разрыв»
    всегда сходится с «выплаты − на счетах», без невидимой поправки на 6 737 ₽."""
    due = [{"date": "03.08.2026", "due": 700_000.0, "cat": "Зарплаты педагогов"}]
    covered, need_live = report.covered_by_platform(due, 6_737.0)
    assert covered == 0.0 and need_live == 700_000.0


# ── горизонт доходов равен горизонту расходов ──

def test_expected_in_window_is_proportional():
    """Нельзя сравнивать выплаты за 15 дней с месячным планом: к 15-му весь месячный
    приход не случится. 6 050 000 × 15/31 = 2 927 419."""
    today = datetime.date(2026, 8, 1)
    got = report.expected_in_window(6_050_000, 0, today, today + datetime.timedelta(days=14))
    assert round(got) == 2_927_419


def test_expected_in_window_uses_remaining_days():
    """В конце месяца остаток плана делится на ОСТАВШИЕСЯ дни, иначе деньги теряются:
    25.08 остаток 1 050 000 и 7 оставшихся дней — все они в окне."""
    today = datetime.date(2026, 8, 25)
    got = report.expected_in_window(6_050_000, 5_000_000, today, datetime.date(2026, 9, 8))
    assert round(got) == 1_050_000


def test_expected_in_window_crosses_month_border():
    """Окно перелезает в сентябрь — добавляется доля плана следующего месяца."""
    today = datetime.date(2026, 8, 25)
    got = report.expected_in_window(6_050_000, 5_000_000, today,
                                    datetime.date(2026, 9, 8), plan_next=3_000_000)
    assert round(got) == 1_050_000 + round(3_000_000 * 8 / 30)


def test_expected_in_window_never_negative():
    """Пришло больше плана — ждём ноль, а не минус."""
    today = datetime.date(2026, 8, 20)
    got = report.expected_in_window(6_050_000, 7_000_000, today, today + datetime.timedelta(days=14))
    assert got == 0.0


# ── вердикт: сначала живые деньги, потом чем закрываем ──

def _vd(**kw):
    base = dict(free=1_411_965.0, need_live=1_865_893.0, covered=598_887.0,
                due_sum=2_464_780.0, wait_win=2_927_419.0, wait_win_safe=2_516_129.0,
                plan_missing=False, next_plan_missing=False,
                window_end=datetime.date(2026, 8, 15))
    base.update(kw)
    return base


def test_verdict_tight_when_income_closes_the_gap():
    """Живых 1 411 965 против нужных 1 865 893. Приход разрыв закрывает — это «впритык»,
    а не авария: «денег не хватит на все расходы, но если заработаем — хватит» (Наташа)."""
    t = report.verdict_line(_vd())
    assert "🟡" in t and "🔴" not in t
    assert report.money(453_928) in t     # разрыв (money даёт неразрывные пробелы)
    assert report.money(2_516_129) in t   # надёжный приход к границе окна
    assert not report.is_red(_vd())


def test_verdict_green_when_live_money_enough():
    t = report.verdict_line(_vd(free=3_000_000.0))
    assert "🟢" in t
    assert "хватает" in t.lower()
    assert report.status(_vd(free=3_000_000.0)) == "green"


def test_verdict_does_not_use_month_plan():
    """Месячный план 6 050 000 в вердикте не появляется — только приход в окне."""
    assert report.money(6_050_000) not in report.verdict_line(_vd())


def test_verdict_warns_when_next_plan_missing():
    t = report.verdict_line(_vd(next_plan_missing=True))
    assert "следующий месяц" in t.lower()


def test_verdict_red_only_when_income_does_not_close_gap():
    """Красный — когда не хватает даже с надёжным приходом. Только тогда уходит флаг."""
    d = _vd(wait_win_safe=100_000.0)
    assert report.status(d) == "red" and report.is_red(d)
    t = report.verdict_line(d)
    assert "🔴" in t
    assert report.money(353_928) in t     # сколько не хватит после прихода


def test_flag_does_not_promise_a_positive_balance():
    """Старый флаг писал «остаток уходит в 3 071 648» — положительное число под
    заголовком «КРАСНЫЙ ФЛАГ». Теперь называется недостача, и она положительная."""
    t = report.render_flag(_vd(wait_win_safe=100_000.0, today=datetime.date(2026, 8, 3)))
    assert "не хватает" in t
    assert report.money(353_928) in t


def test_platform_named_once_in_verdict():
    """Площадка проговаривается один раз — в вердикте, а не в трёх местах подряд."""
    assert report.verdict_line(_vd()).lower().count("площадк") == 1


def test_platform_absent_from_verdict_when_below_threshold():
    assert "площадк" not in report.verdict_line(_vd(covered=0.0)).lower()


# ── блок доходов: 1-го числа нули не несут информации ──

def _inc_d(day, got=0.0):
    return dict(today=datetime.date(2026, 8, day), plan_real=6_050_000.0, crm_total=got,
                plan_missing=False, ft_income=got,
                income_expect=[{"name": "1-1", "plan": 4_000_000.0, "got": got,
                                "wait": max(0.0, 4_000_000.0 - got), "risky": False}])


def test_income_block_collapsed_on_first_day():
    """1-го числа девять строк «0% · ждём X» — шум: деньги приходят со второго."""
    t = report.income_block(_inc_d(1))
    assert "месяц только начался" in t
    assert "1-1" not in t


def test_income_block_detailed_from_second_day():
    t = report.income_block(_inc_d(2, got=120_000.0))
    assert "1-1" in t
    assert "месяц только начался" not in t


def test_income_block_keeps_missing_plan_warning():
    d = _inc_d(5)
    d["plan_missing"] = True
    assert "Плана доходов на месяц нет" in report.income_block(d)


def test_income_block_hides_line_without_plan_and_fact():
    """«Онлайн-подписка — 0% ✅» стояла каждый день, хотя клуб раньше октября не
    запустится. Из канона строку не убираем — просто не печатаем пустую."""
    d = _inc_d(5, got=120_000.0)
    d["income_expect"].append({"name": "Онлайн-подписка", "plan": 0.0, "got": 0.0,
                               "wait": 0.0, "risky": True})
    assert "Онлайн-подписка" not in report.income_block(d)
    assert "1-1" in report.income_block(d)


def test_income_block_shows_line_once_it_has_a_plan():
    """Появится план в октябре — строка вернётся сама, без правки кода."""
    d = _inc_d(5, got=120_000.0)
    d["income_expect"].append({"name": "Онлайн-подписка", "plan": 300_000.0, "got": 0.0,
                               "wait": 300_000.0, "risky": True})
    assert "Онлайн-подписка" in report.income_block(d)


# ── «следом за окном»: корзины вместо простыни планок ──

def test_after_window_aggregates_by_bucket():
    """03.08.2026 за границей окна стояло шесть планок ЗП бэк-офиса подряд — это обои,
    а не информация."""
    ops = [{"date": "20.08.2026", "due": v, "cat": "Зарплаты админов"}
           for v in (13_920.0, 15_000.0, 124_000.0, 11_000.0, 38_500.0, 42_500.0)]
    ops.append({"date": "20.08.2026", "due": 140_000.0, "cat": "% по кредитам"})
    t = report.after_window_block({"after_window": ops})
    assert t.count("ЗП бэк-офис") == 1
    assert report.money(244_920) in t          # 13 920+15 000+124 000+11 000+38 500+42 500
    assert report.money(140_000) in t
    assert "20.08" in t


# ── сторож: комиссия площадки прошла, а выплаты нет ──

def test_unposted_watchdog_catches_commission_without_payout():
    """31.07 прошла комиссия «Рабочие руки» 11 969, а выплат педагогам нет — значит
    выплата на площадке была, а в Финтабло её не занесли, и планка висит непогашенной."""
    facts = [fact("31.07.2026", 11_969, 1544281, cat='Комиссия "рабочие руки"')]
    t = report.unposted_block(facts, datetime.date(2026, 8, 1))
    assert "31.07" in t
    assert report.money(11_969) in t


def test_unposted_watchdog_silent_when_payout_present():
    facts = [fact("31.07.2026", 11_969, 1544281, cat='Комиссия "рабочие руки"'),
             fact("31.07.2026", 700_000, 1543716, cat="Зарплаты педагогов")]
    assert report.unposted_block(facts, datetime.date(2026, 8, 1)) == ""


def test_unposted_watchdog_ignores_old_days():
    """Дальше десяти дней не смотрим: Оля разносит быстрее, старое — уже не сигнал."""
    facts = [fact("01.07.2026", 11_969, 1544281, cat='Комиссия "рабочие руки"')]
    assert report.unposted_block(facts, datetime.date(2026, 8, 1)) == ""


def test_unposted_watchdog_sees_payout_by_description():
    """Выплаты через площадку проходят по разным статьям («Расходы на внутренние
    ивенты» 27.07.2026) — метка в описании «(Рабочие руки)», а не в статье."""
    facts = [fact("27.07.2026", 2_200, 1544281, cat='Комиссия "рабочие руки"'),
             fact("27.07.2026", 20_000, 999001, cat="Расходы на внутренние ивенты",
                  desc="Выплата Булгаков Александр Николаевич 27.07.2026 (Рабочие руки)")]
    assert report.unposted_block(facts, datetime.date(2026, 8, 1)) == ""


# ── планка ЗП, деньги под которую уже на площадке, не выглядит неоплаченной ──

def test_platform_hint_on_salary_row():
    """31.07 педагогам заплатили, но в Финтабло не занесли: планка 03.08 висит.
    Показываем её с пометкой, а не как безусловный будущий платёж."""
    assert report.platform_hint("Зарплаты педагогов", 598_887.0) != ""
    assert report.money(598_887) in report.platform_hint("Зарплаты педагогов", 598_887.0)


def test_platform_hint_silent_for_other_categories():
    assert report.platform_hint("Аренда и содержание здания", 598_887.0) == ""


def test_platform_hint_silent_when_platform_empty():
    assert report.platform_hint("Зарплаты педагогов", 0.0) == ""


# ── один горизонт и одно сообщение в день ──

def _day_d(day=3, free=1_652_815.0, safe=2_515_921.0):
    """Полный d для рендеров: 03.08.2026, понедельник (числа реального прогона)."""
    due = [{"date": "05.08.2026", "due": 433_580.0, "cat": "Зарплаты админов"},
           {"date": "14.08.2026", "due": 1_148_000.0, "cat": "Аренда и содержание здания"},
           {"date": "17.08.2026", "due": 700_000.0, "cat": "Зарплаты педагогов"}]
    covered, need_live = report.covered_by_platform(due, 6_737.0)
    return dict(today=datetime.date(2026, 8, day), mm="08.2026", nm="09.2026",
                window_end=datetime.date(2026, 8, day + 14),
                free=free, detail=[("р/с Точка *1195", 1_398_239.0), ("Наличные", 140_392.0)],
                yest_in=146_163.0, platform=6_737.0, covered=covered, need_live=need_live,
                due_sum=sum(o["due"] for o in due),
                due_buckets={"ЗП педагогов": 700_000.0, "Аренда и содержание здания": 1_148_000.0,
                             "ЗП бэк-офис + админы": 433_580.0},
                next2w=[dict(o, value=o["due"]) for o in due], after_window=[],
                income_expect=[{"name": "1-1", "plan": 4_000_000.0, "got": 330_000.0,
                                "wait": 3_670_000.0, "risky": False}],
                plan_real=6_050_000.0, crm_total=330_000.0, ft_income=330_000.0,
                wait_win=2_940_059.0, wait_win_safe=safe, next_plan_missing=False,
                overdue=[], overdue_sum=0, stale=[], stale_sum=0, underpaid=[],
                unalloc=[], unalloc_sum=0, fm_bad=[], facts=[],
                plan_missing=False, remind_next=False)


def test_monday_sends_one_message_not_two():
    """03.08.2026 понедельник дал три сообщения подряд с дословными повторами."""
    names = [n for n, _ in report.build_messages(_day_d(3))]
    assert names == ["full"]


def test_weekday_sends_pulse():
    assert [n for n, _ in report.build_messages(_day_d(4))] == ["pulse"]


def test_flag_joins_only_on_red():
    """«Впритык» флага не поднимает, «не хватает даже с приходом» — поднимает."""
    assert [n for n, _ in report.build_messages(_day_d(4))] == ["pulse"]
    assert [n for n, _ in report.build_messages(_day_d(4, safe=100_000.0))] == ["pulse", "flag"]


def test_full_has_no_month_horizon():
    """Полный отчёт больше не живёт в месячной системе координат."""
    t = report.render_full(_day_d(3))
    for gone in ("До конца месяца заплатить", "педагогам:", "Когда кончатся деньги",
                 "Оптимистичный план", "Точка риска"):
        assert gone not in t


def test_full_adds_calendar_and_window_arithmetic():
    """То, ради чего полный вообще существует: календарь по дням и арифметика окна."""
    t = report.render_full(_day_d(3))
    assert "По дням:" in t and "05.08" in t
    assert "Хватит ли до 17.08" in t
    assert report.money(1_652_815) in t and report.money(2_515_921) in t


def test_full_contains_everything_pulse_had():
    """Раз полный шлётся вместо пульса, он обязан нести и его содержимое."""
    t = report.render_full(_day_d(3))
    for block in ("Кассы:", "Свободно:", "Доходы:", "Выплаты до 17.08"):
        assert block in t


def test_todo_drops_income_advice_when_money_is_enough():
    """Постоянный список «что делать» замыливается: советы про приходы — по состоянию."""
    assert "Следить за приходами" in report.todo_block(_day_d(3))
    assert "Следить за приходами" not in report.todo_block(_day_d(3, free=9_000_000.0))
