#!/usr/bin/env python3
"""Финотчёт Марса для владельца: состояние → хватит ли до границы окна → что делать.

Горизонт у всех форматов один — скользящее окно WINDOW_DAYS дней. Месячная ретроспектива
живёт отдельным продуктом (`report.py month` 1-го числа), в ежедневный отчёт не лезет:
раньше пульс считал на две недели, а полный — на месяц, и читатель держал в голове две
системы координат (см. docs/report-tighten-2026-08-03.md).

Три формата, но в день уходит один (плюс флаг, если совсем плохо):
  • pulse — короткий пульс, будни
  • full  — понедельник: тот же пульс плюс календарь по дням и арифметика окна.
            Шлётся ВМЕСТО пульса, а не вдобавок — иначе вердикт и кассы дублируются.
  • flag  — только когда разрыв не закрывает даже надёжный приход (см. status)

Данные:
  Доходы: разбивка — из CRM (AlfaCRM, все доходы туда заносит Оля), контрольный итог — Финтабло.
  Расходы: факт — Финтабло; «ещё платить» — плановые операции (isPlan=1) месяца БЕЗ совпавшего факта.
           Матчинг план↔факт — КАСКАД ПО КОНТРАГЕНТАМ (см. match_cascade): A) partnerId, B) фамилия из
           описания, C) точная сумма (статья «Неразнесенное списание» — джокер), C2) единственная планка
           статьи (сумма не критерий), D) агрегаты по сумме фактов статьи. Проматченные
           выпадают; непогашенное с прошедшей датой — «просрочено», недоплата агрегата — «оплачено X из Y».
  Свободный остаток = счета Финтабло МИНУС «Рабочие руки» (транзит под самозанятых, уже выплаченное).

Запуск:
  python3 report.py [pulse|full|auto] [--send] [--date дд.мм.гггг]
    auto (дефолт) = пульс каждый день; по понедельникам — полный; + флаг при риске.
    --send  = отправить в Телеграм (креды из .env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID); без него — печать в консоль.
"""
import sys, os, re, datetime, calendar, urllib.request, urllib.parse, json
import core
import margins as m
import cashflow as cf
import matching
import olya_sheet
import income_lines as il
from matching import (match_cascade, classify_unpaid, settled, parse_dmy,  # noqa: F401
                      _lead_token, _norm, _is_aggregate, _is_joker, _tokens,
                      MATCH_TOL_PCT, MATCH_TOL_ABS, MATCH_WINDOW_DAYS,
                      C2_COVER_LO, C2_COVER_HI, SETTLED_MIN_COVER)

# ── структура доходов (без сумм!): суммы плана читаются из плановых операций Финтабло ──
# Оставлено прежним именем: на INCOME_LINES завязаны блоки отчёта ниже.
# Кортеж (slug, name, dirs, type, risky) заменён на Line — поля читаются по имени.
INCOME_LINES = il.LINES
OB1_LUMP = 610_000         # Оборотный-1 (наличными): планировали закрыть разом — но касса не тянет
WINDOW_DAYS = 14           # горизонт отчёта: скользящие две недели, одинаковые в любой день месяца
# «Рабочие руки» — транзитный счёт под самозанятых: деньги уже ушли со счетов, но выплата
# ещё не разнесена, и планка висит непогашенной. Порог управляет И текстом, И арифметикой:
# ниже него площадки для отчёта не существует — она не вычитается из «нужно живыми» и нигде
# не упоминается, поэтому «разрыв» всегда сходится с «выплаты − на счетах», без невидимых
# поправок. Прежний порог 1 000 ₽ делал строку постоянной: 03.08.2026 отчёт трижды упомянул
# 6 737 ₽ при выплатах на 2,5 млн (Наташа: «зачем эта инфа»).
PLATFORM_MIN = 50_000


def money(x):
    return f"{round(x):,}".replace(",", " ")


def mln(x):
    return f"{x/1_000_000:.2f}".replace(".", ",") + " млн"


def short_acc(name):
    n = (name or "").lower()
    if "налич" in n:
        return "Наличные"
    if "заначк" in n:
        return "Заначка"
    if "точка" in n:
        return "Точка"
    return name


def pay_schedule(next2w, limit=None):
    """[(дд.мм, сумма, 'статьи')] по дням из списка плановых операций next2w."""
    by_day = {}
    for o in next2w:
        by_day.setdefault(o["date"], []).append(o)
    rows = []
    for dt in sorted(by_day, key=parse_dmy):
        ops = by_day[dt]
        s = sum(o.get("due", o["value"]) for o in ops)
        labels = sorted({expense_bucket(o["cat"]) for o in ops})
        rows.append((dt[:5], s, ", ".join(labels)))
    return rows[:limit] if limit else rows


