#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Будівник інтерактивного уроку bibletime (Скіл 3, §3.2 / §3.4 / §3.5 / §3.6)
плюс контроль якості §3.7.

Читає `ua_build/fields.json` (його робить `fields_detect.py`), вставляє віджети
в копію вихідного PDF і зберігає версіонований файл `forms/<база>_vNN.pdf`.

Жодної кнопки submit, жодного JavaScript, жодного вбудованого шрифту:
базові шрифти PDF — цього достатньо (§3.6).

`NeedAppearances` за замовчуванням ВИМКНЕНО (рішення замовника): з ним
переглядач перегенеровує вигляд усіх полів і затирає власний вигляд кнопок
(червону обвідку навколо слова). Увімкнути запасний варіант: `--need-appearances`.

QA (§3.7) виконується одразу після збірки:
  * оверлей `render/fld_pNN.png` — прямокутники полів поверх сторінки;
  * контракт  — кожне завдання з переліку §3.1 має ≥ 1 поле;
  * антиконтракт — жодне поле не лежить на боксі балів;
  * унікальність імен, ≥ 2 кнопки в кожній radio-групі;
  * круговий тест — заповнити, зберегти, відкрити, зчитати, звірити.

Запуск:
    .venv/bin/python ua_build/fields_build.py --pages 3
"""
import argparse
import json
import os
import re
import sys

import fitz

HILITE = (1, 0.97, 0.72)        # §3.5 блідо-жовта заливка
BORDER = (0.80, 0.74, 0.45)     # §3.5 рамка
TXTCOL = (0, 0, 0.55)           # §3.5 темно-синій текст відповіді
DONE_BORDER = (0.35, 0.35, 0.35)
CIRCLE = (0.918, 0.318, 0.353)  # фірмовий червоний — обвідка позначеного варіанта
WS_RING_W = 1.1                 # кільце навколо літери філворда — тонше за обвідку

# --------------------------------------------------------------------------
#  §3.2 тип `paint` — колір заливки контурних літер
# --------------------------------------------------------------------------
#  Один колір на завдання, задається САМЕ ТУТ, при збірці: вибір кольору
#  дитиною потребував би JavaScript, а на мобільних переглядачах його немає
#  (§3.0). Ключ — група поля (`p<стор>_q<завдання>`).
#  Для «зафарбуй літери з крапкою» (стор. 3) і «розфарбуй слова ангелів»
#  (стор. 19) колір по суті завдання неважливий; для віршів узяті теплі
#  відтінки, різні між завданнями, щоб буклет не виглядав одноманітно.
PAINT_COLORS = {
    "p03_q1": (0.99, 0.84, 0.31),   # сонячно-жовтий
    "p06_q2": (0.97, 0.65, 0.16),   # бурштиновий
    "p10_q2": (0.86, 0.40, 0.22),   # теракотовий
    "p17_q1": (0.80, 0.27, 0.40),   # тепло-малиновий
    "p19_q2": (0.96, 0.50, 0.42),   # кораловий
}
PAINT_DEFAULT = (0.98, 0.76, 0.28)
PAINT_STROKE = (0, 0, 0)        # обведення лишається чорним, як на друку

OVERLAY_COLORS = {              # для QA-оверлея
    "char":   (0.00, 0.35, 0.95),
    "text":   (0.00, 0.60, 0.20),
    "area":   (0.55, 0.10, 0.75),
    "choice": (1.00, 0.45, 0.00),
    "multi":  (0.00, 0.65, 0.65),
    "bool":   (0.85, 0.00, 0.55),
    "done":   (0.85, 0.10, 0.10),
    "ws":     (0.95, 0.55, 0.10),
    "paint":  (0.55, 0.30, 0.00),
}

TEST_TEXT = "Їжак Ґедзь дає п'ять"      # кирилиця з Ґ Ї Є і апострофом
TEST_CHAR = "Ґ"


# --------------------------------------------------------------------------
#  дрібні утиліти
# --------------------------------------------------------------------------
def pdf_name(s):
    """Кириличний рядок -> PDF-ім'я з #XX-екрануванням UTF-8 байтів."""
    out = "/"
    for b in s.encode("utf-8"):
        c = chr(b)
        if b < 0x21 or b > 0x7E or c in "#()<>[]{}/%":
            out += "#%02X" % b
        else:
            out += c
    return out


def set_need_appearances(doc, on):
    """Поставити AcroForm/NeedAppearances і ПЕРЕВІРИТИ значення.

    `doc.need_appearances()` як геттер бреше: повертає True, щойно ключ
    присутній, незалежно від значення. Тому ставимо напряму, а читаємо сам
    словник AcroForm (§3.6).
    """
    acro = doc.pdf_catalog()
    doc.xref_set_key(acro, "AcroForm/NeedAppearances", "true" if on else "false")
    val = doc.xref_get_key(acro, "AcroForm")[1] or ""
    m = re.search(r"/NeedAppearances\s*(true|false)", val)
    got = m.group(1) if m else "(ключа немає)"
    want = "true" if on else "false"
    if got != want:
        raise SystemExit("NeedAppearances: очікували %s, у словнику %s" % (want, got))
    return got


def acroform_fields(doc):
    _, val = doc.xref_get_key(doc.pdf_catalog(), "AcroForm/Fields")
    return [int(x) for x in re.findall(r"(\d+)\s+0\s+R", val or "")]


def set_acroform_fields(doc, xrefs):
    doc.xref_set_key(doc.pdf_catalog(), "AcroForm/Fields",
                     "[" + " ".join("%d 0 R" % x for x in xrefs) + "]")


