#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Заготовка областей для розмальовування ілюстрації.

Половину роботи вже зроблено раніше: рендер областей — трафарети, адаптивне
розбиття, віджети, вимірювання покриття — лежить у `verify/highlight_*.py` і
накладається `art_apply.py`. Бракувало іншої половини: САМИХ областей. Їх
Редактор обводив полігонами руками, а це 30–40 хвилин на малюнок, тобто
160–210 годин на 317 завдань «розфарбуй малюнок» по всій програмі.

Тут заготовка робиться машиною, а Редакторові лишається переглянути,
злити зайве й назвати. Рішення замовника від 7 вересня 2026: колір задає
конвеєр, Редактор при потребі міняє, дитина тільки тицяє.

    .venv/bin/python ua_build/artseg.py <урок.pdf> --page 5 \
        --rect 297.6,374.3,489.6,796.3 --task q2 --out editor/

**Чому не «шукати замкнені контури».** Їх немає: кожна лінія намальована
тонким залитим багатокутником, а не обведенням. Тому працюємо по пікселях —
як і заготовка, що робилася для першого буклета.

**Розриви в лініях** — головна пастка заливки: крізь дірку в один піксель
фарба витікає в сусідню фігуру, і дві деталі стають однією. Замикаємо їх
морфологічно: лінії РОЗШИРЮЄМО на `gap/2`, ділимо площину, а потім
повертаємо мітки назад на розширення. Один параметр — «розрив якої ширини
вважаємо замкненим», і він відповідає тому, як людина дивиться на малюнок.

Піднімати цей параметр глобально не можна: на першому буклеті звуження на
1.6 pt повністю з'їло просвіт між пальцями. Тому радіус тут малий за
замовчуванням, а витоки шукаються окремо й називаються в звіті — щоб
Редактор латав їх точково, а не всім малюнком.
"""
import argparse
import collections
import json
import math
import os
import re
import sys
from datetime import datetime

import fitz

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

INK_THR = 200          # темніше за це — лінія
MIN_AREA_PT2 = 30.0    # менші деталі зливаємо: у них не влучають пальцем
DP_EPS_PX = 0.8        # спрощення контуру
LEAK_SHARE = 0.55      # область, що зайняла стільки площі малюнка, — витік
SCRAP_PT2 = 400.0      # одинока область, менша за це, — обрізок, не деталь

# Палітра заготовки. Кольори світлі: заливка непрозора й лягає ПІД чорні
# штрихи, тож темний колір ховає сам малюнок.
PALETTE = ["#f6c14b", "#f29b6b", "#e58080", "#d98cb3", "#a892d4",
           "#7fa8dd", "#6fc2d0", "#77c69a", "#a9c96b", "#c9a978",
           "#e0b0a0", "#9fb8c8"]


# ─────────────────────────────────────────────────────────────── растр

def ink_mask(page, rect, dpi, thr=INK_THR):
    """Маска ліній: 1 — фарба. -> (маска, ширина, висота, масштаб px/pt)."""
    pix = page.get_pixmap(dpi=dpi, clip=rect, colorspace=fitz.csGRAY, alpha=False)
    w, h, s = pix.width, pix.height, pix.samples
    tbl = bytes(1 if v < thr else 0 for v in range(256))
    m = bytearray(w * h)
    for y in range(h):
        row = s[y * pix.stride:y * pix.stride + w]
        m[y * w:(y + 1) * w] = row.translate(tbl)
    return m, w, h, dpi / 72.0


def add_text(m, w, h, page, rect, scale):
    """Домалювати в маску прямокутники надрукованого тексту.

    Інакше просвіти літер стають «областями»: на першому буклеті просвіт
    літери має 4–14 pt², а рот персонажа — 4.2 pt², тож порогом площі їх
    не розділити. Прямокутник спана — груба, але надійна межа.
    """
    n = 0
    for blk in page.get_text("dict", clip=rect)["blocks"]:
        if blk["type"] != 0:
            continue
        for line in blk["lines"]:
            for sp in line["spans"]:
                x0 = int((sp["bbox"][0] - rect.x0) * scale) - 1
                y0 = int((sp["bbox"][1] - rect.y0) * scale) - 1
                x1 = int(math.ceil((sp["bbox"][2] - rect.x0) * scale)) + 1
                y1 = int(math.ceil((sp["bbox"][3] - rect.y0) * scale)) + 1
                for y in range(max(0, y0), min(h, y1)):
                    base = y * w
                    for x in range(max(0, x0), min(w, x1)):
                        m[base + x] = 1
                n += 1
    return n


def dilate(m, w, h, r):
    """Розширення бінарної маски на `r` пікселів, роздільне по осях."""
    if r <= 0:
        return m
    tmp = bytearray(w * h)
    for y in range(h):
        base, acc = y * w, [0] * (w + 1)
        for x in range(w):
            acc[x + 1] = acc[x] + m[base + x]
        for x in range(w):
            a, b = max(0, x - r), min(w, x + r + 1)
            tmp[base + x] = 1 if acc[b] - acc[a] else 0
    out = bytearray(w * h)
    for x in range(w):
        acc = [0] * (h + 1)
        for y in range(h):
            acc[y + 1] = acc[y] + tmp[y * w + x]
        for y in range(h):
            a, b = max(0, y - r), min(h, y + r + 1)
            out[y * w + x] = 1 if acc[b] - acc[a] else 0
    return out


# ─────────────────────────────────────────────────── зв'язні компоненти

def label(mask, w, h):
    """Позначити зв'язні порожні ділянки (4-звʼязність). -> (мітки, скільки)."""
    lab = [0] * (w * h)
    parent = [0]

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[max(ra, rb)] = min(ra, rb)

    nxt = 1
    for y in range(h):
        base = y * w
        for x in range(w):
            i = base + x
            if mask[i]:
                continue
            up = lab[i - w] if y and not mask[i - w] else 0
            left = lab[i - 1] if x and not mask[i - 1] else 0
            if up and left:
                lab[i] = min(up, left)
                union(up, left)
            elif up or left:
                lab[i] = up or left
            else:
                lab[i] = nxt
                parent.append(nxt)
                nxt += 1
    remap, cnt = {}, 0
    for i in range(w * h):
        if lab[i]:
            r = find(lab[i])
            if r not in remap:
                cnt += 1
                remap[r] = cnt
            lab[i] = remap[r]
    return lab, cnt


