#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Локальний сервер панелі обведення областей (`editor/regions.html`).

Заміна `python -m http.server`: та сама статика з кореня проєкту плюс три
маршрути, без яких цикл «поправив → зберіг → перезібрав → перевірив» вимагає
ручного перекладання файлів із теки завантажень:

    GET  /api/index          перелік PNG сторінок і файлів областей
                             (з кількістю областей і часом зміни)
    POST /api/save           {"name": "regions_p16_q2_full.json", "doc": {...}}
                             → пише JSON у `editor/`, попередню версію в *.bak
    POST /api/build          {"name": "regions_p16_q2_full.json"}
                             → запускає ua_build/art_apply.py і повертає шлях
                               до нового PDF, кількість областей і віджетів

Це веб-сторінка, яка пише на диск, тому запобіжники не косметичні:

* типово слухаємо **тільки 127.0.0.1**. Відкрити в локальну мережу можна
  прапорцем `--lan`, і тоді вмикається **токен**: без нього API не відповідає.
  Перевірка заголовка `Host` лишається завжди — приймаються лише власні адреси
  цієї машини (захист від DNS-rebinding: чужий домен, наведений на твій IP,
  не зможе писати нам на диск);
* писати можна **лише в `editor/`** і лише під іменем
  `regions_p<NN>_<завдання>.json`; перевіряється нормалізований шлях, а не
  рядок імені, і окремо — що ні файл, ні каталог не є символьним посиланням;
* перезбірка приймає **лише ім'я файлу з `editor/`**, ніяких додаткових
  аргументів; `art_apply.py` запускається списком аргументів, без шелу;
* перед перезаписом наявного JSON поруч лишається `<ім'я>.json.bak`.

Запуск:
    .venv/bin/python editor/serve.py            # тільки цей комп'ютер
    .venv/bin/python editor/serve.py --lan      # + інші комп'ютери в мережі
    .venv/bin/python editor/serve.py --lan --token моєслово
    .venv/bin/python editor/serve.py --port 8770