def next_version(pattern_dir, stem):
    """forms/<stem>_v01.pdf, _v02, ... — ніколи не перезаписуємо (§2.7.0.1)."""
    rx = re.compile(r"^%s_v(\d+)\.pdf$" % re.escape(stem))
    mx = 0
    if os.path.isdir(pattern_dir):
        for fn in os.listdir(pattern_dir):
            m = rx.match(fn)
            if m:
                mx = max(mx, int(m.group(1)))
    return os.path.join(pattern_dir, "%s_v%02d.pdf" % (stem, mx + 1))


# --------------------------------------------------------------------------
#  §3.2 / §3.5 — віджети
# --------------------------------------------------------------------------
def base_widget(name, rect, ftype):
    w = fitz.Widget()
    w.field_name = name
    w.field_type = ftype
    w.rect = fitz.Rect(rect)
    w.fill_color = HILITE
    w.border_color = BORDER
    w.border_width = 0.5
    w.text_color = TXTCOL
    return w


def add_char(page, name, rect):
    w = base_widget(name, rect, fitz.PDF_WIDGET_TYPE_TEXT)
    w.text_maxlen = 1
    w.text_align = fitz.TEXT_ALIGN_CENTER
    w.text_fontsize = 0                    # автопідбір кегля
    return page.add_widget(w)


def add_text(page, name, rect):
    r = fitz.Rect(rect)
    w = base_widget(name, r, fitz.PDF_WIDGET_TYPE_TEXT)
    w.text_align = fitz.TEXT_ALIGN_LEFT
    w.text_fontsize = 11 if r.height >= 16 else 10
    return page.add_widget(w)


def add_area(page, name, rect):
    w = base_widget(name, rect, fitz.PDF_WIDGET_TYPE_TEXT)
    w.field_flags = fitz.PDF_TX_FIELD_IS_MULTILINE
    w.text_align = fitz.TEXT_ALIGN_LEFT
    w.text_fontsize = 10
    return page.add_widget(w)


def add_check(page, name, rect, kind="multi"):
    w = base_widget(name, rect, fitz.PDF_WIDGET_TYPE_CHECKBOX)
    if kind == "done":                     # §3.5: без заливки, лише рамка
        w.fill_color = None
        w.border_color = DONE_BORDER
    else:                                  # `multi` / `ws` лежать поверх друку
        w.fill_color = None
        w.border_color = None
        w.border_width = 0
    w.text_font = "ZaDb"                   # стиль «галочка»
    w.field_value = False
    a = page.add_widget(w)
    if kind == "ws":
        # клітинка філворда: кільце по краю клітинки, літера лишається читабельною
        circle_ap(page.parent, a.xref, fitz.Rect(rect), "/Yes", lw=WS_RING_W)
    elif kind != "done":
        circle_ap(page.parent, a.xref, fitz.Rect(rect), "/Yes")
    return a


# --------------------------------------------------------------------------
#  §3.2 — власний вигляд позначеного варіанта: «обведено ручкою»
# --------------------------------------------------------------------------
_XOBJ_CACHE = {}


def _form_xobject(doc, w, h, content):
    """Потік вигляду. Однакові потоки перевикористовуються: у сітці філворда
    всі клітинки однакові, і без кешу 212 клітинок дали б 424 зайві об'єкти."""
    key = (id(doc), round(w, 2), round(h, 2), content)
    x = _XOBJ_CACHE.get(key)
    if x is not None:
        return x
    x = doc.get_new_xref()
    doc.update_object(x, "<</Type/XObject/Subtype/Form/FormType 1"
                         "/BBox[0 0 %.2f %.2f]/Resources<<>>>>" % (w, h))
    doc.update_stream(x, content.encode("latin-1"), new=True)
    _XOBJ_CACHE[key] = x
    return x


def _ring_stream(w, h, lw=1.3, col=CIRCLE):
    """Заокруглений прямокутник по краю BBox — те, що дитина намалювала б
    ручкою навколо слова. Радіус = піввисоти, тож виглядає як овал."""
    x0 = y0 = lw / 2 + 0.2
    x1, y1 = w - lw / 2 - 0.2, h - lw / 2 - 0.2
    r = max(1.5, min((y1 - y0) / 2, (x1 - x0) / 2))
    k = 0.5523 * r
    p = [
        "%.2f %.2f m" % (x0 + r, y0),
        "%.2f %.2f l" % (x1 - r, y0),
        "%.2f %.2f %.2f %.2f %.2f %.2f c" % (x1 - r + k, y0, x1, y0 + r - k, x1, y0 + r),
        "%.2f %.2f l" % (x1, y1 - r),
        "%.2f %.2f %.2f %.2f %.2f %.2f c" % (x1, y1 - r + k, x1 - r + k, y1, x1 - r, y1),
        "%.2f %.2f l" % (x0 + r, y1),
        "%.2f %.2f %.2f %.2f %.2f %.2f c" % (x0 + r - k, y1, x0, y1 - r + k, x0, y1 - r),
        "%.2f %.2f l" % (x0, y0 + r),
        "%.2f %.2f %.2f %.2f %.2f %.2f c" % (x0, y0 + r - k, x0 + r - k, y0, x0 + r, y0),
        "h S",
    ]
    return "q %.3f %.3f %.3f RG %.2f w %s Q" % (col[0], col[1], col[2], lw, " ".join(p))


def circle_ap(doc, xref, rect, on_key, lw=1.3):
    """Замінити типовий вигляд кнопки: `on` — червона обвідка навколо слова,
    `Off` — порожній потік. Типова чорна крапка в центрі лягала б просто на
    першу літеру варіанта (§3.2).

    Заливки в потоці немає (він суто стрококовий, `... h S Q`), тож фон
    домальовує переглядач. Прибираємо всі джерела з боку файлу (рішення
    замовника №2):
      * `/MK` без `/BG` і `/BC` — ні фонового кольору, ні кольору рамки;
      * `/BS` з нульовою шириною — рамки немає взагалі;
      * `/H /N` — режим підсвітки «нічого» при натисканні.
    """
    w, h = rect.width, rect.height
    on = _form_xobject(doc, w, h, _ring_stream(w, h, lw=lw))
    off = _form_xobject(doc, w, h, "")
    doc.xref_set_key(xref, "AP/N", "<</Off %d 0 R%s %d 0 R>>" % (off, on_key, on))
    doc.xref_set_key(xref, "AS", "/Off")
    doc.xref_set_key(xref, "MK", "<<>>")
    doc.xref_set_key(xref, "BS", "<</W 0/S/S>>")
    doc.xref_set_key(xref, "H", "/N")
    return on, off