def _scan_income():
    """Все приходы года (ВКЛЮЧАЯ плановые isPlan=1 — фильтруем на месте)."""
    return core.ft_transactions(group="income")


def plan_income_map(all_income, mm):
    """{slug: сумма плана} из плановых income-операций месяца mm (externalId plansync:income:MM.YYYY:slug)."""
    prefix = f"plansync:income:{mm}:"
    res = {}
    for t in all_income:
        ext = t.get("externalId") or ""
        if t.get("isPlan") and ext.startswith(prefix):
            res[ext[len(prefix):]] = res.get(ext[len(prefix):], 0.0) + float(t.get("value") or 0)
    return res


def has_income_plan(all_income, mm):
    prefix = f"plansync:income:{mm}:"
    return any(t.get("isPlan") and (t.get("externalId") or "").startswith(prefix) for t in all_income)


_OUTCOME_CACHE = []


def _scan_outcome():
    """Все расходные операции года (план и факт вперемешку). Кэш: за прогон нужен дважды."""
    if not _OUTCOME_CACHE:
        _OUTCOME_CACHE.extend(core.ft_transactions(group="outcome"))
    return _OUTCOME_CACHE


ft_partners = core.ft_partners


def plan_ops_outcome(cats=None):
    """Плановые расходные операции (isPlan=1) года — вход каскада матчинга.

    Планкам «События Оли» подставляем человека из колонки «Контрагент» её таблицы: в
    «Назначении» он назван как придётся («Вове (бас)», «Саше Б»), и каскад по описанию его
    не узнаёт — выплата висела в «просрочено», хотя деньги ушли. Работает и для планок,
    занесённых до того, как мы стали писать partnerId. Таблица недоступна — матчим как раньше.
    """
    cats = cats or core.ft_categories()
    ops = [matching.plan_op(t, cats) for t in _scan_outcome() if t.get("isPlan")]
    try:
        kontr = olya_sheet.kontr_by_ext(olya_sheet.fetch_rows())
    except Exception:
        return ops
    for o in ops:
        if o.get("ext") in kontr:
            o["kontr"] = kontr[o["ext"]]
    return ops


def fact_ops_outcome(partners=None, cats=None):
    """Фактические (НЕ план) расходные операции года — для матчинга с планом."""
    cats = cats or core.ft_categories()
    partners = partners if partners is not None else core.ft_partners()
    return [matching.fact_op(t, cats, partners) for t in _scan_outcome() if not t.get("isPlan")]


def expense_bucket(cat):
    n = cat.lower()
    if "педагог" in n:
        return "ЗП педагогов"
    if "зарплат" in n or "взнос" in n or "админ" in n:
        return "ЗП бэк-офис + админы"
    if "% по займу" in n or "кредит" in n or "процент" in n or "займ" in n:
        return "проценты по кредитам"
    if "бухгалт" in n:
        return "бухгалтерия"
    if "типограф" in n:
        return "типография"
    if "ивент" in n or "мероприят" in n or "съем" in n or "съём" in n:
        return "события (Оля)"
    return cat


def expected_in_window(plan_month, got_month, today, window_end, plan_next=0.0):
    """Сколько прихода ждём внутри окна — равномерно по ОСТАВШИМСЯ дням месяца.

    Горизонт доходов обязан совпадать с горизонтом расходов: сравнивать выплаты за
    15 дней с месячным планом 6 050 000 нельзя, к 15-му весь месячный приход не случится.
    Делим по оставшимся дням, а не по всем: иначе в конце месяца остаток плана терялся бы.
    Равномерность — сознательное упрощение (абонементы идут волной в начале месяца),
    поэтому в первой половине месяца оценка консервативна.
    """
    last = calendar.monthrange(today.year, today.month)[1]
    eom = datetime.date(today.year, today.month, last)
    left_month = max(0.0, plan_month - got_month)
    days_left = (eom - today).days + 1
    days_in = (min(window_end, eom) - today).days + 1
    got = left_month * days_in / days_left if days_left > 0 else 0.0
    if window_end > eom and plan_next > 0:
        nxt = eom + datetime.timedelta(days=1)
        got += plan_next * (window_end - eom).days / calendar.monthrange(nxt.year, nxt.month)[1]
    return got


# Статьи, которые платятся через площадку «Рабочие руки»: только их гасит её остаток.
_PLATFORM_CATS = ("педагог", "подрядчик", "рабочие руки")


