"""Регрессы на баги, найденные при разборе 25.07.2026. Каждый тест = один баг.

Все — чистые функции, без сети.
"""
import datetime

import cashflow as cf
import enter_cash_expenses as ece
import matching
import plan_sync as ps
import report


# ── 1. кредиты: since/until, бессрочные — под предупреждение ──

def test_credit_active_respects_since():
    # кредит 1,5М начинается с августа: в июле его платежа быть не должно
    assert ps.credit_active(2026, 7, (2026, 8), None) is False
    assert ps.credit_active(2026, 8, (2026, 8), None) is True


def test_credit_active_respects_until():
    assert ps.credit_active(2026, 12, None, (2026, 10)) is False
    assert ps.credit_active(2026, 10, None, (2026, 10)) is True


def test_cashflow_credit_rows_follow_schedule():
    # раньше cashflow складывал ВСЕ кредиты в каждый месяц, игнорируя since
    july = dict(cf.credit_rows((2026, 7)))
    aug = dict(cf.credit_rows((2026, 8)))
    assert "Новый кредит 1,5М (платёж)" not in july
    assert "Новый кредит 1,5М (платёж)" in aug


def test_credits_without_end_reported(monkeypatch):
    """Кредит без until = касса вычитает платёж вечно, про такие предупреждаем.
    Раньше тест утверждал, что бессрочные ЕСТЬ; 29.07.2026 сроки собрали по всем четырём,
    поэтому проверяем саму функцию, а не состояние конфига (см. test_all_credits_have_an_end_date)."""
    monkeypatch.setattr(ps, "CREDITS", [
        ("a", "Бессрочный", 1, 10_000, 20, None, None),
        ("b", "Со сроком",  1, 10_000, 20, None, (2027, 2)),
    ])
    assert ps.credits_without_end() == ["Бессрочный"]


# ── 2. retire удаляет планку только при почти полной оплате ──

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


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


TODAY = datetime.date(2026, 7, 25)


def test_retire_requires_near_full_payment():
    """Планка 85 000, оплачено 45 000 → для отчёта «оплачена», но УДАЛЯТЬ нельзя:
    иначе недоплата исчезла бы из прогноза."""
    p = _plan("20.07.2026", 85_000, 111, desc="Займ", partnerId=7)
    matching.match_cascade([p], [_fact("20.07.2026", 45_000, 111, partnerId=7)], TODAY)
    assert matching.settled(p) is True                                   # для «ещё платить»
    assert matching.settled(p, matching.RETIRE_MIN_COVER) is False       # но не для удаления


def test_retire_allows_small_underpay():
    # типография: план 5 000, факт 4 949 (99%) — это отработавшая планка
    p = _plan("25.07.2026", 5_000, 222, desc="Типография")
    matching.match_cascade([p], [_fact("25.07.2026", 4_949, 222)], TODAY)
    assert matching.settled(p, matching.RETIRE_MIN_COVER) is True


# ── 3. фаза A: факты своей статьи имеют приоритет ──

def test_phase_a_prefers_same_category():
    """У человека две планки (ЗП и взнос) и два платежа. Планка ЗП должна съесть
    платёж СВОЕЙ статьи, а не более ранний платёж по взносам."""
    zp = _plan("05.07.2026", 20_000, 100, desc="ЗП Харитонов", partnerId=7)
    vz = _plan("20.07.2026", 14_000, 200, desc="Взнос Харитонов", partnerId=7)
    # оба факта попадают в окна обеих планок; без приоритета статьи планка ЗП съела бы
    # более ранний платёж по взносам и обе строки поехали бы
    facts = [_fact("07.07.2026", 14_000, 200, partnerId=7),
             _fact("08.07.2026", 20_000, 100, partnerId=7)]
    matching.match_cascade([zp, vz], facts, TODAY)
    assert zp["paid"] == 20_000 and zp["due"] == 0
    assert vz["paid"] == 14_000 and vz["due"] == 0


# ── 4. долги прошлых месяцев больше не теряются, underpaid не копится ──

def test_stale_debt_from_previous_month_is_surfaced():
    old = _plan("15.06.2026", 30_000, 300, desc="Аренда", factMonth="06.2026")
    cls = matching.classify_unpaid([old], [], TODAY, "07.2026", datetime.date(2026, 8, 3))
    assert cls["stale"] == [old]          # раньше такая планка не попадала никуда
    assert cls["rest"] == []


def test_underpaid_only_current_month():
    """Блок «оплачено меньше плана» показывает только текущий месяц — раньше он считался
    по всем планкам года и копил строки с января."""
    old = _plan("05.06.2026", 10_000, 300, desc="Кревер", partnerId=8, factMonth="06.2026")
    cur = _plan("05.07.2026", 10_000, 400, desc="Поляков", partnerId=9)
    facts = [_fact("05.06.2026", 6_000, 300, partnerId=8, factMonth="06.2026"),
             _fact("05.07.2026", 6_000, 400, partnerId=9)]
    cls = matching.classify_unpaid([old, cur], facts, TODAY, "07.2026", datetime.date(2026, 8, 3))
    assert [o["desc"] for o in cls["underpaid"]] == ["Поляков"]


