"""Prüft die Erklärungstexte gegen Katalog und amtlichen Gesetzestext.

Diese Prüfung entscheidet, ob Erklärungen ausgeliefert werden dürfen. Sie
bricht mit Rückgabewert 1 ab, sobald etwas nicht stimmt, und eignet sich
damit für eine Baupipeline.

Was geprüft wird
----------------
1. **Zuordnung** – jede Erklärung gehört zu einer Frage, die es im Katalog
   wirklich gibt.
2. **Fundstellen** – jedes zitierte Gesetz, jeder Paragraf, jeder Absatz,
   jede Nummer und jeder Buchstabe wird im Index aus
   `content/gesetze/index.json` nachgeschlagen. Was dort nicht steht,
   existiert nicht. Bei Normen ohne Absatzgliederung (etwa § 37a WaffG)
   stehen Nummern und Buchstaben nur im Normtext; geprüft wird dann gegen
   dessen Aufzählungsmarken. Satzangaben sind im XML nicht ausgezeichnet
   und werden als Plausibilitätsgrenze geprüft (mehr Sätze als der Text
   hergibt, kann die Angabe nicht meinen).
3. **Anlagen** – bei Anlagen greift die Absatzstruktur nicht. Geprüft wird,
   ob die angegebene Stelle im Volltext der Anlage vorkommt. Ohne
   Stellenangabe wäre nur die Existenz der Anlage nachgewiesen – das wird
   beanstandet.
4. **Redliche Lücken** – eine Erklärung ohne Fundstelle muss begründen,
   warum es keine gibt. Damit ist das Fehlen eine bewusste Aussage und keine
   Nachlässigkeit.
5. **Form** – Länge, Sprache, keine leeren Felder, keine Platzhalter.
6. **Typen** – jede Angabe einer Fundstelle ist eine Zeichenkette. Diese
   Prüfung muss mindestens so streng sein wie der Lader in der Anwendung
   (`app/src/main/erklaerungen.ts`). War sie es einmal nicht: 450 Angaben
   standen als Zahl in der Datei, kamen hier durch und liessen die gepackte
   Anwendung ohne eine einzige Erklärung starten.

Was „geprüft" heißt
-------------------
Existenz der Norm plus Nachweis jeder angegebenen Feinstelle (Absatz, Nummer,
Buchstabe, Anlagen-Stelle; Satz als Plausibilitätsgrenze). Die Ausgabe
unterscheidet „vollständig geprüft" (jede Angabe nachgewiesen) von „nur
Existenz geprüft" (mehr gab die Angabe maschinell nicht her). Ein einziger
Zähler hatte beides vermengt: Er zählte schon nach der Existenzprüfung, und
zwei frühe Rückkehrpfade ließen Nummern ohne Absatzangabe sowie fast alle
Satzangaben ungeprüft passieren – als „geprüft" ausgewiesen waren sie
trotzdem.

Was diese Prüfung NICHT leisten kann
------------------------------------
Ob die zitierte Norm die Aussage inhaltlich trägt. Eine Erklärung kann auf
einen existierenden Paragrafen verweisen und trotzdem falsch sein. Dafür gibt
es die redaktionelle Durchsicht; diese Prüfung schließt nur die Fehlerklasse
aus, die sich maschinell sicher ausschließen lässt.

Aufruf
------
    python data-pipeline/pruefe_erklaerungen.py                # Bestand prüfen
    python data-pipeline/pruefe_erklaerungen.py --selbsttest   # Prüfung prüfen
"""

from __future__ import annotations

import io
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path

WURZEL = Path(__file__).resolve().parent.parent
KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json'
GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json'
ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json'

MIN_KURZ, MAX_KURZ = 30, 400

#: Laengengrenzen der ausfuehrlichen Begruendung.
#:
#: Die Obergrenze fangt Wildwuchs ab, nicht Gruendlichkeit. Gemessen an den
#: ersten 193 Erklaerungen: Median 1194 Zeichen, 90 Prozent unter 1634. Ueber
#: 2500 lag genau eine - die Frage nach der Beschusspflicht, die vier
#: Waffenarten einzeln durchgeht und fuer jede eine eigene Ausnahme nennt.
#: Solche Faelle gibt es, und sie zu verstuemmeln waere der schlechtere Weg.
MIN_TEXT, MAX_TEXT = 80, 3000

MAX_MERKSATZ = 300
MIN_GRUND = 25

#: Wörter, die auf einen unfertigen Text hindeuten.
PLATZHALTER = re.compile(
    r'\b(TODO|TBD|FIXME|Platzhalter|Lorem ipsum|XXX|\.\.\.\s*$)', re.IGNORECASE
)

#: Hoechstzahl der Kernpunkte. Mehr waere keine Pruefliste mehr, sondern eine
#: zweite Musterantwort.
MAX_KERNPUNKTE = 8

#: Ein Kernpunkt ist ein Stichwort, kein Satzgefuege.
MAX_KERNPUNKT_LAENGE = 140

#: Die eine Formulierung, die hier nie stehen darf. Bis Fassung 0.17.0 stand
#: ueber einer aehnlichen Liste "Diese Kernelemente muss Ihre Antwort
#: enthalten" - unbelegt, und ausdruecklich abgeschafft. Die Pruefung haelt
#: fest, dass sie nicht durch die Hintertuer zurueckkommt.
PFLICHTBEHAUPTUNG = re.compile(r'muss\s+(ihre|die)\s+antwort|zwingend\s+erforderlich', re.I)


#: Erlaubte Formen einer Normbezeichnung.
#:
#: Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zaehlen
#: arabisch ("Anlage 1"), BeschussV und SprengG roemisch ("Anlage II"), die
#: AWaffV hat genau eine und nennt sie nur "Anlage".
NORM_MUSTER = re.compile(r'^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$')

#: Eine Nummer ist eine Zahl, ggf. mit Buchstabenzusatz („3", „3a").
NUMMER_MUSTER = re.compile(r'^\d+[a-z]?$')

#: Gliederungsmarken innerhalb einer Anlage: „Abschnitt 1", „Nr. 1.1",
#: „Abbildung 12". Alles Weitere in einer Stellenangabe ist Beschreibung und
#: wird nicht geprueft - sie stammt vom Verfasser, nicht aus dem Gesetz.
GLIEDERUNGSMARKE = re.compile(
    r'(Abschnitt|Unterabschnitt|Abbildung|Anhang|Kategorie|Tabelle|Teil|Nummer|Nrn?\.?)'
    r'\s+([0-9IVX][0-9IVXa-z.]*?)(?=[\s,;:)]|$)',
    re.IGNORECASE,
)