def platform_hint(cat, platform):
    """Пометка к планке, деньги под которую уже лежат на площадке.

    Выплату могли сделать и не занести в Финтабло (31.07.2026 педагогам заплатили,
    операции нет) — тогда планка висит непогашенной и выглядит будущим платежом.
    Скрывать её нельзя (мы не знаем наверняка), но и выдавать за бесспорный долг тоже.

    В ежедневном отчёте больше не зовётся (площадка упоминается один раз, в вердикте) —
    остаётся ради месячного итога, см. report_month.mark_platform.
    """
    if platform < PLATFORM_MIN or not any(k in _norm(cat) for k in _PLATFORM_CATS):
        return ""
    return f" (из них {money(platform)} уже на площадке)"


def covered_by_platform(due_window, platform):
    """Сколько из предстоящих выплат уже лежит на площадке и сколько нужно живыми.

    Остаток площадки гасит только выплаты людям — аренду с него не заплатишь.
    Мелкий остаток не гасит ничего: см. PLATFORM_MIN.
    """
    total = sum(o["due"] for o in due_window)
    if platform < PLATFORM_MIN:
        return 0.0, total
    payable = sum(o["due"] for o in due_window
                  if any(k in _norm(o["cat"]) for k in _PLATFORM_CATS))
    covered = min(platform, payable)
    return covered, max(0.0, total - covered)


def build_income_expect(pinc, crm, crm_other):
    """Доходы построчно по канону: план (из Финтабло) / факт CRM / сколько ещё ждём.

    Факт — через il.crm_fact_for (канон), а не самодельной суммой по l.crm_dirs: у
    prochee/podpiska crm_dirs пуст, «Прочее» получает факт только через ветку other
    (доход CRM вне направлений — мерч, внешние мероприятия). Самодельная сумма по
    crm_dirs всегда возвращала 0 для этих строк (см. находку #3 финального ревью).
    Чистая функция — без сети, тестируем напрямую."""
    res = []
    for l in INCOME_LINES:
        p = pinc.get(l.slug, 0.0)
        got = il.crm_fact_for(l, crm, crm_other)
        res.append({"name": l.name, "plan": p, "got": got,
                    "wait": max(0.0, p - got), "risky": l.risky})
    return res


