"""Чистые функции кассового прогноза: горизонт через границу года, колонки финмодели,
база ЗП педагогов. Баги найдены при разборе 29.07.2026 (Наташа: «мало затрат и мало педагогов»).

Все — без сети.
"""
import cashflow as cf
import plan_sync as ps


# ── шапка финмодели: год в строке 0, месяц в строке 1 ──

def _header(years=("26",) * 12 + ("27",) * 12, n_rows=60):
    """Финмодель-заглушка: колонки с 2-й = янв.26 … дек.27."""
    names = ["янв", "фев", "мар", "апр", "май", "июн",
             "июл", "авг", "сен", "окт", "ноя", "дек"]
    width = 2 + len(years)
    rows = [[""] * width for _ in range(n_rows)]
    for i, y in enumerate(years):
        rows[0][2 + i] = y
        rows[1][2 + i] = names[i % 12]
    return rows


def test_month_columns_maps_both_years():
    idx = cf.month_columns(_header())
    assert idx[(2026, 1)] == 2 and idx[(2026, 12)] == 13
    assert idx[(2027, 1)] == 14 and idx[(2027, 3)] == 16


def test_month_columns_breaks_mm_plus_1_magic():
    """Раньше колонка считалась как mm+1 — для 2027-го это молча брало чужой месяц 2026-го."""
    idx = cf.month_columns(_header())
    assert idx[(2027, 3)] != 3 + 1


def test_month_columns_ignores_junk_header_cells():
    rows = _header()
    rows[0][5] = "итого"          # мусорная колонка в шапке
    rows[1][5] = ""
    idx = cf.month_columns(rows)
    assert (2026, 4) not in idx and (2026, 5) in idx


# ── горизонт прогноза ──

def test_forecast_months_crosses_year_boundary():
    idx = cf.month_columns(_header())
    got = cf.forecast_months((2026, 7), idx, horizon=(2027, 3))
    assert got[0] == (2026, 7) and got[-1] == (2027, 3)
    assert len(got) == 9                       # июл.26 … мар.27 включительно
    assert (2026, 12) in got and (2027, 1) in got


def test_forecast_months_drops_months_absent_from_model():
    idx = cf.month_columns(_header(years=("26",) * 12))     # модель кончается на дек.26
    got, missing = cf.forecast_months((2026, 11), idx, horizon=(2027, 3), report_missing=True)
    assert got == [(2026, 11), (2026, 12)]
    assert missing == [(2027, 1), (2027, 2), (2027, 3)]


def test_forecast_months_at_least_reaches_horizon_when_model_allows():
    idx = cf.month_columns(_header())
    assert cf.forecast_months((2026, 12), idx, horizon=(2027, 3)) == [
        (2026, 12), (2027, 1), (2027, 2), (2027, 3)]


# ── значение строки модели по (год, месяц) ──

def test_pv_reads_year_aware_column():
    rows = _header()
    rows[21][14] = "1 000 000"        # янв.27
    rows[21][2] = "111"               # янв.26 — не должен подмениться
    idx = cf.month_columns(rows)
    assert cf.pv(rows, idx, [21], (2027, 1)) == 1_000_000
    assert cf.pv(rows, idx, [21], (2026, 1)) == 111


def test_pv_returns_zero_for_month_outside_model():
    rows = _header()
    idx = cf.month_columns(rows)
    assert cf.pv(rows, idx, [21], (2029, 5)) == 0.0


# ── ЗП педагогов: максимум из финмодели и расписания CRM ──

def test_teachers_val_takes_the_larger_base():
    """Решение Наташи 29.07.2026: модель занижала (сен: 919к против 1.79М по живому
    расписанию), а расписание занижает месяцы, которые ещё не продлили."""
    assert cf.teachers_val(919_200, 1_792_000) == 1_792_000
    assert cf.teachers_val(985_800, 1_243_000) == 1_243_000
    assert cf.teachers_val(1_394_700, 1_382_000) == 1_394_700


def test_teachers_val_survives_dead_crm():
    # CRM недоступна → проекция 0 → падаем на строку финмодели, а не на ноль
    assert cf.teachers_val(919_200, 0.0) == 919_200


# ── кредиты: график знает про год ──