# --------------------------------------------------------------------------
#  §3.2 тип `paint` — заливка контурної літери ВСЕРЕДИНІ її ж контуру
# --------------------------------------------------------------------------
_FONT_CACHE = {}


def page_font_xref(page, fontname):
    """xref шрифту, яким надруковано контурний напис, у ресурсах сторінки.

    Потік вигляду віджета — окремий Form XObject зі своїми `/Resources`, і щоб
    намалювати в ньому той самий гліф, потрібен той самий шрифт. Вбудовувати
    його вдруге не треба (і не можна: §3.6 забороняє поповнювати `AcroForm/DR`)
    — беремо ГОТОВИЙ об'єкт шрифту зі сторінки й посилаємося на нього.
    """
    key = (id(page.parent), page.number, fontname)
    x = _FONT_CACHE.get(key)
    if x is not None:
        return x
    want = fontname.replace(" ", "").lower()
    for f in page.get_fonts(full=True):
        base = f[3].split("+")[-1].replace(" ", "").replace("#20", "").lower()
        if base == want:
            _FONT_CACHE[key] = f[0]
            return f[0]
    raise SystemExit("на стор. %d немає шрифту %r для заливки контурних літер"
                     % (page.number + 1, fontname))


def _paint_stream(gid, size, lw, tx, ty, fill, stroke=PAINT_STROKE):
    """Той самий гліф режимом «залити + обвести» (`2 Tr`).

    Чому не просто підсвітка позаду літери: замовник хотів справжню
    розмальовку — клік заливає літеру ВСЕРЕДИНІ контуру. Малювати заливку
    окремо від обведення не можна (заливка з'їла б внутрішню половину
    надрукованої обвідки й вона стала б удвічі тоншою), тому потік
    перемальовує гліф цілком: заливка кольором + обведення чорним із тією
    самою товщиною `lw`, що й на друку. Гліф, кегль і базова лінія взяті з
    `get_texttrace()` того самого файлу, тож збіг точний.
    """
    return ("q %.3f %.3f %.3f rg %.3f %.3f %.3f RG %.2f w 2 Tr "
            "BT /F1 %.3f Tf 1 0 0 1 %.3f %.3f Tm <%04X> Tj ET Q"
            % (fill[0], fill[1], fill[2], stroke[0], stroke[1], stroke[2],
               lw, size, tx, ty, gid))


def add_paint(page, name, rect, glyph, fill):
    doc = page.parent
    r = fitz.Rect(rect)
    w = base_widget(name, r, fitz.PDF_WIDGET_TYPE_CHECKBOX)
    w.fill_color = None                    # віджет лежить ПОВЕРХ друкованої літери
    w.border_color = None
    w.border_width = 0
    w.text_font = "ZaDb"
    w.field_value = False
    a = page.add_widget(w)

    fx = page_font_xref(page, glyph["font"])
    content = _paint_stream(glyph["gid"], glyph["size"], glyph["lw"],
                            glyph["ox"] - r.x0,      # у координати XObject:
                            r.y1 - glyph["oy"],      # BBox [0 0 w h] -> /Rect
                            fill)
    on = doc.get_new_xref()
    doc.update_object(on, "<</Type/XObject/Subtype/Form/FormType 1"
                          "/BBox[0 0 %.3f %.3f]/Resources<</Font<</F1 %d 0 R>>>>>>"
                      % (r.width, r.height, fx))
    doc.update_stream(on, content.encode("latin-1"), new=True)
    off = _form_xobject(doc, r.width, r.height, "")
    doc.xref_set_key(a.xref, "AP/N", "<</Off %d 0 R/Yes %d 0 R>>" % (off, on))
    doc.xref_set_key(a.xref, "AS", "/Off")
    doc.xref_set_key(a.xref, "MK", "<<>>")
    doc.xref_set_key(a.xref, "BS", "<</W 0/S/S>>")
    doc.xref_set_key(a.xref, "H", "/N")
    return a


def add_combo(page, name, rect, values):
    w = base_widget(name, rect, fitz.PDF_WIDGET_TYPE_COMBOBOX)
    w.choice_values = list(values)
    w.text_fontsize = 10
    return page.add_widget(w)


def add_radio_group(doc, page, group, opts, rects):
    """Канонічна radio-група: одне батьківське поле + /Kids, on-стани —
    самі українські слова (§3.4). PyMuPDF сам такої структури не робить.

    Кнопка невидима, доки не позначена (ні заливки, ні рамки — вона лежить
    ПОВЕРХ друкованого варіанта). Позначена малює червону обвідку навколо
    слова власним потоком вигляду; типова чорна крапка PyMuPDF лягала б на
    першу літеру варіанта (§3.2).
    """
    kids = []
    for opt, r in zip(opts, rects):
        w = base_widget(group, r, fitz.PDF_WIDGET_TYPE_RADIOBUTTON)
        w.fill_color = None                # прозоро поверх друкованого варіанта
        w.border_color = None
        w.border_width = 0
        w.field_value = False
        a = page.add_widget(w)
        circle_ap(doc, a.xref, fitz.Rect(r), pdf_name(opt))
        kids.append((a.xref, opt))

    parent = doc.get_new_xref()
    kidrefs = " ".join("%d 0 R" % x for x, _ in kids)
    doc.update_object(parent,
                      "<</FT/Btn/Ff 49152/T(%s)/V/Off/DV/Off"
                      "/DA(/Helv 0 Tf 0 0 0.55 rg)/Kids[%s]>>" % (group, kidrefs))
    for xref, _ in kids:
        for k in ("T", "FT", "Ff", "V", "DV"):
            doc.xref_set_key(xref, k, "null")
        doc.xref_set_key(xref, "Parent", "%d 0 R" % parent)
        doc.xref_set_key(xref, "AS", "/Off")

    kidset = {x for x, _ in kids}
    set_acroform_fields(doc, [x for x in acroform_fields(doc) if x not in kidset]
                        + [parent])
    return parent