def compute(today):
    """Один расчёт → dict со всеми числами для любого рендера."""
    mm = core.month_key(today)
    cats = core.ft_categories()

    # 1. остатки
    free_total, detail = cf.balances()
    platform = cf.platform_balance()

    # 2. доходы
    all_income = _scan_income()                       # все приходы года (вкл. план)
    inc_tx = [t for t in all_income if not t.get("isPlan")]
    pinc = plan_income_map(all_income, mm)            # {slug: сумма плана} из Финтабло
    plan_missing = not pinc                           # плана на месяц нет вовсе
    plan_b2c = sum(pinc.get(l.slug, 0.0) for l in INCOME_LINES if l.category == il.CAT_B2C)
    plan_b2b = sum(pinc.get(l.slug, 0.0) for l in INCOME_LINES if l.category == il.CAT_B2B)
    plan_real = plan_b2c + plan_b2b
    # напоминание: с 25-го числа, если на следующий месяц плана доходов ещё нет
    nm = core.next_month_key(today)
    remind_next = today.day >= 25 and not has_income_plan(all_income, nm)
    # разметку финмодели сверяем по подписям строк: поехавший лист молча портит Кассу и
    # План-факт, и об этом нельзя молчать, даже когда сам отчёт из финмодели ничего не берёт.
    fm_bad = cf.check_plan_layout(cf.read_plan())
    token = m.alfa_login()
    # income_with_other (не income_by_direction!) — второе значение это доход CRM ВНЕ
    # направлений (мерч, внешние мероприятия), без него строка «Прочее» канона всегда
    # получала факт 0 (см. находку #3 финального ревью: crm_dirs у prochee/podpiska пуст,
    # факт по этим строкам кладётся через il.crm_fact_for, а не через crm_dirs напрямую).
    crm, crm_other = m.income_with_other(mm, token)
    crm_total = sum(crm.values()) + crm_other
    yest = (today - datetime.timedelta(days=1)).strftime("%d.%m.%Y")
    ft_income = sum(float(t.get("value") or 0) for t in inc_tx
                    if t.get("factMonth") == mm and cf.cash_line(cats.get(t.get("categoryId"), ""), "income") == "Приток")
    # «вчера пришло» — пульс идёт утром, за сегодня ещё почти ничего нет; берём полный прошлый день
    yest_in = sum(float(t.get("value") or 0) for t in inc_tx
                  if t.get("date") == yest and cf.cash_line(cats.get(t.get("categoryId"), ""), "income") == "Приток")
    # доходы построчно: от чего / план (из Финтабло) / пришло по CRM-направлениям / сколько ещё ждём
    income_expect = build_income_expect(pinc, crm, crm_other)

    # 3. расходы: факт + непроматченные плановые (матчинг план↔факт, см. classify_unpaid)
    # Плановая операция попадает в «ещё платить», только если у неё НЕТ совпавшего факта.
    # Проматченные (в т.ч. оплаченные досрочно, дата ещё впереди) выпадают; непроматченные
    # с прошедшей датой — «просрочено, не вижу оплаты» (остаются в сумме, показываем отдельно).
    ops = plan_ops_outcome(cats)
    facts = fact_ops_outcome(cats=cats)
    # Окно отчёта — скользящие WINDOW_DAYS дней. Раньше границей была первая выплата
    # педагогам следующего месяца, из-за чего 1-го числа окно скачком растягивалось
    # с 4 дней до 32, а «нужно заплатить» прыгало с 715 000 до 4 276 862.
    window_end = today + datetime.timedelta(days=WINDOW_DAYS)
    cls = classify_unpaid(ops, facts, today, mm, window_end)
    overdue = cls["overdue"]
    overdue_sum = sum(o["due"] for o in overdue)
    stale = cls["stale"]                                # непогашенное с ПРОШЛЫХ месяцев
    stale_sum = sum(o["due"] for o in stale)
    next2w = cls["next2w"]                              # календарь выплат на 2 недели (без проматченных)
    due_window = cls["due_window"]
    due_sum = sum(o["due"] for o in due_window)
    underpaid = cls["underpaid"]                        # оплачено меньше плана (план — оценка)
    due_buckets = {}
    for o in due_window:
        due_buckets[expense_bucket(o["cat"])] = due_buckets.get(expense_bucket(o["cat"]), 0.0) + o["due"]
    # часть выплат окна уже лежит на площадке — живыми деньгами нужно только остальное
    covered, need_live = covered_by_platform(due_window, platform)
    # неразнесённые списания месяца (статья-помойка) — список для ручного разнесения в Финтабло
    unalloc = sorted([f for f in facts if f.get("factMonth") == mm and "неразнесен" in _norm(f.get("cat"))],
                     key=lambda f: parse_dmy(f["date"]))
    unalloc_sum = sum(f["value"] for f in unalloc)

    # 4. приход в окне — два сценария (Олины события/b2b вероятностны, могут не случиться)
    # приход внутри окна: горизонт доходов обязан совпадать с горизонтом расходов
    plan_next_total = sum(plan_income_map(all_income, nm).values())
    got_all = sum(r["got"] for r in income_expect)
    plan_all = sum(r["plan"] for r in income_expect)
    safe_plan = sum(r["plan"] for r in income_expect if not r["risky"])
    safe_got = sum(r["got"] for r in income_expect if not r["risky"])
    wait_win = expected_in_window(plan_all, got_all, today, window_end, plan_next_total)
    wait_win_safe = expected_in_window(safe_plan, safe_got, today, window_end)
    # план следующего месяца заводится 25-го: до этого окно за границей месяца пусто
    _eom = datetime.date(today.year, today.month, calendar.monthrange(today.year, today.month)[1])
    next_plan_missing = window_end > _eom and plan_next_total == 0

    return dict(today=today, mm=mm, free=free_total, detail=detail,
                plan_real=plan_real, crm_total=crm_total, ft_income=ft_income, yest_in=yest_in,
                overdue=overdue, overdue_sum=overdue_sum, stale=stale, stale_sum=stale_sum,
                fm_bad=fm_bad, next2w=next2w,
                income_expect=income_expect, due_buckets=due_buckets, due_sum=due_sum,
                window_end=window_end, after_window=cls["after_window"],
                platform=platform, covered=covered, need_live=need_live,
                wait_win=wait_win, wait_win_safe=wait_win_safe,
                next_plan_missing=next_plan_missing,
                underpaid=underpaid, unalloc=unalloc, unalloc_sum=unalloc_sum,
                facts=facts,
                plan_missing=plan_missing, remind_next=remind_next, nm=nm)


def status(d):
    """Три ступени вместо «красный / не красный».

    Раньше красным считалось любое «живых денег меньше выплат окна». Но «на счетах не
    хватает, приход закрывает» — обычное состояние школы в начале месяца, а не авария
    (Наташа, 03.08.2026), и флаг в этом случае кричал впустую, дублируя первую строку
    пульса. Красный остаётся на случай, когда не закрывает даже надёжный приход.

    Ожидаемый приход в зелёный не входит: иначе 1-го числа отчёт рапортовал «спокойно»
    на нулевом факте, считая весь месячный план уже пришедшим.
    """
    if d["free"] >= d["need_live"]:
        return "green"
    return "tight" if d["free"] + d["wait_win_safe"] >= d["need_live"] else "red"