def test_credit_rows_respect_year_in_until():
    rows_2026 = dict(cf.credit_rows((2026, 12)))
    rows_2027 = dict(cf.credit_rows((2027, 3)))
    for desc in rows_2027:
        assert desc in rows_2026 or True        # 2027 не может содержать закрытых в 2026
    # кредит с until=(2026,12) в марте 2027 платиться не должен
    assert ps.credit_active(2027, 3, None, (2026, 12)) is False


def _tbank():
    rows = [c for c in ps.CREDITS if "тбанк" in c[1].lower()]
    assert rows, "кредит ТБанка не заведён в plan_sync.CREDITS"
    return rows[0]


def test_tbank_credit_is_in_the_schedule():
    """Кредит ТБанка 1М (дог. 7145134903, 206 600/мес) уходил со счёта с марта 2026,
    но не был заведён ни в CREDITS, ни в кассовом прогнозе."""
    assert _tbank()[3] == 206_600


def test_tbank_credit_stops_after_early_payoff():
    """Закрыт досрочно 15.07.2026 («Полная оплата задолженности FULL», 166 500) деньгами
    беспроцентного займа 1,5М. Дальше платежа быть не должно."""
    _s, _d, _c, _a, _day, since, until = _tbank()
    assert until == (2026, 7)
    assert ps.credit_active(2026, 7, since, until) is True
    assert ps.credit_active(2026, 8, since, until) is False
    assert ps.credit_active(2027, 3, since, until) is False
    assert "Кредит ТБанк 1М (дог. 7145134903, закрыт 15.07)" not in dict(cf.credit_rows((2026, 8)))


# ── НДС отдельной строкой (в строке 40 финмодели его нет — там УСН+1%) ──

def test_nds_is_not_double_counted_with_plan_sync():
    """В plan_sync НДС лежит плоской оценкой 215к (для плановой операции в Финтабло),
    а касса считает его от выручки. Складывать оба — двойной счёт."""
    assert cf.monthly_since_total((2027, 3)) == 0
    assert all("ндс" not in d.lower() for d, _a in cf.monthly_since_rows((2027, 3)))


def test_quarter_helpers():
    assert cf.quarter_of((2026, 7)) == (2026, 3)
    assert cf.quarter_of((2026, 12)) == (2026, 4)
    assert cf.quarter_months((2026, 3)) == [(2026, 7), (2026, 8), (2026, 9)]
    assert cf.prev_quarter((2027, 1)) == (2026, 4)


def test_nds_not_charged_before_october():
    """Начисляется с июля (Q3), платится в следующем квартале → раньше октября платежа нет."""
    rev = lambda ym: 5_000_000
    assert cf.nds_for_month((2026, 8), rev) == 0.0
    assert cf.nds_for_month((2026, 9), rev) == 0.0
    assert cf.nds_for_month((2026, 10), rev) > 0


def test_nds_is_a_third_of_previous_quarter():
    """Окт–дек платим за Q3 (июл+авг+сен), янв–мар 2027 — за Q4 (окт+ноя+дек)."""
    q3 = {(2026, 7): 4_285_555, (2026, 8): 6_500_000, (2026, 9): 5_000_000}
    q4 = {(2026, 10): 5_500_000, (2026, 11): 7_000_000, (2026, 12): 4_500_000}
    rev = lambda ym: {**q3, **q4}.get(ym, 0)
    expected_q3 = 0.05 * sum(q3.values()) / 3
    for mo in (10, 11, 12):
        assert cf.nds_for_month((2026, mo), rev) == expected_q3
    expected_q4 = 0.05 * sum(q4.values()) / 3
    for mo in (1, 2, 3):
        assert cf.nds_for_month((2027, mo), rev) == expected_q4
    # плоские 215к занижали: квартал вырос с 13М до 15,8М
    assert expected_q3 > 215_000


def test_nds_counts_only_months_after_accrual_start():
    """В Q3-2026 под НДС попадает весь квартал, но если бы начисление стартовало
    с августа — июль в базу не входил бы."""
    rev = lambda ym: 6_000_000
    full = cf.nds_for_month((2026, 10), rev)
    part = cf.nds_for_month((2026, 10), rev, accrue_from=(2026, 8))
    assert part == full * 2 / 3


# ── УСН «доходы минус расходы» 15% ──

def _flat(income, expense):
    return (lambda ym: income), (lambda ym: expense)