#: Aufzählungsmarken am Zeilenanfang eines Normtexts: „1." bzw. „a)". Der
#: Punkt darf keine Ziffer nach sich haben, sonst träfe „1." auch „1.1".
NORMTEXT_NUMMER = re.compile(r'^(\d+[a-z]?)\.(?!\d)')
NORMTEXT_BUCHSTABE = re.compile(r'^([a-z]{1,2})\)')


def norm_gliederung(text: str) -> dict[str, list[str]]:
    """Liest die Aufzählungsstruktur aus dem Text einer ungegliederten Norm.

    Der Index legt Gliederung nur je Absatz ab. Beginnt eine Norm ohne
    „(1)"-Marke, hat sie im Index keine Absätze; 251 Paragrafen des Index sind
    das. Zitiert werden davon mit Nummer oder Buchstabe genau vier: § 8 und
    § 37a WaffG, § 29 SprengG, § 34 AWaffV. Ihre Nummern und Buchstaben stehen
    zeilenweise im Normtext, denn der Indexer beginnt für jede `<DT>`-Marke
    eine Zeile.

    Buchstaben gehören zur zuletzt begonnenen Nummer; steht keine voran, zählt
    die Norm unmittelbar mit Buchstaben auf (dieselbe Zuordnung wie falten()
    in data-pipeline/gesetze_index.py).
    """
    gliederung: dict[str, list[str]] = {}
    letzte_nummer: str | None = None

    for zeile in text.split('\n'):
        treffer = NORMTEXT_NUMMER.match(zeile)
        if treffer:
            letzte_nummer = treffer.group(1)
            gliederung.setdefault(letzte_nummer, [])
            continue
        treffer = NORMTEXT_BUCHSTABE.match(zeile)
        if treffer:
            if letzte_nummer is None:
                gliederung.setdefault(treffer.group(1), [])
            else:
                gliederung[letzte_nummer].append(treffer.group(1))

    return gliederung


@dataclass
class Befund:
    frage_id: str
    feld: str
    meldung: str

    def __str__(self) -> str:
        return f'{self.frage_id:12} {self.feld:16} {self.meldung}'


