# -*- coding: utf-8 -*-
"""Генератор і рендер сіток-головоломок для перекладених буклетів.

Навіщо. Кросворди в оригіналі намальовані векторами під **англійські** довжини
слів: ПЛІД(4) не лягає туди, де було FRUIT(5), а БЛАГОСЛОВИВ(11) — туди, де
BLESSED(7). §2.6 каже: спершу спробувати скласти справжній кросворд цільовою
мовою, і лише якщо перетини не сходяться — чесно замінити на нумеровану сітку
й сказати про це. Тут — перше: розкладка з максимумом перетинів.

Модуль спільний для всіх уроків; специфіка уроку (слова, підказки, координати)
живе у файлі перекладу.
"""
import random

import fitz

_RND = random.Random(0)


# ─────────────────────────────────────────────────────────── розкладка

class Placement:
    __slots__ = ('word', 'r', 'c', 'dr', 'dc')

    def __init__(self, word, r, c, dr, dc):
        self.word, self.r, self.c, self.dr, self.dc = word, r, c, dr, dc

    @property
    def across(self):
        return self.dc == 1

    def cells(self):
        return [(self.r + self.dr * i, self.c + self.dc * i)
                for i in range(len(self.word))]


def _fits(grid, word, r, c, dr, dc):
    """Чи можна покласти слово, не зламавши кросворд.

    Три умови, без яких виходить не кросворд, а купа слів:
    сусідні клітинки збігаються за літерою; перед початком і після кінця
    порожньо; кожна **неперетинна** клітинка не має сусідів збоку.
    """
    n = len(word)
    br, bc = r - dr, c - dc                     # клітинка перед словом
    er, ec = r + dr * n, c + dc * n             # клітинка після слова
    if grid.get((br, bc)) or grid.get((er, ec)):
        return False, 0
    hits = 0
    for i, ch in enumerate(word):
        rr, cc = r + dr * i, c + dc * i
        cur = grid.get((rr, cc))
        if cur is not None:
            if cur != ch:
                return False, 0
            hits += 1
            continue
        # порожня клітинка: збоку теж має бути порожньо
        for sr, sc in ((dc, dr), (-dc, -dr)):
            if grid.get((rr + sr, cc + sc)):
                return False, 0
    return True, hits


def place_crossword(words, tries=400, seed=0):
    """Розкладка з перезапусками: беремо найщільнішу, де звʼязані **всі** слова.

    Одного жадібного проходу мало: порядок вирішує. На стор. 2 буклета
    Level4_A1 при впорядкуванні лише за довжиною слово ДУХ не чіплялося
    ні до чого — його єдина спільна літера Д опинялася затиснутою між уже
    покладеними словами. Перемішування слів однакової довжини це розвʼязує.

    Повертає (placements, missed). `missed` не можна мовчки викидати —
    це має потрапити у звіт (§2.6).
    """
    import random
    items = [(w, None) if isinstance(w, str) else tuple(w) for w in words]
    best = None
    for t in range(tries):
        rnd = random.Random(seed + t)
        _RND.seed(seed + t)
        order = list(items)
        # перемішуємо, а на трьох спробах із чотирьох повертаємо порядок за
        # довжиною: довге слово першим дає більше місць для перетинів
        rnd.shuffle(order)
        if t % 4:
            order.sort(key=lambda it: len(it[0]), reverse=True)
        placed, missed = _place_once(order)
        _, _, rows, cols = extent(placed)
        score = (len(missed), rows * cols, max(rows, cols))
        if best is None or score < best[0]:
            best = (score, placed, missed)
        if not best[2] and best[0][1] <= sum(len(w) for w, _ in items) * 2:
            break
    return best[1], best[2]


