#!/usr/bin/env python3
"""Плановые расходы «События Оли» из её Google-таблицы → плановые операции Финтабло.

Одна строка таблицы Оли (Статус = «Не оплачено», Дата платежа в целевом месяце) →
одна ПЛАНОВАЯ операция Финтабло: group=outcome, isPlan=1, дата = «Дата платежа»,
value = «Сумма», categoryId по маппингу «Статья расхода», factMonth = MM.YYYY.

Идемпотентность через externalId `plansync:olya:<hash>`, где hash детерминирован от
(дата|сумма|назначение). Повторный прогон ПЕРЕЗАПИСЫВАЕТ каждую строку (delete+create по
externalId), не задваивая. Строки, которых в таблице БОЛЬШЕ НЕТ (удалили или сдвинули на
другой месяц), но которые ещё висят в плане месяца как plansync:olya:* — при --commit
удаляются. НО строку, помеченную «Оплачено», сносим только
под подтверждающий факт (см. split_orphans): Оля ставит статус в момент решения, деньги
уходят позже, и до тех пор обязательство живо.
Ручные операции с другими externalId и операции других месяцев не трогаются.

Маппинг «Статья расхода» → категория Финтабло (см. CAT_MAP). Колонка Оли осмысленна
(Концерты / квартирники, Съемки, Сервисы, Лекции), маппим по ней. Категории «внутренние
ивенты», «съемки», «b2b-ивенты» попадают в бакет отчёта «события (Оля)»
(report.expense_bucket). «Сервисы» в этот бакет НЕ попадают — dry-run это помечает.

Живые деньги: POST не ретраится (см. margins.ftmut), журнал пишется ДО мутаций.

Запуск:
  python3 enter_olya_plan.py 08.2026            # dry-run: показать план, ничего не пишет
  python3 enter_olya_plan.py 08.2026 --commit   # запись (delete+create + чистка сирот)
  python3 enter_olya_plan.py 08.2026 --show     # что уже занесено за месяц
"""
import sys, os, json, csv, io, re, hashlib, calendar, datetime, urllib.request
import core
import matching
import olya_sheet
import report

MONEYBAG = 151784   # Точка *1195 (для плана счёт не критичен)
DESC_MAX = 200
EXT_PREFIX = olya_sheet.EXT_PREFIX

HERE = os.path.dirname(os.path.abspath(__file__))


# ---------- чистые функции (тестируются без сети) ----------

norm = core.norm
parse_sum = olya_sheet.parse_sum        # разбор таблицы Оли — общий модуль


def row_month(date):
    """'DD.MM.YYYY' -> 'MM.YYYY' или None."""
    d = (date or '').strip()
    return d[3:] if re.match(r'\d{2}\.\d{2}\.\d{4}$', d) else None


def is_target_row(row, mm):
    """Строка идёт в план месяца mm: Статус «Не оплачено» И Дата платежа в этом месяце."""
    status = norm(row.get('Статус'))
    return status.startswith('не оплач') and row_month(row.get('Дата платежа')) == mm


# Детерминированный externalId живёт в olya_sheet: по нему же отчёт достаёт
# «Контрагента» строки для планок, уже лежащих в Финтабло (см. olya_sheet.kontr_by_ext).
ext_id = olya_sheet.ext_id


def map_category(row, cats_by_name):
    """('Статья расхода' + «Проект» → (catid, ftname)). Нет маппинга → (None, None).
    Выездные b2b-мероприятия узнаём по колонке «Проект» — см. olya_sheet.B2B_MARKERS."""
    ftname = olya_sheet.row_category(row)
    if not ftname:
        return None, None
    return cats_by_name.get(ftname), ftname


def clip_desc(s, limit=DESC_MAX):
    return (s or '').strip()[:limit]