class Pruefung:
    def __init__(self, katalog: dict, gesetze: dict) -> None:
        self.fragen = {f['id']: f for f in katalog['fragen']}
        self.gesetze = gesetze['gesetze']
        self.befunde: list[Befund] = []
        #: Stellenangaben ohne erkennbare Gliederungsmarke. Kein Fehler, aber
        #: maschinell nicht nachprüfbar – das soll sichtbar bleiben.
        self.ungeprueft: list[str] = []
        #: Zählt, wie oft eine Norm zitiert wird – als Anhaltspunkt für die
        #: redaktionelle Durchsicht, nicht als Prüfkriterium.
        self.normnutzung: dict[str, int] = {}
        #: Ehrliche Zählung statt eines Sammelzählers: „voll_geprueft" heißt,
        #: jede Angabe der Fundstelle wurde gegen den Gesetzestext gehalten;
        #: „nur_existenz" heißt, mehr als das Vorhandensein der Norm gab die
        #: Angabe maschinell nicht her. Der frühere Sammelzähler wies beides
        #: als „geprüft" aus – 188 der 1843 Fundstellen (Satzangaben,
        #: Nummern ohne Absatz, eine Anlage ohne Stelle) waren es nur halb.
        self.voll_geprueft = 0
        self.nur_existenz = 0
        #: Querverweise je Frage, fuer den Abgleich auf Beidseitigkeit.
        self.verweise: dict[str, set[str]] = {}

    def melden(self, frage_id: str, feld: str, meldung: str) -> None:
        self.befunde.append(Befund(frage_id, feld, meldung))

    # ── Fundstellen ──────────────────────────────────────────────────────

    def fundstelle_pruefen(self, frage_id: str, index: int, fundstelle: dict) -> None:
        ort = f'fundstelle[{index}]'

        # Der Lader in der Anwendung prueft die Typen. Ist diese Pruefung hier
        # laxer, geht etwas durch, das erst beim Nutzer scheitert - genau das
        # ist passiert: 450 Angaben standen als Zahl statt als Zeichenkette in
        # der Datei, kamen durch die Pruefung und liessen die gepackte
        # Anwendung ohne eine einzige Erklaerung starten.
        for feld in ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle'):
            wert = fundstelle.get(feld)
            if wert is not None and not isinstance(wert, str):
                self.melden(
                    frage_id,
                    ort,
                    f'„{feld}" muss eine Zeichenkette sein, ist {type(wert).__name__}: {wert!r}',
                )
                return

        kuerzel = fundstelle.get('gesetz')
        if kuerzel not in self.gesetze:
            self.melden(frage_id, ort, f'Unbekanntes Gesetz: {kuerzel!r}')
            return

        gesetz = self.gesetze[kuerzel]
        norm_name = fundstelle.get('norm', '')

        if not NORM_MUSTER.match(str(norm_name)):
            self.melden(
                frage_id, ort, f'Norm muss „§ 12" oder „Anlage 1" lauten, ist: {norm_name!r}'
            )
            return

        norm = gesetz['normen'].get(norm_name)
        if norm is None:
            self.melden(frage_id, ort, f'{norm_name} gibt es im {kuerzel} nicht')
            return

        self.normnutzung[f'{kuerzel} {norm_name}'] = (
            self.normnutzung.get(f'{kuerzel} {norm_name}', 0) + 1
        )

        # Ab hier ist die Existenz der Norm nachgewiesen. Ob auch jede
        # Feinangabe nachgeprüft werden konnte, entscheiden die beiden Zweige –
        # der Zähler übernimmt deren Antwort, statt vorab „geprüft" zu melden.
        if norm['ist_anlage']:
            vollstaendig = self.anlage_pruefen(frage_id, ort, kuerzel, norm_name, norm, fundstelle)
        else:
            vollstaendig = self.paragraf_pruefen(
                frage_id, ort, kuerzel, norm_name, norm, fundstelle
            )

        if vollstaendig:
            self.voll_geprueft += 1
        else:
            self.nur_existenz += 1

    def paragraf_pruefen(
        self,
        frage_id: str,
        ort: str,
        kuerzel: str,
        norm_name: str,
        norm: dict,
        fundstelle: dict,
    ) -> bool:
        """Prüft die Feinstellen eines Paragrafenzitats.

        True heißt: Jede Angabe der Fundstelle wurde gegen den Gesetzestext
        gehalten (Beanstandungen eingeschlossen). False heißt: Über die
        Existenz der Norm hinaus war nichts nachprüfbar.
        """
        if fundstelle.get('stelle') is not None:
            self.melden(frage_id, ort, '„stelle" ist nur bei Anlagen zulässig')

        absatz_name = fundstelle.get('absatz')
        if absatz_name is None:
            # Zulässig: Ein Paragraf ohne Absatzgliederung wird als Ganzes
            # zitiert. Bei gegliederten Normen ist das aber ungenau – und
            # eine Nummer ließe sich keinem Absatz zuordnen.
            if len(norm['absaetze']) > 1:
                self.melden(
                    frage_id,
                    ort,
                    f'{norm_name} {kuerzel} hat {len(norm["absaetze"])} Absaetze – '
                    'bitte den zutreffenden angeben',
                )
                return False
            if norm['absaetze']:
                # Genau ein Absatz: Jede Feinangabe kann sich nur auf ihn
                # beziehen, also wird gegen ihn geprüft.
                absatz = next(iter(norm['absaetze'].values()))
            else:
                # Norm ohne Absatzgliederung: Nummern und Buchstaben stehen
                # nur im Normtext. Vorher kehrte die Prüfung hier um; die
                # zwölf Feinangaben ohne Absatz blieben ungeprüft, davon acht
                # Nummernangaben zu § 37a WaffG.
                absatz = {'gliederung': norm_gliederung(norm['text']), 'text': norm['text']}
            self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, None, absatz, fundstelle)
            return True

        absatz = norm['absaetze'].get(str(absatz_name))
        if absatz is None:
            vorhanden = ', '.join(norm['absaetze'].keys()) or 'keine'
            self.melden(
                frage_id,
                ort,
                f'{norm_name} {kuerzel} hat keinen Abs. {absatz_name} (vorhanden: {vorhanden})',
            )
            return False

        self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, absatz_name, absatz, fundstelle)
        return True

    def gliederung_pruefen(
        self,
        frage_id: str,
        ort: str,
        kuerzel: str,
        norm_name: str,
        absatz_name: str | None,
        absatz: dict,
        fundstelle: dict,
    ) -> None:
        if absatz_name is None:
            # Ohne Absatzangabe zitiert – die Norm selbst ist die Stelle.
            stelle = f'{norm_name} {kuerzel}'
        else:
            stelle = f'{norm_name} Abs. {absatz_name} {kuerzel}'
        gliederung: dict[str, list[str]] = absatz['gliederung']

        # Der Satz zuerst: Er ist von Nummer und Buchstabe unabhängig. Als er
        # hinter deren Frührückkehren stand, wurde er nur erreicht, wenn
        # Nummer UND Buchstabe angegeben waren – 183 von 184 Satzangaben
        # gingen ungeprüft durch.
        satz = fundstelle.get('satz')
        if satz is not None:
            # Sätze werden im XML nicht ausgezeichnet. Geprüft wird deshalb
            # nur, dass der Text überhaupt so viele Sätze haben kann.
            saetze = absatz['text'].count('.') + absatz['text'].count(';')
            if not str(satz).isdigit() or int(satz) < 1:
                self.melden(frage_id, ort, f'Satz muss eine Zahl ab 1 sein, ist: {satz!r}')
            elif int(satz) > max(1, saetze):
                self.melden(frage_id, ort, f'{stelle} hat vermutlich keinen Satz {satz}')

        nummer = fundstelle.get('nummer')
        if nummer is not None:
            # Ein blosser Buchstabe ist keine Nummer. Ohne diese Regel wuerde
            # eine Fundstelle „Nr. a" durchgehen, sobald der Absatz
            # unmittelbar mit Buchstaben aufzaehlt.
            if not NUMMER_MUSTER.match(str(nummer)):
                self.melden(
                    frage_id,
                    ort,
                    f'„nummer" muss eine Zahl sein, ist: {nummer!r} – '
                    'Buchstaben gehoeren nach „buchstabe"',
                )
                return
            if not gliederung:
                self.melden(frage_id, ort, f'{stelle} ist nicht in Nummern gegliedert')
                return
            if str(nummer) not in gliederung:
                vorhanden = ', '.join(gliederung.keys())
                self.melden(
                    frage_id, ort, f'{stelle} hat keine Nr. {nummer} (vorhanden: {vorhanden})'
                )
                return

        buchstabe = fundstelle.get('buchstabe')
        if buchstabe is None:
            return

        if nummer is None:
            # Zulaessig, wenn der Absatz selbst mit Buchstaben aufzaehlt –
            # etwa § 3a Abs. 2 SprengG.
            if str(buchstabe) in gliederung:
                return
            self.melden(
                frage_id,
                ort,
                f'{stelle} zaehlt nicht unmittelbar mit Buchstaben auf – '
                'bitte zusaetzlich die Nummer angeben',
            )
            return

        buchstaben = gliederung.get(str(nummer), [])
        if not buchstaben:
            self.melden(
                frage_id, ort, f'{stelle} Nr. {nummer} ist nicht in Buchstaben gegliedert'
            )
            return
        if str(buchstabe) not in buchstaben:
            self.melden(
                frage_id,
                ort,
                f'{stelle} Nr. {nummer} hat keinen Buchst. {buchstabe} '
                f'(vorhanden: {", ".join(buchstaben)})',
            )

    def anlage_pruefen(
        self,
        frage_id: str,
        ort: str,
        kuerzel: str,
        norm_name: str,
        norm: dict,
        fundstelle: dict,
    ) -> bool:
        """Prüft die Stellenangabe eines Anlagenzitats.

        True heißt: Die Gliederungsmarken der Stelle wurden im Anlagentext
        nachgewiesen (Beanstandungen eingeschlossen). False heißt: Über die
        Existenz der Anlage hinaus war nichts nachprüfbar.
        """
        # „satz" steht mit in der Liste: Anlagen kennen keine Satzzählung,
        # und eine stumm ignorierte Angabe sähe wie eine geprüfte aus.
        for verboten in ('absatz', 'nummer', 'buchstabe', 'satz'):
            if fundstelle.get(verboten) is not None:
                self.melden(
                    frage_id,
                    ort,
                    f'„{verboten}" ist bei {norm_name} nicht zulässig – bitte „stelle" verwenden',
                )

        stelle = fundstelle.get('stelle')
        if stelle is None:
            # Anlagen sind seitenlang; ohne Stellenangabe wäre nur die
            # Existenz der Anlage nachgewiesen und der Leser suchte allein.
            self.melden(
                frage_id,
                ort,
                f'{norm_name} {kuerzel} ohne „stelle" – so ist nur die Existenz '
                'der Anlage nachgewiesen, bitte die Stelle angeben',
            )
            return False

        # Zeilenumbrüche stören den Vergleich; die Anlagen sind zeilenweise
        # gesetzt, eine Angabe wie „Abbildung 1" aber steht als Wortfolge da.
        text = ' '.join(norm['text'].split())
        marken = GLIEDERUNGSMARKE.findall(str(stelle))

        if not marken:
            # Keine Gliederungsmarke erkennbar – dann ist die Angabe reine
            # Beschreibung und maschinell nicht prüfbar. Das ist kein Fehler,
            # aber es soll sichtbar bleiben.
            self.ungeprueft.append(f'{frage_id} {ort}: „{stelle}"')
            return False

        for wort, zahl in marken:
            if wort.lower().startswith(('nr', 'nummer')):
                # Anlagen listen ihre Nummern ohne das Wort „Nr.".
                # Listennummern stehen als „12." – der Punkt gehoert zur Marke.
                # „1" darf aber nicht in „1.1" treffen: deshalb nicht vor einer
                # Ziffer, auch nicht ueber einen Punkt hinweg.
                gefunden = (
                    re.search(rf'(?<![\d.]){re.escape(zahl)}(?!\.?\d)', text) is not None
                )
            else:
                gefunden = re.search(
                    rf'{re.escape(wort)}\s*{re.escape(zahl)}(?![\d.])', text, re.IGNORECASE
                ) is not None

            if not gefunden:
                self.melden(
                    frage_id, ort, f'„{wort} {zahl}" kommt in {norm_name} {kuerzel} nicht vor'
                )

        return True

    # ── Erklärung als Ganzes ─────────────────────────────────────────────

    def text_pruefen(self, frage_id: str, feld: str, wert: object, unten: int, oben: int) -> bool:
        if not isinstance(wert, str) or not wert.strip():
            self.melden(frage_id, feld, 'fehlt oder ist leer')
            return False
        text = wert.strip()
        if len(text) < unten:
            self.melden(frage_id, feld, f'zu kurz ({len(text)} Zeichen, mindestens {unten})')
            return False
        if len(text) > oben:
            self.melden(frage_id, feld, f'zu lang ({len(text)} Zeichen, höchstens {oben})')
            return False
        if PLATZHALTER.search(text):
            self.melden(frage_id, feld, 'enthält einen Platzhalter')
            return False
        if text != wert:
            self.melden(frage_id, feld, 'beginnt oder endet mit Leerraum')
            return False
        return True

    # ── Antwortmöglichkeiten ─────────────────────────────────────────────

    #: Wörter, die für sich genommen eine Antwortmöglichkeit sind: „Nein",
    #: „keine", „Ja, wenn …". Sie tragen keinen eigenen Sachbegriff, an dem
    #: sich nachweisen ließe, dass die Erklärung sie behandelt.
    #:
    #: Eine Möglichkeit, die aus nichts anderem besteht – das blanke „Nein"
    #: neben zwei Sachantworten –, ist deshalb immer als behandelt zu werten:
    #: Sie ist die Verneinung der ganzen Frage, und die Erklärung beantwortet
    #: sie, indem sie sagt, was gilt. Ein eigener Satz „Antwort c ist falsch"
    #: brächte dort nichts, was nicht schon dastünde.
    ANTWORTWOERTER = ('nein', 'ja', 'keine', 'keiner', 'keines', 'alle', 'doch', 'nichts')

    #: Zu kurz oder zu allgemein, um eine Option von ihren Geschwistern zu
    #: unterscheiden. Ohne diese Liste zählte „Schusswaffe" als Nachweis,
    #: obwohl es in jeder Option derselben Frage steht.
    OHNE_UNTERSCHEIDUNGSKRAFT = frozenset(
        '''schusswaffe schusswaffen waffe waffen munition person personen jahren jahre
        gemaess waffenrechtlich waffenrechtliche waffenrechtlichen sinne gegenstand
        gegenstaende moeglich moeglichkeit erlaubt verboten zulaessig gestattet'''.split()
    )

    @staticmethod
    def _falte(text: str) -> str:
        """Kleinschreibung ohne Umlaute – damit „Armbrust" auf „Armbrüste" trifft."""
        tief = text.lower()
        for von, nach in (('ä', 'ae'), ('ö', 'oe'), ('ü', 'ue'), ('ß', 'ss')):
            tief = tief.replace(von, nach)
        return tief

    @staticmethod
    def _buchstabenbezug(text: str) -> set:
        """Welche Optionsbuchstaben nennt die Erklärung ausdrücklich?

        Der Hausstil kennt mehrere Formen, und genau daran ist die
        ursprüngliche Messung gescheitert: Wer nur nach „a)" sucht, hält
        „das macht Antwort b falsch" für keine Nennung.
        """
        gefunden = set()
        for muster in (
            r'(?:^|[^A-Za-zÄÖÜäöü])([a-f])\)',
            r'„([a-f])“',
            r'\bAntwort(?:möglichkeit)?\s+([a-f])\b',
            r'\bZu\s+([a-f])\b',
            r'\bBuchstabe\s+([a-f])\b',
        ):
            gefunden |= set(re.findall(muster, text))
        return gefunden

    def optionen_pruefen(self, frage_id: str, erklaerung: dict) -> None:
        """Behandelt die Erklärung jede Antwortmöglichkeit?

        **Warum das hier steht.** Eine Erklärung, die eine Antwortmöglichkeit
        übergeht, lässt den Lernenden mit der Frage allein, die ihn am
        meisten beschäftigt: Warum ist gerade die, die ich angekreuzt habe,
        falsch? Am 01.09.2026 traf das auf **keine einzige** der 471
        Auswahlfragen zu – erst durch diese Prüfung bleibt das so.

        **Warum die Prüfung so großzügig ist.** Der Hausstil benennt
        Antwortmöglichkeiten auf zwei Wegen: mit ihrem Buchstaben (130 von
        471) oder mit ihrem Gegenstand – „die Armbrust scheitert am Lauf".
        Der zweite Weg ist der bessere: Wer sich die Erklärung vorlesen
        lässt, hört bei „zu a)" nichts, woran er sich erinnern könnte. Diese
        Prüfung erkennt deshalb beide und fällt nur dann aus, wenn eine
        Möglichkeit auf **keinem** der beiden Wege vorkommt.
        """
        frage = self.fragen.get(frage_id) or {}
        optionen = frage.get('optionen') or []
        if not optionen:
            return

        text = f"{erklaerung.get('kurz') or ''} {erklaerung.get('text') or ''}"
        gefaltet = self._falte(text)
        briefe = self._buchstabenbezug(text)
        kurz_anfang = self._falte((erklaerung.get('kurz') or '')[:40])

        # Wörter, die in allen Möglichkeiten vorkommen, unterscheiden nichts.
        woerter_je_option = []
        for option in optionen:
            roh = (option.get('inhalt') or {}).get('text') or ''
            woerter_je_option.append(
                {w for w in re.findall(r'[a-z]{4,}', self._falte(roh))}
                - self.OHNE_UNTERSCHEIDUNGSKRAFT
                - set(self.ANTWORTWOERTER)
            )
        gemeinsam = set.intersection(*woerter_je_option) if len(woerter_je_option) > 1 else set()

        for option, woerter in zip(optionen, woerter_je_option):
            marke = option.get('label') or '?'
            if marke in briefe:
                continue

            roh = (option.get('inhalt') or {}).get('text') or ''
            wortmenge = set(re.findall(r'[a-z]+', self._falte(roh)))
            if any(w in self.ANTWORTWOERTER and w in kurz_anfang for w in wortmenge):
                continue

            eigen = woerter - gemeinsam
            if not eigen:
                continue
            # Wortstamm statt Wortgleichheit: „Leuchtspurpatronen" gilt als
            # behandelt, wenn irgendwo „Leuchtspurmunition" steht.
            if any(wort[: max(4, min(5, len(wort)))] in gefaltet for wort in eigen):
                continue

            self.melden(
                frage_id,
                f'option[{marke}]',
                'kommt in der Erklärung weder mit ihrem Buchstaben noch mit ihrem '
                f'Gegenstand vor: „{roh[:70]}"',
            )

    def erklaerung_pruefen(self, frage_id: str, erklaerung: dict) -> None:
        if frage_id not in self.fragen:
            self.melden(frage_id, 'zuordnung', 'Diese Frage gibt es im Katalog nicht')
            return

        erlaubt = {
            'kurz',
            'text',
            'fundstellen',
            'ohneFundstelleGrund',
            'merksatz',
            'verwandt',
            'kernpunkte',
        }
        for feld in set(erklaerung) - erlaubt:
            self.melden(frage_id, feld, 'Unbekanntes Feld')

        self.optionen_pruefen(frage_id, erklaerung)
        self.verwandt_pruefen(frage_id, erklaerung.get('verwandt'))
        self.kernpunkte_pruefen(frage_id, erklaerung.get('kernpunkte'))

        self.text_pruefen(frage_id, 'kurz', erklaerung.get('kurz'), MIN_KURZ, MAX_KURZ)
        self.text_pruefen(frage_id, 'text', erklaerung.get('text'), MIN_TEXT, MAX_TEXT)

        if 'merksatz' in erklaerung:
            self.text_pruefen(frage_id, 'merksatz', erklaerung['merksatz'], 10, MAX_MERKSATZ)

        fundstellen = erklaerung.get('fundstellen')
        if not isinstance(fundstellen, list):
            self.melden(frage_id, 'fundstellen', 'fehlt oder ist keine Liste')
            return

        for index, fundstelle in enumerate(fundstellen):
            if not isinstance(fundstelle, dict):
                self.melden(frage_id, f'fundstelle[{index}]', 'ist kein Objekt')
                continue
            self.fundstelle_pruefen(frage_id, index, fundstelle)

        grund = erklaerung.get('ohneFundstelleGrund')
        if not fundstellen:
            if grund is None:
                self.melden(
                    frage_id,
                    'fundstellen',
                    'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein',
                )
            else:
                self.text_pruefen(frage_id, 'ohneFundstelleGrund', grund, MIN_GRUND, 400)
        elif grund is not None:
            self.melden(
                frage_id,
                'ohneFundstelleGrund',
                'gesetzt, obwohl Fundstellen vorhanden sind',
            )

    def kernpunkte_pruefen(self, frage_id: str, kernpunkte: object) -> None:
        """Prüft die Kernpunkte einer offenen Frage.

        Die Punkte sind eine Einschaetzung dieser Software, keine Aussage
        ueber den amtlichen Katalog. Geprueft wird deshalb nur, was sich
        maschinell sicher pruefen laesst - und zwei Dinge, die inhaltlich
        zaehlen: Sie gehoeren ausschliesslich an offene Fragen (bei einer
        Auswahlfrage stehen die Antworten schon da), und sie duerfen nirgends
        behaupten, was eine Antwort enthalten *muss*. Genau diese Behauptung
        hat `docs/entscheidung-freitext.md` Abschnitt 8 ausgeschlossen.
        """
        if kernpunkte is None:
            return

        frage = self.fragen.get(frage_id)
        if frage is not None and frage['typ'] == 'mc':
            self.melden(
                frage_id,
                'kernpunkte',
                'nur bei offenen Fragen zulaessig - bei Auswahlfragen stehen die Antworten da',
            )
            return

        if not isinstance(kernpunkte, list):
            self.melden(frage_id, 'kernpunkte', 'ist keine Liste')
            return

        if len(kernpunkte) < 2:
            # Eine Pruefliste mit einem Punkt ist kein Werkzeug, sondern Beiwerk.
            self.melden(frage_id, 'kernpunkte', 'braucht mindestens zwei Punkte')
            return
        if len(kernpunkte) > MAX_KERNPUNKTE:
            self.melden(
                frage_id, 'kernpunkte', f'mehr als {MAX_KERNPUNKTE} Punkte sind keine Liste mehr'
            )

        gesehen: set[str] = set()
        for punkt in kernpunkte:
            if not isinstance(punkt, str):
                self.melden(frage_id, 'kernpunkte', f'{punkt!r} ist keine Zeichenkette')
                continue
            if punkt in gesehen:
                self.melden(frage_id, 'kernpunkte', f'„{punkt}" steht doppelt')
            gesehen.add(punkt)
            if not punkt.strip():
                self.melden(frage_id, 'kernpunkte', 'leerer Punkt')
            elif len(punkt) > MAX_KERNPUNKT_LAENGE:
                self.melden(
                    frage_id,
                    'kernpunkte',
                    f'zu lang ({len(punkt)} Zeichen, hoechstens {MAX_KERNPUNKT_LAENGE})',
                )
            if PFLICHTBEHAUPTUNG.search(punkt):
                self.melden(
                    frage_id,
                    'kernpunkte',
                    'behauptet, was eine Antwort enthalten muss - das ist unbelegt '
                    '(entscheidung-freitext.md Abschnitt 8)',
                )

    def verwandt_pruefen(self, frage_id: str, verwandt: object) -> None:
        """Prüft die Querverweise einer Erklärung.

        Der Katalog kennt keine Frage-zu-Frage-Beziehung; ``verwandt`` ist eine
        redaktionelle Zuordnung dieser Software. Geprüft wird, was sich
        maschinell sicher prüfen lässt: Existenz, kein Selbstbezug, keine
        Wiederholung. Ob zwei Fragen wirklich zusammengehören, entscheidet die
        Redaktion – die Beidseitigkeit prüft :meth:`verweise_abgleichen`, wenn
        alle Erklärungen gelesen sind.
        """
        if verwandt is None:
            return

        if not isinstance(verwandt, list):
            self.melden(frage_id, 'verwandt', 'ist keine Liste')
            return

        if not verwandt:
            # Eine leere Liste ist eine Angabe, die nichts angibt.
            self.melden(frage_id, 'verwandt', 'ist leer; dann gehoert das Feld weg')
            return

        gesehen: set[str] = set()
        for eintrag in verwandt:
            if not isinstance(eintrag, str):
                self.melden(frage_id, 'verwandt', f'{eintrag!r} ist keine Zeichenkette')
                continue
            if eintrag == frage_id:
                self.melden(frage_id, 'verwandt', 'verweist auf sich selbst')
                continue
            if eintrag in gesehen:
                self.melden(frage_id, 'verwandt', f'{eintrag} steht doppelt')
                continue
            gesehen.add(eintrag)
            if eintrag not in self.fragen:
                self.melden(frage_id, 'verwandt', f'{eintrag} gibt es im Katalog nicht')
            else:
                self.verweise.setdefault(frage_id, set()).add(eintrag)

    def verweise_abgleichen(self) -> None:
        """Verlangt, dass jeder Querverweis beidseitig steht.

        Ein einseitiger Verweis ist fast immer ein Versehen beim Schreiben –
        und er zeigt die Verbindung nur dem, der zufaellig von der richtigen
        Seite kommt. Wer II-34 loest, soll von II-36 erfahren; wer mit II-36
        anfaengt, ebenso.
        """
        for frage_id, ziele in sorted(self.verweise.items()):
            for ziel in sorted(ziele):
                if frage_id not in self.verweise.get(ziel, set()):
                    self.melden(
                        frage_id,
                        'verwandt',
                        f'{ziel} wird genannt, nennt aber {frage_id} nicht zurueck',
                    )

    def alles_pruefen(self, daten: dict) -> None:
        meta = daten.get('meta')
        if not isinstance(meta, dict):
            self.melden('(meta)', 'meta', 'fehlt')
        else:
            for pflicht in ('version', 'stand', 'gesetzesstand', 'hinweis'):
                if pflicht not in meta:
                    self.melden('(meta)', pflicht, 'fehlt')

            # Der vermerkte Gesetzesstand muss zum Index passen. Sonst wären
            # die Erklärungen gegen etwas anderes geprüft worden, als
            # ausgewiesen ist.
            for kuerzel, stand in (meta.get('gesetzesstand') or {}).items():
                if kuerzel not in self.gesetze:
                    self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}')
                elif self.gesetze[kuerzel]['stand'] != stand:
                    self.melden(
                        '(meta)',
                        'gesetzesstand',
                        f'{kuerzel}: vermerkt „{stand}", Index sagt '
                        f'„{self.gesetze[kuerzel]["stand"]}"',
                    )

        zu_frage = daten.get('zuFrage')
        if not isinstance(zu_frage, dict):
            self.melden('(datei)', 'zuFrage', 'fehlt oder ist kein Objekt')
            return

        for frage_id, erklaerung in sorted(zu_frage.items()):
            if not isinstance(erklaerung, dict):
                self.melden(frage_id, '(ganz)', 'ist kein Objekt')
                continue
            self.erklaerung_pruefen(frage_id, erklaerung)

        # Erst wenn alle gelesen sind: Ein Verweis auf eine spaeter stehende
        # Frage waere sonst als einseitig gemeldet worden.
        self.verweise_abgleichen()


