#!/usr/bin/env python3
"""Лист «План-факт»: план из финмодели Марса ↔ факт из Финтабло, по месяцам (весь год).

План — финмодель (янв–апр = факт, май+ = план). Факт — Финтабло.
Плановые строки суммирую так, чтобы совпадали по составу с фактом
(напр. «ЗП бэк-офис» = бэк + фаундеры + админы + аутсорс).

Запуск: python3 plan_fact.py          # записать лист «План-факт»
        python3 plan_fact.py --dry    # только посчитать и напечатать
"""
import os, io, csv, json, urllib.request
import gspread
import core
import cashflow as cf
import income_lines as il
import margins

HERE = os.path.dirname(os.path.abspath(__file__))
SA_FILE = os.path.join(HERE, "gsheets_sa.json")
SHEET_ID = "1C0z2ONr-QrLozztfTCjkFqBteKLZyxFW5yg9RIhbLvA"
FM_ID = "1orFwTHu_qKAagxd3eMU7LyaFsVNkt94gf03QFIZwO4A"
FM_GID = "2015808034"

# метрика: (имя, [строки финмодели для суммы плана], функция факта, «больше=лучше»)
METRICS = [
    ("Выручка",        [18],            lambda f: f.get("Приток", 0.0), True),
    ("ЗП педагогов",   [21],            lambda f: f.get("ЗП педагогов", 0.0), False),
    ("Аренда и помещение", [23, 30, 41], lambda f: f.get("Аренда и помещение", 0.0), False),
    ("ЗП бэк+админы+фаундеры+аутсорс", [31, 32, 35], lambda f: f.get("ЗП бэк-офиса", 0.0), False),
    ("Итого расходы",  [45],            lambda f: sum(f.get(l, 0.0) for l in cf.OUT_LINES), False),
    # строка 50 «Прибыль (Выручка − Расходы)». Раньше стояла 49 — это «Заемные средства»,
    # пустая строка: план прибыли всегда выходил 0, а Δ = весь факт.
    ("Прибыль",        [cf.ROW_PROFIT], lambda f: f.get("Приток", 0.0) - sum(f.get(l, 0.0) for l in cf.OUT_LINES), True),
]
MONTHS12 = [f"{mm:02d}" for mm in range(1, 13)]
YEAR = core.FIN_YEAR
_num = core.num


def income_block(mm, all_income, fact_total, token=None):
    """[(подпись, значение)] — Δ факт−план по строкам канона за месяц mm + «мимо CRM».

    Последняя строка — «мимо CRM»: факт Финтабло минус сумма факта по CRM. Может быть
    отрицательной (возвраты). Прятать её нельзя: иначе детализация молча не сойдётся
    с итогом (на момент написания спеки 31.07.2026 — 148 484 ₽ мимо CRM за июль; число
    живое, в проверке на реальных данных может отличаться — движения в CRM/Финтабло
    продолжаются).

    В лист «План-факт» (main() ниже) это Δ не идёт: там по строкам канона показан
    факт, а не Δ (план закрытых месяцев после снятия планок живёт только в журнале
    exports/income_retire_*.json, а не в живых планках — Δ по строке из живых планок
    было бы бессмысленно). Функция остаётся тестируемым чистым вычислением реконсиляции
    «мимо CRM» и Δ на будущее (напр. для отчёта за конкретный месяц).
    """
    plan = il.plan_from_fintablo(mm, all_income)
    fact = il.crm_fact(mm, token)
    rows = []
    for l in il.LINES:
        rows.append((l.name, fact.get(l.slug, 0.0) - plan.get(l.slug, 0.0)))
    rows.append(("мимо CRM", fact_total - sum(fact.values())))
    return rows


def _retired_plan_total(mm, year=None, base=None):
    """Сумма прогноза месяца mm (MM) из журнала снятия доходных планок закрывшегося
    месяца — единственное, что остаётся от прогноза, когда живых планок уже нет.

    exports/income_retire_{mm}-{year}.json — plan_sync.py --retire-income пишет его
    ДО удаления живых планок закрывшегося месяца (см. docs/income-canon-design-
    2026-07-31.md). il.plan_from_fintablo() для такого месяца всегда вернёт пусто —
    планки удалены, а факт CRM/Финтабло уже накопился.

    None в трёх случаях: файла нет, JSON битый, либо в файле нет ключа "summary".
    Первые два — очевидно «данных нет», лист не должен падать. Третий — журнал без
    summary не похож на тот, что пишет retire (там summary есть всегда): это чужой
    или недописанный файл, и трактовать отсутствие ключа как «план 0» было бы враньём
    не отличимым от настоящего нулевого плана — безопаснее считать, что прогноза нет
    вовсе, как при отсутствующем файле.

    year/base — только для тестов (произвольный год и каталог вместо HERE/YEAR,
    чтобы не трогать настоящий exports/, см. test_plan_fact.py); в проде вызывается
    без них.
    """
    y = YEAR if year is None else year
    jpath = os.path.join(base or HERE, "exports", "income_retire_%s-%s.json" % (mm, y))
    try:
        with open(jpath, encoding="utf-8") as jf:
            data = json.load(jf)
    except (OSError, ValueError):
        return None
    if "summary" not in data:
        return None
    summary = data["summary"] or {}
    return sum(float(s.get("plan") or 0.0) for s in summary.values())


