"""Месячный итог Марса: что случилось в закрывшемся месяце и с чем входим в новый.

Шлётся 1-го числа в 08:30 Екб, перед пульсом, и САМ снимает доходные планки закрывшегося
месяца — раньше это делал отдельный таймер в 09:30. Из-за разнесённых по времени шагов
отчёт и снятие могли разъехаться, а ручной прогон снятия накануне вечером (31.07.2026,
18:13) оставлял отчёт без плана: планки уже удалены, сравнивать не с чем.

Порядок внутри: считаем план↔факт (планки ещё на месте) → журнал → удаление → отправка.
"""
import datetime
import json
import os

import core
import income_lines as il

# Слаги, на которых план заводился до переименования канона. Алиасы в канон намеренно
# не заводим: abonementy обратно на uroki+gruppy не разделяется (см. plan_sync.py:501).
_KNOWN_SLUGS = {l.slug for l in il.LINES}


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


def retire(ops, commit, delete, journal=None):
    """Снять доходные планки закрывшегося месяца.

    Журнал пишется строго ДО мутаций: если упадём посреди удаления, из него видно, что
    восстанавливать. Без --commit не трогаем ничего — это живые деньги.
    """
    if not commit:
        return {"retired": 0, "dry_run": True}
    if journal:
        journal(ops)
    for o in ops:
        delete(o["id"])
    return {"retired": len(ops), "dry_run": False}


def has_legacy(summary):
    """Есть ли в месяце план на слагах, которых уже нет в каноне."""
    return any(s not in _KNOWN_SLUGS and v.get("plan") for s, v in summary.items())


def by_category(summary):
    """План и факт по B2C/B2B — единственный срез, который сходится при легаси-слагах:
    abonementy, uroki и gruppy все лежат в B2C, поэтому итог категории не врёт."""
    known = {l.slug: l for l in il.LINES}
    out = {"B2C": {"plan": 0.0, "fact": 0.0}, "B2B": {"plan": 0.0, "fact": 0.0}}
    for slug, v in summary.items():
        line = known.get(slug)
        cat = "B2B" if (line and line.category == il.CAT_B2B) or slug == "b2b" else "B2C"
        out[cat]["plan"] += v.get("plan", 0.0)
        out[cat]["fact"] += v.get("fact", 0.0)
    return out


def income_vs_plan(summary):
    """Доходы: прогноз ↔ факт по строкам канона.

    При легаси-слагах построчная Δ врёт (план на abonementy, факт на uroki — одни и те же
    деньги выглядят как −2 400 000 и +2 907 064), поэтому сводим по категориям.
    """
    if has_legacy(summary):
        rows = [f"  {k}: план {money(v['plan'])} / факт {money(v['fact'])} / "
                f"Δ {money(v['fact'] - v['plan'])}"
                for k, v in by_category(summary).items() if v["plan"] or v["fact"]]
        return ("<b>Доходы: прогноз ↔ факт</b>\n" + "\n".join(rows) +
                "\n  ⚠️ план заводился на старых слагах, построчно не сравнить")
    rows = []
    for l in il.LINES:
        v = summary.get(l.slug)
        if not v or (not v.get("plan") and not v.get("fact")):
            continue
        rows.append(f"  {l.name}: план {money(v['plan'])} / факт {money(v['fact'])} / "
                    f"Δ {money(v['fact'] - v['plan'])}")
    return "<b>Доходы: прогноз ↔ факт</b>\n" + "\n".join(rows)


def outcome_vs_plan(fact_lines):
    """Расходы месяца по строкам ДДС, от большего к меньшему."""
    rows = [f"  {k} — {money(v)}" for k, v in sorted(fact_lines.items(), key=lambda z: -z[1])]
    return "<b>Расходы по строкам</b>\n" + "\n".join(rows)


# Порог, ниже которого движение считаем плато, а не трендом.
_FLAT = 0.05
# Доля выручки, съеденная обслуживанием долга, с которой это стоит проговорить.
_DEBT_ALARM = 0.10


def _prev_month(mm, back=1):
    mo, y = int(mm[:2]), int(mm[3:])
    for _ in range(back):
        mo -= 1
        if mo == 0:
            mo, y = 12, y - 1
    return f"{mo:02d}.{y}"