#: Bewusst fehlerhafte Erklärungen. Jede muss beanstandet werden – sonst ist
#: die Prüfung nutzlos, ohne dass es auffiele.
SELBSTTEST: list[tuple[str, dict, str]] = [
    (
        'erfundener Paragraf',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]},
        'gibt es im WaffG nicht',
    ),
    (
        'erfundener Absatz',
        {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '7'}]},
        'keinen Abs. 7',
    ),
    (
        'erfundene Nummer',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '99'}]},
        'keine Nr. 99',
    ),
    (
        'erfundener Buchstabe',
        {
            'fundstellen': [
                {
                    'gesetz': 'WaffG',
                    'norm': '§ 12',
                    'absatz': '1',
                    'nummer': '1',
                    'buchstabe': 'z',
                }
            ]
        },
        'keinen Buchst. z',
    ),
    (
        'Absatz als Zahl statt als Zeichenkette',
        {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]},
        'muss eine Zeichenkette sein',
    ),
    (
        'unbekanntes Gesetz',
        {'fundstellen': [{'gesetz': 'BGB', 'norm': '§ 1'}]},
        'Unbekanntes Gesetz',
    ),
    (
        'Nummer in einem ungegliederten Absatz',
        {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'nummer': '1'}]},
        'nicht in Nummern gegliedert',
    ),
    (
        'Buchstabe ohne Nummer, wo der Absatz mit Nummern aufzaehlt',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'buchstabe': 'a'}]},
        'nicht unmittelbar mit Buchstaben',
    ),
    (
        'Buchstabe als Nummer ausgegeben',
        {'fundstellen': [{'gesetz': 'SprengG', 'norm': '§ 3a', 'absatz': '2', 'nummer': 'a'}]},
        'muss eine Zahl sein',
    ),
    (
        'Absatzangabe bei einer Anlage',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1', 'absatz': '1'}]},
        'nicht zulässig',
    ),
    (
        'Anlage, die es nicht gibt',
        {'fundstellen': [{'gesetz': 'BeschussV', 'norm': 'Anlage IX'}]},
        'gibt es im BeschussV nicht',
    ),
    (
        'Stelle, die in der Anlage nicht vorkommt',
        {
            'fundstellen': [
                {'gesetz': 'WaffG', 'norm': 'Anlage 1', 'stelle': 'Abschnitt 1 Nr. 99.99'}
            ]
        },
        'kommt in Anlage 1',
    ),
    (
        'gegliederte Norm ohne Absatzangabe',
        {'fundstellen': [{'gesetz': 'AWaffV', 'norm': '§ 13'}]},
        'bitte den zutreffenden angeben',
    ),
    # Die folgenden Fälle liefen früher stumm durch: Der Satz-Check war nur
    # hinter Nummer UND Buchstabe erreichbar, Feinangaben ohne Absatz kehrten
    # vor jeder Gliederungsprüfung um, und eine Anlage ohne Stelle galt als
    # geprüft, obwohl nur ihre Existenz nachgewiesen war.
    (
        'erfundener Satz ohne Buchstaben-Angabe',
        {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': '99'}]},
        'keinen Satz 99',
    ),
    (
        'Satz, der keine Zahl ist',
        {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': 'zwei'}]},
        'Satz muss eine Zahl ab 1 sein',
    ),
    (
        'erfundene Nummer in einer Norm ohne Absatzgliederung',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '99'}]},
        'keine Nr. 99',
    ),
    (
        'erfundener Buchstabe in einer Norm ohne Absatzgliederung',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '3', 'buchstabe': 'z'}]},
        'keinen Buchst. z',
    ),
    (
        'Anlage ohne Stellenangabe',
        {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1'}]},
        'nur die Existenz',
    ),
    (
        'Fundstelle fehlt ohne Begründung',
        {'fundstellen': []},
        'ohneFundstelleGrund',
    ),
    (
        'Begründung trotz vorhandener Fundstelle',
        {
            'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
            'ohneFundstelleGrund': 'Es gibt hierzu keine gesetzliche Regelung im engeren Sinne.',
        },
        'obwohl Fundstellen vorhanden',
    ),
    (
        'Platzhalter im Text',
        {
            # Bewusst lang genug, damit der Fall an der Platzhalterpruefung
            # scheitert und nicht schon an der Mindestlaenge.
            'text': (
                'TODO: Die Begruendung zu dieser Frage fehlt noch und muss vor der '
                'Auslieferung ergaenzt werden, damit hier nichts Unfertiges steht.'
            )
        },
        'Platzhalter',
    ),
    ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'Unbekanntes Feld'),
    ('erfundene verwandte Frage', {'verwandt': ['XI.9-99']}, 'gibt es im Katalog nicht'),
    ('verwandt als Text statt Liste', {'verwandt': 'II-34'}, 'ist keine Liste'),
    ('leere Verwandtenliste', {'verwandt': []}, 'ist leer'),
]