def _place_once(order):
    """`order` — список (слово, орієнтація), орієнтація 'a'/'d' або None.

    Орієнтацію доводиться закріплювати, бо блоки підказок у макеті фіксовані:
    на стор. 2 буклета три рядки «по горизонталі» і три «по вертикалі». Якщо
    генератор покладе чотири слова вниз, підказки нікуди буде подіти.
    """
    grid, placed, missed = {}, [], []
    first, forient = order[0]
    fdir = (1, 0) if forient == 'd' else (0, 1)
    for i, ch in enumerate(first):
        grid[(fdir[0] * i, fdir[1] * i)] = ch
    placed.append(Placement(first, 0, 0, *fdir))

    for word, orient in order[1:]:
        cands = []
        for p in placed:
            for i, ch in enumerate(p.word):
                pr, pc = p.r + p.dr * i, p.c + p.dc * i
                dr, dc = (1, 0) if p.across else (0, 1)
                if orient and ((orient == 'a') != (dc == 1)):
                    continue
                for j, wch in enumerate(word):
                    if wch != ch:
                        continue
                    r, c = pr - dr * j, pc - dc * j
                    ok, hits = _fits(grid, word, r, c, dr, dc)
                    if not ok:
                        continue
                    # щільніше — краще: менший габарит виграє за рівних перетинів
                    cells = [(r + dr * k, c + dc * k) for k in range(len(word))]
                    span = (max(x for x, _ in list(grid) + cells)
                            - min(x for x, _ in list(grid) + cells)
                            + max(y for _, y in list(grid) + cells)
                            - min(y for _, y in list(grid) + cells))
                    score = (hits, -span)
                    cands.append((score, r, c, dr, dc))
        # Серед рівноцінних місць вибираємо випадково. Без цього жадібний
        # алгоритм детермінований і завжди робить ту саму помилку: на стор. 2
        # БЛАГОСЛОВИВ щоразу забирав єдину Л у слова ЗЕМЛЕЮ, після чого ПЛІД
        # і ДУХ могли чіплятися тільки одне до одного, тобто в окремий острів.
        if cands:
            top = max(c[0] for c in cands)
            best = _RND.choice([c for c in cands if c[0] == top])
        else:
            best = None
        if best is None:
            missed.append((word, orient))
            continue
        _, r, c, dr, dc = best
        for k, ch in enumerate(word):
            grid[(r + dr * k, c + dc * k)] = ch
        placed.append(Placement(word, r, c, dr, dc))

    # Другий прохід. Слово, яке не чіплялося на своєму місці в черзі, часто
    # чіпляється, коли покладено всі інші: на стор. 2 ланцюг ЗЕМЛЕЮ → ПЛІД →
    # ДУХ замикається лише в такому порядку.
    for _ in range(len(missed)):
        still = []
        for word, orient in missed:
            best = None
            for p in placed:
                for i, ch in enumerate(p.word):
                    pr, pc = p.r + p.dr * i, p.c + p.dc * i
                    dr, dc = (1, 0) if p.across else (0, 1)
                    if orient and ((orient == 'a') != (dc == 1)):
                        continue
                    for j, wch in enumerate(word):
                        if wch != ch:
                            continue
                        r, c = pr - dr * j, pc - dc * j
                        ok, hits = _fits(grid, word, r, c, dr, dc)
                        if ok and (best is None or hits > best[0]):
                            best = (hits, r, c, dr, dc)
            if best is None:
                still.append((word, orient))
                continue
            _, r, c, dr, dc = best
            for k, ch in enumerate(word):
                grid[(r + dr * k, c + dc * k)] = ch
            placed.append(Placement(word, r, c, dr, dc))
        if len(still) == len(missed):
            break
        missed = still
    return placed, [w for w, _ in missed]


def number(placed):
    """Номери в порядку читання, як у справжньому кросворді.

    Клітинка, з якої починається слово, дістає номер; якщо з неї починаються
    і горизонтальне, і вертикальне слово — номер спільний.
    """
    starts = sorted({(p.r, p.c) for p in placed})
    nums = {}
    for i, rc in enumerate(sorted(starts, key=lambda t: (t[0], t[1])), start=1):
        nums[rc] = i
    across = [(nums[(p.r, p.c)], p) for p in placed if p.across]
    down = [(nums[(p.r, p.c)], p) for p in placed if not p.across]
    return nums, sorted(across), sorted(down)


def extent(placed):
    cells = [rc for p in placed for rc in p.cells()]
    r0 = min(r for r, _ in cells); r1 = max(r for r, _ in cells)
    c0 = min(c for _, c in cells); c1 = max(c for _, c in cells)
    return r0, c0, r1 - r0 + 1, c1 - c0 + 1


# ─────────────────────────────────────────────────────────── малювання

def cover(page, rects, wipes=None):
    """Прибрати стару сітку: текст видалити, графіку **закрити** білим.

    `PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED` не використовуємо ніколи — він
    знімає обведення й у фігур поза прямокутником (§2.1).
    """
    for r in rects:
        page.add_redact_annot(fitz.Rect(r))
    page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE,
                          graphics=fitz.PDF_REDACT_LINE_ART_NONE,
                          text=fitz.PDF_REDACT_TEXT_REMOVE)
    for r in rects:
        page.draw_rect(fitz.Rect(r), color=None, fill=(1, 1, 1))
        if wipes is not None:
            wipes.append([page.number, list(map(float, r))])


def draw_grid(page, placed, nums, origin, cell, colour, width=0.8,
              numfont='helv', numsize=7.2):
    """Клітинки кросворда з номерами. `origin` — лівий верхній кут сітки."""
    r0, c0, _, _ = extent(placed)
    ox, oy = origin
    drawn = set()
    for p in placed:
        for (r, c) in p.cells():
            if (r, c) in drawn:
                continue
            drawn.add((r, c))
            x = ox + (c - c0) * cell
            y = oy + (r - r0) * cell
            page.draw_rect(fitz.Rect(x, y, x + cell, y + cell),
                           color=colour, width=width)
    for (r, c), n in nums.items():
        x = ox + (c - c0) * cell
        y = oy + (r - r0) * cell
        page.insert_text((x + 1.4, y + numsize + 0.6), str(n),
                         fontname=numfont, fontsize=numsize, color=colour)
    return len(drawn)


