#!/usr/bin/env python3
"""Таблица расходов Оли: чтение, разбор сумм, статьи. Общий модуль для двух скриптов.

Раньше `enter_cash_expenses` (оплаченное → факт) и `enter_olya_plan` (неоплаченное → план)
читали одну и ту же таблицу двумя разными парсерами сумм и держали свои копии CAT_MAP.
"""
import csv, hashlib, io, re, urllib.request

import core

EXT_PREFIX = "plansync:olya:"

SHEET_ID = "1MaEMGjyFfMeHw-xhaW38MjT954Bg9a3rFEZSAjLe1t0"
GID = "0"
CSV_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={GID}"

norm = core.norm

# статья таблицы (norm) -> статья Финтабло (norm)
CAT_MAP = {
    'концерты / квартирники': 'расходы на внутренние ивенты',
    'съемки':                 'расходы на съемки',
    'сервисы':                'сервисы',
    'лекции':                 'расходы на b2b-ивенты',
    'b2b ивенты':             'расходы на b2b-ивенты',   # заведена 25.07: выездные корпоративы
}

# ОСНОВНОЙ путь — статья «B2B ивенты» в таблице (заведена 25.07.2026, 15 строк размечены).
# Ниже — СТРАХОВКА на случай, если новое выездное мероприятие снова запишут в «Концерты /
# квартирники»: узнаём его по колонке «Проект», где событие названо целиком. Она ловит и те
# строки, где в назначении самого события не видно («логистика команды», «доставка оборудования»).
# Расходы по b2b-событию в «Концертах» уезжали в маржу «Внутренних мероприятий», хотя
# выручка по ним идёт в B2B — ради этого вся возня.
B2B_MARKERS = (
    r"хор\w*\s+в\s+пг\b",        # хор в ПГ 17.07.26
    r"хор\w*\s+вк\b",            # хор ВК The Birch (август 2026)
)
B2B_CATEGORY = 'расходы на b2b-ивенты'


def is_b2b_event(project, naznach=""):
    """Строка относится к выездному b2b-мероприятию? Смотрим «Проект», при пустом —
    назначение (страховка для строк без проекта). Чистая функция."""
    for text in (project, naznach):
        n = norm(text)
        if n and any(re.search(mark, n) for mark in B2B_MARKERS):
            return True
    return False


def parse_sum(s):
    """Сумма из таблицы: 'р.5 000' → 5000.0, '1 234,56' → 1234.56, '1.500' → 1500.0.

    Сначала убираем «р.»/«₽» и пробелы (иначе точка от «р.» делает число дробным: .5000 = 0.5).
    Точка как разделитель ТЫСЯЧ ('1.500') раньше давала 1.5 — дробной частью считаем
    только хвост из 1–2 цифр.
    """
    s = str(s or '').replace('р.', '').replace('₽', '').replace('руб', '') \
                    .replace('\xa0', '').replace(' ', '')
    s = re.sub(r'[^\d,.\-]', '', s)
    if not s:
        return None
    m = re.search(r'[.,](\d{1,2})$', s)          # хвост из 1–2 цифр = копейки
    if m:
        s = f"{re.sub(r'[.,]', '', s[:m.start()])}.{m.group(1)}"
    else:
        s = re.sub(r'[.,]', '', s)
    try:
        return round(float(s), 2)
    except ValueError:
        return None


def ft_category_name(art, naznach="", project=""):
    """Статья таблицы (+ проект/назначение) → имя статьи Финтабло или None. Чистая функция."""
    if is_b2b_event(project, naznach):
        return B2B_CATEGORY
    return CAT_MAP.get(norm(art))


def row_category(row):
    """Строка таблицы → имя статьи Финтабло (учитывает колонку «Проект»). Чистая функция."""
    return ft_category_name(row.get('Статья расхода'), row.get('Назначение'), row.get('Проект'))


# ── исполнители из колонки «Контрагент»: статья выплаты по акту ──
# enter_akty ставил статью по САМОЙ ЧАСТОЙ статье человека в истории «Рабочих рук». Педагогу
# за уроки платят дважды в месяц, поэтому «Зарплаты педагогов» всегда побеждали, и акт за
# концерт уезжал туда же: доп-работа пачкала ЗП-лампу (маржинальность направлений врала),
# а план из таблицы Оли не матчился с фактом — статьи разные. Источник истины по статье —
# сама таблица Оли, здесь она и читается.
NOT_A_PERSON = {'ооо', 'оао', 'зао', 'пао', 'нко', 'такси', 'база', 'наличные', 'наличными',
                'наличкой', 'наличка', 'менеджер', 'менеджеру', 'площадке', 'sova', 'реп'}
NAME_PREFIX = {'сз', 'ип', 'самозанятый'}   # служебные приставки, не часть имени
NAME_MIN = 3                                # короче — инициал или частица (фамилии «Ким», «Цой» живут)


def _words(s):
    return re.findall(r'[а-яa-z]+', norm(s))