# ── 5. разметка финмодели: номера строк проверяются, а не берутся на веру ──

def _fm(profit_label="Прибыль (Выручка − Расходы)"):
    """Финмодель-заглушка: подпись каждой ожидаемой строки берём из самой FM_EXPECT,
    чтобы фикстуру не приходилось чинить руками при каждой новой строке прогноза."""
    rows = [[""] * 3 for _ in range(60)]
    for r, keys in cf.FM_EXPECT.items():
        rows[r][1] = keys[0]
    rows[cf.ROW_PROFIT][1] = profit_label
    return rows


def test_check_plan_layout_ok():
    assert cf.check_plan_layout(_fm()) == []


def test_check_plan_layout_catches_shift():
    bad = cf.check_plan_layout(_fm(profit_label="Заемные средства"))
    assert bad and "прибыл" in bad[0]


def test_profit_row_is_50_not_49():
    """План прибыли брали из строки 49 «Заемные средства» — она пустая, план всегда был 0."""
    assert cf.ROW_PROFIT == 50


# ── 6. суммы из таблицы Оли: точка как разделитель тысяч ──

def test_parse_sum_formats():
    assert ece.parse_sum("р.5 000") == 5000.0
    assert ece.parse_sum("1 234,56") == 1234.56
    assert ece.parse_sum("1.500") == 1500.0          # раньше давало 1.5
    assert ece.parse_sum("р.12 500,00") == 12500.0
    assert ece.parse_sum("") is None


# ── 7. русские месяцы во всех датах, а не только July/August ──

def test_ru_date_all_months():
    import core
    assert core.ru_date(datetime.date(2026, 9, 1)) == "1 сентября"
    assert core.ru_date(datetime.date(2026, 12, 31)) == "31 декабря"
    assert "September" not in report.render_flag(
        {"today": datetime.date(2026, 9, 1), "window_end": datetime.date(2026, 9, 15),
         "need_live": 0, "free": 0, "wait_win_safe": 0})


# ── 8. правка даты в таблице Оли не создаёт вторую операцию ──

def _row(date, amount, naznach):
    return {"date": date, "amount": amount, "naznach": naznach, "art": "Концерты / квартирники",
            "ftname": "расходы на внутренние ивенты", "kontr": "", "catid": 1,
            "ext": ece.ext_id(date, amount, naznach)}


def _tx(date, value, ext=None, desc=""):
    return {"id": 1, "date": date, "value": value, "group": "outcome",
            "externalId": ext, "description": desc}


def test_date_change_fixes_existing_instead_of_duplicating():
    """Оля перенесла дату оплаченной строки: externalId считается от даты, поэтому связь
    рвётся. Раньше строка занеслась бы второй раз — теперь правим дату у существующей."""
    row = _row("08.07.2026", 7100.0, "Оплата аренды реп. базы к концерту в ДЕПО")
    old = _tx("06.07.2026", 7100.0, ext=ece.ext_id("06.07.2026", 7100.0, row["naznach"]),
              desc="Оплата аренды реп. базы к концерту в ДЕПО — Sova реп база")
    to_add, to_fix, _se, skip_da, orphans = ece.plan_actions([row], [old])
    assert to_add == [] and skip_da == [] and orphans == []
    assert [(t["date"], f["date"]) for t, f in to_fix] == [("06.07.2026", "08.07.2026")]


def test_manual_payment_of_same_sum_does_not_block_own_row():
    """Чужой ручной платёж той же суммы в тот же день (две РАЗНЫЕ репбазы по 7 100)
    не должен глушить занос строки, у которой есть своя операция."""
    row = _row("08.07.2026", 7100.0, "Оплата аренды реп. базы к концерту в ДЕПО")
    own = _tx("06.07.2026", 7100.0, ext=ece.ext_id("06.07.2026", 7100.0, row["naznach"]),
              desc="Оплата аренды реп. базы к концерту в ДЕПО — Sova реп база")
    alien = _tx("08.07.2026", 7100.0, desc="оплата репбазы для репетиции крыши")
    to_add, to_fix, _se, skip_da, _o = ece.plan_actions([row], [own, alien])
    assert len(to_fix) == 1 and to_add == [] and skip_da == []


def test_unchanged_row_does_nothing():
    row = _row("08.07.2026", 7100.0, "Оплата репбазы")
    same = _tx("08.07.2026", 7100.0, ext=row["ext"], desc="Оплата репбазы")
    to_add, to_fix, skip_ext, skip_da, orphans = ece.plan_actions([row], [same])
    assert (to_add, to_fix, skip_ext, skip_da, orphans) == ([], [], 1, [], [])