def with_journal_plan(mm, summary):
    """Подставить план из журнала снятия, если живых планок уже нет.

    Обычно месячный отчёт читает план до снятия, но планки могли снять раньше срока
    вручную (31.07.2026, 18:13) — тогда план нулевой, и весь факт выглядит как рост.
    Журнал пишется перед удалением, поэтому в нём план сохранился.
    """
    path = journal_path(mm)
    if not os.path.exists(path):
        return summary
    try:
        with open(path, encoding="utf-8") as f:
            saved = (json.load(f) or {}).get("summary") or {}
    except (OSError, ValueError):
        return summary
    out = {k: dict(v) for k, v in summary.items()}
    for slug, v in saved.items():
        row = out.setdefault(slug, {"plan": 0.0, "fact": 0.0, "delta": 0.0})
        if not row.get("plan") and v.get("plan"):
            row["plan"] = v["plan"]
            row["delta"] = row.get("fact", 0.0) - row["plan"]
    return out


def how_are_we_doing(revenue, mm, plan_misses, outcome_lines, fin_in=0.0):
    """Выводы по механическим правилам — отчёт считает, а не сочиняет.

    Сравнения идут по выручке b2c+b2b: по «Притоку» июль 2025 равен 37,7 млн из-за
    возврата депозита, и любой вывод на этой базе был бы мусором (см. cashflow.cash_line).
    """
    out = []
    tri = [revenue.get(x) for x in (_prev_month(mm, 2), _prev_month(mm, 1), mm)]
    if all(tri):
        chain = " → ".join(money(x) for x in tri)
        # «подряд» — значит монотонно. Ряд 4 206 969 → 4 197 958 → 4 441 859 ростом
        # подряд не является: в середине был спад, это колебание вокруг уровня.
        if (max(tri) - min(tri)) / max(tri) <= _FLAT:
            out.append(f"• Третий месяц на плато: {chain}")
        elif tri[0] < tri[1] < tri[2]:
            out.append(f"• Рост третий месяц подряд: {chain}")
        elif tri[0] > tri[1] > tri[2]:
            out.append(f"• Спад третий месяц подряд: {chain}")
        else:
            direction = "выше" if tri[2] > tri[0] else "ниже"
            out.append(f"• Выручка колеблется, месяц закрылся {direction} трёхмесячного начала: {chain}")
    prev_year = f"{mm[:2]}.{int(mm[3:]) - 1}"
    same = revenue.get(prev_year)
    if same and revenue.get(mm):
        out.append(f"• Год к году {100 * revenue[mm] / same - 100:+.0f}% "
                   f"({prev_year} — {money(same)})")
    ytd = sum(v for k, v in revenue.items() if k[3:] == mm[3:] and k[:2] <= mm[:2])
    ytd_prev = sum(v for k, v in revenue.items()
                   if k[3:] == str(int(mm[3:]) - 1) and k[:2] <= mm[:2])
    if ytd_prev:
        out.append(f"• С начала года {money(ytd)} против {money(ytd_prev)} "
                   f"за те же месяцы — в {ytd / ytd_prev:.1f} раза")
    for name, fact, plan in plan_misses:
        out.append(f"• {name} второй месяц ниже плана: {money(fact)} из {money(plan)}")
    # Не пересказываем блок расходов («аренда 30% расходов» и так видно), а считаем
    # то, чего в нём нет: сколько выручки съели расходы и обслуживание долга.
    total = sum(outcome_lines.values())
    rev = revenue.get(mm) or 0
    if total and rev:
        out.append(f"• Расходы съели {100 * total / rev:.0f}% выручки "
                   f"({money(total)} из {money(rev)})")
    debt = sum(v for k, v in outcome_lines.items()
               if "кредит" in k.lower() or "займ" in k.lower() or "процент" in k.lower())
    if rev and debt / rev >= _DEBT_ALARM:
        out.append(f"• На обслуживание долга ушло {100 * debt / rev:.0f}% выручки — {money(debt)}")
    # Кредиты и займы в выручку не входят (см. cash_line), но именно они могли закрыть
    # месяц: в июле 2026 операционно −290 412 при кредите 1,5 млн 15.07. Молчать об этом
    # нельзя — иначе «расходы съели 107% выручки» выглядит как терпимая арифметика.
    if fin_in > 0:
        gap = rev - total
        if gap < 0:
            out.append(f"• Месяц закрыт заёмными: пришло {money(fin_in)} кредитом или займом, "
                       f"а операционно минус {money(-gap)} — без займа платить было бы нечем")
        else:
            out.append(f"• Из денег месяца {money(fin_in)} — заёмные (кредит или заём), "
                       f"это не выручка; операционно месяц дал {money(gap)}")
    return "<b>Как идём</b>\n" + "\n".join(out)


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


