# -*- coding: utf-8 -*-
"""Gegenprobe zur Kreuzerkennung: die Tintendichte im Kästchen-Inneren.

## Warum es dieses Skript gibt

`parse_catalog.py` entscheidet allein über Vektorpfade: Ein Ankreuzkästchen
gilt als angekreuzt, wenn zwei Diagonalsegmente vollständig darin liegen
(`PageData.is_checked`). Diese Entscheidung trägt den halben Katalog – aus ihr
folgt, welche Antwort richtig ist. Sie mit denselben Vektoren nachzuprüfen,
belegt nichts: Ein Denkfehler in der Geometrie stünde in Prüfung und Umsetzung
gleichermaßen.

Diese Probe geht deshalb einen zweiten, unabhängigen Weg. Sie rendert dieselben
Kästchen bei 8-facher Vergrößerung als Graustufenbild und zählt die dunklen
Bildpunkte innerhalb des Rahmens. Beide Verfahren teilen genau eine Annahme –
wo ein Kästchen liegt –, und keine einzige darüber, was ein Kreuz ist.

## Was die Messung am Original ergeben hat (Katalogstand 16.12.2024)

    Kästchen gesamt        1430
      Vektor sagt LEER      801   Tintendichte 0,0000 bis 0,0000
      Vektor sagt KREUZ     629   Tintendichte 0,1653 bis 0,1978
      Widersprüche            0

Das Entscheidende ist nicht der Trennwert, sondern die **Lücke**: Zwischen der
dunkelsten leeren und dem hellsten angekreuzten Kästchen liegen 0,165 – rund
das Achtfache dessen, was ein Schwellwert an Spielraum bräuchte. Wo eine
Erkennung eine solche Lücke hat, ist sie nicht knapp richtig, sondern
eindeutig. Anlass der Messung waren die 28 Fragen, bei denen **alle** Optionen
angekreuzt sind (Verdacht: Layout nicht erkannt, vorsichtshalber alles
genommen); auch deren Kästchen liegen sämtlich in der oberen Gruppe.

## Ausführen

    python data-pipeline/diagnose_kreuze.py                 # ganzer Katalog
    python data-pipeline/diagnose_kreuze.py --seiten 6-20   # Ausschnitt
    python data-pipeline/diagnose_kreuze.py --selbsttest    # die Probe prüfen

Exit-Code 0, wenn beide Verfahren übereinstimmen und die Lücke bleibt; sonst 1.
Voraussetzung ist allein PyMuPDF – dieselbe und einzige Abhängigkeit, die
`parse_catalog.py` schon mitbringt.
"""
from __future__ import annotations

import argparse
import sys
from dataclasses import dataclass
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]

import fitz  # noqa: E402
import parse_catalog as pc  # noqa: E402

WURZEL = Path(__file__).resolve().parent.parent
PDF_VORGABE = WURZEL / "Fragenkatalog_sachkunde_mitAntworten.pdf"

# 8-fach: Ein Kästchen von ~19 pt Kantenlänge wird damit zu ~150 Bildpunkten
# je Seite. Feiner brächte nichts – die Strichstärke des Kreuzes liegt bereits
# bei mehreren Bildpunkten –, gröber ließe dünne Striche verschwinden.
ZOOM = 8.0

# Abstand zum Rahmen in pt. Der Rahmen ist selbst schwarz; ohne diesen Rand
# maße die Probe ihn mit und fände in jedem Kästchen Tinte.
RAND = 1.6

# Bis 127 gilt ein Bildpunkt als dunkel. Die Vorlage kennt nur Schwarz auf
# Weiß; der Wert liegt in der Mitte und ist damit unkritisch.
DUNKELSCHWELLE = 128

# Grenzwerte der Bewertung, hergeleitet aus der oben festgehaltenen Messung:
# leere Kästchen maßen 0,0000, angekreuzte mindestens 0,1653. Die Schranken
# lassen also reichlich Luft und schlagen erst an, wenn die Trennung wirklich
# zu wackeln beginnt.
LEER_HOECHSTENS = 0.02
KREUZ_MINDESTENS = 0.10
MINDESTABSTAND = 0.05


