#!/usr/bin/env python3
"""Синхронизация points.json модуля доставки со справочником BXB (ListPoints).

Зачем: карта на сайте показывала пункты, которых у BXB нет. Покупатель выбирал
такой пункт — тарифной зоны не находилось, и модуль отдавал цену 0 (заказы
14846, 14887, 14951, 14956) либо посылку потом не удавалось создать в BXB
(заказ 14928). Оба случая лечатся одним: список пунктов должен быть ровно
таким, какой BXB реально обслуживает.

Что делает:
  * тянет ListPoints (страна 643, locale=ru) — коды, зоны, GPS, лимиты;
  * обновляет у существующих пунктов зону, лимиты веса/габаритов, координаты;
  * добавляет новые пункты;
  * помечает closed=true те, которых у BXB больше нет (не удаляет — по ним
    могут быть старые заказы, и point_info должен уметь их найти);
  * печатает отчёт и пишет его в sync_points.log.

Берутся только пункты сети 5POST (коды OM*). PVZID-* — это ПВЗ КСЕ, другая
сеть со своими тарифами; они в модуль не заводятся.

  python3 sync_points.py --points /путь/points.json            # разбор, без записи
  python3 sync_points.py --points /путь/points.json --apply    # записать
"""
import argparse, datetime, io, json, os, shutil, sys, urllib.parse, urllib.request

# На сервере локаль не выставлена, и stdout уходит в latin-1: любой русский
# символ в отчёте роняет скрипт (в том числе под кроном). Принудительно UTF-8.
if (getattr(sys.stdout, 'encoding', '') or '').lower().replace('-', '') != 'utf8':
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

TOKEN = os.environ.get('BXB_TOKEN', 'gwa-gQegy3pg4T2yTaI_iJuDKdnoo2Ki')
BXB_URL = 'https://go.bxb.delivery/json.php'
PREFIX = 'MGRC'          # код в модуле = MGRC<PartnerCode>.<Code>
COUNTRY = '643'


def fetch_points():
    url = BXB_URL + '?' + urllib.parse.urlencode({
        'method': 'ListPoints', 'token': TOKEN,
        'countryCode': COUNTRY, 'locale': 'ru'})
    with urllib.request.urlopen(url, timeout=180) as r:
        data = json.loads(r.read().decode())
    err = data.get('error') or {}
    if err.get('isError'):
        sys.exit(f"BXB вернул ошибку: {err.get('errorCode')} {err.get('errorMessage')}")
    return data.get('result') or []


DAYS = [('Mo', 'Пн'), ('Tu', 'Вт'), ('We', 'Ср'), ('Th', 'Чт'),
        ('Fr', 'Пт'), ('Sa', 'Сб'), ('Su', 'Вс')]


def normalize_schedule(s):
    """BXB отдаёт режим работы вперемешку: часть «Пн-Вс 08:00-23:00», часть
    «Mo-Su 08:00-22:00» — параметр locale на это поле влияет не всегда.
    Приводим к русскому виду, всю неделю пишем «Ежедневно» — так было
    в исходном справочнике, и так понятнее покупателю."""
    s = (s or '').strip()
    for en, ru in DAYS:
        s = s.replace(en, ru)
    return s.replace('Пн-Вс', 'Ежедневно').strip()


def num(v, default=0):
    try:
        return int(float(v))
    except (TypeError, ValueError):
        return default