def margin_block(pnl_by_month, months):
    """Маржинальность направлений за три месяца — в процентах от выручки направления.

    Проценты, а не рубли: «1-1 — 5 030 610» не говорит, эффективно ли направление,
    а «68%» говорит. Сумма остаётся рядом, чтобы был виден масштаб.

    Окно трёхмесячное намеренно: за один месяц события и B2B дают случайный разброс,
    и направление, которое просто не проводило мероприятий, выглядело бы убыточным.
    """
    agg, rev = {}, {}
    for m in months:
        pnl = pnl_by_month.get(m) or {}
        for d, v in pnl.get("margin", {}).items():
            agg[d] = agg.get(d, 0.0) + v
        for d, v in pnl.get("revenue", {}).items():
            rev[d] = rev.get(d, 0.0) + v
    if not agg:
        return ""
    def _pct(d):
        r = rev.get(d, 0.0)
        return agg[d] / r if r else None
    rows = []
    # без выручки процент не считается (направление ничего не продало) — таких вниз
    for d in sorted(agg, key=lambda z: (_pct(z) is None, -(_pct(z) or 0))):
        p = _pct(d)
        share = f"{100 * p:.0f}%" if p is not None else "нет выручки"
        rows.append(f"  {d} — {share} ({money(agg[d])})")
    span = f"{_MONTHS_RU_NOM[int(months[0][:2]) - 1]}–{_MONTHS_RU_NOM[int(months[-1][:2]) - 1]}"
    return f"<b>Маржа по направлениям ({span})</b>\n" + "\n".join(rows)


def big_upcoming(ops, nm, threshold=300_000):
    """Крупные выплаты нового месяца, которые РЕАЛЬНО предстоят.

    Берём остаток долга (`due`), а не исходную сумму планки: планка — оценка, и если она
    уже погашена фактом (ЗП педагогов выплачена 31.07 и разнесена), в список ей не место.
    Раньше блок строился по сырым плановым операциям и показывал оплаченное.
    """
    rows = []
    for o in sorted(ops, key=lambda z: (z["date"][3:], z["date"][:2])):
        due = o.get("due", o["value"])
        if o["date"][3:] == nm and due >= threshold:
            rows.append((o["date"][:5], o["cat"], due, o["cat"]))
    return rows


def mark_platform(rows, platform):
    """Разметить, какие предстоящие выплаты уже обеспечены остатком площадки.

    Гасим по порядку дат и уменьшаем остаток: иначе одна и та же сумма помечала бы
    каждую планку ЗП, и две пометки по 598 887 читались бы как 1,2 млн покрытия.
    """
    import report as rp
    out, left = [], platform
    for when, what, value, cat in rows:
        hint = ""
        if left >= rp.PLATFORM_MIN and rp.platform_hint(cat, left):
            take = min(left, value)
            hint = f" (из них {money(take)} уже на площадке)"
            left -= take
        out.append((when, what, value, hint))
    return out


def render(d):
    """Месячный итог одним сообщением. Порядок блоков: итог → доходы → расходы →
    как идём → маржа → с чем входим → техническая строка про снятые планки."""
    mm = d["mm"]
    prev_year = f"{mm[:2]}.{int(mm[3:]) - 1}"
    same = d["revenue_by_month"].get(prev_year)
    yoy = f" · год к году {100 * d['revenue'] / same - 100:+.0f}%" if same else ""
    ahead = "\n".join(f"  {when} — {what} {money(v)}{hint}"
                      for when, what, v, hint in d["big_ahead"])
    return "\n\n".join(filter(None, [
        f"📊 <b>Марс · итоги {_MONTHS_RU_GEN[int(mm[:2]) - 1]} {mm[3:]}</b>\n"
        f"Выручка {money(d['revenue'])} · расходы {money(d['outcome'])} · "
        f"разница {money(d['revenue'] - d['outcome'])}{yoy}",
        income_vs_plan(d["summary"]),
        outcome_vs_plan(d["outcome_lines"]),
        how_are_we_doing(d["revenue_by_month"], mm, d["plan_misses"], d["outcome_lines"],
                         d.get("fin_in", 0.0)),
        margin_block(d["pnl"], d["months"]),
        f"<b>С чем входим в новый месяц</b>\n"
        f"  Касса {money(d['free'])}, на площадке {money(d['platform'])}\n"
        f"  План доходов {money(d['plan_next'])}\n"
        f"  Крупное впереди:\n{ahead}" if ahead else
        f"<b>С чем входим в новый месяц</b>\n"
        f"  Касса {money(d['free'])}, на площадке {money(d['platform'])}\n"
        f"  План доходов {money(d['plan_next'])}",
        f"Снято доходных планок за {mm}: {d['retired']}",
    ]))


def journal_path(mm):
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "exports",
                        "income_retire_%s.json" % mm.replace(".", "-"))


def write_journal(mm, summary, ops):
    path = journal_path(mm)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump({"month": mm, "summary": summary, "deleted": ops},
                  f, ensure_ascii=False, indent=1)
    return path