@dataclass(frozen=True)
class Kaestchen:
    """Ein vermessenes Ankreuzkästchen."""

    seite: int
    y: float
    dichte: float
    vektor_kreuz: bool


# ------------------------------------------------------------------ Messung


def messe(doc: fitz.Document, von: int, bis: int) -> list[Kaestchen]:
    """Vermisst alle Kästchen der Seiten `von` bis `bis` (1-basiert).

    Die Umsetzungstabelle macht aus dem Graustufenbild eine Maske und lässt
    `bytes.count` zählen. Eine Python-Schleife über 23 Millionen Bildpunkte
    dauerte Minuten, das hier Sekunden – dieselbe Zahl kommt heraus.
    """
    maske = bytes(255 if wert < DUNKELSCHWELLE else 0 for wert in range(256))
    werte: list[Kaestchen] = []

    for pno in range(von, bis + 1):
        seite = doc[pno - 1]
        daten = pc.PageData(seite, pno)
        for kasten in daten.checkboxes:
            innen = fitz.Rect(
                kasten.x0 + RAND, kasten.y0 + RAND, kasten.x1 - RAND, kasten.y1 - RAND
            )
            bild = seite.get_pixmap(
                matrix=fitz.Matrix(ZOOM, ZOOM), clip=innen, colorspace=fitz.csGRAY
            )
            proben = bild.samples
            dunkel = proben.translate(maske).count(255)
            werte.append(
                Kaestchen(
                    seite=pno,
                    y=kasten.y0,
                    dichte=dunkel / max(1, len(proben)),
                    vektor_kreuz=daten.is_checked(kasten),
                )
            )

    return werte


# ----------------------------------------------------------------- Bewertung


@dataclass(frozen=True)
class Befund:
    """Das Ergebnis einer Messreihe, samt aller Zahlen für den Bericht."""

    leer: int
    kreuz: int
    leer_min: float
    leer_max: float
    kreuz_min: float
    kreuz_max: float
    schwelle: float
    widersprueche: list[Kaestchen]
    beanstandungen: list[str]

    @property
    def abstand(self) -> float:
        return self.kreuz_min - self.leer_max


def bewerte(werte: list[Kaestchen]) -> Befund | None:
    """Vergleicht beide Verfahren. `None`, wenn nichts zu vergleichen war."""
    leer = [k for k in werte if not k.vektor_kreuz]
    kreuz = [k for k in werte if k.vektor_kreuz]
    if not leer or not kreuz:
        return None

    leer_max = max(k.dichte for k in leer)
    kreuz_min = min(k.dichte for k in kreuz)

    # Der Trennwert wird nicht vorgegeben, sondern aus der Messung genommen:
    # genau zwischen den beiden Gruppen. Überlappen sie, liegt er mitten im
    # Gedränge und die Widersprüche werden sichtbar – das ist gewollt.
    schwelle = (leer_max + kreuz_min) / 2
    widersprueche = [k for k in werte if (k.dichte > schwelle) != k.vektor_kreuz]

    beanstandungen: list[str] = []
    if widersprueche:
        beanstandungen.append(
            f"{len(widersprueche)} Kästchen: Vektorurteil und Tintendichte "
            f"widersprechen sich."
        )
    if leer_max > LEER_HOECHSTENS:
        beanstandungen.append(
            f"Ein leeres Kästchen trägt Tinte: {leer_max:.4f} > {LEER_HOECHSTENS:.4f}."
        )
    if kreuz_min < KREUZ_MINDESTENS:
        beanstandungen.append(
            f"Ein angekreuztes Kästchen ist fast leer: "
            f"{kreuz_min:.4f} < {KREUZ_MINDESTENS:.4f}."
        )
    if kreuz_min - leer_max < MINDESTABSTAND:
        beanstandungen.append(
            f"Die Lücke zwischen beiden Gruppen ist auf "
            f"{kreuz_min - leer_max:.4f} geschrumpft (mindestens "
            f"{MINDESTABSTAND:.4f}) – aus der Lücke würde ein Schwellwert."
        )

    return Befund(
        leer=len(leer),
        kreuz=len(kreuz),
        leer_min=min(k.dichte for k in leer),
        leer_max=leer_max,
        kreuz_min=kreuz_min,
        kreuz_max=max(k.dichte for k in kreuz),
        schwelle=schwelle,
        widersprueche=widersprueche,
        beanstandungen=beanstandungen,
    )