def is_red(d):
    return status(d) == "red"


# ─────────────────────────── РЕНДЕРЫ (Telegram HTML) ───────────────────────────
def overdue_block(d):
    """Блок «просрочено, не вижу оплаты» — планки месяца с прошедшей датой без факта (по контрагенту).
    Для агрегатов показывает частичное покрытие «оплачено X из Y». Пусто, если таких нет."""
    if not d["overdue"]:
        return ""
    rows = []
    for o in d["overdue"]:
        b = expense_bucket(o["cat"])
        if o.get("aggregate") and o.get("paid", 0) > 1e-6:
            rows.append(f"• {b} — ещё {money(o['due'])} (оплачено {money(o['paid'])} из {money(o['value'])}, было на {o['date'][:5]})")
        elif o.get("paid", 0) > 1e-6:      # частичный платёж <50% — долг всё равно висит, но платёж виден
            rows.append(f"• {b} — {money(o['due'])} из {money(o['value'])} (видна частичная оплата {money(o['paid'])} ₽, было на {o['date'][:5]})")
        else:
            rows.append(f"• {b} — {money(o['due'])} (было на {o['date'][:5]})")
    return (f"\n\n⚠️ <b>Просрочено, не вижу оплаты ({money(d['overdue_sum'])}):</b>\n" + "\n".join(rows)
            + "\n<i>Матчим по контрагенту; если платили под другим именем/статьёй — проверь вручную.</i>")


def stale_block(d):
    """Непогашенные планки ПРОШЛЫХ месяцев. Раньше они нигде не показывались: «Просрочено»
    фильтруется по текущему месяцу, и старый долг просто выпадал из поля зрения."""
    if not d["stale"]:
        return ""
    rows = "\n".join(f"• {o['date'][:5]} — {expense_bucket(o['cat'])} — {money(o['due'])}"
                     for o in d["stale"][:10])
    more = f"\n… и ещё {len(d['stale']) - 10}" if len(d["stale"]) > 10 else ""
    return (f"\n\n🕰 <b>Висит с прошлых месяцев ({money(d['stale_sum'])}):</b>\n{rows}{more}"
            "\n<i>Либо оплачено под другим контрагентом, либо план так и не сбылся — проверь.</i>")


def fm_warning(d):
    """Финмодель поехала — оптимистичный план не читается (молчать тут нельзя)."""
    if not d.get("fm_bad"):
        return ""
    return "\n\n⚠️ <b>Финмодель поехала</b> — " + "; ".join(d["fm_bad"]) + " (план из неё не беру)"


def underpaid_block(d):
    """Планки-люди, где факт оказался меньше плана (план — грубая оценка). Не долг, а сверка."""
    if not d["underpaid"]:
        return ""
    def _row(o):
        who = _lead_token(o["desc"]).title()
        tag = f" ({who})" if who else ""
        return f"• {expense_bucket(o['cat'])}{tag} — оплачено {money(o['paid'])} из плановых {money(o['value'])}"
    rows = "\n".join(_row(o) for o in d["underpaid"])
    return f"\n\n💸 <b>Оплачено меньше плана (план — оценка, не долг):</b>\n{rows}"


def unalloc_block(d):
    """Факты месяца в статье-помойке «Неразнесенное списание» — списком, чтобы разнести в Финтабло."""
    if not d["unalloc"]:
        return ""
    rows = "\n".join(f"• {f['date'][:5]} — {money(f['value'])} — {(f['desc'] or '').strip()[:45]}"
                     for f in d["unalloc"])
    return (f"\n\n🗂 <b>Не разнесено по статьям ({money(d['unalloc_sum'])} ₽) — зайди в Финтабло и разнеси:</b>\n{rows}")


# Сколько дней назад ищем комиссию без выплаты: дальше смысла нет, Оля разносит быстрее.
_WATCHDOG_DAYS = 10