def _revenue_by_month(cats, years):
    """Выручка по месяцам за несколько лет — база всех сравнений.

    Считается по «Притоку» уже ПОСЛЕ фикса cash_line: возврат депозита и переводы между
    счетами туда не попадают, иначе июль 2025 равнялся бы 37,7 млн вместо 1,6 млн.
    """
    import cashflow as cf
    res = {}
    for y in years:
        for t in core.ft_transactions(year=y, group="income"):
            if t.get("isPlan"):
                continue
            if cf.cash_line(cats.get(t.get("categoryId"), ""), "income") != "Приток":
                continue
            mm = t.get("factMonth")
            if mm:
                res[mm] = res.get(mm, 0.0) + float(t.get("value") or 0)
    return res


def _plan_misses(summary_prev, summary):
    """Строки дохода, недобравшие план два месяца подряд — тянущийся недобор, не разовый."""
    out = []
    for l in il.LINES:
        cur, prev = summary.get(l.slug), (summary_prev or {}).get(l.slug)
        if not cur or not prev:
            continue
        if cur.get("plan") and cur["fact"] < cur["plan"] and prev.get("plan") and prev["fact"] < prev["plan"]:
            out.append((l.name, cur["fact"], cur["plan"]))
    return out


def build(today, commit=False):
    """Собрать месячный итог за закрывшийся месяц и (при commit) снять его планки.

    Данные читаются ДО снятия — иначе план исчезнет и сравнивать будет не с чем.
    """
    import cashflow as cf
    import margins as mg
    import plan_sync as ps
    import report as rp

    mm = _prev_month(core.month_key(today))     # закрывшийся месяц
    year = int(mm[3:])
    cats = core.ft_categories()

    all_income = core.ft_transactions(year=year, group="income")
    summary = with_journal_plan(mm, ps.income_summary(mm, all_income))
    ops = ps.income_plans(all_income, mm)
    summary_prev = ps.income_summary(_prev_month(mm), core.ft_transactions(
        year=int(_prev_month(mm)[3:]), group="income"))

    revenue_by_month = _revenue_by_month(cats, sorted({year, year - 1}))
    outcome_lines = {}
    for t in core.ft_transactions(year=year, group="outcome"):
        if t.get("isPlan") or t.get("factMonth") != mm:
            continue
        line = cf.cash_line(cats.get(t.get("categoryId"), ""), "outcome")
        outcome_lines[line] = outcome_lines.get(line, 0.0) + float(t.get("value") or 0)

    # заёмные поступления месяца: в выручку не входят, но месяц могли закрыть именно они
    fin_in = 0.0
    for t in core.ft_transactions(year=year, group="income"):
        if t.get("isPlan") or t.get("factMonth") != mm:
            continue
        if cf.cash_line(cats.get(t.get("categoryId"), ""), "income") == "__fin_in__":
            fin_in += float(t.get("value") or 0)

    months = [_prev_month(mm, 2), _prev_month(mm, 1), mm]
    try:
        pnl = mg.assemble(months)
    except Exception as e:                      # маржа тянет CRM — отчёт важнее неё
        print(f"⚠ маржа не посчиталась: {e}")
        pnl = {}

    free, _ = cf.balances()
    nm = core.month_key(today)
    plan_next = sum(rp.plan_income_map(core.ft_transactions(group="income"), nm).values())
    platform = cf.platform_balance()
    # Матчим план↔факт, иначе в «Крупное впереди» попадает уже оплаченное: ЗП педагогов
    # выплачена 31.07 и разнесена, а планка 03.08 всё равно висела как предстоящая.
    out_ops = rp.plan_ops_outcome(cats)
    cls = rp.classify_unpaid(out_ops, rp.fact_ops_outcome(cats=cats), today,
                             core.month_key(today), today + datetime.timedelta(days=45))
    big_ahead = [(when, rp.expense_bucket(cat), due, raw)
                 for when, cat, due, raw in big_upcoming(cls["unpaid"], nm)]

    res = retire(ops, commit, delete=lambda i: core.ftmut("DELETE", f"/v1/transaction/{i}"),
                 journal=lambda o: write_journal(mm, summary, o))
    return render(dict(
        mm=mm, revenue=revenue_by_month.get(mm, 0.0), outcome=sum(outcome_lines.values()),
        summary=summary, outcome_lines=outcome_lines, revenue_by_month=revenue_by_month,
        fin_in=fin_in,
        pnl=pnl, months=months, free=free, platform=platform,
        plan_next=plan_next, big_ahead=mark_platform(big_ahead[:5], platform),
        retired=res["retired"],
        plan_misses=_plan_misses(summary_prev, summary)))
