# -*- coding: utf-8 -*-
"""Extrahiert den amtlichen BVA-Fragenkatalog (§ 7 WaffG) in strukturiertes JSON.

Quelle: "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)",
Bundesverwaltungsamt, Stand 16.12.2024. Der Fragenwortlaut wird unverändert
übernommen (§ 62 UrhG); es findet keine inhaltliche Bearbeitung statt.

Layoutgrundlage (an der Vorlage vermessen, siehe explore_*.py):
  Spalte 1  x  71-103   amtliche Fragennummer
  Spalte 2  x 104-292   Fragetext
  Spalte 3  x 292-505   Antwortoptionen bzw. Musterantwort
  Spalte 4  x 505-524   Ankreuzkästchen (Kreuz = zwei Diagonalsegmente)

Ein Kästchen markiert jeweils den Beginn einer Antwortoption; Fragen ohne
Kästchen sind offene Fragen mit Musterantwort. Kernelemente der Musterantworten
sind im PDF unterstrichen (Füllrechtecke der Höhe ~0,84 pt).
"""
from __future__ import annotations

import argparse
import hashlib
import io
import json
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path

import fitz

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")

# --------------------------------------------------------------- Konstanten

FIRST_CONTENT_PAGE = 5          # 0-basiert; PDF-Seite 6 trägt die erste Frage
HEADER_BOTTOM = 92.0            # unterhalb davon beginnt der Fragenbereich
COL_NUM_END = 103.0
COL_QUESTION_END = 292.0
COL_ANSWER_END = 540.0        # Musterantworten laufen bis x≈530
CHECKBOX_MIN_X = 500.0        # linkeste Kästchenkante liegt bei x≈503

CHECKBOX_MIN, CHECKBOX_MAX = 6.0, 20.0
CHECKBOX_SQUARENESS = 4.0
UNDERLINE_MIN_H, UNDERLINE_MAX_H = 0.6, 1.6   # Tabellenlinien liegen bei ~0,48
UNDERLINE_MIN_W = 3.0
# Anteil der Wortbreite, den die Unterstreichung überdecken muss.
#
# War 0,55. Nachgemessen über alle Inhaltsseiten: 285 Wörter liegen deutlich
# über der Schwelle, genau ein einziges liegt zwischen 0,05 und 0,55 – die
# Verteilung hat dort eine echte Lücke, die Schwelle ist also nicht knapp
# gewählt. Dieses eine Wort ist „P2," in Frage IV-36: Der Balken unterstreicht
# „Unterklasse P2", überdeckt „P2," aber nur zu 51,7 %. Angezeigt wurde
# dadurch nicht die Antwort, sondern ihr halber Anfang.
#
# 0,40 holt es zurück und ändert im ganzen Katalog sonst nichts – kein
# einziger zusätzlicher Treffer, weil zwischen 0,05 und 0,52 nichts liegt.
UNDERLINE_MIN_OVERLAP = 0.40
LINE_TOLERANCE = 3.0            # y-Toleranz beim Gruppieren zu Textzeilen

LABEL_INDENT = 20.0             # Optionslabel stehen am linken Rand der Spalte

OPTION_LABELS = "abcdefgh"

# Nach einem Bindestrich am Zeilenende folgen diese Wörter bei einem
# Ergänzungsstrich ("Waffen- und Munitionsrecht") statt bei Silbentrennung.
CONJUNCTIONS = {
    "und", "oder", "bzw", "bzw.", "sowie", "wie", "als", "noch", "aber",
    "beziehungsweise", "respektive",
}

CHAPTER_TITLES = {
    "I": "Waffenrecht und sonstige Rechtsvorschriften",
    "II": "Waffentechnik (Waffen, Munition, Geschosse)",
    "III": "Handhabung von Schusswaffen und Munition",
    "IV": "Not- und Seenotsignalmittel",
}

CATALOG_META = {
    "titel": "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)",
    "herausgeber": "Bundesverwaltungsamt",
    "stand": "2024-12-16",
    "quellenangabe": (
        "Amtlicher Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG) "
        "des Bundesverwaltungsamtes, Stand 16.12.2024. Diese Software ist kein "
        "Angebot des Bundesverwaltungsamtes."
    ),
    "quelle_url": (
        "https://www.bva.bund.de/DE/Services/Buerger/Ausweis-Dokumente-Recht/"
        "Waffenrecht/Sachkundepruefung/sachkunde_node.html"
    ),
}

# ------------------------------------------------------------ Datenstrukturen


@dataclass
class Segment:
    """Textabschnitt mit Auszeichnung (für hervorgehobene Kernelemente)."""

    text: str
    hervorgehoben: bool = False


@dataclass
class Word:
    text: str
    x0: float
    y0: float
    x1: float
    y1: float
    underlined: bool = False

    @property
    def cy(self) -> float:
        return (self.y0 + self.y1) / 2


@dataclass
class Option:
    label: str
    segmente: list[Segment]
    korrekt: bool
    bilder: list[str] = field(default_factory=list)
    seite: int = 0             # Seite der Option (nur intern)
    bereich_start: float = 0.0  # y-Bereich der Option auf dieser Seite
    bereich_ende: float = 0.0

    @property
    def text(self) -> str:
        return "".join(s.text for s in self.segmente)