def grow(lab, mask, w, h, rounds):
    """Повернути мітки на розширення ліній — щоб області стикалися по осі
    штриха, а не лишали між собою нічийну смугу."""
    for _ in range(rounds):
        add = []
        for y in range(h):
            base = y * w
            for x in range(w):
                i = base + x
                if lab[i]:
                    continue
                for j in ((i - w) if y else -1, (i + w) if y < h - 1 else -1,
                          (i - 1) if x else -1, (i + 1) if x < w - 1 else -1):
                    if j >= 0 and lab[j]:
                        add.append((i, lab[j]))
                        break
        if not add:
            break
        for i, v in add:
            lab[i] = v
    return lab


# ─────────────────────────────────────────────────────────── контури

def rings(lab, w, h, target):
    """Контури області `target` як замкнені кільця в пікселях.

    Збираємо одиничні відрізки на межі «моє / не моє» й зшиваємо їх у петлі.
    Дірки виходять окремими кільцями — саме те, що треба: перевірка
    належності точки в `highlight_test.inside()` рахує парність перетинів
    по ВСІХ шляхах, тож дірка сама себе вирізає.
    """
    edges = {}
    for y in range(h):
        base = y * w
        for x in range(w):
            if lab[base + x] != target:
                continue
            if y == 0 or lab[base - w + x] != target:
                edges[(x + 1, y)] = (x, y)
            if y == h - 1 or lab[base + w + x] != target:
                edges[(x, y + 1)] = (x + 1, y + 1)
            if x == 0 or lab[base + x - 1] != target:
                edges[(x, y)] = (x, y + 1)
            if x == w - 1 or lab[base + x + 1] != target:
                edges[(x + 1, y + 1)] = (x + 1, y)
    out = []
    while edges:
        start = next(iter(edges))
        loop, p = [start], start
        while True:
            q = edges.pop(p, None)
            if q is None or q == start:
                break
            loop.append(q)
            p = q
        if len(loop) >= 4:
            out.append(loop)
    return out