def merge_shared(doc, name):
    """Одне логічне поле на кілька сторінок (рішення замовника №6).

    «Ім'я» й «Учитель» мають однакове ім'я на всіх сторінках, тож дитина
    вписує ім'я один раз. Технічно це робиться батьківським полем із /Kids:
    значення живе в /V батька, віджети лишаються віджетами. Синхронізує сам
    переглядач, без JavaScript.

    Зробити поле редагованим лише на стор. 1 неможливо: ReadOnly (`Ff` біт 1)
    — ознака ПОЛЯ, а не окремого віджета, тож він або діє скрізь, або ніде.
    """
    kids = [w.xref for page in doc for w in page.widgets()
            if w.field_name == name]
    if len(kids) < 2:
        return None, len(kids)
    parent = doc.get_new_xref()
    doc.update_object(parent,
                      "<</FT/Tx/T(%s)/V()/DV()/Ff 0"
                      "/DA(0 0 0.55 rg /Helv 11 Tf)/Kids[%s]>>"
                      % (name, " ".join("%d 0 R" % x for x in kids)))
    for x in kids:
        for k in ("T", "FT", "V", "DV", "Ff"):
            doc.xref_set_key(x, k, "null")
        doc.xref_set_key(x, "Parent", "%d 0 R" % parent)
    kidset = set(kids)
    set_acroform_fields(doc, [x for x in acroform_fields(doc) if x not in kidset]
                        + [parent])
    return parent, len(kids)


# --------------------------------------------------------------------------
#  збірка
# --------------------------------------------------------------------------
def build(src, data, pages, out, need_app=False, paint_colors=None):
    colors = dict(PAINT_COLORS)
    colors.update(paint_colors or {})
    doc = fitz.open(src)
    stats = {}
    shared = {}                    # спільні імена -> кількість віджетів
    groups = {}                    # ім'я групи -> [(значення, rect)]

    for pno in pages:
        d = data["pages"][str(pno)]
        page = doc[pno]
        # спершу все, крім radio: radio правимо через xref, і додавання нових
        # віджетів після цього переписує AcroForm/Fields
        for f in d["fields"]:
            t, name, r = f["type"], f["name"], fitz.Rect(f["rect"])
            if t == "char":
                add_char(page, name, r)
            elif t == "text":
                add_text(page, name, r)
            elif t == "area":
                add_area(page, name, r)
            elif t == "done":
                add_check(page, name, r, kind="done")
            elif t == "multi":
                add_check(page, name, r, kind="multi")
            elif t == "ws":
                add_check(page, name, r, kind="ws")
            elif t == "paint":
                add_paint(page, name, r, f,
                          colors.get(f.get("group"), PAINT_DEFAULT))
            elif t == "bool":
                add_combo(page, name, r, f.get("values") or ["ПРАВДА", "НЕПРАВДА"])
            elif t == "choice":
                groups.setdefault((pno, f["group"]), []).append((f["on"], r))
                continue
            else:
                raise SystemExit("невідомий тип поля: %r" % t)
            stats[t] = stats.get(t, 0) + 1

    for (pno, group), opts in groups.items():
        add_radio_group(doc, doc[pno], group,
                        [o for o, _ in opts], [r for _, r in opts])
        stats["choice"] = stats.get("choice", 0) + len(opts)

    for name in data.get("shared_names") or ():
        parent, cnt = merge_shared(doc, name)
        if parent:
            shared[name] = cnt

    set_need_appearances(doc, need_app)
    doc.save(out, deflate=True, garbage=3)
    doc.close()
    return stats, shared


# --------------------------------------------------------------------------
#  §3.7.1 — оверлей
# --------------------------------------------------------------------------
def overlay(pdf, data, pages, outdir="render", dpi=110):
    os.makedirs(outdir, exist_ok=True)
    doc = fitz.open(pdf)
    files = []
    for pno in pages:
        page = doc[pno]
        d = data["pages"][str(pno)]
        for s in d["score_boxes"]:          # чорний список — сірим пунктиром
            page.draw_rect(fitz.Rect(s["rect"]), color=(0.5, 0.5, 0.5),
                           width=1.2, dashes="[3 3] 0")
        for f in d["fields"]:
            c = OVERLAY_COLORS.get(f["type"], (0, 0, 0))
            page.draw_rect(fitz.Rect(f["rect"]), color=c, width=1.1)
        fn = os.path.join(outdir, "fld_p%02d.png" % (pno + 1))
        page.get_pixmap(dpi=dpi).save(fn)
        files.append(fn)
    doc.close()
    return files


def ws_grids(data, pno):
    """Клітинки філворда сторінки, згруповані в ґратки: {grid: {(r, c): поле}}."""
    grids = {}
    for f in data["pages"][str(pno)]["fields"]:
        if f["type"] == "ws":
            grids.setdefault(f.get("grid", 1), {})[(f["r"], f["col"])] = f
    return grids


WS_DIRS = [(0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1)]