@dataclass
class Question:
    amtliche_nummer: str
    kapitel: str
    abschnitt: str | None
    abschnitt_titel: str | None
    seite: int
    frage_segmente: list[Segment] = field(default_factory=list)
    optionen: list[Option] = field(default_factory=list)
    antwort_segmente: list[Segment] = field(default_factory=list)
    bilder: list[str] = field(default_factory=list)
    warnungen: list[str] = field(default_factory=list)

    @property
    def typ(self) -> str:
        if self.optionen:
            return "mc"
        if "Lösung:" in "".join(s.text for s in self.antwort_segmente):
            return "lueckentext"
        return "freitext"

    @property
    def id(self) -> str:
        if self.kapitel == "I":
            teil, nummer = self.amtliche_nummer.split(".")
            return f"I.{teil}-{int(nummer):02d}"
        return f"{self.kapitel}-{int(self.amtliche_nummer):02d}"


# ------------------------------------------------------------- Hilfsfunktionen


def normalise(text: str) -> str:
    """Vereinheitlicht Sonderzeichen, ohne den Wortlaut zu verändern."""
    text = text.replace("\u00a0", " ").replace("\u2011", "-")
    text = unicodedata.normalize("NFC", text)
    return re.sub(r"[ \t]+", " ", text).strip()


# Ein Trennstrich innerhalb des letzten Worts einer Zeile, die selbst auf
# einen Trennstrich endet. Beide gehören zu derselben Silbenkette.
INNERER_TRENNSTRICH = re.compile(r"(?<=[a-zäöüß])-(?=[a-zäöüß])")


def _kette_aufloesen(part: str) -> tuple[str, str | None]:
    """Löst eine mehrfache Silbentrennung im letzten Wort einer Zeile auf.

    Das Original trennt gelegentlich zweimal in einem Wort: »er-wer-« am
    Zeilenende, »ben« in der nächsten Zeile – gemeint ist »erwerben«. Der
    Zeilenendstrich verschwindet hier ohnehin; blieb der innere stehen,
    entstand »er-werben«, also weder das gedruckte Bild noch das gemeinte
    Wort. Die Zusammenführung wäre auf halbem Weg stehengeblieben.

    Aufgelöst wird ausschließlich zwischen zwei **Kleinbuchstaben** und
    ausschließlich im letzten Wort einer Zeile, die auf einen Trennstrich
    endet. Damit bleiben zwei Gruppen unberührt, und das ist Absicht:

    * Komposita mit großem zweiten Glied (»Kleinkaliber-Repetier-« +
      »gewehr«, »CO2-Waffen«) – dort steht der Strich zu Recht.
    * Striche mitten in einer Zeile, ohne jeden Umbruch (»er-klärt« in
      Frage 2.123 b, »orange-farbenen« in IV-52, »lever-action« in 1.28).
      Sie sind kein Extraktionsschaden, sondern der gedruckte Wortlaut –
      teils ein Fehler des Herausgebers, teils richtig. Beides zu ändern
      hieße, am amtlichen Text zu arbeiten; das tut diese Pipeline nicht.

    Liefert das bereinigte Stück und, falls etwas aufgelöst wurde, einen
    Eintrag für die QS-Liste.
    """
    if " " in part:
        kopf, _, wort = part.rpartition(" ")
        kopf += " "
    else:
        kopf, wort = "", part
    if not INNERER_TRENNSTRICH.search(wort):
        return part, None
    return kopf + INNERER_TRENNSTRICH.sub("", wort), wort