def simplify(pts, eps):
    """Дуглас–Пекер для ЗАМКНЕНОГО кільця.

    Кільце не можна подавати в Дугласа–Пекера як є: перша й остання точки
    збігаються, відрізок між ними вироджений, відстань до нього для всіх
    точок нульова — і алгоритм чесно повертає дві точки замість контуру.
    Тому спершу ріжемо кільце на дві дуги в найдальшій від початку точці, а
    вже кожну дугу спрощуємо звичайним чином.
    """
    if len(pts) < 4:
        return pts

    def rdp(a):
        if len(a) < 3:
            return a
        x0, y0 = a[0]
        x1, y1 = a[-1]
        dx, dy = x1 - x0, y1 - y0
        n = math.hypot(dx, dy) or 1.0
        best, bi = -1.0, 0
        for i in range(1, len(a) - 1):
            d = abs(dy * (a[i][0] - x0) - dx * (a[i][1] - y0)) / n
            if d > best:
                best, bi = d, i
        if best <= eps:
            return [a[0], a[-1]]
        return rdp(a[:bi + 1])[:-1] + rdp(a[bi:])

    sys.setrecursionlimit(10000)
    x0, y0 = pts[0]
    far = max(range(len(pts)), key=lambda i: (pts[i][0] - x0) ** 2
              + (pts[i][1] - y0) ** 2)
    a = rdp(pts[:far + 1])
    b = rdp(pts[far:] + [pts[0]])
    return a[:-1] + b[:-1]


# ─────────────────────────────────────────────────────────── кольори

def neighbours(lab, w, h, ids):
    """Пари областей, розділених лише лінією (тобто сусідніх на малюнку)."""
    adj = collections.defaultdict(set)
    R = 4
    for y in range(h):
        base = y * w
        for x in range(w):
            a = lab[base + x]
            if not a:
                continue
            for dx, dy in ((R, 0), (0, R)):
                nx, ny = x + dx, y + dy
                if nx >= w or ny >= h:
                    continue
                b = lab[ny * w + nx]
                if b and b != a:
                    adj[a].add(b)
                    adj[b].add(a)
    return adj


def colourise(ids, adj):
    """Жадібне розфарбування: у сусідів не буває однакового кольору."""
    out = {}
    for i in sorted(ids, key=lambda k: -len(adj.get(k, ()))):
        used = {out[j] for j in adj.get(i, ()) if j in out}
        for c in PALETTE:
            if c not in used:
                out[i] = c
                break
        else:
            out[i] = PALETTE[len(out) % len(PALETTE)]
    return out


# ─────────────────────────────────────────────────────────────── збірка