def find_word(cells, word):
    """Шлях клітинок, у яких лежить слово (усі 8 напрямків).

    Потрібно лише для ПЕРЕВІРКИ вигляду: у самому PDF пошуку немає й бути не
    може — автоперевірки правильності не робимо (§3.2, тип `ws`).
    """
    w = [c for c in word.upper() if c.isalpha()]
    if len(w) < 2:
        return None
    for (r, c) in sorted(cells):
        for dr, dc in WS_DIRS:
            path = [(r + dr * i, c + dc * i) for i in range(len(w))]
            if all(p in cells and cells[p]["ch"].upper() == w[i]
                   for i, p in enumerate(path)):
                return [cells[p]["name"] for p in path]
    return None


def mark_words(pdf, data, pages, words, outdir="render", need_app=False, dpi=150):
    """Позначити в кожній сітці філворда одне справжнє слово й відрендерити.

    Позначаємо ТІЛЬКИ через `/AS` віджета і `/V` поля — `widget.update()`
    знищив би власний потік вигляду (§3.6).
    """
    os.makedirs(outdir, exist_ok=True)
    doc = fitz.open(pdf)
    xrefs = {}
    for page in doc:
        for w in page.widgets():
            xrefs[w.field_name] = w.xref
    files, found = [], []
    for pno in pages:
        hit = None
        for gi, cells in sorted(ws_grids(data, pno).items()):
            for word in words:
                path = find_word(cells, word)
                if path:
                    hit = (word, path)
                    break
            if hit:
                break
        if not hit:
            found.append((pno, None, 0))
            continue
        word, path = hit
        for nm in path:
            x = xrefs.get(nm)
            if x is None:
                continue
            doc.xref_set_key(x, "AS", "/Yes")
            doc.xref_set_key(x, "V", "/Yes")
        found.append((pno, word, len(path)))
    set_need_appearances(doc, need_app)
    for pno, word, _ in found:
        fn = os.path.join(outdir, "ws_p%02d.png" % (pno + 1))
        doc[pno].get_pixmap(dpi=dpi).save(fn)
        files.append(fn)
    doc.close()
    return files, found


def paint_groups(data, pno):
    """Контурні літери сторінки, згруповані за завданням: {група: [поля]}."""
    out = {}
    for f in data["pages"][str(pno)]["fields"]:
        if f["type"] == "paint":
            out.setdefault(f.get("group") or "?", []).append(f)
    return out


def mark_paint(pdf, data, pages, outdir="render", need_app=False, dpi=150,
               every=2):
    """Позначити контурні літери й відрендерити (§3.7.1).

    Робимо два рендери на сторінку:
      * `paint_pNN.png` — кожна `every`-та літера. Саме він відповідає на
        головне питання: чи лягла заливка ВСЕРЕДИНУ контуру й чи не наїхала
        на сусідню незафарбовану літеру;
      * `paintall_pNN.png` — усі літери, тобто як виглядатиме виконане
        завдання.

    Позначаємо тільки через `/AS` віджета і `/V` поля: `widget.update()`
    знищив би власний потік вигляду (§3.6).
    """
    os.makedirs(outdir, exist_ok=True)
    files, info = [], []
    for tag, step in (("paint", every), ("paintall", 1)):
        doc = fitz.open(pdf)
        xrefs = {}
        for page in doc:
            for w in page.widgets():
                xrefs[w.field_name] = w.xref
        for pno in pages:
            n = 0
            for group, fs in sorted(paint_groups(data, pno).items()):
                for i, f in enumerate(fs):
                    if i % step:
                        continue
                    x = xrefs.get(f["name"])
                    if x is None:
                        continue
                    doc.xref_set_key(x, "AS", "/Yes")
                    doc.xref_set_key(x, "V", "/Yes")
                    n += 1
            if tag == "paint":
                info.append((pno, n))
        set_need_appearances(doc, need_app)
        for pno in pages:
            fn = os.path.join(outdir, "%s_p%02d.png" % (tag, pno + 1))
            doc[pno].get_pixmap(dpi=dpi).save(fn)
            files.append(fn)
        doc.close()
    return files, info


def mark_demo(pdf, pages, outdir="render", need_app=False, dpi=110, skip=()):
    """Позначити ПЕРШИЙ варіант у кожній групі й відрендерити — щоб побачити
    власний вигляд «обведено» (§3.2).

    Позначаємо ТІЛЬКИ через `/AS` віджета і `/V` поля. `widget.update()` тут
    використовувати не можна: він перегенеровує вигляд і знищує власний потік
    — побачиш чорну крапку й вирішиш, що обвідка не працює (§3.6)."""
    os.makedirs(outdir, exist_ok=True)
    doc = fitz.open(pdf)
    for x in acroform_fields(doc):
        _, kids = doc.xref_get_key(x, "Kids")
        ks = [int(k) for k in re.findall(r"(\d+) 0 R", kids or "")]
        if len(ks) < 2:
            continue
        _, ap = doc.xref_get_key(ks[0], "AP/N")
        names = [n for n in re.findall(r"/([^\s/]+)\s+\d+\s+0\s+R", ap or "")
                 if n != "Off"]
        if not names:
            continue
        doc.xref_set_key(x, "V", "/" + names[0])
        for i, k in enumerate(ks):
            doc.xref_set_key(k, "AS", "/" + names[0] if i == 0 else "/Off")
    for page in doc:
        for w in page.widgets():
            if w.field_name in skip:       # клітинки філворда — не всі підряд
                continue
            if w.field_type == fitz.PDF_WIDGET_TYPE_CHECKBOX:
                doc.xref_set_key(w.xref, "AS", "/Yes")
                doc.xref_set_key(w.xref, "V", "/Yes")
    set_need_appearances(doc, need_app)
    files = []
    for pno in pages:
        fn = os.path.join(outdir, "mark_p%02d.png" % (pno + 1))
        doc[pno].get_pixmap(dpi=dpi).save(fn)
        files.append(fn)
    doc.close()
    return files