def join_hyphenated(parts: list[str]) -> tuple[str, list[str]]:
    """Führt am Zeilenende getrennte Wörter zusammen.

    Liefert den Text und die Liste der Fälle, bei denen ein Ergänzungsstrich
    angenommen wurde (»Waffen- und …«) – diese gehen in den QS-Bericht.
    """
    out: list[str] = []
    ambiguous: list[str] = []
    for idx, part in enumerate(parts):
        part = part.rstrip()
        is_last = idx == len(parts) - 1
        if not is_last and part.endswith("-") and len(part) > 1:
            nxt = parts[idx + 1].lstrip()
            first_word = nxt.split(" ")[0].strip(",;.").lower() if nxt else ""
            tail = part[:-1].split(" ")[-1]
            if first_word in CONJUNCTIONS:
                # Ergänzungsstrich: »Kinder- und Jugendarbeit«
                ambiguous.append(f"{tail}- {first_word}")
                out.append(part + " ")
            elif nxt[:1].isupper():
                # Echter Bindestrich im Kompositum: »Physikalisch-Technische«
                ambiguous.append(f"{tail}-{nxt.split(' ')[0]}")
                out.append(part)
            elif nxt[:1].isdigit() or tail[:-1].endswith(tuple("0123456789")):
                # Bindestrich an einer Zahl – kein Silbentrennstrich.
                #
                # Zwei gemessene Fälle, beide bis Fassung 0.24.1 verfälscht:
                # »(DIN/EN 1143-« + »1)« wurde zu »DIN/EN 11431«, einer Norm,
                # die es nicht gibt – ausgerechnet in einer als richtig
                # markierten Antwort (I.4-17 b, PDF-Seite 71). Und »ein 13-« +
                # »jähriger« wurde zu »13jähriger«, während die eigene Antwort
                # a) derselben Frage »13-jähriger« schreibt (I.2-123,
                # PDF-Seite 53).
                #
                # Deutsch trennt nicht zwischen Ziffer und Folgesilbe: Wo vor
                # oder nach dem Strich eine Ziffer steht, ist er gedruckter
                # Wortlaut und kein Extraktionsartefakt.
                ambiguous.append(f"{tail}-{nxt.split(' ')[0]} (Zahl)")
                out.append(part)
            elif nxt[:1] and not nxt[:1].isalnum():
                # Der Strich schließt eine Einschaltung, die nächste Zeile
                # beginnt mit einem Satzzeichen: »allgemeine WBK -grün-« +
                # »(ohne Voreintrag)« (I.2-06 b, PDF-Seite 26). Bis Fassung
                # 0.24.1 fiel der schließende Strich weg UND das Leerzeichen
                # dazu – »-grün(ohne Voreintrag)«, ein Wortgebilde, das ein
                # Bildschirmleser in einem Zug vorliest.
                ambiguous.append(f"{tail}- {nxt.split(' ')[0]} (Satzzeichen)")
                out.append(part + " ")
            else:
                # Silbentrennung am Zeilenende: »Signalge-« + »bung«
                stueck, aufgeloest = _kette_aufloesen(part[:-1])
                if aufgeloest is not None:
                    ambiguous.append(f"{aufgeloest}- {first_word} (doppelt getrennt)")
                out.append(stueck)
        else:
            out.append(part + ("" if is_last else " "))
    return normalise("".join(out)), ambiguous


"""Ab dieser Weite ist der Zwischenraum keine Wortlücke mehr, sondern eine
Lücke zum Ausfüllen.

Gemessen am Original: Ein gewöhnlicher Wortabstand liegt bei rund 3 Punkt,
der weiteste innerhalb einer Spalte bei knapp 12. Die Lücken der einen
Lückentextfrage (5.01, Seite 73) sind 76 und 119 Punkt weit – sie beginnen
also erst weit jenseits jedes Wortabstands.
"""
LUECKE_AB_PUNKT = 24.0

"""Wie eine Lücke im Text dargestellt wird.

Fünf Unterstriche, weil das Original an dieser Stelle eine Schreiblinie
druckt. Kein erfundenes Wort: Was hier steht, gibt das gedruckte Bild wieder
und ergänzt den amtlichen Wortlaut nicht.

Die Sprachausgabe macht daraus „Lücke“ (`renderer/lernen/vorlesetexte.ts`) –
eine Reihe von Unterstrichen ist für einen Bildschirmleser sonst entweder
stumm oder Buchstabensalat.
"""
LUECKENZEICHEN = "_____"


def words_to_segments(lines: list[list[Word]]) -> tuple[list[Segment], list[str]]:
    """Baut aus Wortzeilen zusammenhängende Segmente mit Hervorhebungs-Flag."""
    line_texts: list[str] = []
    line_marks: list[list[bool]] = []
    for line in lines:
        stuecke: list[str] = []
        marks: list[bool] = []
        for i, w in enumerate(line):
            if i > 0:
                vorher = line[i - 1]
                if w.x0 - vorher.x1 >= LUECKE_AB_PUNKT:
                    # Eine Lücke gehört zu keinem Wort und trägt deshalb keine
                    # Hervorhebung – sonst stünde sie als „Kernelement“ in der
                    # Prüfliste unter der Musterantwort.
                    #
                    # Folgt ein Satzzeichen, entfällt das Leerzeichen dahinter:
                    # Im Original schließt das Komma unmittelbar an die
                    # Schreiblinie an, und „diejenige _____ ,“ wäre die einzige
                    # Stelle im ganzen Katalog mit einem Leerzeichen vor einem
                    # Komma.
                    nachsatz = "" if w.text[:1] in ",;.:!?" else " "
                    trenner = f" {LUECKENZEICHEN}{nachsatz}"
                    marks.extend([False] * len(trenner))
                else:
                    # Das Leerzeichen nach einem Wort erbt dessen Markierung –
                    # wie seit jeher; die Begründung steht unten.
                    trenner = " "
                    marks.append(vorher.underlined)
                stuecke.append(trenner)
            stuecke.append(w.text)
            marks.extend([w.underlined] * len(w.text))
        line_texts.append("".join(stuecke))
        line_marks.append(marks)

    merged_text, ambiguous = join_hyphenated(line_texts)

    # Hervorhebungen zeichenweise auf den zusammengeführten Text übertragen.
    #
    # Das Trennzeichen zwischen zwei Zeilen erbt die Markierung, wenn beide
    # Seiten markiert sind. Vorher stand hier fest `False`, und das zerriss jede
    # Unterstreichung, die über einen Zeilenumbruch lief.
    #
    # Nachgewiesen an Frage 1.03: Die Zeile endet mit unterstrichenem „zum“, die
    # nächste beginnt mit unterstrichenem „Angriff“. Die Worterkennung markiert
    # beide richtig – erst hier wurden sie getrennt. Angezeigt wurde daraufhin
    # unter „Diese Kernelemente muss Ihre Antwort enthalten“ als erster Punkt
    # das Wort „zum“.
    #
    # Innerhalb einer Zeile geschieht dasselbe längst: Das Leerzeichen nach
    # einem Wort erbt dessen Markierung (siehe `marks.extend` oben). Diese
    # Zeile stellt nur die Gleichbehandlung über den Umbruch hinweg her – sie
    # erfindet kein Zeichen, sondern führt zusammen, was die Quelle
    # nachweislich zusammen unterstrichen hat.
    #
    # Gemessen: 14 zerrissene Listen werden geheilt, 89 Einträge werden zu 71,
    # danach bleibt kein zerrissener Lauf und kein reiner Funktionswort-Eintrag
    # übrig.
    flat: list[bool] = []
    for i, marks in enumerate(line_marks):
        if i > 0:
            # Trennstelle zur vorigen Zeile: markiert, wenn beide Seiten es sind.
            vorher = flat[-1] if flat else False
            nachher = marks[0] if marks else False
            flat.append(vorher and nachher)
        flat.extend(marks)

    raw = " ".join(line_texts)
    segments = _align_marks(raw, flat, merged_text)
    return segments, ambiguous