def selbsttest(katalog: dict, gesetze: dict) -> int:
    """Prüft die Prüfung.

    Eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht
    zu unterscheiden – bis jemand sich auf sie verlässt. Deshalb wird jede
    Fehlerart einmal absichtlich erzeugt.
    """
    # Eine gültige Erklärung als Ausgangspunkt; jeder Fall ändert genau eines.
    frage_id = katalog['fragen'][0]['id']
    gueltig = {
        'kurz': 'Ein Satz, der die richtige Antwort in ausreichender Laenge traegt.',
        'text': (
            'Eine Begruendung von hinreichender Laenge, damit die Formpruefung sie '
            'nicht wegen Kuerze beanstandet und der Fall wirklich an der gemeinten '
            'Stelle scheitert.'
        ),
        'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
    }

    fehlgeschlagen = 0
    # Mitgezaehlt statt am Ende gerechnet: Die Schlusszeile nannte bis Fassung
    # 0.24.1 `len(SELBSTTEST) + 1` und damit 26 Faelle, gefahren wurden 32 -
    # nach der Liste laufen sieben weitere Faelle einzeln. Wer die Zahl als
    # Mass der Abdeckung nahm, unterschaetzte sie um sechs; und faellt einmal
    # ein Fall aus der Liste heraus, sagte sie darueber gar nichts. Dasselbe
    # Muster wie in pruefe_glossar.py.
    gelaufen = 0
    for name, aenderung, erwartet in SELBSTTEST:
        gelaufen += 1
        fall = {**gueltig, **aenderung}
        pruefung = Pruefung(katalog, gesetze)
        pruefung.erklaerung_pruefen(frage_id, fall)

        getroffen = any(erwartet in b.meldung for b in pruefung.befunde)
        if getroffen:
            print(f'  erkannt:      {name}')
        else:
            gemeldet = '; '.join(b.meldung for b in pruefung.befunde) or '(nichts beanstandet)'
            print(f'  DURCHGELASSEN: {name}')
            print(f'                 erwartet: {erwartet}')
            print(f'                 gemeldet: {gemeldet}')
            fehlgeschlagen += 1

    # Kernpunkte gehoeren nur an offene Fragen - der Fall braucht deshalb eine
    # Auswahlfrage und passt nicht in die Liste oben, die gegen die erste
    # Frage des Katalogs laeuft.
    mc_id = next(f['id'] for f in katalog['fragen'] if f['typ'] == 'mc')
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.erklaerung_pruefen(mc_id, {**gueltig, 'kernpunkte': ['a', 'b']})
    if any('nur bei offenen Fragen' in b.meldung for b in pruefung.befunde):
        print('  erkannt:      Kernpunkte an einer Auswahlfrage')
    else:
        print('  DURCHGELASSEN: Kernpunkte an einer Auswahlfrage')
        fehlgeschlagen += 1

    # Und die Formulierung, die hier nie stehen darf.
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.erklaerung_pruefen(
        frage_id, {**gueltig, 'kernpunkte': ['das muss Ihre Antwort enthalten', 'b']}
    )
    if any('unbelegt' in b.meldung for b in pruefung.befunde):
        print('  erkannt:      Pflichtbehauptung in einem Kernpunkt')
    else:
        print('  DURCHGELASSEN: Pflichtbehauptung in einem Kernpunkt')
        fehlgeschlagen += 1

    # Eine Pruefliste mit einem Punkt ist kein Werkzeug.
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'kernpunkte': ['nur einer']})
    if any('mindestens zwei' in b.meldung for b in pruefung.befunde):
        print('  erkannt:      einzelner Kernpunkt')
    else:
        print('  DURCHGELASSEN: einzelner Kernpunkt')
        fehlgeschlagen += 1

    # Der Selbstbezug braucht die eigene Kennung und laesst sich deshalb nicht
    # als feste Zeichenkette in die Liste oben schreiben.
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'verwandt': [frage_id]})
    if any('sich selbst' in b.meldung for b in pruefung.befunde):
        print('  erkannt:      Verweis auf sich selbst')
    else:
        print('  DURCHGELASSEN: Verweis auf sich selbst')
        fehlgeschlagen += 1

    # Und die Beidseitigkeit: Sie braucht zwei Erklaerungen und passt deshalb
    # nicht in die Einzelfallliste.
    zweite_id = katalog['fragen'][1]['id']
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.alles_pruefen(
        {
            'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
            'zuFrage': {
                frage_id: {**gueltig, 'verwandt': [zweite_id]},
                zweite_id: dict(gueltig),
            },
        }
    )
    if any('nennt aber' in b.meldung for b in pruefung.befunde):
        print('  erkannt:      einseitiger Querverweis')
    else:
        print('  DURCHGELASSEN: einseitiger Querverweis')
        fehlgeschlagen += 1

    # Beidseitig gesetzt darf derselbe Fall nicht beanstandet werden.
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.alles_pruefen(
        {
            'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
            'zuFrage': {
                frage_id: {**gueltig, 'verwandt': [zweite_id]},
                zweite_id: {**gueltig, 'verwandt': [frage_id]},
            },
        }
    )
    if any('verwandt' == b.feld for b in pruefung.befunde):
        print('  FALSCHER ALARM bei beidseitigem Querverweis')
        fehlgeschlagen += 1
    else:
        print('  ok:           beidseitiger Querverweis bleibt unbeanstandet')

    # Und die gueltige Erklaerung darf nicht beanstandet werden.
    pruefung = Pruefung(katalog, gesetze)
    gelaufen += 1
    pruefung.erklaerung_pruefen(frage_id, gueltig)
    if pruefung.befunde:
        print('  FALSCHER ALARM bei einer gueltigen Erklaerung:')
        for befund in pruefung.befunde:
            print(f'    {befund}')
        fehlgeschlagen += 1
    else:
        print('  erkannt:      gueltige Erklaerung bleibt unbeanstandet')

    if fehlgeschlagen:
        print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} Faelle.')
        return 1

    print(f'\nSelbsttest bestanden: {gelaufen} Faelle.')
    return 0