# --------------------------------------------------------------------------
#  §3.7.2–4 — контракт, антиконтракт, унікальність
# --------------------------------------------------------------------------
def check_contract(data, pages):
    problems, ok = [], []
    for pno in pages:
        d = data["pages"][str(pno)]
        for t in d["tasks"]:
            mine = [f["name"] for f in d["fields"] if f.get("task") == t["n"]]
            if mine:
                ok.append((pno, t["n"], t["type"], sorted(set(mine))))
            else:
                problems.append("стор.%d Q%s (%s) — БЕЗ ПОЛІВ: %s"
                                % (pno + 1, t["n"], t["type"], t["instr"][:60]))
        orphan = [f["name"] for f in d["fields"] if f.get("task") is None]
        if orphan:
            problems.append("стор.%d поля поза переліком завдань: %s"
                            % (pno + 1, orphan))
    return ok, problems


def check_anti(data, pages):
    bad = []
    for pno in pages:
        d = data["pages"][str(pno)]
        boxes = [fitz.Rect(s["rect"]) for s in d["score_boxes"]]
        for f in d["fields"]:
            r = fitz.Rect(f["rect"])
            for b in boxes:
                inter = r & b
                if inter.is_valid and inter.width > 0.5 and inter.height > 0.5:
                    bad.append("%s сидить на боксі балів %s" % (f["name"], list(b)))
    return bad


def check_names(pdf, shared=()):
    """Наскрізна унікальність імен (§3.7.4).

    Виняток лише для `shared` («Ім'я», «Учитель»): там повтор імені — це і є
    механізм синхронізації, тож перевіряємо натомість, що всі однойменні
    віджети зібрані в ОДНЕ поле з /Kids.
    """
    doc = fitz.open(pdf)
    seen, dup, radios, sh = {}, [], {}, {}
    for page in doc:
        for w in page.widgets():
            n = w.field_name
            if n in shared:
                sh[n] = sh.get(n, 0) + 1
                continue
            if w.field_type == fitz.PDF_WIDGET_TYPE_RADIOBUTTON:
                radios[n] = radios.get(n, 0) + 1
                continue
            if n in seen:
                dup.append(n)
            seen[n] = 1
    # кожне спільне ім'я має бути рівно одним полем верхнього рівня
    tops = {}
    for x in acroform_fields(doc):
        m = re.search(r"/T\s*\((.*?)\)", doc.xref_object(x))
        if m:
            tops[m.group(1)] = tops.get(m.group(1), 0) + 1
    doc.close()
    bad_shared = [(n, c, tops.get(n, 0)) for n, c in sh.items() if tops.get(n, 0) != 1]
    thin = [(n, c) for n, c in radios.items() if c < 2]
    return dup, radios, thin, sh, bad_shared


def check_name_sync(pdf, tmp, name="child_name", value="Оксана Ґудзь-Її"):
    """Емпірична перевірка синхронізації (рішення №6): записати значення в
    поле, зберегти, відкрити заново й прочитати його на КОЖНІЙ сторінці, де є
    віджет цього поля."""
    doc = fitz.open(pdf)
    xref = None
    for x in acroform_fields(doc):
        if re.search(r"/T\s*\(%s\)" % re.escape(name), doc.xref_object(x)):
            xref = x
            break
    if xref is None:
        doc.close()
        return None
    doc.xref_set_key(xref, "V", "(%s)" % value)
    doc.save(tmp, deflate=True, garbage=3)
    doc.close()

    doc2 = fitz.open(tmp)
    got = []
    for page in doc2:
        for w in page.widgets():
            if w.field_name == name:
                got.append((page.number + 1, w.field_value))
    doc2.close()
    return value, got


def check_overlaps(data, pages):
    bad = []
    for pno in pages:
        d = data["pages"][str(pno)]
        fs = d["fields"]
        for i in range(len(fs)):
            for j in range(i + 1, len(fs)):
                a, b = fitz.Rect(fs[i]["rect"]), fitz.Rect(fs[j]["rect"])
                it = a & b
                if it.is_valid and it.width > 1 and it.height > 1:
                    bad.append("%s x %s" % (fs[i]["name"], fs[j]["name"]))
    return bad


# --------------------------------------------------------------------------
#  оверлей-дайджест: які сторінки треба дивитися очима
# --------------------------------------------------------------------------
SCORE_RE = re.compile(r"/\s*(\d+)")
DONE_SCORE = 3          # `done` замість справжнього вводу при балі >= цього


def digest(data, pages):
    """Перелік сторінок, які варто відкрити очима, і чому.

    Оверлеї будуються для всіх сторінок, але дивитися їх усі на 120 уроках
    неможливо. Сторінка потрапляє в дайджест, лише якщо детектор:
      * щось відкинув (`skipped`);
      * не зміг класифікувати завдання (`?` / «геометрії немає й словник
        мовчить»);
      * поставив `done` там, де бал >= DONE_SCORE — тобто завдання коштує
        багато, а вводу немає;
      * лишив завдання зовсім без полів.
    """
    out = []
    for pno in pages:
        d = data["pages"][str(pno)]
        why = []
        for s in d.get("skipped") or ():
            why.append("відкинуто: " + s)
        for t in d["tasks"]:
            mine = [f for f in d["fields"] if f.get("task") == t["n"]]
            m = SCORE_RE.search(t.get("score") or "")
            sc = int(m.group(1)) if m else 0
            if not mine:
                why.append("Q%s (%s) — БЕЗ ПОЛІВ" % (t["n"], t["type"]))
            if t["type"] == "?" or "словник мовчить" in (t.get("type_src") or ""):
                why.append("Q%s — тип не визначено: %s | %r"
                           % (t["n"], t.get("type_src"), t["instr"][:50]))
            if (all(f["type"] == "done" for f in mine) and mine and sc >= DONE_SCORE):
                why.append("Q%s — лише чекбокс «виконано», а бал %s"
                           % (t["n"], t.get("score")))
        if why:
            out.append((pno, why))
    return out