def draw_rows(page, origin, cell, lengths, colour, width=0.8, gap=0.0,
              numfont='helv', numsize=7.2, numbers=True):
    """Нумеровані рядки клітинок — заміна сітці, коли перетини не сходяться.

    Саме цей варіант §2.6 називає чесною заміною: механіка «впиши слово»
    зберігається, зникає лише читання по діагоналі.
    """
    ox, oy = origin
    for i, n in enumerate(lengths):
        y = oy + i * (cell + gap)
        if numbers:
            page.insert_text((ox - 11, y + cell * 0.72), f'{i + 1}.',
                             fontname=numfont, fontsize=numsize, color=colour)
        for k in range(n):
            x = ox + k * cell
            page.draw_rect(fitz.Rect(x, y, x + cell, y + cell),
                           color=colour, width=width)


def draw_cells(page, origin, cell, n, colour, width=0.8, pitch=None):
    """Ряд окремих клітинок під коротку відповідь."""
    ox, oy = origin
    pitch = pitch or cell
    for k in range(n):
        x = ox + k * pitch
        page.draw_rect(fitz.Rect(x, oy, x + cell, oy + cell),
                       color=colour, width=width)


# ─────────────────────────────────────────── текст для обведення по крапках

def dotted_text(page, point, text, fontname, fontfile, fontsize,
                color=(0, 0, 0), dot=None, gap=None):
    """Напис із крапок — заміна шрифту `SassoonInfantDt`.

    У рівнях 0 і 1 великі вірші набрані шрифтом `SassoonInfantDtB`, де `Dt`
    означає *dotted*: гліфи складено з крапок, і дитина обводить їх олівцем.
    Кирилиці цей шрифт не має, тож ефект робимо самі: малюємо текст у режимі
    **обведення** (`render_mode = 1`) і вмикаємо для цього штриха **штриховий
    пунктир**.

    Пунктир не виставляється через API — `insert_text` його не знає. Але
    PyMuPDF дописує кожен виклик окремим потоком вмісту, тож достатньо
    поставити оператори на початок саме цього потоку: вони діятимуть на все
    обведення в ньому й ні на що більше.

    Крапки роблять не короткою рискою, а **штрихом нульової довжини з круглим
    наконечником** (`1 J` + `[0 gap] 0 d`): тоді кожна крапка — рівне коло
    діаметром у товщину лінії. Проста коротка риска дає дрібні рисочки, з яких
    літера не читається.

    `dot` — діаметр крапки в пунктах, `gap` — відстань між центрами. Обидва
    типово пропорційні кеглю (0.024 і 0.069), бо саме таке співвідношення
    підібрано порівнянням із оригінальним Sassoon на 55 pt: густіше — літера
    зливається в суцільну лінію, рідше — розсипається.

    Одна відмінність від оригіналу лишається свідомо: Sassoon-Dotted малює
    **скелет** літери однією лінією, а ми обводимо **контур** гліфа, тобто дві
    лінії на штрих. На вигляд це звичайне українське прописне завдання
    «обведи по крапках», тож для мети підходить.
    """
    dot = dot if dot is not None else fontsize * 0.024
    gap = gap if gap is not None else fontsize * 0.069
    before = set(page.get_contents())
    page.insert_font(fontname=fontname, fontfile=fontfile)
    page.insert_text(point, text, fontname=fontname, fontsize=fontsize,
                     color=color, render_mode=1,
                     border_width=dot / max(fontsize, 1e-6))
    new = [x for x in page.get_contents() if x not in before]
    if not new:                      # PyMuPDF дописав у наявний потік
        return False
    xref = new[-1]
    doc = page.parent
    prefix = f'1 J [0.01 {gap:.2f}] 0 d\n'.encode()
    doc.update_stream(xref, prefix + doc.xref_stream(xref))
    return True


def centre_in_cells(slots, letters, font, size):
    """Розкласти літери по колонках сітки, центруючи кожну в своїй клітинці.

    `slots` — [(y, x, ширина_оригінального_гліфа, ключ, рядок, спан)] у порядку
    читання. Повертає {ключ: {(рядок, спан): x}}.

    Навіщо. Якщо просто підставити українську літеру на місце англійської,
    вона стане лівим краєм там, де стояв лівий край чужого гліфа. Ширини
    різні, тож центри «пливуть» на кілька пунктів — сітка виглядає неохайно,
    а детектор філворда (він міряє крок між центрами) розсипає її на частини:
    на Level4_A1 з восьми рядів розпізнавалися лише чотири.

    Колонку задає медіана центрів оригінальних гліфів — вона стійка до
    поодиноких вузьких літер на кшталт «І».
    """
    cols = {}
    for y, x, w, key, li, si in slots:
        cols.setdefault(round(x / 4), []).append(x + w / 2)
    # злити близькі колонки в одну
    centres = sorted(sum(v) / len(v) for v in cols.values())
    merged = []
    for c in centres:
        if merged and c - merged[-1][-1] < 8:
            merged[-1].append(c)
        else:
            merged.append([c])
    cx = [sum(g) / len(g) for g in merged]

    out = {}
    for (y, x, w, key, li, si), ch in zip(slots, letters):
        centre = min(cx, key=lambda c: abs(c - (x + w / 2)))
        out.setdefault(key, {})[(li, si)] = centre - font.text_length(
            ch, fontsize=size) / 2
    return out
