"""Прогноз закрытого месяца в листе «План-факт»: живые планки Финтабло против
журнала снятия (exports/income_retire_MM-YYYY.json). Без сети — плановые планки
мокаются через monkeypatch, журнал читается из tmp_path, а не из настоящего exports/.
"""
import json
import plan_fact as pf
import income_lines as il


def _write_journal(tmp_path, mm, year, plan_by_slug):
    """Кладёт в tmp_path/exports/income_retire_{mm}-{year}.json журнал в формате,
    который пишет plan_sync.py --retire-income (см. income_lines.plan_from_fintablo
    и plan_sync.income_summary — summary всегда {slug: {plan, fact, delta}})."""
    d = tmp_path / "exports"
    d.mkdir(exist_ok=True)
    summary = {slug: {"plan": v, "fact": 0.0, "delta": -v} for slug, v in plan_by_slug.items()}
    (d / ("income_retire_%s-%s.json" % (mm, year))).write_text(
        json.dumps({"month": "%s.%s" % (mm, year), "summary": summary}), encoding="utf-8")


# --- _retired_plan_total: чтение журнала само по себе ---

def test_retired_plan_total_returns_none_when_no_journal(tmp_path):
    """Журнала нет вовсе — None, а не исключение и не 0."""
    assert pf._retired_plan_total("07", 2026, base=str(tmp_path)) is None


def test_retired_plan_total_returns_none_on_broken_json(tmp_path):
    """Файл есть, но это не JSON — та же ветка except ValueError, ничего не падает."""
    d = tmp_path / "exports"
    d.mkdir()
    (d / "income_retire_07-2026.json").write_text("{не json совсем", encoding="utf-8")
    assert pf._retired_plan_total("07", 2026, base=str(tmp_path)) is None


def test_retired_plan_total_returns_none_without_summary_key(tmp_path):
    """Журнал без ключа summary — не похож на тот, что пишет retire (там summary
    есть всегда), значит файл чужой или недописанный. Выбор: None, а не 0.0 — иначе
    это неотличимо от настоящего нулевого плана. Врать про «план 0» опаснее, чем
    честно оставить месяц без прогноза, поэтому осторожный вариант — как «файла нет»."""
    d = tmp_path / "exports"
    d.mkdir()
    (d / "income_retire_07-2026.json").write_text(json.dumps({"month": "07.2026"}), encoding="utf-8")
    assert pf._retired_plan_total("07", 2026, base=str(tmp_path)) is None


def test_retired_plan_total_sums_all_slugs_including_legacy(tmp_path):
    """Сумма идёт по ВСЕМ слагам в summary, включая легаси (koncert/syomki/abonementy,
    см. Task 5) — иначе деньги легаси-планок опять потерялись бы из итога."""
    _write_journal(tmp_path, "07", 2026, {"hor": 200_000.0, "koncert": 275_000.0})
    assert pf._retired_plan_total("07", 2026, base=str(tmp_path)) == 475_000.0


# --- _forecast_total: живые планки против журнала ---

def test_forecast_total_prefers_live_plan_over_journal(tmp_path, monkeypatch):
    """Главный тест: живые планки ЕСТЬ и журнал ЕСТЬ одновременно — побеждают живые
    планки, а не журнал. Без этого теста устаревший журнал может однажды перебить
    заново заведённый на месяц прогноз."""
    monkeypatch.setattr(il, "plan_from_fintablo", lambda mm, ai=None: {"uroki": 500_000.0})
    _write_journal(tmp_path, "07", 2026, {"uroki": 4_052_000.0})
    got = pf._forecast_total("07", 2026, [], base=str(tmp_path))
    assert got == 500_000.0


def test_forecast_total_falls_back_to_journal_when_no_live_plan(tmp_path, monkeypatch):
    """Живых планок нет совсем (пустой словарь — обычно потому, что месяц закрылся
    и retire их снял) — прогноз берём из журнала."""
    monkeypatch.setattr(il, "plan_from_fintablo", lambda mm, ai=None: {})
    _write_journal(tmp_path, "07", 2026, {"uroki": 2_907_064.0, "hor": 200_000.0,
                                          "arenda": 600_000.0, "b2b": 307_000.0,
                                          "koncert": 275_000.0, "syomki": 270_000.0,
                                          "abonementy": 2_400_000.0})
    got = pf._forecast_total("07", 2026, [], base=str(tmp_path))
    assert got == 6_959_064.0


def test_forecast_total_zero_sum_live_plan_is_not_replaced_by_journal(tmp_path, monkeypatch):
    """Пограничный случай из ре-ревью: живые планки ЕСТЬ (словарь не пуст), но в сумме
    дают 0 — это тоже факт («планки завели заново, пока нулевые»), не «данных нет».
    Журнал подставлять нельзя: иначе устаревшее число из retire молча перекроет
    текущий, честный ноль."""
    monkeypatch.setattr(il, "plan_from_fintablo", lambda mm, ai=None: {"uroki": 0.0})
    _write_journal(tmp_path, "07", 2026, {"uroki": 4_052_000.0})
    got = pf._forecast_total("07", 2026, [], base=str(tmp_path))
    assert got == 0.0


def test_forecast_total_no_live_plan_and_no_journal_is_zero(tmp_path, monkeypatch):
    """Ни планок, ни журнала — прогноз 0.0 (месяц не выпадает из листа, но и не
    выдумывает число)."""
    monkeypatch.setattr(il, "plan_from_fintablo", lambda mm, ai=None: {})
    got = pf._forecast_total("07", 2026, [], base=str(tmp_path))
    assert got == 0.0


# --- build_forecast_and_delta_rows: ряд «Δ факт−прогноз» (находка #5 финального ревью) ---

def test_delta_row_is_fact_minus_forecast():
    """Δ = факт Финтабло минус прогноз (планки), не наоборот — положительное число
    значит «пришло больше прогноза», отрицательное — «недобор»."""
    plan_row, delta_row = pf.build_forecast_and_delta_rows(
        ["07", "08"], {"07"}, {"07": 4_399_718.0}, forecast_fn=lambda mm: 4_052_000.0)
    assert plan_row == [4_052_000, ""]
    assert delta_row == [347_718, ""]


def test_delta_row_empty_for_month_without_fact():
    """Месяц без факта (mm not in have) — пустая ячейка и в прогнозе, и в Δ, как
    везде в листе, а не 0 (0 выглядел бы как «пришло ровно по прогнозу»)."""
    plan_row, delta_row = pf.build_forecast_and_delta_rows(
        ["09"], set(), {}, forecast_fn=lambda mm: 1_000.0)
    assert plan_row == [""]
    assert delta_row == [""]


def test_delta_row_negative_when_fact_below_forecast():
    plan_row, delta_row = pf.build_forecast_and_delta_rows(
        ["01"], {"01"}, {"01": 2_848_414.0}, forecast_fn=lambda mm: 3_000_000.0)
    assert delta_row == [-151_586]
