#!/usr/bin/env python3
"""Матчинг плановых операций Финтабло с фактическими — ЕДИНЫЙ алгоритм.

Раньше матчеров было два: аккуратный каскад в report.py (для «ещё платить») и грубый
«статья + сумма ±5%» в plan_sync.find_retire, который по этому совпадению УДАЛЯЛ плановую
операцию. Грубый матчер мог погасить планку случайным платежом близкой суммы — обязательство
исчезало из прогноза. Теперь оба зовут этот модуль.

Каскад по контрагентам, каждый рубль факта тратится один раз:
  A. partnerId — если у планки проставлен контрагент (см. plan_sync): его факты в окне гасят
     планку, сумма НЕ критерий; недоплата → «оплачено X из Y». Кандидаты сортируются так,
     чтобы факты ТОЙ ЖЕ статьи шли первыми (у одного человека может быть и ЗП, и взнос).
  A2. ФИО из колонки «Контрагент» таблицы Оли (p['kontr']) — гасим ТОЛЬКО тройкой
     контрагент + СУММА + СТАТЬЯ. Такая планка из остальных шагов исключена (p['person']):
     раз человек известен, догадки по сумме или по единственной планке статьи запрещены —
     ложный матч прячет живой долг, а непогашенная планка просто видна в «просрочено».
  B. фамилия — из description планки берём фамилию/имя и ищем её ТОЛЬКО в ИМЕНИ КОНТРАГЕНТА
     факта (не в свободном тексте — там мусорные совпадения); сумма НЕ критерий.
  C. точная сумма — |факт−план| ≤ max(1% планки, 50 ₽); статья «Неразнесенное списание» —
     ДЖОКЕР (гасит любую статью планки), для прочих статья должна совпасть. Совпадения суммы
     мало: если описания называют разных людей, матч отклоняем (см. _name_conflict).
  C2. уникальная пара по статье — если в статье осталась РОВНО ОДНА непогашенная не-агрегатная
     планка, гасим её фактами той же статьи в окне (сумма не критерий, но санити [50%;150%]).
  D. агрегат — планка во множественном («Администраторы») гасится суммой ещё не использованных
     фактов той же статьи в окне; частичное покрытие → «оплачено X из Y».
Окно дат: факт мог пройти за 14 дней ДО срока планки (оплатили заранее) и не позже сегодня.
"""
import datetime
import re

MATCH_TOL_PCT = 0.01
MATCH_TOL_ABS = 50.0
MATCH_WINDOW_DAYS = 14
C2_COVER_LO = 0.5          # C2: суммарная оплата кандидатов не меньше 50% планки
C2_COVER_HI = 1.5          # …и не больше 150% (иначе матч подозрителен — не гасим)
SETTLED_MIN_COVER = 0.5    # планка «оплачена» только если покрыта ≥50%; меньше — по сути неоплата
RETIRE_MIN_COVER = 0.95    # а УДАЛЯТЬ отработавшую планку можно только при покрытии ≥95%

# Ведущие слова описания, которые НЕ являются фамилией (иначе «Оплата…»/«Кредит…»/«Счёт…» ложно матчатся).
NON_NAME = {"зп", "выплата", "оплата", "взнос", "взносы", "кредит", "аренда", "налог", "налоги",
            "ндс", "енп", "комиссия", "банк", "банковское", "процент", "проценты", "типография",
            "бухгалтерия", "интернет", "бот", "сервис", "сервисы", "подписки", "подписка",
            "хозяйственные", "аутсорс", "вода", "пожарка", "водоотведение", "прочее", "прочие",
            # финансовые/канцелярские слова из описаний фактов — тоже не фамилии
            "счет", "счёт", "платеж", "платёж", "договор", "перевод", "возврат", "карта",
            "товар", "услуги", "услуга", "доп", "новый", "зачисления", "оборотный", "займ", "займу"}
# Ведущие слова, помечающие АГРЕГАТ (планка на нескольких людей сразу).
AGG_WORDS = {"администраторы", "педагоги", "педагогов", "сотрудники", "команда"}