def _name_tokens(s):
    """Имя → значимые токены (без приставок «СЗ»/«ИП» и коротких частиц). Чистая функция."""
    return {t for t in _words(s) if t not in NAME_PREFIX and len(t) >= NAME_MIN}


def _is_person(kontr):
    """В «Контрагенте» человек, а не компания или способ оплаты? Чистая функция.

    В колонке живут и «Яндекс Такси», и «ООО ВАЗЗАП», и «Наличными, на площадке» —
    их в индекс исполнителей не берём.
    """
    w = set(_words(kontr))
    return bool(w) and not (w & NOT_A_PERSON)


SERVICE_MIN_HITS = 2      # сколько значимых слов описания должно совпасть, чтобы решать
STOP_WORDS = {'оплата', 'оплатить', 'услуги', 'услуга', 'работы', 'работа', 'сумма',
              'руб', 'без', 'ндс', 'для', 'над', 'при', 'что', 'это'}


def _dates_in(text):
    """Даты события из текста: «ДЕПО 11.07», «концерт 22/08» → {(11, 7), (22, 8)}. Чистая функция.

    В «Назначении» и «Проекте» у Оли дата события есть почти всегда, а дата акта — это
    дата работы. По ним и разводятся двойники: у Кокорева 15 000 за ДЕПО (11.07) и
    15 000 за cover session (25.07) — суммой и фамилией их не отличить, датой отличить можно.
    """
    out = set()
    for d, mo in re.findall(r'\b(\d{1,2})[./](\d{1,2})(?![\d.])', str(text or '')):
        d, mo = int(d), int(mo)
        if 1 <= d <= 31 and 1 <= mo <= 12:
            out.add((d, mo))
    return out


def _meaning(text):
    """Значимые слова описания — для сверки акта со строкой таблицы. Чистая функция."""
    return {t for t in _words(text) if len(t) >= NAME_MIN and t not in STOP_WORDS}


def payment_rows(rows):
    """Строки-выплаты людям → список записей для сопоставления с актом. Чистая функция.

    Месяц в ключ НЕ входит: работа и платёж бывают в разных месяцах (cover session 25.07
    оплачивается 03.08), а акт датирован работой. Привязка к месяцу такие строки теряла,
    и статья падала обратно в «Зарплаты педагогов».
    """
    out = []
    for r in rows:
        kontr = r.get('Контрагент')
        cat = row_category(r)
        amt = parse_sum(r.get('Сумма'))
        date = (r.get('Дата платежа') or '').strip()
        m = re.match(r'\d{2}\.(\d{2}\.\d{4})$', date)
        if not (cat and amt and m and kontr and _is_person(kontr)):
            continue
        toks = _name_tokens(kontr)
        if not toks:
            continue
        text = f"{r.get('Назначение') or ''} {r.get('Проект') or ''}"
        out.append({'tokens': toks, 'amount': round(amt, 2), 'pay_date': date,
                    'month': m.group(1), 'cat': cat, 'text': text,
                    'dates': _dates_in(text), 'words': _meaning(text)})
    return out


def match_payment(prows, fio, amount, akt_date, service=""):
    """(строка таблицы | None, кандидаты) для акта (ФИО, сумма, дата акта, описание услуги).

    Сначала фамилия + сумма. Если подходит ровно одна строка — она и есть. Если несколько,
    разводим по акту: сперва по ДАТЕ ПЛАТЕЖА (акт Рабочих рук выписан днём выплаты, так что
    совпадение с «Датой платежа» — самая сильная улика; заодно отсекает ещё не оплаченные
    будущие строки), затем по ДАТЕ СОБЫТИЯ из «Назначения», затем по пересечению слов
    описания услуги с текстом строки.

    Ничем не различить — возвращаем (None, кандидаты): пусть занос пометит акт «проверь,
    за что», а не проставит статью наугад. Чистая функция.
    """
    sn = _surname(fio)
    if not sn:
        return None, []
    cand = [r for r in prows if sn in r['tokens'] and abs(r['amount'] - float(amount)) < 0.01]
    if len(cand) <= 1:
        return (cand[0] if cand else None), cand
    by_pay = [r for r in cand if r['pay_date'] == (akt_date or '').strip()]
    if len(by_pay) == 1:
        return by_pay[0], cand
    m = re.match(r'(\d{1,2})\.(\d{1,2})\.', (akt_date or '').strip())
    if m:
        by_date = [r for r in cand if (int(m.group(1)), int(m.group(2))) in r['dates']]
        if len(by_date) == 1:
            return by_date[0], cand
    want = _meaning(service)
    if want:
        scored = sorted((len(want & r['words']), i, r) for i, r in enumerate(cand))[::-1]
        # Минимум два совпадения: по одному общему слову матчер уверенно выбирал не то
        # событие, когда описание в акте оказывалось обманчивым. Лучше пометить.
        if scored[0][0] >= SERVICE_MIN_HITS and scored[0][0] > scored[1][0]:
            return scored[0][2], cand
    return None, cand