def test_usn_paid_only_in_advance_months():
    """Авансы — апрель/июль/октябрь, годовой расчёт — март. В остальные месяцы 0."""
    inc, exp = _flat(1_000_000, 500_000)
    for mo in (1, 2, 5, 6, 8, 9, 11, 12):
        assert cf.usn_payment((2026, mo), inc, exp) == 0.0
    for mo in (3, 4, 7, 10):
        assert cf.usn_payment((2026, mo), inc, exp) > 0


def test_usn_advance_is_fifteen_percent_of_cumulative_profit():
    inc, exp = _flat(1_000_000, 600_000)
    # аванс за Q1 = 15% от (3М доходов − 1,8М расходов) = 180к
    assert cf.usn_payment((2026, 4), inc, exp) == 0.15 * (3_000_000 - 1_800_000)
    # за полугодие начислено 360к, из них 180к уже уплачено → доплата 180к
    assert cf.usn_payment((2026, 7), inc, exp) == 0.15 * (6_000_000 - 3_600_000) - 180_000


def test_usn_no_advance_while_cumulative_loss():
    """Пока прибыль нарастающим итогом в минусе, авансов нет — модель же размазывала
    6% с оборота по осени независимо от прибыли."""
    inc, exp = _flat(1_000_000, 1_200_000)
    for mo in (4, 7, 10):
        assert cf.usn_payment((2026, mo), inc, exp) == 0.0


def test_minimum_tax_applies_when_profit_is_thin():
    """Прибыль есть, но маленькая: 15% с неё (18к) меньше 1% с доходов (120к) → за год
    платим минимальный налог. Проверяем СУММУ всех платежей года: мартовский идёт за
    вычетом авансов, поэтому сам по себе минимальному налогу не равен."""
    inc, exp = _flat(1_000_000, 990_000)          # доходы 12М, прибыль 120к
    paid = sum(cf.usn_payment((2026, mo), inc, exp) for mo in (4, 7, 10))
    paid += cf.usn_payment((2027, 3), inc, exp)
    assert paid == 0.01 * 12_000_000
    assert paid > 0.15 * 120_000                   # минимальный налог больше обычного


def test_march_payment_is_net_of_advances():
    inc, exp = _flat(1_000_000, 500_000)
    adv9 = 0.15 * (9_000_000 - 4_500_000)
    year = max(0.15 * (12_000_000 - 6_000_000), 0.01 * 12_000_000)
    assert cf.usn_payment((2027, 3), inc, exp) == year - adv9


def test_usn_never_negative():
    """Убыточный конец года не должен превратиться в «возврат» и надуть кассу."""
    inc = lambda ym: 1_000_000 if ym[1] <= 6 else 100_000
    exp = lambda ym: 100_000 if ym[1] <= 6 else 2_000_000
    assert cf.usn_payment((2027, 3), inc, exp) >= 0
    assert cf.usn_payment((2026, 10), inc, exp) >= 0


def test_model_tax_row_is_excluded_from_lines():
    """Строку 40 модели заменили своим расчётом — она не должна попасть в расходы дважды."""
    assert cf.ROW_MODEL_TAX not in {r for _nm, rr, _bk in cf.ALL_LINES for r in rr}


# ── разбивка доходов ──

def test_revenue_sections_cover_every_income_row():
    """Разбивка доходов переехала на канон income_lines.LINES (девять строк), но её
    model_rows должны по-прежнему покрывать все 12 доходных строк финмодели РОВНО ПО
    ОДНОМУ разу: если строку забыли — она потеряется молча (ни в прогнозе, ни в фолбэке
    на модель её никто не увидит), а если строка задвоилась в двух строках канона —
    это двойной счёт той же выручки. Множество не ловит дубли, поэтому сверяем длины."""
    rows = [r for _nm, rr in cf.ALL_REV_LINES for r in rr]
    assert len(rows) == len(set(rows)) == 12
    assert set(rows) == {3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16}


def test_revenue_line_names_are_unique():
    """Имена строк канона должны быть уникальны, иначе в напечатанной разбивке окажутся
    две одинаковые подписи с разными числами (до канона так дублировалась «Аренда» —
    отдельно в B2C и в B2B)."""
    names = [nm for nm, _rr in cf.ALL_REV_LINES]
    assert len(names) == len(set(names))


def test_revenue_rows_are_layout_checked():
    """Строки доходов тоже под проверкой подписей — сдвиг не должен пройти молча."""
    for r in (3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16):
        assert r in cf.FM_EXPECT