def segment(pdf, pno, rect, dpi=150, gap_pt=0.8, min_area=MIN_AREA_PT2):
    doc = fitz.open(pdf)
    page = doc[pno]
    rect = fitz.Rect(rect)
    m, w, h, scale = ink_mask(page, rect, dpi)
    spans = add_text(m, w, h, page, rect, scale)
    r = max(0, int(round(gap_pt * scale / 2)))
    dil = dilate(m, w, h, r)
    lab, n = label(dil, w, h)

    # тло — компонента, що торкається краю малюнка
    border = set()
    for x in range(w):
        border.add(lab[x])
        border.add(lab[(h - 1) * w + x])
    for y in range(h):
        border.add(lab[y * w])
        border.add(lab[y * w + w - 1])
    border.discard(0)

    # Знаменник покриття рахуємо ДО відсіву, інакше він виходить
    # тавтологічним: відкинуті шматки зникають і з чисельника, і зі
    # знаменника, а покриття завжди дорівнює 100 %. Беремо все, що
    # всередині зовнішнього контуру й не є лінією.
    enclosed = sum(1 for i in range(w * h)
                   if not dil[i] and lab[i] and lab[i] not in border)
    area_px = collections.Counter(v for v in lab if v)
    px2 = (72.0 / dpi) ** 2
    art_px = w * h
    keep, small, leaks = [], 0, []
    for i, a in area_px.items():
        if i in border:
            continue
        if a * px2 < min_area:
            small += 1
            continue
        if a / art_px > LEAK_SHARE:
            leaks.append((i, a * px2))
        keep.append(i)

    for i in range(w * h):
        if lab[i] not in keep:
            lab[i] = 0
    lab = grow(lab, dil, w, h, r + 1)

    # Область, яка не межує з жодною іншою, — не частина малюнка, а
    # обрізок: просвіт біля напису, крапка над «і», плямка в кутку. Деталь
    # самої фігури завжди має сусіда через лінію. Великі одинокі області
    # лишаємо: це може бути окремий предмет поруч із персонажем.
    adj = neighbours(lab, w, h, keep)
    scraps = [i for i in keep
              if not adj.get(i) and area_px[i] * px2 < SCRAP_PT2]
    if scraps:
        keep = [i for i in keep if i not in scraps]
        for i in range(w * h):
            if lab[i] in scraps:
                lab[i] = 0
        adj = neighbours(lab, w, h, keep)
    col = colourise(keep, adj)

    regions = []
    for k, i in enumerate(sorted(keep, key=lambda v: -area_px[v]), 1):
        paths = []
        for loop in rings(lab, w, h, i):
            pts = simplify(loop, DP_EPS_PX)
            if len(pts) >= 3:
                paths.append([[round(rect.x0 + px / scale, 2),
                               round(rect.y0 + py / scale, 2)] for px, py in pts])
        if not paths:
            continue
        regions.append({"id": "a%02d" % k, "name": "", "color": col[i],
                        "paths": paths,
                        "trace_area_pt2": round(area_px[i] * px2, 1)})
    # Габарит малюнка — не та величина, з якою треба порівнювати: у ньому
    # здебільшого білий папір навколо персонажа. Питання «чи все
    # розфарбовується» стосується того, що ВСЕРЕДИНІ зовнішнього контуру:
    # це всі пікселі, крім тла й самих ліній.
    # Фігуру, яку прямокутник ріже, впіймати числом.
    #
    # Це та сама пастка, що й «нутро сполучилося з краєм», тільки з іншого
    # боку: обрізана частина стає ТЛОМ, тобто зникає і з чисельника, і зі
    # знаменника — покриття лишається 100 %, а на малюнку халат
    # нерозфарбований. Ознака — фарба, що доходить до самої межі
    # прямокутника: у правильно взятому кадрі там білий папір.
    touch = 0
    for x in range(w):
        touch += m[x] + m[(h - 1) * w + x]
    for y in range(h):
        touch += m[y * w] + m[y * w + w - 1]
    cut = touch > (w + h) * 0.04

    covered = sum(area_px[i] for i in keep)
    doc.close()
    return regions, {"px": (w, h), "spans": spans, "grow_px": r,
                     "components": n, "small": small, "leaks": leaks,
                     "scraps": len(scraps),
                     "cut": cut,
                     "art_pt2": round(rect.width * rect.height, 1),
                     "inside_pt2": round(enclosed * px2, 1),
                     "covered_pt2": round(covered * px2, 1)}


