#!/usr/bin/env python3
"""Общий слой finsync: .env, клиенты Финтабло и AlfaCRM, пагинация, календарь.

Зачем: раньше клиент Финтабло был скопирован в пяти скриптах, и только в margins были
ретраи и защита «POST не повторяем». Остальные копии при 500-й молча возвращали
{'__error': 500}. Теперь клиент один.

Год НЕ хардкодится: FIN_YEAR = текущий календарный год, переопределяется переменной
окружения FINSYNC_YEAR (полезно, чтобы пересчитать прошлый год).
⚠️ Финмодель в Google Sheets привязана к своему году (колонки B..M = месяцы): при переходе
на новый год нужна новая финмодель, иначе план поедет — сами скрипты при этом работают.
"""
import os, json, time, datetime, urllib.request, urllib.parse, urllib.error

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


# ── .env ──
def load_env(path=None):
    """Читает .env рядом со скриптом в os.environ (не перетирая уже заданные переменные)."""
    p = path or os.path.join(HERE, '.env')
    if not os.path.exists(p):
        raise RuntimeError(f"нет файла {p} — нужны FINTABLO_TOKEN, FINTABLO_BASE_URL, ALFACRM_*")
    with open(p) as fh:
        for line in fh:
            line = line.strip()
            if '=' in line and not line.startswith('#'):
                k, v = line.split('=', 1)
                os.environ.setdefault(k, v)


def env(name):
    v = os.environ.get(name)
    if not v:
        raise RuntimeError(f"в .env нет {name}")
    return v


load_env()
FT_TOKEN = env('FINTABLO_TOKEN')
FT_BASE = env('FINTABLO_BASE_URL')
A_HOST = env('ALFACRM_HOST')
A_EMAIL = env('ALFACRM_EMAIL')
A_KEY = env('ALFACRM_KEY')
A_BASE = f"https://{A_HOST}"

# Филиалы AlfaCRM: 3 «Сретенка» и 2 «Цветной». Филиал 5 («ОП») отдаёт ровно тот же набор
# платежей, что и 2 (проверено), филиал 4 — ТЕСТ. Дублировать их незачем.
BRANCHES = [2, 3]


# ── календарь: год берём из даты, а не из константы ──
FIN_YEAR = int(os.environ.get('FINSYNC_YEAR') or datetime.date.today().year)


def month_key(d=None):
    """date → 'MM.YYYY' (формат factMonth Финтабло). Без аргумента — сегодня."""
    d = d or datetime.date.today()
    return f"{d.month:02d}.{d.year}"


def next_month_key(d=None):
    """'MM.YYYY' следующего месяца относительно d (по умолчанию — сегодня)."""
    d = d or datetime.date.today()
    nf = (d.replace(day=28) + datetime.timedelta(days=8)).replace(day=1)
    return month_key(nf)


def months_to_now(d=None, year=None):
    """Месяцы года до текущего включительно: ['01.2026', …, '07.2026'].

    Считается ОТ КАЛЕНДАРНОЙ ДАТЫ, а не от последней операции в Финтабло: раньше «текущий
    месяц» определялся по первой попавшейся операции API, и плановые операции следующего
    месяца (isPlan=1, их создаёт plan_sync) уводили весь расчёт на месяц вперёд.
    """
    d = d or datetime.date.today()
    y = year or FIN_YEAR
    end = d.month if d.year == y else (12 if d.year > y else 0)
    return [f"{m:02d}.{y}" for m in range(1, end + 1)]


def year_range(year=None):
    """('01.01.YYYY', '31.12.YYYY') — границы года для запросов Финтабло."""
    y = year or FIN_YEAR
    return f"01.01.{y}", f"31.12.{y}"


def month_range_alfa(mm):
    """'MM.YYYY' → ('YYYY-MM-01', 'YYYY-MM-DD') для AlfaCRM.

    ⚠️ AlfaCRM понимает ТОЛЬКО дефисы: с точками ('2026.07.01') фильтр молча игнорируется
    и отдаётся вся история (проверено: total 5917 против 436).
    """
    import calendar
    mo, y = int(mm[:2]), int(mm[3:])
    return f"{y}-{mo:02d}-01", f"{y}-{mo:02d}-{calendar.monthrange(y, mo)[1]:02d}"


def year_range_alfa(year=None):
    y = year or FIN_YEAR
    return f"{y}-01-01", f"{y}-12-31"


# ── Финтабло ──
def ftget(path, **pp):
    """GET с ретраями (идемпотентно)."""
    q = urllib.parse.urlencode({k: v for k, v in pp.items() if v is not None})
    req = urllib.request.Request(
        f"{FT_BASE}{path}?{q}", headers={'Authorization': f'Bearer {FT_TOKEN}'})
    last = None
    for attempt in range(5):
        try:
            return json.load(urllib.request.urlopen(req, timeout=30))
        except (urllib.error.URLError, ConnectionError) as e:
            last = e
            if isinstance(e, urllib.error.HTTPError) and e.code not in (429, 500, 502, 503, 504):
                raise
            time.sleep(1 + attempt)
    raise last


# Методы, которые можно безопасно повторить: результат от повтора не меняется.
# POST (создание операции) СЮДА НЕ ВХОДИТ — см. ftmut.
_IDEMPOTENT = {'GET', 'HEAD', 'PUT', 'DELETE'}