def bericht(werte: list[Kaestchen], befund: Befund | None) -> None:
    """Gibt die Messwerte aus – auch dann, wenn nichts zu beanstanden ist."""
    print(f"Kästchen vermessen: {len(werte)}")
    if befund is None:
        print("  Eine der beiden Gruppen ist leer – hier gibt es nichts zu trennen.")
        return

    print(
        f"  Vektor sagt LEER  : {befund.leer:5d}   Tintendichte "
        f"{befund.leer_min:.4f} bis {befund.leer_max:.4f}"
    )
    print(
        f"  Vektor sagt KREUZ : {befund.kreuz:5d}   Tintendichte "
        f"{befund.kreuz_min:.4f} bis {befund.kreuz_max:.4f}"
    )
    print(f"  Lücke zwischen den Gruppen: {befund.abstand:.4f}")
    print(f"  Daraus abgeleiteter Trennwert: {befund.schwelle:.4f}")
    print(f"  Widersprüche Vektor gegen Tinte: {len(befund.widersprueche)}")
    for k in befund.widersprueche[:20]:
        urteil = "KREUZ" if k.vektor_kreuz else "LEER "
        print(f"    S.{k.seite:3d} y={k.y:7.2f}  Vektor {urteil}  Dichte {k.dichte:.4f}")
    if len(befund.widersprueche) > 20:
        print(f"    … und {len(befund.widersprueche) - 20} weitere")


# --------------------------------------------------------------- Selbsttest