def _forecast_total(mm, year, all_income, base=None):
    """Итог ряда «прогноз (планки)» за месяц mm (MM) года year.

    Живые планки Финтабло побеждают всегда, если они вообще есть — даже когда их
    сумма 0.0: это тоже факт («планки завели, но нулевые»), а не «данных нет,
    смотри в журнал». Различаем именно по наличию словаря (`if plan`), а не по сумме
    (`if total`) — иначе месяц с нулевой суммой живых планок молча подменялся бы
    устаревшим числом из журнала снятия, хотя планки на него уже завели заново.

    Журнал снятия смотрим, только если живых планок нет совсем (пустой словарь) —
    обычно потому, что месяц закрылся и planки уже сняты (plan_sync.py
    --retire-income). Нет и журнала — 0.0: прогноза никогда не было, но лист не
    должен из-за этого падать или показывать пустую ячейку там, где есть факт.
    """
    plan = il.plan_from_fintablo(f"{mm}.{year}", all_income)
    if plan:
        return sum(plan.values())
    retired = _retired_plan_total(mm, year, base)
    return retired if retired is not None else 0.0


def build_forecast_and_delta_rows(months, have, fact_totals, forecast_fn):
    """Ряды «прогноз (планки)» и «Δ факт−прогноз» блока «Выручка по строкам» — спека
    §4 просила план/прогноз/факт/Δ, а Δ была потеряна (владелица вычитала прогноз из
    факта глазами, см. находку #5 финального ревью). Δ = факт Финтабло − прогноз;
    для месяцев без факта (mm not in have) — пустая ячейка, как везде в листе.

    forecast_fn(mm) — обычно _forecast_total(mm, YEAR, all_income); вынесено
    параметром, чтобы функция считалась без сети (см. test_plan_fact.py).
    """
    plan_row, delta_row = [], []
    for mm in months:
        if mm not in have:
            plan_row.append("")
            delta_row.append("")
            continue
        forecast = forecast_fn(mm)
        plan_row.append(round(forecast))
        delta_row.append(round(fact_totals[mm] - forecast))
    return plan_row, delta_row