def plan_description(naznach, project, limit=DESC_MAX):
    """Описание планки: назначение + событие из колонки «Проект». Чистая функция.

    Проект называет событие целиком («концерт ДЕПО 11.07»), а назначение — за что платим.
    Вместе они дают человекочитаемую строку в Финтабло и вторую улику при матче с фактом.
    Если событие уже названо в назначении — не повторяем.
    """
    n, p = (naznach or '').strip(), (project or '').strip()
    if not p or norm(p) in norm(n):
        return clip_desc(n, limit)
    return clip_desc(f"{n} · {p}" if n else p, limit)


def resolve_partner(kontr, partners):
    """ФИО из колонки «Контрагент» → id контрагента Финтабло. Чистая функция.

    Совпасть должны фамилия И имя (см. matching._same_person), и ровно с одним контрагентом:
    тёзку по имени берём за другого человека и лучше оставим планку без партнёра.
    """
    hits = [(pid, name) for pid, name in partners.items() if matching._same_person(kontr, name)]
    if not hits:
        return None
    # В Финтабло у людей задвоены карточки («Кокорев Юрий» и «Кокорев Юрий Сергеевич»).
    # Если все кандидаты — один человек, берём самую полную запись; если разные — не гадаем.
    first = hits[0][1]
    if all(matching._same_person(first, name) for _, name in hits[1:]):
        return max(hits, key=lambda h: len(matching._person_words(h[1])))[0]
    return None


def build_ops(rows, mm, cats_by_name):
    """Целевые строки месяца → (ops, skipped). ops идемпотентны по ext (последняя выигрывает)."""
    ops, skipped = {}, []
    for r in rows:
        if not is_target_row(r, mm):
            continue
        date = (r.get('Дата платежа') or '').strip()
        amount = parse_sum(r.get('Сумма'))
        naznach = (r.get('Назначение') or '').strip()
        catid, ftname = map_category(r, cats_by_name)
        art = (r.get('Статья расхода') or '').strip()
        if catid is None or amount is None:
            reason = 'нет категории' if catid is None else 'нет суммы'
            skipped.append({'art': art, 'date': date, 'amount': amount,
                            'naznach': naznach, 'reason': reason})
            continue
        ext = ext_id(date, amount, naznach)
        ops[ext] = {'ext': ext, 'date': date, 'amount': amount, 'naznach': naznach,
                    'kontr': (r.get('Контрагент') or '').strip(),
                    'project': (r.get('Проект') or '').strip(),
                    'art': art, 'catid': catid, 'catname': ftname,
                    'bucket': report.expense_bucket(ftname)}
    return ops, skipped


def month_exts(rows, mm):
    """externalId ВСЕХ строк таблицы за месяц, независимо от статуса. Чистая функция.

    Нужен, чтобы отличить «строку удалили/перенесли» от «строку пометили Оплачено»:
    в первом случае обязательства больше нет, во втором оно живо, пока деньги не ушли.
    """
    out = set()
    for r in rows:
        if row_month(r.get('Дата платежа')) != mm:
            continue
        amount = parse_sum(r.get('Сумма'))
        if amount is not None:
            out.add(ext_id((r.get('Дата платежа') or '').strip(), amount,
                           (r.get('Назначение') or '').strip()))
    return out


def split_orphans(orphans, still_in_table, confirmed_paid):
    """Сироты плана → (удалить, оставить с предупреждением). Чистая функция.

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

    Удаляем, если строки в таблице больше нет вовсе (событие отменили или сдвинули на
    другой месяц) — обязательство исчезло вместе со строкой. Строку, помеченную
    «Оплачено», сносим только под подтверждающий факт; иначе оставляем висеть.
    """
    to_delete, to_keep = {}, {}
    for ext, v in orphans.items():
        (to_delete if (ext not in still_in_table or ext in confirmed_paid) else to_keep)[ext] = v
    return to_delete, to_keep