def _align_marks(raw: str, marks: list[bool], merged: str) -> list[Segment]:
    """Überträgt zeichenweise Auszeichnungen vom Roh- auf den bereinigten Text."""
    if len(marks) < len(raw):
        marks = marks + [False] * (len(raw) - len(marks))
    result: list[Segment] = []
    ri = 0
    for ch in merged:
        # nächstes passendes Zeichen im Rohtext suchen (Trennstriche entfallen)
        while ri < len(raw) and raw[ri] != ch:
            ri += 1
        flag = marks[ri] if ri < len(marks) else False
        ri += 1
        if result and result[-1].hervorgehoben == flag:
            result[-1].text += ch
        else:
            result.append(Segment(ch, flag))
    # Whitespace am Rand eines hervorgehobenen Segments neutralisieren
    for seg in result:
        if seg.hervorgehoben and not seg.text.strip():
            seg.hervorgehoben = False
    return _merge_adjacent(result)


def _merge_adjacent(segments: list[Segment]) -> list[Segment]:
    merged: list[Segment] = []
    for seg in segments:
        if merged and merged[-1].hervorgehoben == seg.hervorgehoben:
            merged[-1].text += seg.text
        elif seg.text:
            merged.append(Segment(seg.text, seg.hervorgehoben))
    return [s for s in merged if s.text]


def group_lines(words: list[Word]) -> list[list[Word]]:
    """Gruppiert Wörter anhand ihrer Grundlinie zu Textzeilen."""
    lines: list[list[Word]] = []
    for w in sorted(words, key=lambda w: (round(w.cy, 1), w.x0)):
        if lines and abs(lines[-1][0].cy - w.cy) <= LINE_TOLERANCE:
            lines[-1].append(w)
        else:
            lines.append([w])
    for line in lines:
        line.sort(key=lambda w: w.x0)
    return lines


# ------------------------------------------------------------- Seitenanalyse