def test_sections_cover_every_expense_row_of_the_model():
    """Разбивка по разделам должна покрывать ВСЕ расходные строки финмодели, кроме
    кредитов (стр.38, считаем явно) и итога (стр.45) — иначе «Не разнесено» перестанет
    быть нулём и часть костов молча уедет в неучтённые."""
    shown = {r for _nm, rr, _bk in cf.ALL_LINES for r in rr}
    income = {r for _nm, rr in cf.ALL_REV_LINES for r in rr}
    expected = set(cf.FM_EXPECT) - income - {cf.ROW_REVENUE, cf.ROW_PROFIT, cf.ROW_CREDITS,
                                             cf.ROW_TOTAL_OUT, cf.ROW_MODEL_TAX}
    assert shown == expected


def test_every_line_has_a_known_fact_bucket():
    """Бакет строки должен быть одной из OUT_LINES: по нему в текущем месяце гасится
    уже оплаченное. Опечатка в бакете = строка не уменьшится на оплаченное."""
    for nm, _rr, bk in cf.ALL_LINES:
        assert bk in cf.OUT_LINES, f"{nm}: бакет «{bk}» не из OUT_LINES"


def test_kharitonova_credit_ends_in_february_2027():
    """Последний платёж — февраль 2027 (Наташа, 29.07.2026): в марте его быть не должно."""
    feb = dict(cf.credit_rows((2027, 2)))
    mar = dict(cf.credit_rows((2027, 3)))
    ksy = [d for d in feb if "харитонов" in d.lower()]
    assert ksy, "кредит Харитоновой пропал из графика"
    assert ksy[0] not in mar


def test_loan_15m_runs_36_payments():
    """1,5М — 36 платежей по 70к с августа 2026 → последний июль 2029, в августе уже нет."""
    _s, _d, _c, amt, _day, since, until = next(c for c in ps.CREDITS if c[0] == "loan15m")
    assert (amt, since, until) == (70_000, (2026, 8), (2029, 7))
    n = sum(1 for y in (2026, 2027, 2028, 2029) for m in range(1, 13)
            if ps.credit_active(y, m, since, until))
    assert n == 36
    assert ps.credit_active(2029, 8, since, until) is False


def test_nazipov_interest_stops_when_principal_returns():
    """Проценты по займу Назипова идут до возврата тела (5 млн, строка 43 модели, март 2027):
    в марте проценты ещё платим, в апреле — уже нет."""
    _s, _d, _c, _a, _day, since, until = next(c for c in ps.CREDITS if c[0] == "proc_zaym")
    assert until == (2027, 3)
    assert ps.credit_active(2027, 3, since, until) is True
    assert ps.credit_active(2027, 4, since, until) is False


def test_all_credits_have_an_end_date():
    """Бессрочный кредит = касса вычитает платёж вечно. Все сроки собраны 29.07.2026."""
    assert ps.credits_without_end() == []


def test_credit_bucket_is_a_known_fact_line():
    """Итог раздела «Кредиты и займы» и строки под ним должны гаситься одним и тем же
    бакетом — иначе в текущем месяце итог не сходится с суммой строк."""
    assert "Проценты по кредитам/займам" in cf.OUT_LINES


def test_line_names_are_unique():
    names = [nm for nm, _rr, _bk in cf.ALL_LINES]
    assert len(names) == len(set(names))


def test_nds_base_share_scales_everything():
    rev = lambda ym: 6_000_000
    assert cf.nds_for_month((2026, 10), rev, base_share=0.5) == \
        cf.nds_for_month((2026, 10), rev) * 0.5


# ── выручка: прогноз (планки Финтабло) важнее финмодели ──

def test_revenue_prefers_fintablo_plan_over_model():
    """Прогноз Наташи (планки) важнее финмодели: 29.07.2026 договорились, что
    прогноз живёт в данных, а не в REVENUE_OVERRIDE."""
    model = {"uroki": 3_980_807.0, "b2b": 600_000.0}
    plan = {"uroki": 4_000_000.0, "b2b": 500_000.0}
    assert cf.pick_revenue(plan, model) == plan


def test_revenue_falls_back_to_model_when_no_plan():
    model = {"uroki": 3_980_807.0}
    assert cf.pick_revenue({}, model) == model