# --------------------------------------------------------------------------
#  §3.7.5 — круговий тест
# --------------------------------------------------------------------------
def roundtrip(pdf, tmp, shared=()):
    os.makedirs(os.path.dirname(tmp) or ".", exist_ok=True)
    doc = fitz.open(pdf)
    written = {}

    for page in doc:
        for w in page.widgets():
            ft = w.field_type
            if w.field_name in shared:
                continue           # спільні поля заповнюємо через батька
            if ft == fitz.PDF_WIDGET_TYPE_TEXT:
                val = TEST_CHAR if w.text_maxlen == 1 else TEST_TEXT
                w.field_value = val
                w.update()
                written[w.field_name] = val
            elif ft == fitz.PDF_WIDGET_TYPE_COMBOBOX:
                val = (w.choice_values or ["ПРАВДА"])[0]
                w.field_value = val
                w.update()
                written[w.field_name] = val
            elif ft == fitz.PDF_WIDGET_TYPE_CHECKBOX:
                w.field_value = True
                w.update()
                written[w.field_name] = "Yes"

    # radio: PyMuPDF не вміє кириличних on-станів через Widget.update(),
    # тому вмикаємо перший варіант правкою /V батька і /AS дітей
    acro = doc.pdf_catalog()
    fields = [int(x) for x in
              re.findall(r"(\d+) 0 R", doc.xref_get_key(acro, "AcroForm/Fields")[1] or "")]
    for x in fields:
        _, kids = doc.xref_get_key(x, "Kids")
        if not kids or doc.xref_get_key(x, "FT")[1] != "/Btn":
            continue
        kid_xrefs = [int(k) for k in re.findall(r"(\d+) 0 R", kids)]
        m = re.search(r"/T\s*\((.*?)\)", doc.xref_object(x))
        gname = m.group(1) if m else None
        # перший варіант групи
        first = None
        for k in kid_xrefs:
            _, ap = doc.xref_get_key(k, "AP/N")
            names = [n for n in re.findall(r"/([^\s/]+)\s+\d+\s+0\s+R", ap or "")
                     if n != "Off"]
            if names and first is None:
                first = "/" + names[0]
        for k in kid_xrefs:
            _, ap = doc.xref_get_key(k, "AP/N")
            doc.xref_set_key(k, "AS", first if first and first[1:] in (ap or "") else "/Off")
        if first and gname:
            doc.xref_set_key(x, "V", first)
            written[gname] = first

    # спільні текстові поля («Ім'я», «Учитель») — значення в /V батька
    for x in acroform_fields(doc):
        m = re.search(r"/T\s*\((.*?)\)", doc.xref_object(x))
        if m and m.group(1) in shared:
            doc.xref_set_key(x, "V", "(%s)" % TEST_TEXT)
            written[m.group(1)] = TEST_TEXT

    doc.save(tmp, deflate=True, garbage=3)
    doc.close()

    doc2 = fitz.open(tmp)
    got = {}
    for page in doc2:
        for w in page.widgets():
            n = w.field_name
            if w.field_type == fitz.PDF_WIDGET_TYPE_RADIOBUTTON:
                continue
            got[n] = w.field_value
    acro = doc2.pdf_catalog()
    fields = [int(x) for x in
              re.findall(r"(\d+) 0 R", doc2.xref_get_key(acro, "AcroForm/Fields")[1] or "")]
    for x in fields:
        _, kids = doc2.xref_get_key(x, "Kids")
        if not kids:
            continue
        m = re.search(r"/T\s*\((.*?)\)", doc2.xref_object(x))
        if m:
            got[m.group(1)] = doc2.xref_get_key(x, "V")[1]
    doc2.close()

    def unname(s):
        """/#D0#9E#D0#B4... -> «Одна» (переглядачі й PyMuPDF повертають
        по-різному, порівнюємо однаково розкодованими)."""
        if not isinstance(s, str) or not s.startswith("/"):
            return s
        raw = bytearray()
        i = 1
        while i < len(s):
            if s[i] == "#" and i + 2 < len(s):
                raw.append(int(s[i + 1:i + 3], 16))
                i += 3
            else:
                raw.extend(s[i].encode("utf-8"))
                i += 1
        return raw.decode("utf-8", "replace")

    miss = []
    for k, v in written.items():
        g = got.get(k)
        if v == "Yes":
            okv = g in (True, "Yes", "/Yes", "On", "/On")
        else:
            okv = (unname(g) == unname(v))
        if not okv:
            miss.append((k, v, g))
    return tmp, written, got, miss