def parse_dmy(s):
    d, mo, y = s.split("."); return datetime.date(int(y), int(mo), int(d))


def _norm(s):
    return re.sub(r"\s+", " ", str(s or "").strip()).lower().replace("ё", "е")


def _tokens(s):
    return set(re.findall(r"[а-яa-z]+", _norm(s)))


def _lead_token(desc):
    """Первое значимое слово описания (пропуская префиксы вроде «ЗП»/«Оплата»)."""
    for t in re.findall(r"[А-Яа-яЁёA-Za-z]+", desc or ""):
        n = _norm(t)
        if len(n) >= 3 and n not in NON_NAME:
            return n
    return ""


def _is_aggregate(desc):
    """Планка агрегатная, если её первое слово — множественное (администраторы/педагоги/…)."""
    for t in re.findall(r"[А-Яа-яЁёA-Za-z]+", desc or ""):
        n = _norm(t)
        if len(n) >= 3 and n not in {"зп", "выплата"}:
            return n in AGG_WORDS
    return False


NAME_STEM = 4              # сравниваем имена по основе: склонение меняет окончание, не начало
SURNAME_STEM = 5           # фамилию — строже: «Королева» и «Коротаев» на четырёх буквах совпадают


def _name_conflict(plan_desc, fact_desc):
    """Ведущие слова описаний называют РАЗНЫХ людей? Чистая функция.

    Шаг C матчит по одной лишь сумме, и этого мало: 27.07.2026 планку «Саше Русманову за МК
    ЩУКА» погасил факт «Сергею звукорежиссёру за хор в ПГ» — те же 5 000 в статье b2b-ивентов,
    но это два человека и два события, и обязательство молча исчезло из прогноза.

    Сравниваем по основе, потому что склонение меняет окончание: «Полине»/«Полина» — один
    человек, «Саше»/«Сергею» — разные. Если имя одной стороны вообще встречается в тексте
    другой — это тот же платёж, конфликта нет. Безымянные описания («Типография», «Счёт №…»)
    ведущего слова не дают (см. NON_NAME) и матч не блокируют — поведение как раньше.

    НЕ применяется к джокеру «Неразнесенное списание»: там описание — сырой текст из банка,
    первое слово в нём случайное («Для зачисления на счет Харитонова…» → «для»), и проверка
    рубила бы верные матчи. У джокера статья ничего не значит по определению, вся опора —
    сумма и дата; здесь же статья УЖЕ совпала, и описание — единственная добавочная улика.

    Блокировать матч безопаснее, чем ошибиться в другую сторону: непогашенная планка уедет
    в «просрочено, не вижу оплаты», где её видно, а ложный матч прячет живой долг.
    """
    a, b = _lead_token(plan_desc), _lead_token(fact_desc)
    if not a or not b or a[:NAME_STEM] == b[:NAME_STEM]:
        return False
    return a not in _tokens(fact_desc) and b not in _tokens(plan_desc)


# Правовые формы — не фамилии: без этого «ООО ОНЛАЙН ТРЕЙД» и «ООО ТРЕЙДСЕРВИС»
# сходились по «ООО» + общей основе «трейд».
ORG_WORDS = {"ооо", "оао", "зао", "пао", "нко", "чоп"}

# Оля зовёт людей по-домашнему («Харитонов Саша»), Финтабло держит паспортное имя
# («Харитонов Александр Игоревич»). Сводим к полной форме, иначе один и тот же человек
# выглядит двумя. Фамилию это не отменяет — она проверяется отдельно.
DIMINUTIVES = {
    "саша": "александр", "шура": "александр", "ксюша": "ксения", "вова": "владимир",
    "юра": "юрий", "сережа": "сергей", "стас": "станислав", "настя": "анастасия",
    "оля": "ольга", "катя": "екатерина", "маша": "мария", "даша": "дарья",
    "леша": "алексей", "женя": "евгений", "дима": "дмитрий", "миша": "михаил",
    "паша": "павел", "петя": "петр", "коля": "николай", "толя": "анатолий",
    "таня": "татьяна", "света": "светлана", "софа": "софья", "поля": "полина",
    "лена": "елена", "ира": "ирина", "аня": "анна", "надя": "надежда", "вера": "вера",
    "данила": "даниил", "алиса": "алиса", "марта": "марта",
}