def main(dry=False):
    url = f"https://docs.google.com/spreadsheets/d/{FM_ID}/export?format=csv&gid={FM_GID}"
    plan_rows = list(csv.reader(io.StringIO(
        urllib.request.urlopen(url, timeout=30).read().decode("utf-8"))))
    bad = cf.check_plan_layout(plan_rows)
    if bad:
        raise RuntimeError("разметка финмодели поехала — план считался бы из чужих строк:\n  "
                           + "\n  ".join(bad))
    months, fact = cf.fact_by_month()
    have = {mo[:2] for mo in months}                 # месяцы с фактом

    def plan_val(rlist, mm):
        col = int(mm) + 1
        s = 0.0
        for r in rlist:
            row = plan_rows[r] if r < len(plan_rows) else []
            if col < len(row):
                s += _num(row[col])
        return s

    ml = [f"{mm}.{str(YEAR)[2:]}" for mm in MONTHS12]
    rows = [["ПЛАН-ФАКТ  ·  план из финмодели ↔ факт Финтабло"] + [""] * 12]
    rows.append(["план: янв–апр = факт, май+ = план;  факт тянется из Финтабло"] + [""] * 12)
    rows.append([""] * 13)
    rows.append(["Показатель / месяц"] + ml)
    header_row = len(rows)

    meta = {"plan": [], "fact": [], "delta_good": [], "delta_bad": [], "delta_plain": [], "name": []}
    for name, prows, ffn, more_better in METRICS:
        pv = [round(plan_val(prows, mm)) for mm in MONTHS12]
        fv = [round(ffn(fact[f"{mm}.{YEAR}"])) if mm in have else "" for mm in MONTHS12]
        dv = [(fv[i] - pv[i]) if mm in have else "" for i, mm in enumerate(MONTHS12)]
        rows.append([f"{name}"] + [""] * 12); meta["name"].append(len(rows))
        rows.append(["   план"] + pv); meta["plan"].append(len(rows))
        rows.append(["   факт"] + fv); meta["fact"].append(len(rows))
        rows.append(["   Δ факт−план"] + dv)
        (meta["delta_good"] if more_better else meta["delta_bad"]).append(len(rows))
        rows.append([""] * 13)

    # --- «Выручка по строкам»: детализация строки «Выручка» по канону income_lines ---
    # По строкам канона показываем ФАКТ CRM (не Δ, как в income_block выше): план на
    # закрытый месяц после снятия планок (plan_sync.py --retire-income) живёт только в
    # журнале exports/income_retire_*.json, а не в живых планках Финтабло — Δ по каждой
    # строке из живых планок была бы бессмысленной (план всегда 0). Прогноз итогом —
    # отдельным рядом «прогноз (планки)» (живые планки, а если их нет — из журнала).
    all_income = core.ft_transactions(group="income")
    token = margins.alfa_login()
    pay_items = margins._alfa_pay_items(token)   # один раз на весь прогон, не на каждый месяц
    # income_all_months — ОДИН проход по AlfaCRM за весь год (не 12 отдельных походов на
    # каждый месяц, как раньше): il.crm_fact(mkey, ...) внутри цикла по месяцам заново гонял
    # полную пагинацию /v2api/{br}/pay/index по всем филиалам на каждой итерации — то, что
    # раньше укладывалось в 10-20 сек (CRM тут вообще не трогали), стало 7-12 полных выгрузок
    # (см. находку #4 финального ревью). Числа не меняются — тот же маппинг платёж→направление.
    crm_by_month = margins.income_all_months([f"{mm}.{YEAR}" for mm in MONTHS12 if mm in have],
                                             token, pay_items)
    rows.append(["Выручка по строкам"] + [""] * 12); meta["name"].append(len(rows))

    # факт Финтабло по месяцу — тот же, что «Выручка/факт» в METRICS[0] (f.get("Приток")),
    # считаем один раз и используем и в Δ факт−прогноз, и в «мимо CRM» ниже.
    fact_totals = {mm: METRICS[0][2](fact[f"{mm}.{YEAR}"]) for mm in MONTHS12 if mm in have}

    # ряды «прогноз (планки)» и «Δ факт−прогноз» над строками канона. Формат Δ как у
    # остальных — курсив + delta_good (для выручки больше факта — лучше).
    plan_row, delta_row = build_forecast_and_delta_rows(
        MONTHS12, have, fact_totals, lambda mm: _forecast_total(mm, YEAR, all_income))
    rows.append(["   прогноз (планки)"] + plan_row)
    rows.append(["   Δ факт−прогноз"] + delta_row); meta["delta_good"].append(len(rows))

    line_rows = {l.slug: [] for l in il.LINES}
    other_row = []
    for mm in MONTHS12:
        if mm not in have:
            for slug in line_rows:
                line_rows[slug].append("")
            other_row.append("")
            continue
        mkey = f"{mm}.{YEAR}"
        crm = il.crm_fact_from_month_data(crm_by_month[mkey])
        for l in il.LINES:
            line_rows[l.slug].append(round(crm.get(l.slug, 0.0)))
        other_row.append(round(fact_totals[mm] - sum(crm.values())))

    for l in il.LINES:
        rows.append([f"   {l.name}"] + line_rows[l.slug])
        meta["fact"].append(len(rows))
    # «мимо CRM» — сверочная строка, а не показатель результата: крупный плюс не значит
    # «перевыполнили выручку», это сигнал разнести доход, лежащий мимо направлений CRM.
    # Курсив как у остальных «расчётных» строк оставляем, красно-зелёную подсветку — нет.
    rows.append(["   мимо CRM"] + other_row); meta["delta_plain"].append(len(rows))
    rows.append([""] * 13)

    if dry:
        print("DRY-RUN (лист не трогаю)")
        for r in rows:
            print(" | ".join(str(c)[:22] for c in r))
        for name, prows, ffn, mb in METRICS:
            p_ = sum(plan_val(prows, mm) for mm in MONTHS12 if mm in have)
            f_ = sum(ffn(fact[f"{mm}.{YEAR}"]) for mm in MONTHS12 if mm in have)
            print(f"  {name:34} план {p_:>13,.0f} | факт {f_:>13,.0f} | Δ {f_-p_:>+12,.0f}".replace(",", " "))
        return

    gc = gspread.service_account(filename=SA_FILE)
    sh = gc.open_by_key(SHEET_ID)
    try:
        ws = sh.worksheet("План-факт")
    except gspread.WorksheetNotFound:
        ws = sh.add_worksheet("План-факт", rows=len(rows) + 5, cols=14)
    sid = ws.id
    # сброс форматов/правил
    md = sh.fetch_sheet_metadata()
    nc = next((len(s.get("conditionalFormats", []) or []) for s in md["sheets"]
               if s["properties"]["sheetId"] == sid), 0)
    reset = [{"repeatCell": {"range": {"sheetId": sid}, "cell": {"userEnteredFormat": {}}, "fields": "userEnteredFormat"}}]
    reset += [{"deleteConditionalFormatRule": {"sheetId": sid, "index": 0}} for _ in range(nc)]
    sh.batch_update({"requests": reset})
    ws.clear()
    ws.update(rows, "A1")
    nrow = len(rows)

    W = {"red": 1, "green": 1, "blue": 1}
    fmts = [
        {"range": f"A1:N{nrow}", "format": {"textFormat": {"fontSize": 10}}},
        {"range": f"B5:N{nrow}", "format": {"numberFormat": {"type": "NUMBER", "pattern": "#,##0"}}},
        {"range": "A1:N1", "format": {"textFormat": {"bold": True, "fontSize": 11}}},
        {"range": f"A{header_row}:N{header_row}", "format": {
            "backgroundColor": {"red": 0.12, "green": 0.14, "blue": 0.18},
            "textFormat": {"bold": True, "foregroundColor": W}, "horizontalAlignment": "CENTER"}},
        {"range": f"A{header_row}", "format": {"horizontalAlignment": "LEFT"}},
    ]
    for r in meta["name"]:
        fmts.append({"range": f"A{r}:N{r}", "format": {
            "backgroundColor": {"red": 0.90, "green": 0.92, "blue": 0.94},
            "textFormat": {"bold": True}}})
    for r in meta["fact"]:
        fmts.append({"range": f"A{r}:N{r}", "format": {"textFormat": {"bold": True}}})
    for r in meta["delta_good"] + meta["delta_bad"] + meta["delta_plain"]:
        fmts.append({"range": f"A{r}:N{r}", "format": {"textFormat": {"italic": True}}})
    ws.batch_format(fmts)
    ws.freeze(rows=header_row, cols=1)

    # цвет Δ: для «больше=лучше» (выручка, прибыль) минус=красный; для расходов плюс(перерасход)=красный
    reqs = [{"updateDimensionProperties": {
        "range": {"sheetId": sid, "dimension": "COLUMNS", "startIndex": 0, "endIndex": 1},
        "properties": {"pixelSize": 230}, "fields": "pixelSize"}}]
    RED = {"textFormat": {"foregroundColor": {"red": 0.72, "green": 0.11, "blue": 0.11}}}
    GRN = {"textFormat": {"foregroundColor": {"red": 0.11, "green": 0.55, "blue": 0.24}}}
    def rule(r, cond, fmt):
        return {"addConditionalFormatRule": {"rule": {
            "ranges": [{"sheetId": sid, "startRowIndex": r - 1, "endRowIndex": r,
                        "startColumnIndex": 1, "endColumnIndex": 14}],
            "booleanRule": {"condition": {"type": cond, "values": [{"userEnteredValue": "0"}]}, "format": fmt}},
            "index": 0}}
    for r in meta["delta_good"]:
        reqs.append(rule(r, "NUMBER_LESS", RED)); reqs.append(rule(r, "NUMBER_GREATER", GRN))
    for r in meta["delta_bad"]:
        reqs.append(rule(r, "NUMBER_GREATER", RED)); reqs.append(rule(r, "NUMBER_LESS", GRN))
    sh.batch_update({"requests": reqs})

    print("OK →", sh.url, "| вкладка План-факт")
    for name, prows, ffn, mb in METRICS:
        p = sum(plan_val(prows, mm) for mm in MONTHS12 if mm in have)
        f = sum(ffn(fact[f"{mm}.{YEAR}"]) for mm in MONTHS12 if mm in have)
        print(f"  {name:34} план {p:>13,.0f} | факт {f:>13,.0f} | Δ {f-p:>+12,.0f}".replace(",", " "))


if __name__ == "__main__":
    import sys
    main(dry="--dry" in sys.argv)