def selbsttest(doc: fitz.Document) -> int:
    """Prüft die Probe.

    Eine Probe, die nichts beanstandet, ist von einer funktionierenden nicht zu
    unterscheiden – dieselbe Regel wie in `pruefe_erklaerungen.py`. Deshalb
    wird jedes Fehlerbild einmal absichtlich erzeugt: an echten Messwerten,
    nicht an erfundenen, damit die Fälle auch dann noch stimmen, wenn eine neue
    Katalogfassung andere Zahlen bringt.
    """
    seiten_bis = min(pc.FIRST_CONTENT_PAGE + 5, doc.page_count)
    echte = messe(doc, pc.FIRST_CONTENT_PAGE + 1, seiten_bis)
    grundbefund = bewerte(echte)
    if grundbefund is None:
        print("Selbsttest nicht durchführbar: Die Probeseiten tragen nur eine Gruppe.")
        return 1

    ein_leeres = next(i for i, k in enumerate(echte) if not k.vektor_kreuz)
    ein_kreuz = next(i for i, k in enumerate(echte) if k.vektor_kreuz)

    def mit(index: int, **aenderung: object) -> list[Kaestchen]:
        kopie = list(echte)
        alt = kopie[index]
        kopie[index] = Kaestchen(
            seite=aenderung.get("seite", alt.seite),  # type: ignore[arg-type]
            y=aenderung.get("y", alt.y),  # type: ignore[arg-type]
            dichte=aenderung.get("dichte", alt.dichte),  # type: ignore[arg-type]
            vektor_kreuz=aenderung.get("vektor_kreuz", alt.vektor_kreuz),  # type: ignore[arg-type]
        )
        return kopie

    faelle: list[tuple[str, list[Kaestchen], str]] = [
        (
            "Vektor behauptet ein Kreuz, wo keine Tinte ist",
            mit(ein_leeres, vektor_kreuz=True),
            "widersprechen sich",
        ),
        (
            "Vektor übersieht ein Kreuz, das im Bild steht",
            mit(ein_kreuz, vektor_kreuz=False),
            "widersprechen sich",
        ),
        (
            "ein leeres Kästchen trägt plötzlich Tinte",
            mit(ein_leeres, dichte=0.09),
            "trägt Tinte",
        ),
        (
            "ein angekreuztes Kästchen ist fast leer",
            mit(ein_kreuz, dichte=0.03),
            "ist fast leer",
        ),
    ]

    fehlgeschlagen = 0
    for name, verfaelscht, erwartet in faelle:
        befund = bewerte(verfaelscht)
        gemeldet = befund.beanstandungen if befund else []
        if any(erwartet in text for text in gemeldet):
            print(f"  erkannt:       {name}")
        else:
            print(f"  DURCHGELASSEN: {name}")
            print(f"                 erwartet: …{erwartet}…")
            print(f"                 gemeldet: {'; '.join(gemeldet) or '(nichts)'}")
            fehlgeschlagen += 1

    if grundbefund.beanstandungen:
        print("  FALSCHER ALARM an den unveränderten Messwerten:")
        for text in grundbefund.beanstandungen:
            print(f"    {text}")
        fehlgeschlagen += 1
    else:
        print("  erkannt:       die echte Messung bleibt unbeanstandet")

    print()
    bericht(echte, grundbefund)
    print()
    if fehlgeschlagen:
        print(f"Selbsttest fehlgeschlagen: {fehlgeschlagen} Fälle.")
        return 1
    print(f"Selbsttest bestanden: {len(faelle) + 1} Fälle.")
    return 0


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


def seitenbereich(text: str, doc: fitz.Document) -> tuple[int, int]:
    """`6-20` oder `6` – 1-basiert wie die Seitenzahlen im PDF-Betrachter."""
    teile = text.split("-", 1)
    von = int(teile[0])
    bis = int(teile[1]) if len(teile) > 1 else von
    if not 1 <= von <= bis <= doc.page_count:
        raise SystemExit(
            f"Seitenbereich {text} liegt nicht in 1-{doc.page_count}."
        )
    return von, bis


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--pdf", default=str(PDF_VORGABE))
    ap.add_argument(
        "--seiten",
        default=None,
        help="Seitenbereich, etwa 6-20. Ohne Angabe: alle Inhaltsseiten.",
    )
    ap.add_argument(
        "--selbsttest",
        action="store_true",
        help="Erzeugt jedes Fehlerbild absichtlich und prüft, dass es auffällt.",
    )
    args = ap.parse_args()

    pfad = Path(args.pdf)
    if not pfad.exists():
        print(f"PDF nicht gefunden: {pfad}")
        return 1

    doc = fitz.open(pfad)
    try:
        if args.selbsttest:
            return selbsttest(doc)

        if args.seiten:
            von, bis = seitenbereich(args.seiten, doc)
        else:
            von, bis = pc.FIRST_CONTENT_PAGE + 1, doc.page_count

        print(f"Vorlage: {pfad.name}")
        print(f"Seiten {von} bis {bis}, Vergrößerung {ZOOM:.0f}-fach")
        print()
        werte = messe(doc, von, bis)
        befund = bewerte(werte)
        bericht(werte, befund)
        print()

        if befund is None:
            print("Kein Urteil möglich – die Probe hatte nichts zu trennen.")
            return 1
        if befund.beanstandungen:
            for text in befund.beanstandungen:
                print(f"BEANSTANDET: {text}")
            return 1

        print(
            "Beide Verfahren stimmen überein. Die Kreuzerkennung steht vor "
            "einer Lücke, nicht vor einem Schwellwert."
        )
        return 0
    finally:
        doc.close()


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