class PageData:
    """Aufbereitete Geometrie einer PDF-Seite."""

    def __init__(self, page: fitz.Page, page_no: int):
        self.page = page
        self.page_no = page_no
        self.checkboxes: list[fitz.Rect] = []
        self.diagonals: list[tuple[fitz.Point, fitz.Point]] = []
        self.underlines: list[fitz.Rect] = []
        self.row_separators: list[float] = []
        self._collect_vectors()
        self.words = self._collect_words()
        self.images = self._collect_images()

    def _collect_vectors(self) -> None:
        for d in self.page.get_drawings():
            filled = d.get("fill") is not None
            for item in d["items"]:
                if item[0] == "re":
                    r = item[1]
                    if (CHECKBOX_MIN < r.width < CHECKBOX_MAX
                            and abs(r.width - r.height) < CHECKBOX_SQUARENESS
                            and r.x0 > CHECKBOX_MIN_X):
                        self.checkboxes.append(r)
                    elif (filled and UNDERLINE_MIN_H < r.height < UNDERLINE_MAX_H
                            and r.width > UNDERLINE_MIN_W):
                        self.underlines.append(r)
                    elif (filled and r.height < UNDERLINE_MIN_H
                            and r.width > 300 and r.y0 > HEADER_BOTTOM):
                        self.row_separators.append(r.y0)
                elif item[0] == "l":
                    p1, p2 = item[1], item[2]
                    if abs(p1.x - p2.x) > 2 and abs(p1.y - p2.y) > 2:
                        self.diagonals.append((p1, p2))
                    elif (abs(p1.y - p2.y) < 0.7 and abs(p1.x - p2.x) > 300
                            and p1.y > HEADER_BOTTOM):
                        self.row_separators.append(p1.y)
        self.checkboxes.sort(key=lambda r: r.y0)
        self.row_separators = sorted(set(round(y, 1) for y in self.row_separators))

    def _collect_words(self) -> list[Word]:
        out: list[Word] = []
        for x0, y0, x1, y1, text, *_ in self.page.get_text("words"):
            if y0 < HEADER_BOTTOM:
                continue
            w = Word(normalise(text), x0, y0, x1, y1)
            if w.text:
                w.underlined = self._is_underlined(w)
                out.append(w)
        return out

    def _is_underlined(self, w: Word) -> bool:
        """Ein Wort gilt als hervorgehoben, wenn direkt darunter eine
        Unterstreichung mit deutlicher horizontaler Überlappung liegt."""
        for u in self.underlines:
            if not (-1.5 <= u.y0 - w.y1 <= 4.5):
                continue
            overlap = min(w.x1, u.x1) - max(w.x0, u.x0)
            if overlap > UNDERLINE_MIN_OVERLAP * (w.x1 - w.x0):
                return True
        return False

    def _collect_images(self) -> list[tuple[fitz.Rect, str]]:
        out: list[tuple[fitz.Rect, str]] = []
        for info in self.page.get_images(full=True):
            xref = info[0]
            for rect in self.page.get_image_rects(xref):
                if rect.y0 >= HEADER_BOTTOM:
                    out.append((rect, str(xref)))
        return out

    def is_checked(self, box: fitz.Rect) -> bool:
        hits = 0
        for p1, p2 in self.diagonals:
            inside = (box.x0 - 2 <= p1.x <= box.x1 + 2 and box.y0 - 2 <= p1.y <= box.y1 + 2
                      and box.x0 - 2 <= p2.x <= box.x1 + 2 and box.y0 - 2 <= p2.y <= box.y1 + 2)
            if inside:
                hits += 1
        return hits >= 2

    def question_anchors(self) -> list[tuple[str, float]]:
        """Amtliche Fragennummern der Seite mit ihrer y-Position."""
        pattern = re.compile(r"^(\d{1,3}\.\d{2,3}|\d{1,3})$")
        anchors = []
        for w in self.words:
            if w.x0 < COL_NUM_END and pattern.match(w.text):
                anchors.append((w.text, w.y0))
        anchors.sort(key=lambda a: a[1])
        return anchors


# ----------------------------------------------------------------- Extraktion