# Фамилия, свёрнутая в инициал: «АЛЕКСАНДРА АЛЕКСАНДРОВНА Т.» (карточки клиентов).
_ANON_SURNAME = re.compile(r"(?:^|\s)[А-ЯЁA-Z]\.(?:\s|$)")


def _canon_name(word):
    """Уменьшительное имя → полная форма. Чистая функция."""
    return DIMINUTIVES.get(word, word)


def _person_words(s):
    """Значимые слова имени ПО ПОРЯДКУ: без «СЗ»/«ИП», правовых форм и канцелярита.

    Порядок сохраняем, потому что первое слово — обычно фамилия (см. _same_person).
    Чистая функция.
    """
    out = []
    for t in re.findall(r"[А-Яа-яЁёA-Za-z]+", str(s or "")):
        n = _norm(t)
        if len(n) >= 3 and n not in NON_NAME and n not in ORG_WORDS and n not in out:
            out.append(n)
    return out


def _same_person(kontr, partner_name):
    """Колонка «Контрагент» Оли и контрагент факта — один человек? Чистая функция.

    Совпасть должна ФАМИЛИЯ (первое слово) и ещё хотя бы одно слово — имя или отчество.
    Без фамилии проверка пропускала однофамильцев наоборот: «Кокорев Юрий Сергеевич» и
    «Шапошников Юрий Сергеевич» дают два совпадения (имя + отчество), и гонорар одного
    гасил обязательство перед другим. Одного имени тоже мало: «Александр» есть и у
    Русманова, и у Булгакова. Сравниваем по основе — склонение меняет окончание, а
    таблица и Финтабло пишут по-разному («Хохлова Ксения» / «Хохлова Ксения Игоревна»).
    """
    # Карточки клиентов Марса анонимны — «АЛЕКСАНДРА АЛЕКСАНДРОВНА Т.», фамилия свёрнута
    # в инициал. Имя с отчеством совпадают у кого угодно, а фамилии, которая решает, нет:
    # такую карточку не сверяем ни с кем.
    if _ANON_SURNAME.search(str(kontr or "")) or _ANON_SURNAME.search(str(partner_name or "")):
        return False
    a, b = _person_words(kontr), _person_words(partner_name)
    if len(a) < 2 or len(b) < 2:
        return False

    def hit(word, words):
        w1 = _canon_name(word)
        return any(w1[:NAME_STEM] == _canon_name(w)[:NAME_STEM] for w in words)

    # Фамилия обязана СОВПАСТЬ С ФАМИЛИЕЙ, а не с чем попало: «Ционов Сергей Вячеславович» и
    # «Сергеева Юлия Вячеславовна» иначе сходились по «Сергей≈Сергеева» + однокоренным
    # отчествам. Порядок бывает любой («Хохлова Ксения» / «Ксения Хохлова»), поэтому фамилией
    # считаем первое слово — либо, при перестановке, второе у обеих сторон сразу.
    # Сверяем по пяти буквам: четырёх мало, «Королева» и «Коротаев» это разные люди.
    def surname_eq(x, y):
        return _canon_name(x)[:SURNAME_STEM] == _canon_name(y)[:SURNAME_STEM]

    swapped = len(a) > 1 and len(b) > 1 and surname_eq(a[0], b[1]) and surname_eq(a[1], b[0])
    if not (surname_eq(a[0], b[0]) or swapped):
        return False

    # Совпадения считаем БИЕКТИВНО: одно слово другой стороны закрывает ровно одно наше.
    # Иначе «Александра» и «Александровна» (одна основа «алек») обе цеплялись за единственную
    # «Александру» и давали два совпадения — так клиентка «Александра Романовна Б.»
    # превращалась в «Монастыршину Александру Александровну».
    free, hits = list(b), 0
    for x in a:
        for y in free:
            if _canon_name(x)[:NAME_STEM] == _canon_name(y)[:NAME_STEM]:
                free.remove(y)
                hits += 1
                break
    return hits >= 2