def merge_into(path, doc, rect):
    """Долучити нові області до вже наявних у файлі. -> скільки збережено.

    Файл областей один на сторінку й завдання, а малюнків на сторінці буває
    кілька. Доти кожне розпізнавання просто перезаписувало файл — і робота
    з попереднім малюнком зникала **мовчки**: Редактор розпізнала Захарія,
    зберегла, перезібрала, потім розпізнала Єлисавету — і Захарія не стало.
    Знайдено 8 вересня 2026.

    Обсяг заміни задає РАМКА, а не файл: області всередині нової рамки
    замінюються (це повторне розпізнавання того самого малюнка), а ті, що
    поза нею, лишаються недоторканими разом з іменами й кольорами, які їм
    дала людина. Так обидва випадки виходять природно: перезапустити з
    іншим «розривом» — і додати ще один малюнок.
    """
    if not os.path.exists(path):
        return 0
    try:
        with open(path, encoding="utf-8") as fh:
            old = json.load(fh)
    except (OSError, ValueError):
        return 0
    r = fitz.Rect(rect)
    keep = []
    for reg in old.get("regions") or []:
        pts = [p for pa in reg.get("paths") or [] for p in pa]
        if not pts:
            continue
        b = fitz.Rect(min(p[0] for p in pts), min(p[1] for p in pts),
                      max(p[0] for p in pts), max(p[1] for p in pts))
        inter = b & r
        # «всередині рамки» — більша частина площі, а не дотик кутом:
        # сусідній малюнок може заходити в рамку краєм.
        share = (inter.get_area() / b.get_area()) if b.get_area() else 0.0
        if share < 0.5:
            keep.append(reg)
    if not keep:
        return 0
    num = 0
    for reg in keep:
        m = re.search(r"(\d+)$", str(reg.get("id") or ""))
        if m:
            num = max(num, int(m.group(1)))
    for i, reg in enumerate(doc["regions"], num + 1):
        reg["id"] = "a%02d" % i
    doc["regions"] = keep + doc["regions"]
    if old.get("art_rect_pt"):
        o = fitz.Rect(old["art_rect_pt"])
        u = o | r
        doc["art_rect_pt"] = [round(v, 1) for v in u]
    return len(keep)


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("pdf")
    ap.add_argument("--page", type=int, required=True, help="сторінка з 1")
    ap.add_argument("--rect", required=True, help="x0,y0,x1,y1 у пунктах")
    ap.add_argument("--task", default="q1")
    ap.add_argument("--dpi", type=int, default=150)
    ap.add_argument("--gap", type=float, default=0.8,
                    help="розрив якої ширини (pt) вважаємо замкненим")
    ap.add_argument("--min-area", type=float, default=MIN_AREA_PT2)
    ap.add_argument("--out", default=os.path.join(ROOT, "editor"))
    ap.add_argument("--name", default=None,
                    help="ім'я файла набору (типово regions_p<NN>_<task>_auto.json)")
    a = ap.parse_args(argv)

    rect = [float(v) for v in a.rect.split(",")]
    regs, info = segment(a.pdf, a.page - 1, rect, a.dpi, a.gap, a.min_area)
    stem = os.path.splitext(os.path.basename(a.pdf))[0]
    doc = {"format": "bibletime-regions/1", "pdf": stem + ".pdf",
           "page": a.page, "page_index": a.page - 1, "dpi": a.dpi,
           "pt_per_px": 72.0 / a.dpi, "origin_pt": [0, 0], "task": a.task,
           "opacity": 1.0, "created": datetime.now().isoformat(timespec="seconds"),
           "art_rect_pt": [round(v, 1) for v in rect],
           "source": "artseg.py (заготовка, потребує перегляду Редактором)",
           "regions": regs}
    path = os.path.join(a.out, a.name or
                        "regions_p%02d_%s_auto.json" % (a.page, a.task))
    kept = merge_into(path, doc, rect)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(doc, f, ensure_ascii=False, indent=1)
    os.replace(tmp, path)

    cov = info["covered_pt2"]
    print("растр %dx%d px, лінії розширено на %d px, текстових спанів у масці: %d"
          % (info["px"][0], info["px"][1], info["grow_px"], info["spans"]))
    print("компонент знайдено %d -> областей %d "
          "(дрібних відкинуто %d, обрізків %d)"
          % (info["components"], len(regs), info["small"], info["scraps"]))
    print("площа областей %.0f pt²; всередині контуру %.0f pt² — покриття %.0f %%"
          % (cov, info["inside_pt2"],
             100.0 * cov / max(info["inside_pt2"], 1)))
    if info["cut"]:
        print("!! прямокутник ріже малюнок: фарба доходить до межі кадру. "
              "Обрізана частина стане тлом і НЕ розфарбовується — "
              "візьміть кадр із запасом.")
    if info["leaks"]:
        print("!! підозра на витік: %s"
              % ", ".join("область %d, %.0f pt²" % l for l in info["leaks"]))
    if kept:
        print("у файлі вже було %d областей поза цією рамкою — збережено "
              "разом з іменами й кольорами" % kept)
    print("->", os.path.relpath(path, ROOT))
    return 0


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