class CatalogParser:
    def __init__(self, pdf_path: Path, alt_path: Path | None = None):
        self.doc = fitz.open(pdf_path)
        self.pdf_path = pdf_path
        self.questions: list[Question] = []
        self.hyphen_cases: list[tuple[str, str]] = []
        self.assets: dict[str, dict] = {}
        # Alternativtexte sind redaktioneller Inhalt und werden getrennt vom
        # amtlichen Katalog gepflegt (content/alttexte.json).
        self.alt_texts: dict[str, dict] = {}
        if alt_path and alt_path.exists():
            self.alt_texts = {
                k: v for k, v in json.loads(alt_path.read_text(encoding="utf-8")).items()
                if not k.startswith("_")
            }

    # -- Kapitelkontext -----------------------------------------------------

    def _chapter_context(self, page: fitz.Page) -> tuple[str, str | None, str | None]:
        head = page.get_text("text", clip=fitz.Rect(60, 30, 540, HEADER_BOTTOM))
        lines = [l.strip() for l in head.splitlines() if l.strip()]
        chapter = None
        for line in lines:
            m = re.match(r"Kapitel\s+(I{1,3}V?|IV)\.", line)
            if m:
                chapter = m.group(1)
                break
        section = section_title = None
        for line in lines:
            m = re.match(r"^(\d)\.\s+(.+)$", line)
            if m and chapter == "I":
                section = f"I.{m.group(1)}"
                section_title = m.group(2).strip()
                break
        return chapter or "?", section, section_title

    # -- Hauptlauf ----------------------------------------------------------

    def parse(self) -> None:
        pending: Question | None = None
        for pno in range(FIRST_CONTENT_PAGE, self.doc.page_count):
            page = self.doc[pno]
            data = PageData(page, pno + 1)
            chapter, section, section_title = self._chapter_context(page)
            anchors = data.question_anchors()

            # Bereich oberhalb der ersten Fragennummer gehört zur Vorseiten-Frage.
            # Die Grenze wird identisch zum regulären Fall gesetzt, damit die
            # Bereiche lückenlos und überschneidungsfrei bleiben – sonst würde
            # etwa ein Prüfzeichen, das minimal höher sitzt als die zugehörige
            # Fragennummer, zusätzlich der Vorseiten-Frage zugeschlagen.
            if pending is not None:
                upper = anchors[0][1] - LINE_TOLERANCE if anchors else 10_000.0
                self._fill(pending, data, HEADER_BOTTOM, upper, continuation=True)

            for idx, (number, y0) in enumerate(anchors):
                y1 = anchors[idx + 1][1] if idx + 1 < len(anchors) else 10_000.0
                q = Question(
                    amtliche_nummer=number,
                    kapitel=chapter,
                    abschnitt=section,
                    abschnitt_titel=section_title,
                    seite=pno + 1,
                )
                self._fill(q, data, y0 - LINE_TOLERANCE, y1 - LINE_TOLERANCE)
                self.questions.append(q)
                pending = q if idx == len(anchors) - 1 else None

            if not anchors and pending is None:
                continue

        self._melde_leere_optionen()

    def _melde_leere_optionen(self) -> None:
        """Meldet Antwortmöglichkeiten, die weder Text noch Bild tragen.

        Bis Fassung 0.19.2 stand hier ein Zerschneider: Fand er eine leere
        Antwortmöglichkeit, deren Bereich ein Bild der Nachbaroption
        überlappte, zerschnitt er dieses Bild anhand der hellsten Pixelzeile
        und gab jeder Seite eine Hälfte. Er sprang im ganzen Katalog genau
        einmal an – bei Frage I.3-05 – und lag dort falsch: Er trennte ein
        Doppelzeichen, das zusammengehört, und hängte die obere Hälfte
        (BKA-Raute) an die *falsche* Antwortmöglichkeit. Die Ursache lag eine
        Stufe früher, in der Zuordnung; siehe {@link _option_for_image}.

        Ein Bild an der falschen Antwort ist in einer Prüfungssoftware kein
        Schönheitsfehler. Deshalb rät hier nichts mehr: Bleibt eine
        Antwortmöglichkeit leer, sagt der Katalog das, und ein Mensch sieht
        nach. Im vorliegenden Katalog bleibt keine leer.
        """
        for q in self.questions:
            for o in q.optionen:
                if not o.text.strip() and not o.bilder:
                    q.warnungen.append(
                        f"Antwortmöglichkeit {o.label}) trägt weder Text noch Bild")

    def _fill(self, q: Question, data: PageData, top: float, bottom: float,
              continuation: bool = False) -> None:
        """Trägt Fragetext, Optionen, Musterantwort und Bilder einer Seite ein."""
        in_range = [w for w in data.words if top <= w.y0 < bottom]
        q_words = [w for w in in_range if COL_NUM_END <= w.x0 < COL_QUESTION_END]
        a_words = [w for w in in_range if COL_QUESTION_END <= w.x0 < COL_ANSWER_END]
        boxes = [b for b in data.checkboxes if top <= b.y0 < bottom]

        if q_words:
            segs, amb = words_to_segments(group_lines(q_words))
            self._append(q.frage_segmente, segs, continuation)
            self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]

        if boxes:
            self._fill_options(q, data, a_words, boxes, bottom)
        elif a_words:
            segs, amb = words_to_segments(group_lines(a_words))
            self._append(q.antwort_segmente, segs, continuation or bool(q.antwort_segmente))
            self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]

        self._assign_images(q, data, top, bottom)

    def _assign_images(self, q: Question, data: PageData, top: float, bottom: float) -> None:
        """Ordnet Abbildungen der Frage bzw. den Antwortoptionen zu."""
        for rect, xref in data.images:
            if not (top <= rect.y0 < bottom):
                continue
            if rect.x0 < COL_QUESTION_END or not q.optionen:
                self._add_image(q.bilder, self._register_asset(xref, data))
                continue
            option = self._option_for_image(q, data.page_no, rect)
            self._add_image(option.bilder if option else q.bilder,
                            self._register_asset(xref, data))

    @staticmethod
    def _add_image(target: list[str], key: str) -> None:
        if key not in target:
            target.append(key)

    @staticmethod
    def _append(target: list[Segment], segs: list[Segment], joined: bool) -> None:
        if target and segs:
            target.append(Segment(" " if joined else "\n"))
        target.extend(segs)

    @staticmethod
    def _option_for_image(q: Question, page_no: int, rect: fitz.Rect) -> Option | None:
        """Ordnet ein Bild der Antwortoption zu.

        Zwei Regeln, in dieser Reihenfolge:

        **1. Das Optionslabel steht im Bild.** Besteht eine Antwortmöglichkeit
        nur aus einem Prüfzeichen und trägt keinen Text, setzt der Satz das
        Zeichen senkrecht mittig in seine Tabellenzeile. Das Label sitzt dann
        *innerhalb* des Bildrechtecks: Das Zeichen beginnt oberhalb seines
        eigenen Labels und kann bis in die nächste Zeile hineinreichen. Bei
        einem Zeichen doppelter Höhe liegt seine Mitte dadurch noch vor dem
        eigenen Label – die Mitte sagt hier nichts mehr.

        **2. Sonst die Bildmitte.** Steht neben dem Zeichen auch Text, hängt es
        unter der ersten Zeile seiner Option; dann trifft der Bereich zwischen
        zwei Labels zu.

        Nachgemessen über den ganzen Katalog: 43 Bilder stehen in
        Antwortspalten. Bei 38 liegt kein Label im Bildrechteck – dort
        entscheidet weiterhin Regel 2, unverändert. Die übrigen 5 gehören
        sämtlich zu Frage I.3-05, der einzigen, deren Antwortmöglichkeiten
        ausschließlich aus Zeichen bestehen. Vier davon ordnen beide Regeln
        gleich zu; beim fünften – dem Doppelzeichen BKA-Raute über
        PTB-Trapez – widersprechen sie sich, und Regel 1 hat recht: Dasselbe
        Bildobjekt bildet in Frage I.2-70 ungeteilt eine einzige
        Antwortmöglichkeit („Reizstoff-Sprühdosen mit dem Zeichen“).
        """
        im_bild = [
            o for o in q.optionen
            if o.seite == page_no and rect.y0 <= o.bereich_start + LINE_TOLERANCE < rect.y1
        ]
        if len(im_bild) == 1:
            return im_bild[0]

        mitte = (rect.y0 + rect.y1) / 2
        for o in q.optionen:
            if o.seite == page_no and o.bereich_start <= mitte < o.bereich_ende:
                return o
        return None

    def _fill_options(self, q: Question, data: PageData, a_words: list[Word],
                      boxes: list[fitz.Rect], bottom: float) -> None:
        """Zerlegt die Antwortspalte in Optionen.

        Die Grenzen ergeben sich aus den Optionslabels (»a)«, »b)«, …) am linken
        Spaltenrand, nicht aus den Kästchen: Kästchen sitzen vertikal mittig zur
        Option und lägen bei mehrzeiligen Optionen unterhalb deren erster Zeile.
        Jedem Optionsbereich wird anschließend das darin liegende Kästchen
        zugeordnet.
        """
        starts = self._label_positions(a_words)
        if len(starts) != len(boxes):
            q.warnungen.append(
                f"S.{data.page_no}: {len(starts)} Optionslabel, {len(boxes)} Kästchen "
                f"– Zuordnung über Kästchenposition")
            starts = [(OPTION_LABELS[i] if i < len(OPTION_LABELS) else str(i + 1), b.y0)
                      for i, b in enumerate(boxes)]

        for idx, (label, y_start) in enumerate(starts):
            start = y_start - LINE_TOLERANCE
            end = starts[idx + 1][1] - LINE_TOLERANCE if idx + 1 < len(starts) else bottom
            chunk = [w for w in a_words if start <= w.y0 < end]
            segs: list[Segment] = []
            if chunk:
                segs, amb = words_to_segments(group_lines(chunk))
                self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]
            # Optionen ohne Text sind zulässig: dort ist ein Prüfzeichen (Bild)
            # die Antwort. Das Bild wird später über den Bereich zugeordnet.
            _, segs = self._strip_label(segs, idx)
            # Optionen und Kästchen stehen beide streng von oben nach unten und
            # sind gleich viele – die Zuordnung erfolgt daher über die Position
            # in der Reihenfolge, nicht über y-Bereiche (Kästchen sitzen mittig).
            q.optionen.append(Option(label=label, segmente=segs,
                                     korrekt=data.is_checked(boxes[idx]),
                                     seite=data.page_no,
                                     bereich_start=start, bereich_ende=end))

    @staticmethod
    def _label_positions(a_words: list[Word]) -> list[tuple[str, float]]:
        """Findet Optionslabels am linken Rand der Antwortspalte."""
        out: list[tuple[str, float]] = []
        for w in a_words:
            if w.x0 < COL_QUESTION_END + LABEL_INDENT and re.fullmatch(r"[a-h]\)", w.text):
                out.append((w.text[0], w.y0))
        out.sort(key=lambda t: t[1])
        return out

    @staticmethod
    def _strip_label(segs: list[Segment], index: int) -> tuple[str, list[Segment]]:
        """Trennt das führende »a)« vom Optionstext ab."""
        joined = "".join(s.text for s in segs)
        m = re.match(r"^([a-h])\)\s*", joined)
        if not m:
            return OPTION_LABELS[index] if index < len(OPTION_LABELS) else str(index + 1), segs
        cut = m.end()
        out: list[Segment] = []
        for seg in segs:
            if cut <= 0:
                out.append(seg)
            elif len(seg.text) <= cut:
                cut -= len(seg.text)
            else:
                out.append(Segment(seg.text[cut:], seg.hervorgehoben))
                cut = 0
        return m.group(1), _merge_adjacent(out)

    # -- Bilder -------------------------------------------------------------

    def _register_asset(self, xref: str, data: PageData) -> str:
        if xref in self.assets:
            return self.assets[xref]["id"]
        pix = fitz.Pixmap(self.doc, int(xref))
        if pix.n - pix.alpha >= 4:
            pix = fitz.Pixmap(fitz.csRGB, pix)
        asset_id = self._store(pix.tobytes("png"), pix.width, pix.height)
        self.assets[xref] = next(m for m in self.assets.values() if m["id"] == asset_id)
        return asset_id

    def _store(self, blob: bytes, breite: int, hoehe: int) -> str:
        """Legt ein Bild ab; inhaltsgleiche Bilder teilen sich eine Datei."""
        digest = hashlib.sha1(blob).hexdigest()[:8]
        for meta in self.assets.values():
            if meta["sha1"] == digest:
                return meta["id"]
        asset_id = f"zeichen-{digest}"
        self.assets[f"sha:{digest}"] = {"id": asset_id, "sha1": digest, "png": blob,
                                        "breite": breite, "hoehe": hoehe}
        return asset_id

    # -- Ausgabe ------------------------------------------------------------

    def referenced_assets(self) -> set[str]:
        """Bild-IDs, die tatsächlich an einer Frage oder Option hängen.

        Beim Zerschneiden entstehen Ersatzbilder; das ursprüngliche Kombibild
        wird dann nicht mehr referenziert und soll auch nicht ausgegeben werden.
        """
        used: set[str] = set()
        for q in self.questions:
            used.update(q.bilder)
            for o in q.optionen:
                used.update(o.bilder)
        return used

    def to_json(self) -> dict:
        chapters: dict[str, dict] = {}
        for q in self.questions:
            ch = chapters.setdefault(q.kapitel, {
                "id": q.kapitel, "titel": CHAPTER_TITLES.get(q.kapitel, ""), "abschnitte": {},
            })
            if q.abschnitt:
                ch["abschnitte"].setdefault(q.abschnitt, {
                    "id": q.abschnitt, "titel": q.abschnitt_titel,
                })
        for ch in chapters.values():
            ch["abschnitte"] = sorted(ch["abschnitte"].values(), key=lambda a: a["id"])

        return {
            "meta": {
                **CATALOG_META,
                "quelldatei_sha256": sha256_of(self.pdf_path),
                "fragen_gesamt": len(self.questions),
            },
            "kapitel": [chapters[k] for k in ("I", "II", "III", "IV") if k in chapters],
            # Zweistufiges Bildkonzept (Prüfplan, Szenario S4): `alt` bleibt
            # neutral und verrät nie die Lösung; `beschreibung` erklärt die
            # Bedeutung des Zeichens und wird von der Anwendung erst nach dem
            # Beantworten gezeigt.
            "bilder": [
                {"id": m["id"], "datei": f"{m['id']}.png",
                 "breite": m["breite"], "hoehe": m["hoehe"],
                 "alt": self.alt_texts.get(m["id"], {}).get("alt"),
                 "beschreibung": self.alt_texts.get(m["id"], {}).get("beschreibung")}
                for m in dict((m["id"], m) for m in self.assets.values()).values()
                if m["id"] in self.referenced_assets()
            ],
            "fragen": [self._question_json(q) for q in self.questions],
        }

    @staticmethod
    def _question_json(q: Question) -> dict:
        out = {
            "id": q.id,
            "amtliche_nummer": q.amtliche_nummer,
            "kapitel": q.kapitel,
            "abschnitt": q.abschnitt,
            "typ": q.typ,
            "seite": q.seite,
            "frage": segments_json(q.frage_segmente),
            "bilder": q.bilder,
        }
        if q.typ == "mc":
            out["optionen"] = [
                {"label": o.label, "inhalt": segments_json(o.segmente),
                 "korrekt": o.korrekt, "bilder": o.bilder}
                for o in q.optionen
            ]
        else:
            out["musterantwort"] = segments_json(q.antwort_segmente)
        if q.warnungen:
            out["warnungen"] = q.warnungen
        return out