def to_record(b):
    """Запись BXB -> схема points.json модуля."""
    lat = lng = None
    gps = (b.get('GPS') or '').split(',')
    if len(gps) == 2:
        try:
            lat, lng = float(gps[0]), float(gps[1])
        except ValueError:
            pass
    partner = b.get('PartnerCode') or '29'
    # лимиты BXB приходят в мм и граммах, модуль хранит см и кг
    dims = [num(b.get('WidthLimit')) // 10, num(b.get('HeightLimit')) // 10,
            num(b.get('LengthLimit')) // 10]
    return {
        'code': f"{PREFIX}{partner}.{b['Code']}",
        'country': 'Россия',
        'region': b.get('Region') or '',
        'city': b.get('CityName') or '',
        'zip': str(b.get('ZipCode') or ''),
        'address': b.get('Address') or '',
        'zone': num(b.get('TariffZone'), -1),
        'export': True,
        'wmax_kg': max(num(b.get('MaxWeight')) // 1000, 0),
        'dims_max': dims,
        'sum_dims_max': num(b.get('SumDimensionLimit')) // 10,
        'howto': b.get('TripDescription') or '',
        'lat': lat,
        'lng': lng,
        'schedule': normalize_schedule(b.get('WorkSchedule')),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--points', required=True, help='путь к points.json модуля')
    ap.add_argument('--apply', action='store_true', help='записать изменения')
    args = ap.parse_args()

    raw = fetch_points()
    bxb = {b['Code']: b for b in raw if str(b.get('Code', '')).startswith('OM')}
    skipped = len(raw) - len(bxb)
    if len(bxb) < 20000:
        sys.exit(f'BXB отдал подозрительно мало пунктов ({len(bxb)}) — прерываюсь, '
                 f'чтобы не затереть базу неполными данными')

    current = json.load(open(args.points, encoding='utf-8'))
    by_bare = {}
    for p in current:
        by_bare.setdefault(str(p['code']).split('.')[-1], p)

    added, closed, reopened, zone_fixed, updated, coords_added = [], [], [], [], 0, 0

    for bare, p in by_bare.items():
        b = bxb.get(bare)
        if not b:
            if not p.get('closed'):
                p['closed'] = True
                closed.append((p['code'], p.get('city'), p.get('zone')))
            continue
        if p.pop('closed', None):
            reopened.append(p['code'])
        fresh = to_record(b)
        if int(p.get('zone', -1)) != fresh['zone']:
            zone_fixed.append((p['code'], p.get('zone'), fresh['zone']))
        changed = False
        for k in ('zone', 'wmax_kg', 'dims_max', 'sum_dims_max',
                  'region', 'city', 'zip', 'address', 'howto', 'schedule'):
            if p.get(k) != fresh[k]:
                p[k] = fresh[k]
                changed = True
        if p.get('lat') is None and fresh['lat'] is not None:
            p['lat'], p['lng'] = fresh['lat'], fresh['lng']
            coords_added += 1
            changed = True
        updated += bool(changed)

    for bare, b in bxb.items():
        if bare not in by_bare:
            rec = to_record(b)
            current.append(rec)
            added.append((rec['code'], rec['city'], rec['zone']))

    no_zone = [p for p in current if int(p.get('zone', -1)) < 1 and not p.get('closed')]
    active = [p for p in current if not p.get('closed')]
    no_coords = [p for p in active if p.get('lat') is None]

    stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
    lines = [
        f'[{stamp}] синхронизация points.json с BXB ListPoints',
        f'  BXB отдал: {len(raw)} пунктов, из них 5POST (OM*): {len(bxb)}, пропущено не-5POST: {skipped}',
        f'  добавлено новых:      {len(added)}',
        f'  закрыто (нет в BXB):  {len(closed)}',
        f'  снова открыто:        {len(reopened)}',
        f'  исправлено зон:       {len(zone_fixed)}',
        f'  обновлено записей:    {updated}',
        f'  проставлено координат:{coords_added}',
        f'  ИТОГО активных:       {len(active)}   (всего в файле {len(current)})',
        f'  без тарифной зоны:    {len(no_zone)}   <-- эти отдавали бы страховочную цену',
        f'  без координат:        {len(no_coords)}   <-- эти не видны на карте',
    ]
    for label, rows in (('закрыты', closed[:10]), ('новые', added[:10]), ('зоны', zone_fixed[:10])):
        if rows:
            lines.append(f'  примеры ({label}): ' + '; '.join(str(r) for r in rows))
    report = '\n'.join(lines)
    print(report)

    if not args.apply:
        print('\n(разбор без записи; для применения добавь --apply)')
        return

    backup = args.points + '.bak'
    shutil.copy2(args.points, backup)
    tmp = args.points + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(current, f, ensure_ascii=False, separators=(',', ':'))
    os.replace(tmp, args.points)          # атомарная подмена, сайт не увидит полуфайла
    with open(os.path.join(os.path.dirname(args.points) or '.', 'sync_points.log'),
              'a', encoding='utf-8') as f:
        f.write(report + '\n\n')
    print(f'\nзаписано: {args.points}\nбэкап:    {backup}')


if __name__ == '__main__':
    main()