def main() -> int:
    katalog_test = '--selbsttest' in sys.argv

    if katalog_test:
        katalog = json.load(io.open(KATALOG, encoding='utf-8'))
        gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
        return selbsttest(katalog, gesetze)

    if not ERKLAERUNGEN.exists():
        print(f'Keine Erklärungen gefunden: {ERKLAERUNGEN.relative_to(WURZEL)}')
        print('Das ist zulässig – die Anwendung läuft auch ohne.')
        return 0

    katalog = json.load(io.open(KATALOG, encoding='utf-8'))
    gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
    daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8'))

    pruefung = Pruefung(katalog, gesetze)
    pruefung.alles_pruefen(daten)

    zu_frage = daten.get('zuFrage') or {}
    gesamt = len(katalog['fragen'])
    abgedeckt = len([k for k in zu_frage if k in pruefung.fragen])
    mit_fundstelle = len([e for e in zu_frage.values() if (e or {}).get('fundstellen')])

    print(f'Fragen im Katalog:      {gesamt}')
    print(f'davon mit Erklärung:    {abgedeckt}  ({abgedeckt / gesamt * 100:.1f} %)')
    print(f'davon mit Fundstelle:   {mit_fundstelle}')
    # Zwei Zahlen statt einer: Ein Sammelzähler hatte 188 nur zur Hälfte
    # geprüfte Fundstellen als „geprüft" ausgewiesen.
    print(f'geprüfte Fundstellen:   {pruefung.voll_geprueft + pruefung.nur_existenz}')
    print(f'  vollständig geprüft:  {pruefung.voll_geprueft}  (jede Angabe nachgewiesen)')
    print(
        f'  nur Existenz geprüft: {pruefung.nur_existenz}'
        '  (Feinangabe maschinell nicht prüfbar)'
    )
    print(f'zitierte Normen:        {len(pruefung.normnutzung)}')

    if pruefung.ungeprueft:
        print(f'\nNicht maschinell prüfbar ({len(pruefung.ungeprueft)}):')
        for eintrag in pruefung.ungeprueft[:20]:
            print(f'  {eintrag}')
        if len(pruefung.ungeprueft) > 20:
            print(f'  … und {len(pruefung.ungeprueft) - 20} weitere')

    if pruefung.befunde:
        print(f'\n{len(pruefung.befunde)} Beanstandungen:\n')
        for befund in pruefung.befunde[:200]:
            print(f'  {befund}')
        if len(pruefung.befunde) > 200:
            print(f'  … und {len(pruefung.befunde) - 200} weitere')
        return 1

    print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.')
    return 0


if __name__ == '__main__':
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')  # type: ignore[union-attr]
    raise SystemExit(main())