"""
import argparse
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
EDITOR = HERE
ART_APPLY = ROOT / 'ua_build' / 'art_apply.py'
DEFAULT_PY = ROOT / '.venv' / 'bin' / 'python'

HOST = '127.0.0.1'                 # типово назовні не світимо; змінює --lan
TOKEN = None                       # обов'язковий, щойно слухаємо не лише себе
ALLOWED_HOSTS = set()              # імена/адреси, дозволені в заголовку Host
MAX_BODY = 8 * 1024 * 1024         # 8 МБ — набір на 35 областей важить 28 КБ
BUILD_TIMEOUT = 900                # с; повне накладання 35 областей ~ хвилина

# Єдиний дозволений шаблон імені файлу областей. `<завдання>` навмисно ширше
# за `q2`: Редактор тримає поруч робочі набори на кшталт `q2_full`.
NAME_RE = re.compile(r'regions_p(\d{2})_([A-Za-z0-9][A-Za-z0-9_-]{0,40})\.json')
PNG_RE = re.compile(r'page_(.+)_p(\d+)_(\d+)dpi\.png', re.I)

# Однорядкова програма підрахунку віджетів у готовому PDF. Сам сервер
# лишається на стандартній бібліотеці — PyMuPDF працює в підпроцесі.
COUNT_SRC = (
    "import sys, fitz\n"
    "d = fitz.open(sys.argv[1]); p = int(sys.argv[2]) - 1\n"
    "print(sum(len(list(pg.widgets())) for pg in d),\n"
    "      len(list(d[p].widgets())) if 0 <= p < d.page_count else -1,\n"
    "      d.page_count)\n"
)

BUILD_LOCK = threading.Lock()      # art_apply сам обирає номер версії файлу
PYTHON = str(DEFAULT_PY)


# --------------------------------------------------------------------- шляхи
def safe_target(name, must_exist=False):
    """Перевірити ім'я файлу областей. -> (Path, None) або (None, пояснення).

    Перевіряємо не лише рядок: після нормалізації каталог файлу має ЗБІГАТИСЯ
    з `editor/`, інакше `..`, абсолютний шлях чи символьне посилання виведуть
    запис кудись іще.
    """
    if not isinstance(name, str) or not name.strip():
        return None, "ім'я файлу не задано"
    name = name.strip()
    if not NAME_RE.fullmatch(name):
        return None, ("ім'я «%s» не дозволене. Записувати можна лише в editor/ "
                      "і лише під іменем regions_p<NN>_<завдання>.json "
                      "(напр. regions_p16_q2_full.json)" % name[:120])
    p = EDITOR / name
    if p.is_symlink():
        return None, "«%s» — символьне посилання, запис відхилено" % name
    try:
        parent = p.parent.resolve(strict=True)
    except OSError as e:
        return None, "каталог editor/ недоступний: %s" % e
    if parent != EDITOR.resolve():
        return None, "шлях «%s» веде за межі editor/" % name
    if must_exist and not p.is_file():
        return None, "файлу editor/%s немає — спершу збережіть його" % name
    return p, None


def local_addresses():
    """Власні адреси цієї машини — усе, що дозволено в заголовку `Host`.

    Перевірка `Host` лишається і в мережевому режимі: інакше сторонній сайт,
    чий домен наведено на твою адресу, зміг би слати запити до API з браузера
    Редактора. Тож приймаємо лише те, що справді є цією машиною.
    """
    out = {'127.0.0.1', 'localhost', '::1', ''}
    try:
        host = socket.gethostname()
        out.add(host.lower())
        out.add(host.split('.')[0].lower())
        out.add(host.split('.')[0].lower() + '.local')
    except OSError:
        pass
    for fam, kind in ((socket.AF_INET, '8.8.8.8'), (socket.AF_INET6, '2001:4860:4860::8888')):
        s_ = socket.socket(fam, socket.SOCK_DGRAM)
        try:                                   # нічого не шлемо: лише маршрут
            s_.connect((kind, 53))
            out.add(s_.getsockname()[0])
        except OSError:
            pass
        finally:
            s_.close()
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None):
            out.add(info[4][0])
    except OSError:
        pass
    return {a for a in out if a is not None}


def region_count(path):
    try:
        j = json.load(open(path, encoding='utf-8'))
        return len(j.get('regions') or []), j.get('task'), j.get('page')
    except Exception:
        return -1, None, None


def index_payload():
    """Перелік PNG і файлів областей у editor/ — джерело для перемикачів."""
    pngs, regions = {}, []
    for fn in sorted(os.listdir(EDITOR)):
        m = PNG_RE.fullmatch(fn)
        if m:
            pngs.setdefault(m.group(1), {})[int(m.group(2))] = fn
            continue
        if NAME_RE.fullmatch(fn):
            st = os.stat(EDITOR / fn)
            n, task, page = region_count(EDITOR / fn)
            regions.append({
                'name': fn, 'page': int(NAME_RE.fullmatch(fn).group(1)),
                'task': task or NAME_RE.fullmatch(fn).group(2),
                'count': n, 'mtime': st.st_mtime,
                'mtime_text': datetime.fromtimestamp(st.st_mtime).strftime('%d.%m %H:%M'),
                'bytes': st.st_size,
            })
    regions.sort(key=lambda r: (r['page'], -r['mtime']))
    return {'ok': True, 'server': 'bibletime-editor/1', 'root': str(ROOT),
            'python': PYTHON, 'pngs': pngs, 'regions': regions,
            'can_build': ART_APPLY.is_file() and os.path.exists(PYTHON)}


# ------------------------------------------------------------------ обробник
class Handler(SimpleHTTPRequestHandler):
    server_version = 'bibletime-editor/1'

    def __init__(self, *a, **kw):
        super().__init__(*a, directory=str(ROOT), **kw)

    # -- дрібниці
    def log_message(self, fmt, *args):
        sys.stderr.write('%s  %s\n' % (time.strftime('%H:%M:%S'), fmt % args))

    def send_json(self, obj, code=200):
        body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        self.wfile.write(body)

    def local_host(self):
        """Host має бути адресою цієї машини — див. `local_addresses()`."""
        h = (self.headers.get('Host') or '').rsplit(':', 1)[0].strip('[]')
        return h.lower() in ALLOWED_HOSTS

    def token_ok(self):
        """Токен із заголовка або з рядка запиту. Без нього API мовчить."""
        if not TOKEN:
            return True
        given = self.headers.get('X-Editor-Token')
        if not given and '?' in self.path:
            from urllib.parse import parse_qs
            given = (parse_qs(self.path.split('?', 1)[1]).get('token') or [''])[0]
        # порівнюємо БАЙТИ: `compare_digest` на рядках вимагає ASCII, і
        # токен із кирилицею роняв обробник — запит обривався без відповіді
        return secrets.compare_digest((given or '').encode('utf-8'),
                                      TOKEN.encode('utf-8'))

    def guard(self):
        """Спільна перевірка для всіх маршрутів /api/. -> None або відповідь."""
        if not self.local_host():
            self.send_json({'ok': False, 'error': 'чужий Host'}, 403)
            return False
        if not self.token_ok():
            self.log_message('ВІДМОВА: невірний або відсутній токен')
            self.send_json({'ok': False, 'error': 'потрібен токен доступу'}, 401)
            return False
        return True

    def read_body(self):
        n = int(self.headers.get('Content-Length') or 0)
        if n <= 0:
            return None, 'порожнє тіло запиту'
        if n > MAX_BODY:
            return None, 'тіло запиту завелике (%d Б, межа %d)' % (n, MAX_BODY)
        try:
            return json.loads(self.rfile.read(n).decode('utf-8')), None
        except Exception as e:
            return None, 'тіло не є коректним JSON: %s' % e

    # -- маршрути
    def do_GET(self):
        p = self.path.split('?', 1)[0]
        if p.startswith('/api/'):
            if not self.guard():
                return
            if p in ('/api/ping', '/api/index'):
                return self.send_json(index_payload())
            return self.send_json({'ok': False, 'error': 'немає маршруту ' + p}, 404)
        return super().do_GET()

    def do_POST(self):
        p = self.path.split('?', 1)[0]
        if not p.startswith('/api/'):
            return self.send_error(405, 'POST лише для /api/')
        if not self.guard():
            return
        body, err = self.read_body()
        if err:
            return self.send_json({'ok': False, 'error': err}, 400)
        if p == '/api/save':
            return self.api_save(body)
        if p == '/api/build':
            return self.api_build(body)
        return self.send_json({'ok': False, 'error': 'немає маршруту ' + p}, 404)

    # -- запис JSON
    def api_save(self, body):
        target, err = safe_target(body.get('name'))
        if err:
            self.log_message('ВІДМОВА запису: %s', err)
            return self.send_json({'ok': False, 'error': err}, 400)
        doc = body.get('doc')
        if not isinstance(doc, dict) or not isinstance(doc.get('regions'), list):
            return self.send_json(
                {'ok': False, 'error': 'у тілі немає doc.regions — нічого писати'}, 400)

        backup = None
        try:
            if target.exists():
                bak = target.with_name(target.name + '.bak')
                shutil.copy2(target, bak)
                backup = bak.name
            text = json.dumps(doc, ensure_ascii=False, indent=1)
            tmp = target.with_name(target.name + '.tmp')
            with open(tmp, 'w', encoding='utf-8') as f:
                f.write(text)
            os.replace(tmp, target)
        except OSError as e:
            return self.send_json({'ok': False, 'error': 'не вдалося записати: %s' % e}, 500)

        st = target.stat()
        self.log_message('записано %s (%d областей, %d Б)%s', target.name,
                         len(doc['regions']), st.st_size,
                         backup and ', копія -> ' + backup or '')
        return self.send_json({
            'ok': True, 'name': target.name, 'path': str(target),
            'rel': os.path.relpath(target, ROOT), 'regions': len(doc['regions']),
            'bytes': st.st_size, 'backup': backup,
            'saved_at': datetime.fromtimestamp(st.st_mtime).strftime('%H:%M:%S'),
        })

    # -- перезбірка PDF
    def api_build(self, body):
        target, err = safe_target(body.get('name'), must_exist=True)
        if err:
            self.log_message('ВІДМОВА перезбірки: %s', err)
            return self.send_json({'ok': False, 'error': err}, 400)
        if not ART_APPLY.is_file():
            return self.send_json({'ok': False, 'error': 'немає %s' % ART_APPLY}, 500)
        if not os.path.exists(PYTHON):
            return self.send_json({'ok': False, 'error': 'немає інтерпретатора %s' % PYTHON}, 500)

        cmd = [PYTHON, str(ART_APPLY), str(target)]     # список, без шелу
        t0 = time.time()
        with BUILD_LOCK:                                # art_apply сам нумерує версію
            try:
                r = subprocess.run(cmd, cwd=str(ROOT), capture_output=True,
                                   text=True, timeout=BUILD_TIMEOUT)
            except subprocess.TimeoutExpired:
                return self.send_json({'ok': False, 'cmd': cmd,
                                       'error': 'art_apply.py не завершився за %d с'
                                                % BUILD_TIMEOUT}, 504)
        dt = round(time.time() - t0, 1)
        out = (r.stdout or '').strip()
        errtxt = (r.stderr or '').strip()
        if r.returncode != 0:
            self.log_message('перезбірка ПОМИЛКА (%s)', r.returncode)
            return self.send_json({'ok': False, 'cmd': cmd, 'seconds': dt,
                                   'stdout': out, 'stderr': errtxt[-4000:],
                                   'error': 'art_apply.py завершився з кодом %d'
                                            % r.returncode}, 500)

        pdf = None
        m = re.search(r'збережено ->\s*(.+)$', out, re.M)
        if m:
            pdf = m.group(1).strip()
        info = {'regions': None, 'widgets': None, 'page': None}
        m = re.search(r'стор\.\s*(\d+):\s*(\d+)\s+областей\s*->\s*(\d+)\s+віджет', out)
        if m:
            info = {'page': int(m.group(1)), 'regions': int(m.group(2)),
                    'widgets': int(m.group(3))}
        res = {'ok': True, 'name': target.name, 'seconds': dt, 'stdout': out,
               'stderr': errtxt[-2000:], 'pdf': pdf, 'json': target.name}
        res.update(info)
        if pdf and os.path.exists(pdf):
            res['pdf_rel'] = os.path.relpath(pdf, ROOT)
            res['url'] = '/' + os.path.relpath(pdf, ROOT).replace(os.sep, '/')
            res['bytes'] = os.path.getsize(pdf)
            res.update(self.count_widgets(pdf, info.get('page') or 1))
        self.log_message('перезібрано %s за %s с', pdf, dt)
        return self.send_json(res)

    def count_widgets(self, pdf, page):
        """Скільки віджетів у готовому файлі — це і є перевірка «нічого не з'їли»."""
        try:
            r = subprocess.run([PYTHON, '-c', COUNT_SRC, pdf, str(page)],
                               cwd=str(ROOT), capture_output=True, text=True, timeout=120)
            if r.returncode == 0:
                a, b, c = r.stdout.split()
                return {'widgets_total': int(a), 'widgets_page': int(b),
                        'pages': int(c)}
            return {'widgets_total': None, 'count_error': (r.stderr or '').strip()[-500:]}
        except Exception as e:
            return {'widgets_total': None, 'count_error': str(e)}