def segments_json(segs: list[Segment]) -> dict:
    merged = _merge_adjacent(segs)
    return {
        "text": "".join(s.text for s in merged),
        "segmente": [{"t": s.text, "h": True} if s.hervorgehoben else {"t": s.text}
                     for s in merged],
    }


def sha256_of(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


# ----------------------------------------------------------------------- CLI


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--pdf", default=str(Path(__file__).resolve().parent.parent
                                         / "Fragenkatalog_sachkunde_mitAntworten.pdf"))
    ap.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "content" / "katalog"))
    args = ap.parse_args()

    pdf_path = Path(args.pdf)
    out_dir = Path(args.out)
    (out_dir / "assets").mkdir(parents=True, exist_ok=True)

    alt_path = Path(__file__).resolve().parent.parent / "content" / "alttexte.json"
    parser = CatalogParser(pdf_path, alt_path)
    parser.parse()
    payload = parser.to_json()

    (out_dir / "katalog.json").write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
        # LF wie im Archiv verlangt, nicht das CRLF von Windows.
        newline="\n",
    )

    referenced = parser.referenced_assets()
    written = set()
    for meta in parser.assets.values():
        if meta["id"] in written or meta["id"] not in referenced:
            continue
        (out_dir / "assets" / f"{meta['id']}.png").write_bytes(meta["png"])
        written.add(meta["id"])

    (out_dir / "trennstriche.txt").write_text(
        "\n".join(f"{n}\t{c}" for n, c in parser.hyphen_cases),
        encoding="utf-8",
        newline="\n",
    )

    counts = {}
    for q in parser.questions:
        counts[q.typ] = counts.get(q.typ, 0) + 1
    print(f"Fragen gesamt: {len(parser.questions)}")
    print(f"Typen: {counts}")
    print(f"Bilder: {len(written)}")
    print(f"Ergänzungsstrich-Verdachtsfälle: {len(parser.hyphen_cases)}")
    print(f"Ausgabe: {out_dir / 'katalog.json'}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