def unposted_block(facts, today):
    """Комиссия площадки прошла, а выплаты в тот же день нет — операцию не занесли.

    Пока выплата не разнесена, её плановая планка висит непогашенной, и отчёт требует
    те же деньги второй раз (31.07.2026: комиссия 11 969 есть, выплаты 700 000 нет).
    """
    lo = today - datetime.timedelta(days=_WATCHDOG_DAYS)
    by_day = {}
    for f in facts:
        d = parse_dmy(f["date"])
        if not (lo <= d <= today):
            continue
        n = _norm(f.get("cat"))
        # Выплата опознаётся по описанию, а не по статье: через площадку платят и с
        # «Расходов на внутренние ивенты» (27.07.2026), и с «Зарплаты педагогов» —
        # общая метка только одна, «(Рабочие руки)» в назначении платежа.
        via_platform = "рабоч" in _norm(f.get("desc")) or any(k in n for k in _PLATFORM_CATS)
        kind = ("fee" if "комисс" in n and "рабоч" in n
                else "pay" if via_platform else None)
        if kind:
            by_day.setdefault(f["date"], {})
            by_day[f["date"]][kind] = by_day[f["date"]].get(kind, 0.0) + f["value"]
    rows = [f"• {day[:5]} — прошла комиссия {money(v['fee'])}, а выплаты не вижу"
            for day, v in sorted(by_day.items(), key=lambda z: parse_dmy(z[0]))
            if v.get("fee") and not v.get("pay")]
    if not rows:
        return ""
    return "\n\n💡 <b>Похоже, выплата не занесена в Финтабло:</b>\n" + "\n".join(rows)


def verdict_line(d):
    """Три ступени: хватает живыми / впритык, закрываем приходом / не закрывает и приход.

    Ожидаемый приход намеренно не растворяется в главной цифре — иначе в первый день
    месяца отчёт писал «спокойно, остаётся 3 165 103» при 1,4 млн на счетах и нулевом
    факте, потому что считал весь месячный план уже пришедшим.
    """
    if d["plan_missing"]:
        return ("⚪ <b>Прогноз неполный.</b> Плана доходов на этот месяц ещё нет — не могу оценить кассу. "
                "Пришли цифры от Оли и Блиновой, занесу и пересчитаю.")
    till = d["window_end"].strftime("%d.%m")
    # площадка упоминается ровно здесь и только когда на ней лежит заметная сумма
    plat = f", из них {money(d['covered'])} уже на площадке" if d["covered"] else ""
    tail = ("\n⚠️ Плана на следующий месяц ещё нет — приход за границей месяца не учтён."
            if d.get("next_plan_missing") else "")
    head = (f"На счетах {money(d['free'])}, выплаты до {till} — {money(d['due_sum'])}{plat}.")
    st = status(d)
    if st == "green":
        return (f"🟢 <b>Живых денег хватает.</b> {head} "
                f"Остаётся {money(d['free'] - d['need_live'])}.{tail}")
    gap = d["need_live"] - d["free"]
    after = d["free"] + d["wait_win_safe"] - d["need_live"]
    if st == "tight":
        return (f"🟡 <b>Впритык — закрываем приходом.</b> {head} Живыми не хватает {money(gap)}, "
                f"но к {till} ждём надёжных {money(d['wait_win_safe'])} — "
                f"закрывается, остаётся {money(after)}.{tail}")
    return (f"🔴 <b>Не хватает даже с приходом.</b> {head} Живыми не хватает {money(gap)}, "
            f"надёжного прихода к {till} ждём {money(d['wait_win_safe'])} — "
            f"всё равно не хватит {money(-after)}.{tail}")


_MONTHS_RU = ("января", "февраля", "марта", "апреля", "мая", "июня",
              "июля", "августа", "сентября", "октября", "ноября", "декабря")


def income_block(d):
    """Доходы месяца. В первый день — одной строкой: построчные нули не несут информации,
    деньги приходят каждый день начиная со второго (Наташа, 01.08.2026)."""
    if d["plan_missing"]:
        return ("⚠️ <b>Плана доходов на месяц нет</b> — жду цифры от Оли и Блиновой\n"
                f"(за месяц пришло {money(d['ft_income'])})")
    if d["today"].day == 1:
        return (f"<b>Доходы {_MONTHS_RU[d['today'].month - 1]}:</b> пришло {money(d['crm_total'])} "
                f"из {money(d['plan_real'])} — месяц только начался")
    rows = []
    for r in d["income_expect"]:
        # строка без плана и без факта информации не несёт: «Онлайн-подписка — 0% ✅»
        # стояла каждый день, хотя клуб раньше октября не запустится. Из канона строку не
        # убираем (она заведена на вырост, см. income_lines) — просто не печатаем, пока
        # по ней нет ни плана, ни денег: появится план — вернётся сама.
        if not r["plan"] and not r["got"]:
            continue
        pct = round(100 * r["got"] / r["plan"]) if r["plan"] else 0
        risk = " ⚠️" if r["risky"] else ""
        rows.append(f"• {r['name']} — {pct}% · ждём {money(r['wait'])}{risk}"
                    if r["wait"] > 0 else f"• {r['name']} — {pct}% ✅")
    got_total = sum(r["got"] for r in d["income_expect"])
    plan_total = sum(r["plan"] for r in d["income_expect"])
    pct_all = round(100 * got_total / plan_total) if plan_total else 0
    wait = sum(r["wait"] for r in d["income_expect"])
    return f"<b>Доходы: {pct_all}% плана · ждём {money(wait)}</b>\n" + "\n".join(rows)