def category_breakdown(ops):
    """{ftname: (count, sum)} по операциям."""
    out = {}
    for o in ops.values():
        c, s = out.get(o['catname'], (0, 0.0))
        out[o['catname']] = (c + 1, s + o['amount'])
    return out


money = core.money


# ---------- сетевые части ----------

fetch_rows = olya_sheet.fetch_rows
outcome_cats_by_name = olya_sheet.outcome_cats_by_name


def existing_olya_plan(mm):
    """{ext: {'id','value','desc'}} — уже занесённые plansync:olya:* плановые расходы месяца."""
    last = calendar.monthrange(int(mm[3:]), int(mm[:2]))[1]
    out = {}
    for t in core.ft_all('/v1/transaction', group='outcome',
                         dateFrom=f"01.{mm}", dateTo=f"{last}.{mm}"):
        ext = t.get('externalId') or ''
        if t.get('isPlan') and ext.startswith(EXT_PREFIX):
            out[ext] = {'id': t.get('id'), 'value': float(t.get('value') or 0),
                        'desc': t.get('description') or ''}
    return out


# ---------- CLI ----------

def show(mm):
    cur = existing_olya_plan(mm)
    print(f"\nПлан «События Оли» {mm} — сейчас в Финтабло (plansync:olya:*): {len(cur)} операций")
    tot = 0.0
    for ext, v in sorted(cur.items(), key=lambda kv: kv[1]['desc']):
        tot += v['value']
        print(f"  {money(v['value']):>9}  {v['desc'][:60]}")
    print(f"  {'ИТОГО':>9}: {money(tot)}")