# --------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--fields", default="ua_build/fields.json")
    ap.add_argument("--src", default=None, help="перевизначити вихідний PDF")
    ap.add_argument("--stem", default="Level2_UA_form")
    ap.add_argument("--outdir", default="forms")
    ap.add_argument("--pages", default="", help="напр. 3 (порожньо = всі з fields.json)")
    ap.add_argument("--need-appearances", action="store_true",
                    help="запасний варіант: поставити AcroForm/NeedAppearances "
                         "true (за замовчуванням false — з ним переглядач "
                         "затирає власний вигляд кнопок)")
    ap.add_argument("--mark-pages", default="",
                    help="сторінки для демо позначених варіантів (render/mark_pNN.png)")
    ap.add_argument("--mark-ws", default="",
                    help="слова через кому: позначити по одному справжньому "
                         "слову в кожній сітці філворда (render/ws_pNN.png)")
    ap.add_argument("--mark-paint", action="store_true",
                    help="позначити контурні літери через одну й відрендерити "
                         "(render/paint_pNN.png і paintall_pNN.png)")
    ap.add_argument("--paint-colors", default="",
                    help="перевизначити колір заливки: "
                         "p03_q1=#RRGGBB,p06_q2=#RRGGBB")
    ap.add_argument("--digest-only", action="store_true",
                    help="нічого не збирати — лише показати, які сторінки "
                         "треба переглянути очима")
    ap.add_argument("--quiet", action="store_true",
                    help="не друкувати повний перелік контракту")
    a = ap.parse_args()

    data = json.load(open(a.fields, encoding="utf-8"))
    src = a.src or data["source"]
    pages = ([int(x) for x in a.pages.split(",") if x.strip() != ""]
             if a.pages else sorted(int(k) for k in data["pages"]))
    shared = tuple(data.get("shared_names") or ())

    if a.digest_only:
        print_digest(data, pages)
        return 0

    pc = {}
    for item in a.paint_colors.split(","):
        if not item.strip():
            continue
        k, _, v = item.partition("=")
        v = v.strip().lstrip("#")
        pc[k.strip()] = tuple(int(v[i:i + 2], 16) / 255.0 for i in (0, 2, 4))

    out = next_version(a.outdir, a.stem)
    stats, sh = build(src, data, pages, out, need_app=a.need_appearances,
                      paint_colors=pc)

    print("PDF:", out)
    print("поля за типами:", stats, "разом", sum(stats.values()))
    if stats.get("paint"):
        used = sorted({f.get("group") for pno in pages
                       for f in data["pages"][str(pno)]["fields"]
                       if f["type"] == "paint"})
        print("кольори заливки:", ", ".join(
            "%s=%s" % (g, tuple(round(v, 2) for v in
                                pc.get(g, PAINT_COLORS.get(g, PAINT_DEFAULT))))
            for g in used))
    print("NeedAppearances:", "true" if a.need_appearances else "false",
          "| спільні поля:", sh or "немає")
    print("розмір: %.2f МБ (джерело %.2f МБ)"
          % (os.path.getsize(out) / 2**20, os.path.getsize(src) / 2**20))

    pngs = overlay(out, data, pages)
    print("оверлей: %d файлів (%s ... %s)" % (len(pngs), pngs[0], pngs[-1]))
    ws_names = {f["name"] for pno in pages
                for f in data["pages"][str(pno)]["fields"]
                if f["type"] in ("ws", "paint")}
    if a.mark_pages:
        mp = [int(x) for x in a.mark_pages.split(",") if x.strip() != ""]
        print("демо «обведено»:", ", ".join(mark_demo(out, mp, skip=ws_names)))
    if a.mark_paint:
        pp = sorted({pno for pno in pages if paint_groups(data, pno)})
        files, info = mark_paint(out, data, pp)
        print("\n--- перевірка контурних літер ---")
        for pno, n in info:
            print("   стор.%d: позначено %d літер через одну -> "
                  "render/paint_p%02d.png (усі: paintall_p%02d.png)"
                  % (pno + 1, n, pno + 1, pno + 1))
    if a.mark_ws:
        words = [w.strip() for w in a.mark_ws.split(",") if w.strip()]
        wsp = sorted({pno for pno in pages if ws_grids(data, pno)})
        files, found = mark_words(out, data, wsp, words)
        print("\n--- перевірка сіток філворда ---")
        for (pno, word, n), fn in zip(found, files):
            print("   стор.%d: %s -> %s" % (pno + 1,
                  ("позначено %r (%d клітинок)" % (word, n)) if word
                  else "жодного зі слів %s не знайдено" % words, fn))

    ok, prob = check_contract(data, pages)
    print("\n--- контракт §3.7.2 ---")
    if not a.quiet:
        for pno, n, t, names in ok:
            print("  стор.%d Q%s (%s): %s" % (pno + 1, n, t, ", ".join(names)))
    print("  завдань із полями: %d" % len(ok))
    print("  проблеми:", prob or "немає")

    print("--- антиконтракт §3.7.3 ---")
    print("  ", check_anti(data, pages) or "жодне поле не на боксі балів")

    dup, radios, thin, sh2, bad_shared = check_names(out, shared)
    print("--- імена §3.7.4 ---")
    print("   дублікати:", dup or "немає")
    print("   radio-груп: %d | груп із <2 кнопок: %s" % (len(radios), thin or "немає"))
    print("   спільні (свідомо): %s | зібрані неправильно: %s"
          % (sh2 or "немає", bad_shared or "немає"))
    print("   перетини полів:", check_overlaps(data, pages) or "немає")

    tmp, written, got, miss = roundtrip(out, "verify/roundtrip_form.pdf", shared)
    print("--- круговий тест §3.7.5 ---")
    print("   заповнено й зчитано: %d/%d" % (len(written) - len(miss), len(written)))
    print("   розбіжності:", miss or "немає")
    print("   файл:", tmp)

    if shared:
        res = check_name_sync(out, "verify/name_sync.pdf")
        print("--- синхронізація «Ім'я» ---")
        if res is None:
            print("   поля child_name немає")
        else:
            val, got2 = res
            bad = [g for g in got2 if g[1] != val]
            print("   записали %r в поле, зчитали на %d сторінках: %s"
                  % (val, len(got2), "усі збіглися" if not bad else bad))

    print_digest(data, pages)
    return 0


def print_digest(data, pages):
    dg = digest(data, pages)
    print("\n--- оверлей-дайджест ---")
    if not dg:
        print("   дивитись очима: жодної сторінки")
        return
    print("   дивитись очима: стор. %s"
          % ", ".join(str(p + 1) for p, _ in dg))
    for pno, why in dg:
        print("   стор.%d (render/fld_p%02d.png):" % (pno + 1, pno + 1))
        for w in why:
            print("      - " + w)


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