def _kontr_conflict(p, f):
    """Планка знает своего человека, а факт — другого? Тогда матч по сумме запрещён.

    «Оплата Олегу (кахон)» — это Козлов, и платёж Соловьевой на ту же сумму в тот же день
    его не гасит: иначе живой долг молча исчезает из прогноза (та же логика, что _name_conflict,
    но по надёжному источнику — колонке «Контрагент», а не по первому слову описания).
    """
    kontr, pname = p.get("kontr"), f.get("partner_name")
    return bool(kontr) and bool(pname) and not _same_person(kontr, pname)


def _is_joker(fact):
    """Статья-помойка «Неразнесенное списание» — джокер: гасит планку любой статьи."""
    return "неразнесен" in _norm(fact.get("cat"))


# ── адаптеры: операция Финтабло → запись для каскада ──

def plan_op(t, cats):
    # ext нужен вызывающим, чтобы найти СВОЮ планку в общем прогоне каскада
    # (enter_olya_plan так проверяет, ушли ли деньги по plansync:olya:*).
    return {"ext": t.get("externalId") or "",
            "date": t.get("date"), "factMonth": t.get("factMonth"),
            "catId": t.get("categoryId"), "cat": cats.get(t.get("categoryId"), ""),
            "value": float(t.get("value") or 0),
            "partnerId": t.get("partnerId"), "desc": t.get("description") or "",
            "matched": False}


def fact_op(t, cats, partners):
    return {"date": t.get("date"), "catId": t.get("categoryId"),
            "cat": cats.get(t.get("categoryId"), ""), "factMonth": t.get("factMonth"),
            "value": float(t.get("value") or 0),
            "partnerId": t.get("partnerId"),
            "desc": t.get("description") or "",
            "partner_name": partners.get(t.get("partnerId"), "")}