def test_revenue_override_is_empty_and_stays_empty():
    # хардкод перекрывал финмодель молча — расхождение доходило до 790к в месяц
    assert cf.REVENUE_OVERRIDE == {}


# ── revenue_breakdown: чистая функция, вынесенная из замыканий push() (ревью Task 4,
# находка 5 — прежде вся нетривиальная денежная логика проверялась только живым прогоном) ──

def test_revenue_breakdown_sums_to_total_for_future_month():
    """Строки канона должны сходиться с суммой на входе — ровно то свойство, которое
    rev_unassigned проверяет в push() (там сверяется с независимым источником, а не с
    самим собой — см. rev_unassigned; здесь же проверяем арифметику revenue_breakdown)."""
    src = {"uroki": 3_000_000.0, "gruppy": 300_000.0, "b2b": 500_000.0}
    total = sum(v for _n, v in cf.revenue_breakdown(src))
    assert total == 3_800_000.0


def test_revenue_breakdown_applies_rem_ratio_for_current_month():
    """Текущий (незакрытый) месяц гасится тем же REM_REV, что и «ВЫРУЧКА» в rev_val —
    иначе сумма строк разбивки разойдётся с показанным остатком."""
    src = {"uroki": 4_000_000.0, "b2b": 500_000.0}
    bd = dict(cf.revenue_breakdown(src, rem_ratio=0.5))
    assert bd["1-1"] == 2_000_000.0
    assert bd["B2B"] == 250_000.0
    assert sum(bd.values()) == 2_250_000.0


def test_revenue_breakdown_uses_model_fallback_end_to_end():
    """Сквозной путь plan_from_fintablo(пусто) → pick_revenue → revenue_breakdown: без
    планок разбивка берёт финмодель, а не нулевые планки."""
    model = {"uroki": 3_980_807.0}
    src = cf.pick_revenue({}, model)
    bd = dict(cf.revenue_breakdown(src))
    assert bd["1-1"] == 3_980_807.0
    assert bd["Групповые"] == 0.0


# ── текущий месяц: частичная планка + факт, её перекрывший, не должны рисовать
# отрицательную «ВЫРУЧКА» (ревью Task 4, Critical, второй раунд) ──

def test_current_month_revenue_never_negative_when_partial_plan_is_exceeded():
    """Реальный кейс с июлём: план занесён частично (три строки канона из девяти,
    1 107 000 ₽), а факт уже собрал больше (4 396 813 ₽). «Остаток к сбору» не может
    быть отрицательным — это неполная планка, а не кассовый разрыв. Коэффициент гашения
    строк разбивки должен при этом стать нулевым, а не отрицательным — иначе строки
    «ушли бы в минус вместе с шапкой» вместо честного нуля."""
    rem_rev, rem_ratio = cf.current_month_revenue(plan_total=1_107_000.0, collected=4_396_813.0)
    assert rem_rev == 0.0
    assert rem_ratio == 0.0
    bd = cf.revenue_breakdown({"hor": 200_000.0, "arenda": 600_000.0, "b2b": 307_000.0}, rem_ratio)
    assert sum(v for _n, v in bd) == rem_rev == 0.0


def test_current_month_revenue_scales_normally_when_plan_not_yet_collected():
    """Обычный случай (план не превышен) — поведение не поменялось: коэффициент гашения
    пропорционален собранной доле, и сумма строк разбивки по-прежнему равна остатку."""
    rem_rev, rem_ratio = cf.current_month_revenue(plan_total=4_000_000.0, collected=1_000_000.0)
    assert rem_rev == 3_000_000.0
    assert rem_ratio == 0.75
    bd = cf.revenue_breakdown({"uroki": 4_000_000.0}, rem_ratio)
    assert sum(v for _n, v in bd) == rem_rev == 3_000_000.0


# ── доход: движение своих денег ≠ выручка ──

def test_cash_line_deposit_return_is_not_income():
    """Возврат депозита — движение своих денег, не выручка. Из-за этого июль 2025
    по «Притоку» равен 37 746 766 при реальной выручке 1 626 328."""
    assert cf.cash_line("Возврат депозита", "income") == "__fin_in__"
    assert cf.cash_line("Проценты по депозиту", "income") == "__fin_in__"
    assert cf.cash_line("Перевод между счетами", "income") == "__fin_in__"
    assert cf.cash_line("Выручка b2c", "income") == "Приток"
    assert cf.cash_line("Выручка b2b", "income") == "Приток"