def test_truly_new_row_is_added():
    row = _row("09.07.2026", 3000.0, "Новая трата")
    to_add, to_fix, _se, skip_da, _o = ece.plan_actions([row], [])
    assert len(to_add) == 1 and to_fix == [] and skip_da == []


# ── 9. выездной хор в ПГ — это B2B, а не внутренний ивент ──

def _sheet_row(art, naznach, project):
    return {"Статья расхода": art, "Назначение": naznach, "Проект": project}


def test_pg_choir_goes_to_b2b_category():
    """В таблице Оли такие строки лежат в «Концерты / квартирники» (= внутренние ивенты),
    хотя выручка по мероприятию идёт в B2B. Различаем по колонке «Проект»."""
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("Концерты / квартирники",
                                       "Оплата Сергею звукорежисееру за хор в ПГ 17.07",
                                       "хор в ПГ 17.07.26")) == "расходы на b2b-ивенты"
    # в назначении события не видно — проект всё равно относит строку к b2b
    assert osh.row_category(_sheet_row("Концерты / квартирники",
                                       "логистика команды: туда-обратно",
                                       "хор ВК The Birch")) == "расходы на b2b-ивенты"


def test_ordinary_concert_stays_internal():
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("Концерты / квартирники",
                                       "Оплата Насте (клавиши) за концерт 25.07",
                                       "концерт на крыше 25.07")) == "расходы на внутренние ивенты"


def test_lektsii_statya_still_maps_to_b2b():
    """«Лекции» (корпоративные МК — «МК по кринжу для команды ЩУКА») — b2b по самой статье."""
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("Лекции", "Оплата спикеру",
                                       "МК по кринжу для команды ЩУКА 09.07")) == "расходы на b2b-ивенты"


def test_vk_choir_is_b2b_too():
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("Концерты / квартирники",
                                       "Оплата Сергею Ционову за проведение хора ВК",
                                       "хор ВК The Birch")) == "расходы на b2b-ивенты"


def test_b2b_statya_from_sheet_wins():
    """Основной путь — статья «B2B ивенты» в таблице (заведена 25.07)."""
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("B2B ивенты", "Оплата гитаристу",
                                       "гитарник ВДНХ 28.08")) == "расходы на b2b-ивенты"


def test_project_marker_is_backup_when_statya_wrong():
    """Если новое выездное событие снова запишут в «Концерты» — ловим по проекту."""
    import olya_sheet as osh
    assert osh.row_category(_sheet_row("Концерты / квартирники", "доставка",
                                       "хор в ПГ 20.09.26")) == "расходы на b2b-ивенты"


# ── 6. «Рабочие руки»: площадка видна, только когда на ней заметная сумма ──

def _rr_data(rr):
    """Минимальный d для рендеров: интересует только «Рабочие руки».

    covered/need_live считаются так же, как в compute(): весь долг окна — выплаты людям.
    """
    covered, need_live = report.covered_by_platform(
        [{"date": "28.07.2026", "due": 162_200.0, "cat": "Зарплаты педагогов"}], rr)
    return dict(today=datetime.date(2026, 7, 27), free=1_572_100,
                detail=[("р/с Точка *1195", 1_371_267)], mm="07.2026", yest_in=0,
                due_buckets={"ЗП педагогов": 162_200},
                overdue=[], stale=[], underpaid=[], unalloc=[], unalloc_sum=0, fm_bad=[],
                next2w=[], ft_income=3_680_000, plan_real=4_362_000, crm_total=3_680_000,
                income_expect=[{"name": "1-1", "plan": 4_362_000.0, "got": 3_680_000.0,
                                "wait": 682_000.0, "risky": False}],
                plan_missing=False, remind_next=False, nm="08.2026", facts=[],
                # окно отчёта: verdict_line считает по живым деньгам (см. report.status)
                window_end=datetime.date(2026, 8, 10), due_sum=162_200,
                platform=rr, covered=covered, need_live=need_live,
                wait_win=680_000, wait_win_safe=500_000, next_plan_missing=False,
                after_window=[])


def test_platform_invisible_when_transit_is_noise():
    """6 737 ₽ на площадке при выплатах на 162 200 — не новость, а шум: ни строки о ней,
    ни вычета из «нужно живыми» (Наташа, 03.08.2026)."""
    d = _rr_data(6_737)
    assert d["need_live"] == 162_200          # мелкий остаток ничего не гасит
    for text in (report.render_pulse(d), report.render_full(d)):
        assert "площадк" not in text.lower()
        assert "Рабочие руки" not in text


def test_platform_shown_once_when_material():
    """598 887 под невыплаченную ЗП — это уже важно: гасит долг и называется один раз."""
    d = _rr_data(598_887)
    assert d["need_live"] == 0.0
    text = report.render_pulse(d)
    assert text.lower().count("площадк") == 1