def main():
    args = sys.argv[1:]
    if not args:
        print(__doc__); return
    mm = args[0]
    if not re.match(r'\d{2}\.\d{4}$', mm):
        print(f"⚠️ первый аргумент — месяц MM.YYYY, получено '{mm}'"); return
    commit = '--commit' in args
    if '--show' in args:
        show(mm); return

    rows = fetch_rows()
    cats_by_name = outcome_cats_by_name()
    ops, skipped = build_ops(rows, mm, cats_by_name)
    cur = existing_olya_plan(mm)

    orphans = {ext: v for ext, v in cur.items() if ext not in ops}   # были в плане, нет среди «Не оплачено»
    # Подтверждение оплаты берём из ТОГО ЖЕ каскада, что и отчёт, и на полном наборе
    # план+факт: иначе сирота могла бы «съесть» факт, предназначенный живой планке.
    try:
        cats_all = core.ft_categories()
        all_plans = report.plan_ops_outcome(cats_all)
        matching.match_cascade(all_plans, report.fact_ops_outcome(cats=cats_all), datetime.date.today())
        confirmed = {o['ext'] for o in all_plans if o.get('ext') and matching.settled(o)}
    except Exception as ex:                 # нет связи — лучше не удалить, чем удалить зря
        print(f"⚠ не смог свериться с фактами ({ex}) — сироты сегодня не трогаю")
        confirmed = None
    if confirmed is None:
        orphan_del, orphan_keep = {}, dict(orphans)
    else:
        orphan_del, orphan_keep = split_orphans(orphans, month_exts(rows, mm), confirmed)
    total = sum(o['amount'] for o in ops.values())

    print(f"\n{'ЗАПИСЬ' if commit else 'DRY-RUN'} · план «События Оли» {mm}")
    print(f"строк в таблице: {len(rows)} | целевых (Не оплачено, {mm}): "
          f"{len(ops) + len(skipped)} | к заносу: {len(ops)}")
    print(f"\n{'дата':<11} {'сумма':>10}  {'категория':<28} {'бакет':<16} назначение")
    for o in sorted(ops.values(), key=lambda x: (x['date'], -x['amount'])):
        flag = '' if o['bucket'] == 'события (Оля)' else '  ⚠не-события'
        print(f"{o['date']:<11} {money(o['amount']):>10}  {o['catname'][:28]:<28} "
              f"{o['bucket'][:16]:<16} {o['naznach'][:40]}{flag}")

    print(f"\nИТОГО к заносу: {money(total)}")
    print("Разбивка по категориям:")
    for name, (cnt, s) in sorted(category_breakdown(ops).items(), key=lambda x: -x[1][1]):
        bucket = report.expense_bucket(name)
        mark = '' if bucket == 'события (Оля)' else f'  ⚠ бакет «{bucket}», НЕ события (Оля)'
        print(f"  {name:<30} {cnt:>3} шт  {money(s):>10}{mark}")

    if skipped:
        print(f"\n⚠ пропущено (не смаппилось): {len(skipped)}")
        for s in skipped:
            print(f"   ⏭ {s['date']:<11} {s['art']:<24} {s['reason']} | {s['naznach'][:40]}")

    if orphan_del:
        print(f"\n🗑 сироты к удалению (строки нет в таблице либо оплата подтверждена фактом): {len(orphan_del)}")
        for ext, v in orphan_del.items():
            print(f"   {money(v['value']):>9}  {v['desc'][:55]}")
    if orphan_keep:
        s = sum(v['value'] for v in orphan_keep.values())
        print(f"\n⏳ в таблице «Оплачено», но списания не вижу — планку ОСТАВЛЯЮ ({len(orphan_keep)} на {money(s)}):")
        for ext, v in orphan_keep.items():
            print(f"   {money(v['value']):>9}  {v['desc'][:55]}")
        print("   <i>Уйдёт само, когда деньги спишутся или занесём акт. Убрать планку "
              "раньше — значит спрятать живой расход.</i>".replace('<i>', '').replace('</i>', ''))

    overwrite = sum(1 for e in ops if e in cur)
    fresh = sum(1 for e in ops if e not in cur)
    print(f"\nв плане сейчас: {len(cur)} | перезапишется: {overwrite} | "
          f"новых: {fresh} | удалится сирот: {len(orphan_del)} | оставлено ждать: {len(orphan_keep)}")

    if not commit:
        print("\n— это dry-run, ничего не записано. Добавь --commit для записи.")
        return

    # журнал до мутаций — чтобы сбой посреди delete+create не потерял данные молча
    exp = os.path.join(HERE, 'exports')
    os.makedirs(exp, exist_ok=True)
    jpath = os.path.join(exp, f"olya_plan_journal_{mm.replace('.', '-')}.json")
    with open(jpath, 'w', encoding='utf-8') as jf:
        json.dump({'month': mm, 'existing': cur, 'ops': list(ops.values()),
                   'orphans': orphan_del, 'kept': orphan_keep}, jf, ensure_ascii=False, indent=1)

    # Контрагент из одноимённой колонки: с ним планку гасит шаг A каскада (по partnerId),
    # а не догадки по описанию — в «Назначении» человек назван как придётся («Саше Б»).
    partners = core.ft_partners()
    written = 0
    for ext, o in ops.items():
        if ext in cur:
            core.ftmut('DELETE', f"/v1/transaction/{cur[ext]['id']}")
        body = dict(
            value=o['amount'], group='outcome', moneybagId=MONEYBAG, date=o['date'],
            factMonth=mm, categoryId=o['catid'], isPlan=1,
            description=plan_description(o['naznach'], o.get('project')), externalId=ext)
        pid = resolve_partner(o.get('kontr'), partners)
        if pid:
            body['partnerId'] = pid
        core.ftmut('POST', '/v1/transaction', body)
        written += 1
    for ext, v in orphan_del.items():
        core.ftmut('DELETE', f"/v1/transaction/{v['id']}")

    print(f"\n✅ записано планом: {written} на {money(total)} | удалено сирот: {len(orphan_del)}"
          f" | оставлено ждать списания: {len(orphan_keep)}")
    print(f"Проверь: python3 enter_olya_plan.py {mm} --show")


if __name__ == '__main__':
    main()