def main(argv=None):
    ap = argparse.ArgumentParser(
        description='Сервер панелі обведення областей: статика + запис JSON у '
                    'editor/ + перезбірка PDF. Типово слухає лише 127.0.0.1.',
        epilog='панель: http://<адреса>:<порт>/editor/regions.html')
    ap.add_argument('--port', type=int, default=8765, help='порт (типово 8765)')
    ap.add_argument('--lan', action='store_true',
                    help='відкрити доступ з інших комп\'ютерів локальної мережі '
                         '(еквівалент --host 0.0.0.0); вмикає токен')
    ap.add_argument('--host', default=None,
                    help='адреса для прослуховування (типово 127.0.0.1)')
    ap.add_argument('--token', default=None,
                    help='слово-пропуск для API; у мережевому режимі '
                         'генерується саме, якщо не задано')
    ap.add_argument('--no-token', action='store_true',
                    help='мережевий режим БЕЗ токена — лише для довіреної мережі')
    ap.add_argument('--allow-host', action='append', default=[],
                    help='додаткове імʼя в заголовку Host (можна кілька разів)')
    ap.add_argument('--python', default=str(DEFAULT_PY),
                    help='інтерпретатор для art_apply.py (типово .venv/bin/python)')
    a = ap.parse_args(argv)

    global PYTHON, HOST, TOKEN, ALLOWED_HOSTS
    PYTHON = a.python
    HOST = a.host or ('0.0.0.0' if a.lan else '127.0.0.1')
    public = HOST not in ('127.0.0.1', 'localhost', '::1')

    ALLOWED_HOSTS = {h.lower() for h in local_addresses()}
    ALLOWED_HOSTS.update(h.lower() for h in a.allow_host)

    # Токен їде в заголовку HTTP, а заголовки не переносять не-ASCII: з
    # кирилицею він мовчки не збігався й панель діставала 401.
    if a.token and not a.token.isascii():
        ap.error('токен має бути з латинських літер і цифр — заголовки HTTP '
                 'не переносять кирилицю (спробуй --token %s)'
                 % secrets.token_urlsafe(9))
    if public and not a.no_token:
        TOKEN = a.token or secrets.token_urlsafe(9)
    elif a.token:
        TOKEN = a.token

    srv = ThreadingHTTPServer((HOST, a.port), Handler)
    q = ('?token=' + TOKEN) if TOKEN else ''
    print('корінь    : %s' % ROOT)
    print('запис     : %s (лише regions_p<NN>_<завдання>.json)' % EDITOR)
    print('перезбірка: %s %s <json>' % (PYTHON, ART_APPLY))
    print('панель    : http://127.0.0.1:%d/editor/regions.html%s' % (a.port, q))
    if TOKEN:
        print('            ^ відкривай саме це посилання, РАЗОМ із ?token=… —')
        print('              без нього панель покаже «каталог не прочитано»')
    if public:
        ips = sorted(x for x in ALLOWED_HOSTS
                     if re.fullmatch(r'\d+\.\d+\.\d+\.\d+', x)
                     and not x.startswith('127.'))
        for ip in ips:
            print('  з мережі: http://%s:%d/editor/regions.html%s' % (ip, a.port, q))
        if not ips:
            print('  з мережі: адресу не визначено — подивись у Системних '
                  'параметрах → Мережа')
        print('токен     : %s' % (TOKEN or 'ВИМКНЕНО (--no-token)'))
        print('увага     : сервер пише файли й перезбирає PDF. Відкривай лише '
              'в довіреній мережі;')
        print('            Host перевіряється, тож чужий сайт писати не зможе.')
    print('зупинити  : Ctrl-C')
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print('\nзупинено')
    finally:
        srv.server_close()
    return 0


if __name__ == '__main__':
    sys.exit(main())