def accounts_block(d):
    """Кассы и свободный остаток. Транзитной площадки здесь нет: она упоминается один
    раз, в вердикте, и только когда на ней лежит заметная сумма (см. PLATFORM_MIN)."""
    accs = "\n".join(f"• {short_acc(n)} — {money(b)}" for n, b in sorted(d["detail"], key=lambda z: -z[1]))
    yest = f"  (вчера пришло +{money(d['yest_in'])})" if d["yest_in"] else ""
    return f"<b>Кассы:</b>\n{accs}\nСвободно: <b>{money(d['free'])}</b>{yest}"


def payments_block(d):
    """Выплаты окна по корзинам расходов."""
    pay = "\n".join(f"• {n} — {money(v)}"
                    for n, v in sorted(d["due_buckets"].items(), key=lambda z: -z[1])) or "• нет"
    return f"<b>Выплаты до {d['window_end'].strftime('%d.%m')} ({money(d['due_sum'])}):</b>\n{pay}"


def after_window_block(d):
    """Первые платежи сразу за границей окна — окно фиксированной длины режет по живому,
    и крупный платёж через день после границы должен быть виден.

    Агрегируем по корзинам, как основной блок: шесть отдельных планок «ЗП бэк-офис»
    подряд (03.08.2026) — это не информация, а обои.
    """
    if not d["after_window"]:
        return ""
    when = d["after_window"][0]["date"][:5]
    agg = {}
    for o in d["after_window"]:
        b = expense_bucket(o["cat"])
        agg[b] = agg.get(b, 0.0) + o["due"]
    rows = ", ".join(f"{n} {money(v)}" for n, v in sorted(agg.items(), key=lambda z: -z[1]))
    return f"\n\n<b>Следом за окном ({when}):</b> {rows}"


def remind_block(d):
    return (f"\n\n⚠️ <b>Через несколько дней {d['nm'][:2]} месяц</b> — плана доходов ещё нет, пришли цифры"
            if d["remind_next"] else "")


def problem_blocks(d):
    """Всё, что требует вмешательства. Общее для пульса и полного — иначе разъезжается."""
    return (f"{overdue_block(d)}{stale_block(d)}{underpaid_block(d)}{unalloc_block(d)}"
            f"{unposted_block(d['facts'], d['today'])}{fm_warning(d)}{remind_block(d)}")


def render_pulse(d):
    return (
        f"💰 <b>Марс · {d['today'].strftime('%d.%m')}</b>\n"
        f"{verdict_line(d)}\n\n"
        f"{accounts_block(d)}\n\n"
        f"{income_block(d)}\n\n"
        f"{payments_block(d)}"
        f"{after_window_block(d)}"
        f"{problem_blocks(d)}"
    )


def runway_block(d):
    """Арифметика окна в четыре строки: с чем входим, что придёт, что уходит, что остаётся.

    Раньше на этом месте стояла таблица «когда кончатся деньги» до конца месяца — второй
    горизонт в том же отчёте. Считаем по тому же окну, что и всё остальное.

    ВАЖНО: неявная склейка соседних f-строк происходит ДО вызова .replace(), поэтому
    «.replace(",", " ")» внутри такой склейки чистил запятые во ВСЁМ тексте выше
    («1,38 млн» → «1 38 млн»). Числа форматируем заранее через money().
    """
    till = d["window_end"].strftime("%d.%m")
    free, safe = money(d["free"]), money(d["wait_win_safe"])
    need = money(d["need_live"])
    left = money(d["free"] + d["wait_win_safe"] - d["need_live"])
    risky = d["wait_win"] - d["wait_win_safe"]
    extra = (f"\nПлюс до {money(risky)}, если случатся события и B2B — на них не рассчитываем."
             if risky > 1000 else "")
    plat = (f"\n<i>Из выплат окна {money(d['covered'])} уже лежит на площадке «Рабочие руки» "
            f"— в строке ниже их нет.</i>" if d["covered"] else "")
    return (
        f"<b>🛬 Хватит ли до {till}</b>{plat}\n"
        f"<pre>"
        f" {free:>11}   сейчас на счетах\n"
        f"+{safe:>11}   надёжный приход\n"
        f"-{need:>11}   выплаты окна\n"
        f"------------\n"
        f" {left:>11}   остаётся к {till}\n"
        f"</pre>{extra}"
    )