def match_cascade(plan_ops, fact_ops, today):
    """Гасит плановые операции фактическими каскадом A→B→C→C2→D. Чистая функция без сети.
    Мутирует plan_ops: ставит o['matched'], o['paid'] (сколько погашено фактами),
    o['aggregate'], o['due'] (сколько ещё платить = value − paid)."""
    for p in plan_ops:
        p["paid"] = 0.0
        p["matched"] = False
        p["aggregate"] = _is_aggregate(p.get("desc"))
        # Планка, у которой известно ФИО (колонка «Контрагент» Оли), гасится ТОЛЬКО связкой
        # контрагент + сумма + статья (шаг A2). Догадки по сумме или по единственной планке
        # статьи для неё запрещены: раз человек известен, ошибиться права нет — ложный матч
        # прячет живой долг, а непогашенная планка просто видна в «просрочено».
        p["person"] = bool(p.get("kontr")) and not p["aggregate"]
    facts = [dict(f, remaining=float(f["value"])) for f in fact_ops]

    def in_window(p, f):
        lo = parse_dmy(p["date"]) - datetime.timedelta(days=MATCH_WINDOW_DAYS)
        return lo <= parse_dmy(f["date"]) <= today

    def consume(p, elig):
        """Списывает остатки фактов elig на покрытие планки p (в порядке elig, дробя факт)."""
        need = p["value"] - p["paid"]
        for f in elig:
            if need <= 1e-6:
                break
            take = min(f["remaining"], need)
            f["remaining"] -= take
            p["paid"] += take
            need -= take

    plans_ordered = sorted(plan_ops, key=lambda o: (parse_dmy(o["date"]), o["value"], o.get("desc") or ""))

    # A. по контрагенту (partnerId): сумма не критерий, гасим фактами того же партнёра.
    #    Сначала факты ТОЙ ЖЕ статьи: у Харитонова есть и ЗП, и взнос — иначе планка ЗП
    #    съела бы кусок платежа по взносам и «оплачено X из Y» показывало бы ерунду.
    for p in plans_ordered:
        if p["aggregate"] or p["person"] or p["matched"] or not p.get("partnerId"):
            continue
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and f.get("partnerId") == p["partnerId"]],
                      key=lambda f: (f["catId"] != p["catId"], parse_dmy(f["date"]), f["value"]))
        consume(p, elig)
        if p["paid"] > 1e-6:
            p["matched"] = True

    # A2. по колонке «Контрагент» таблицы Оли (p['kontr']): в «Назначении» человек назван
    #     как придётся («Вове (бас)», «Саше Б»), а в этой колонке стоит полное ФИО.
    #     Факт с РАВНОЙ суммой берём целиком и первым: у Кокорева в один день 10 000 за хор
    #     и 15 000 за ДЕПО — иначе планка на 10 000 отгрызла бы кусок от чужого факта.
    for p in plans_ordered:
        if p["aggregate"] or p["matched"] or not p.get("kontr"):
            continue
        # Совпасть обязаны все три: человек, СУММА и СТАТЬЯ. Статья — потому что у человека
        # бывает и ЗП педагога, и гонорар за съёмку; сумма — потому что у него же бывает
        # несколько работ подряд (у Кокорева 27.07 хор за 10 000 и ДЕПО за 15 000).
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and f["catId"] == p["catId"]
                       and abs(f["value"] - p["value"]) <= 0.01
                       and _same_person(p["kontr"], f.get("partner_name"))],
                      key=lambda f: parse_dmy(f["date"]))
        if elig:
            elig[0]["remaining"] = 0.0
            p["paid"] = p["value"]
            p["matched"] = True

    # B. по фамилии/имени из описания планки — ТОЛЬКО против ИМЕНИ КОНТРАГЕНТА факта.
    for p in plans_ordered:
        if p["aggregate"] or p["person"] or p["matched"]:
            continue
        key = _lead_token(p.get("desc"))
        if len(key) < 3:
            continue
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and key in _tokens(f.get("partner_name"))],
                      key=lambda f: (f["catId"] != p["catId"], parse_dmy(f["date"]), f["value"]))
        consume(p, elig)
        if p["paid"] > 1e-6:
            p["matched"] = True

    # C. по точной сумме (± допуск); «Неразнесенное списание» — джокер по статье
    for p in plans_ordered:
        if p["aggregate"] or p["person"] or p["matched"]:
            continue
        pv = p["value"]
        tol = max(MATCH_TOL_PCT * pv, MATCH_TOL_ABS)
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and abs(f["value"] - pv) <= tol
                       and not _kontr_conflict(p, f)
                       and (_is_joker(f) if f["catId"] != p["catId"]
                            else not _name_conflict(p.get("desc"), f.get("desc")))],
                      key=lambda f: (parse_dmy(f["date"]), abs(f["value"] - pv)))
        for f in elig[:1]:                 # ровно один факт-двойник
            f["remaining"] = 0.0
            p["paid"] = pv
            p["matched"] = True

    # C2. уникальная пара по статье (см. докстринг модуля). «Одна планка статьи» считаем среди
    #     тех, у кого ЕСТЬ подходящий факт статьи в окне — иначе планка соседнего месяца ложно
    #     блокировала бы матч (две «% по займу», на 25.07 и 25.08).
    cand = {}
    for p in plan_ops:
        if p["aggregate"] or p["person"] or p["matched"]:
            continue
        if any(f["remaining"] > 1e-6 and in_window(p, f) and f["catId"] == p["catId"] for f in facts):
            cand.setdefault(p["catId"], []).append(p)
    for catId in sorted(cand, key=lambda c: c or 0):
        if len(cand[catId]) != 1:
            continue                       # >1 планки претендуют на факты статьи → неоднозначность
        p = cand[catId][0]
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and f["catId"] == p["catId"] and not _kontr_conflict(p, f)],
                      key=lambda f: (parse_dmy(f["date"]), f["value"]))
        cover = sum(f["remaining"] for f in elig)
        if not (C2_COVER_LO * p["value"] <= cover <= C2_COVER_HI * p["value"]):
            continue
        consume(p, elig)
        if p["paid"] > 1e-6:
            p["matched"] = True

    # D. агрегатные планки: гасим суммой ещё не использованных фактов той же статьи (дробя факты)
    for p in plans_ordered:
        if not p["aggregate"] or p["matched"]:
            continue
        elig = sorted([f for f in facts if f["remaining"] > 1e-6 and in_window(p, f)
                       and f["catId"] == p["catId"]],
                      key=lambda f: (parse_dmy(f["date"]), f["value"]))
        consume(p, elig)
        if p["paid"] >= p["value"] - 1e-6:
            p["matched"] = True

    for p in plan_ops:
        p["due"] = max(0.0, p["value"] - p["paid"])
    return plan_ops