def ext_id(date, amount, naznach):
    """Детерминированный externalId плановой операции. Чистая функция.

    Живёт здесь, а не в enter_olya_plan: по этому же ключу отчёт находит человека
    из колонки «Контрагент» для планок, которые уже лежат в Финтабло (kontr_by_ext).
    """
    h = hashlib.md5(f"{date}|{amount}|{naznach}".encode()).hexdigest()[:12]
    return f"{EXT_PREFIX}{h}"


def kontr_by_ext(rows):
    """{externalId планки: ФИО из колонки «Контрагент»}. Чистая функция.

    Колонка «Контрагент» — самый надёжный ключ к человеку: в «Назначении» он назван как
    придётся («Вове (бас)», «Саше Б»), а здесь стоит полное ФИО. Так матчинг узнаёт своего
    человека даже у планок, занесённых до того, как мы стали писать partnerId.
    """
    out = {}
    for r in rows:
        kontr = (r.get('Контрагент') or '').strip()
        amount = parse_sum(r.get('Сумма'))
        if not (kontr and _is_person(kontr) and amount is not None):
            continue
        out[ext_id((r.get('Дата платежа') or '').strip(), amount,
                   (r.get('Назначение') or '').strip())] = kontr
    return out


def expense_index(rows):
    """Строки таблицы → {(MM.YYYY, сумма): [(токены имени, статья Финтабло)]}. Чистая функция.

    Ключ — месяц И сумма: один человек в одном месяце получает и за b2b-хор, и за внутренний
    концерт, и за съёмку (у Кокорева в июле 10 000 за хор в ПГ и 15 000 за ДЕПО), так что
    статью различает только сумма. Статус не смотрим — акт приходит после оплаты, к этому
    моменту строка уже «Оплачено».
    """
    idx = {}
    for r in rows:
        kontr = r.get('Контрагент')
        cat = row_category(r)
        amt = parse_sum(r.get('Сумма'))
        m = re.match(r'\d{2}\.(\d{2}\.\d{4})$', (r.get('Дата платежа') or '').strip())
        if not (cat and amt and m and kontr and _is_person(kontr)):
            continue
        toks = _name_tokens(kontr)
        if toks:
            idx.setdefault((m.group(1), round(amt, 2)), []).append((toks, cat))
    return idx


def _surname(fio):
    """Фамилия из ФИО акта — первое значимое слово. Чистая функция.

    Финтабло и акты пишут человека как «Фамилия Имя Отчество» (на этом же держится
    two2pid в enter_akty), поэтому первое слово — фамилия.
    """
    w = [t for t in _words(fio) if t not in NAME_PREFIX and len(t) >= NAME_MIN]
    return w[0] if w else ""


def lookup_category(index, fio, amount, month):
    """Статья для выплаты (ФИО из акта, сумма, месяц) по таблице Оли — или None. Чистая функция.

    Ищем ФАМИЛИЮ из акта среди слов «Контрагента», не заботясь о порядке: в таблице
    встречается и «СЗ Кокорев Юрий Сергеевич», и «СЗ Ксения Хохлова» (имя первым),
    и «Харитонов Саша» против «ХАРИТОНОВ АЛЕКСАНДР ИГОРЕВИЧ» в акте.

    Именно фамилию, а не любое общее слово: по отчеству «Сергеевич» акт Барышникова
    цеплялся за строку Кокорева той же суммы, а по имени «Александр» Булгаков — за
    строку Русманова. И то и другое давало ложную неоднозначность или чужую статью.

    None = «в таблице такого нет либо неоднозначно»; статью тогда решает прежняя логика
    enter_akty (история выплат человека). Молча не угадываем: если под один ключ подходят
    строки с РАЗНЫМИ статьями, отдаём None и просим проверить глазами.
    """
    hits = category_candidates(index, fio, amount, month)
    return hits.pop() if len(hits) == 1 else None


def category_candidates(index, fio, amount, month):
    """Все статьи таблицы Оли, подходящие под (ФИО, сумма, месяц). Чистая функция.

    Больше одной — человек в этом месяце получает одинаковую сумму за разные вещи
    (у Ционова в августе 10 000 за съёмку и 10 000 за b2b-хор). Различить нечем,
    поэтому lookup_category молчит, а enter_akty про такие говорит вслух: иначе они
    тихо падают на историю выплат, то есть обратно в «Зарплаты педагогов».
    """
    sn = _surname(fio)
    if not sn:
        return set()
    return {cat for toks, cat in index.get((month, round(float(amount), 2)), []) if sn in toks}


def fetch_rows():
    raw = urllib.request.urlopen(CSV_URL, timeout=30).read().decode('utf-8')
    return list(csv.DictReader(io.StringIO(raw)))


def outcome_cats_by_name():
    """{norm(имя): id} только outcome-категории Финтабло."""
    return {norm(c['name']): c['id']
            for c in core.ftget('/v1/category').get('items', [])
            if c.get('group') == 'outcome'}