def todo_block(d):
    """Что делать — по состоянию, а не константой: постоянный список замыливается."""
    rows = []
    if status(d) != "green":
        rows.append("Следить за приходами — они и есть разница между «хватает» и «впритык».")
        rows.append("Если приходы отстают — придержать события Оли, статья гибкая.")
    rows.append(f"Оборотный {OB1_LUMP // 1000}к — гасим досрочно, но частями: "
                "кидать свободную наличку по мере появления, разом касса не тянет.")
    return "<b>Что делать</b>\n" + "\n".join(f"{i}. {r}" for i, r in enumerate(rows, 1))


def render_full(d):
    """Понедельничный разбор. Шлётся ВМЕСТО пульса, поэтому содержит всё, что и он,
    плюс календарь по дням и арифметику окна."""
    cal = "\n".join(f"— {dt}  <b>{money(s)}</b>  {lbl}"
                    for dt, s, lbl in pay_schedule(d["next2w"])) or "— нет"
    return (
        f"💰 <b>Марс · деньги на {core.ru_date(d['today'])}</b>\n"
        f"{verdict_line(d)}\n\n"
        f"{accounts_block(d)}\n\n"
        f"{income_block(d)}\n\n"
        f"{payments_block(d)}\n\n"
        f"<b>По дням:</b>\n{cal}"
        f"{after_window_block(d)}\n\n"
        f"{runway_block(d)}"
        f"{problem_blocks(d)}\n\n"
        f"{todo_block(d)}"
    )


def render_flag(d):
    """Уходит только на красном (см. status): на «впритык» он дублировал вердикт пульса."""
    till = d["window_end"].strftime("%d.%m")
    short = d["need_live"] - d["free"] - d["wait_win_safe"]
    return (
        f"🔴 <b>Марс · КРАСНЫЙ ФЛАГ {d['today'].strftime('%d.%m')}</b>\n"
        f"Выплаты до {till} — {money(d['need_live'])} живыми. На счетах {money(d['free'])}, "
        f"надёжного прихода к этому сроку ждём {money(d['wait_win_safe'])} — "
        f"не хватает <b>{money(short)}</b>.\n"
        f"Само не закроется: ускорять приходы или двигать выплаты."
    )


def build_messages(d, mode="auto"):
    """Какие сообщения уходят в этот день. Чистая функция — тестируется без сети.

    В `auto` за раз уходит ОДНО сообщение: по понедельникам полный ВМЕСТО пульса, а не
    вдобавок. Раньше слали оба, и вердикт, кассы и «не разнесено по статьям» приходили
    дважды подряд слово в слово. Флаг добавляется только на красном.
    """
    if mode != "auto":
        return [(mode, {"pulse": render_pulse, "full": render_full, "flag": render_flag}[mode](d))]
    msgs = [("full", render_full(d)) if d["today"].weekday() == 0 else ("pulse", render_pulse(d))]
    if is_red(d):
        msgs.append(("flag", render_flag(d)))
    return msgs


# ─────────────────────────── ОТПРАВКА ───────────────────────────
def send_telegram(text):
    tok = os.environ.get("TELEGRAM_BOT_TOKEN"); chat = os.environ.get("TELEGRAM_CHAT_ID")
    if not tok or not chat:
        raise RuntimeError("нет TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID в .env")
    data = urllib.parse.urlencode({"chat_id": chat, "text": text, "parse_mode": "HTML",
                                   "disable_web_page_preview": "true"}).encode()
    r = urllib.request.urlopen(f"https://api.telegram.org/bot{tok}/sendMessage", data=data, timeout=20)
    return json.load(r).get("ok")


def main():
    args = sys.argv[1:]
    mode = "auto"
    send = "--send" in args
    date = datetime.date.today()
    for i, a in enumerate(args):
        if a in ("pulse", "full", "flag", "auto", "month"):
            mode = a
        if a == "--date" and i + 1 < len(args):
            date = parse_dmy(args[i + 1])
    # месячный итог не считает пульс: у него свой сбор данных и он сам снимает планки
    if mode == "month":
        import report_month
        text = report_month.build(date, commit="--commit" in args)
        if send:
            print(f"[month] отправлено: ok={send_telegram(text)}")
        else:
            print(f"\n===== month =====\n{text}\n")
        return

    d = compute(date)

    for name, text in build_messages(d, mode):
        if send:
            ok = send_telegram(text)
            print(f"[{name}] отправлено: ok={ok}")
        else:
            print(f"\n===== {name} =====\n{text}\n")


if __name__ == "__main__":
    main()