def settled(o, min_cover=SETTLED_MIN_COVER):
    """Планка считается оплаченной: матч + покрытие ≥ min_cover.
    Гард честности: матч на <50% (партнёр заплатил крохи) — это по сути НЕоплата,
    иначе долг спрятался бы в «оплачено меньше плана». Агрегат матчится только при полном покрытии."""
    if not o["matched"]:
        return False
    if o["aggregate"]:
        return True
    return o["paid"] >= min_cover * o["value"] - 1e-6


def classify_unpaid(plan_ops, fact_ops, today, mm, window_end):
    """Матчит план↔факт и раскладывает непогашенные планки. Чистая функция.

    • unpaid       — не-settled планки (неоплаченные + матч <50% + агрегаты с частичным покрытием);
    • rest         — «ещё платить» в этом месяце (factMonth==mm), сумма долга = o['due'];
    • overdue      — из rest те, у кого дата уже прошла (просрочено);
    • stale        — непогашенные планки ПРОШЛЫХ месяцев с прошедшей датой (раньше молча терялись);
    • next2w       — непогашенные выплаты на 2 недели вперёд (календарь);
    • due_window   — непогашенные от сегодня до границы окна + просроченные этого месяца;
    • after_window — непогашенные сразу за границей окна (только ближайший день): окно
                     фиксированной длины режет по живому, и крупный платёж через день
                     после границы должен быть виден;
    • fut          — из rest строго будущие (для «ближайшей выплаты»);
    • underpaid    — settled планки-люди ТЕКУЩЕГО месяца (покрытие 50–99%): «оплачено X из Y»."""
    match_cascade(plan_ops, fact_ops, today)

    unpaid = [o for o in plan_ops if not settled(o)]
    rest = [o for o in unpaid if o["factMonth"] == mm]
    overdue = sorted([o for o in rest if parse_dmy(o["date"]) < today], key=lambda o: parse_dmy(o["date"]))
    stale = sorted([o for o in unpaid if o["factMonth"] != mm and parse_dmy(o["date"]) < today],
                   key=lambda o: parse_dmy(o["date"]))
    horizon = today + datetime.timedelta(days=14)
    next2w = sorted([o for o in unpaid if today <= parse_dmy(o["date"]) <= horizon],
                    key=lambda o: parse_dmy(o["date"]))
    due_window = [o for o in unpaid if today <= parse_dmy(o["date"]) <= window_end] + overdue
    _after = sorted([o for o in unpaid if parse_dmy(o["date"]) > window_end],
                    key=lambda o: parse_dmy(o["date"]))
    _first = parse_dmy(_after[0]["date"]) if _after else None
    after_window = [o for o in _after if parse_dmy(o["date"]) == _first]
    fut = [o for o in rest if parse_dmy(o["date"]) > today]
    # «оплачено меньше плана» — только текущий месяц (иначе блок копил бы строки с января)
    underpaid = [o for o in plan_ops if o["factMonth"] == mm and settled(o) and not o["aggregate"]
                 and o["paid"] > 1e-6 and o["paid"] < o["value"] - 1e-6]
    return dict(unpaid=unpaid, rest=rest, overdue=overdue, stale=stale, next2w=next2w,
                due_window=due_window, after_window=after_window, fut=fut, underpaid=underpaid)