def ftmut(method, path, body=None):
    """Мутация в Финтабло.

    POST НЕ ретраим: если Финтабло записал операцию, а ответ потерялся (таймаут/5xx),
    ретрай создал бы ДУБЛЬ плановой операции — это живые деньги. Одиночный POST-сбой
    лечится повторным прогоном скрипта: заносы идемпотентны по externalId и пишут журнал
    до мутаций, так что потери нет. Идемпотентные методы (DELETE/PUT) ретраим.
    """
    up = method.upper()
    req = urllib.request.Request(
        f"{FT_BASE}{path}", data=json.dumps(body).encode() if body else None,
        headers={'Authorization': f'Bearer {FT_TOKEN}', 'Content-Type': 'application/json'}, method=method)
    attempts = 5 if up in _IDEMPOTENT else 1
    last = None
    for attempt in range(attempts):
        try:
            r = urllib.request.urlopen(req, timeout=30)
            return json.load(r) if up != 'DELETE' else r.status
        except (urllib.error.URLError, ConnectionError) as e:
            last = e
            if isinstance(e, urllib.error.HTTPError):
                # Повторный DELETE по уже удалённой операции — считаем успехом (идемпотентно).
                if up == 'DELETE' and e.code == 404 and attempt > 0:
                    return e.code
                if e.code not in (429, 500, 502, 503, 504):
                    raise
            if attempt == attempts - 1:
                break
            time.sleep(1 + attempt)
    raise last


FT_PAGE = 1000


def ft_all(path, **pp):
    """Все страницы Финтабло одним списком (pageSize=1000)."""
    out, pg = [], 1
    while True:
        d = ftget(path, pageSize=FT_PAGE, page=pg, **pp)
        it = d.get('items', [])
        out += it
        if len(it) < FT_PAGE:
            return out
        pg += 1


def ft_transactions(year=None, **pp):
    """Операции Финтабло за год (по умолчанию FIN_YEAR). Фильтры — как у /v1/transaction."""
    pp.setdefault('dateFrom', year_range(year)[0])
    pp.setdefault('dateTo', year_range(year)[1])
    return ft_all('/v1/transaction', **pp)


def ft_categories():
    return {c['id']: c['name'] for c in ftget('/v1/category').get('items', [])}


def ft_partners():
    """{partnerId: имя контрагента}."""
    return {p.get('id'): (p.get('name') or '') for p in ftget('/v1/partner').get('items', [])}


def ft_moneybag_balance(mb_id):
    return next(x['balance'] for x in ftget('/v1/moneybag')['items'] if x['id'] == mb_id)


# ── AlfaCRM ──
def alfa(path, body, token):
    """POST в AlfaCRM. Ошибку НЕ проглатываем.

    Раньше после исчерпания ретраев возвращалось пустое {} — вызывающий видел items=[]
    и считал, что страницы кончились: часть выручки молча терялась. Теперь — исключение.
    """
    hdr = {'Content-Type': 'application/json'}
    if token:
        hdr['X-ALFACRM-TOKEN'] = token
    req = urllib.request.Request(A_BASE + path, data=json.dumps(body).encode(),
                                 method='POST', headers=hdr)
    last = None
    for attempt in range(4):
        try:
            return json.load(urllib.request.urlopen(req, timeout=40))
        except (urllib.error.URLError, ConnectionError) as e:
            last = e
            if isinstance(e, urllib.error.HTTPError) and e.code not in (429, 500, 502, 503, 504):
                raise RuntimeError(f"AlfaCRM {path}: HTTP {e.code}")
            time.sleep(1 + attempt)
    raise RuntimeError(f"AlfaCRM {path}: не ответил после 4 попыток ({last})")


def alfa_login():
    tok = alfa('/v2api/auth/login', {'email': A_EMAIL, 'api_key': A_KEY}, None).get('token')
    if not tok:
        raise RuntimeError("AlfaCRM: авторизация не удалась (проверь ALFACRM_EMAIL/ALFACRM_KEY)")
    return tok


ALFA_PAGE = 500
ALFA_MAX_PAGES = 200


def alfa_all(path, body, token, page_size=ALFA_PAGE):
    """Все страницы AlfaCRM одним списком.

    Признак конца — `total` из ответа (в AlfaCRM `count` = размер СТРАНИЦЫ, не всего).
    Упереться в потолок страниц молча нельзя: это была бы тихая потеря данных → исключение.
    """
    out, page = [], 0
    while page < ALFA_MAX_PAGES:
        r = alfa(path, dict(body, pageSize=page_size, page=page), token)
        items = r.get('items', [])
        out += items
        total = r.get('total')
        if not items or (total is not None and len(out) >= total):
            return out
        page += 1
    raise RuntimeError(f"AlfaCRM {path}: больше {ALFA_MAX_PAGES} страниц — данные могли обрезаться")


# ── форматирование ──
RU_MONTH_GEN = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
                "июля", "августа", "сентября", "октября", "ноября", "декабря"]


def ru_date(d):
    """date → «25 июля» (раньше месяц переводился через .replace('July','июля') — работало
    ровно два месяца в году)."""
    return f"{d.day} {RU_MONTH_GEN[d.month]}"


def ru_month(mm):
    """'07.2026' → 'июля'."""
    return RU_MONTH_GEN[int(mm[:2])]

def money(x):
    """1234567 → '1 234 567'."""
    return f"{round(x):,}".replace(",", " ")


def num(s):
    """Первое число из строки таблицы ('1 234,50 ₽' → 1234.5). Мусор → 0.0."""
    import re
    s = (s or "").replace("\xa0", "").replace(" ", "").replace(",", ".")
    mm = re.findall(r"-?\d+\.?\d*", s)
    return float(mm[0]) if mm else 0.0


def norm(s):
    """Нормализация имени статьи/строки: схлопнуть пробелы, lower, ё→е."""
    import re
    return re.sub(r"\s+", " ", str(s or "").strip()).lower().replace("ё", "е")
