# SPDX-FileCopyrightText: 2026 Olaf Willerding
# SPDX-License-Identifier: EUPL-1.2
"""Erzeugt die Datenschutzerklärung für die Anwendung.

## Warum es diese Datei gibt

Die Datenschutzerklärung stand bis Fassung 0.24.2 **nirgends in der
Anwendung**. Das Wort kam in der ganzen Oberfläche kein einziges Mal als
sichtbarer Text vor; `docs/datenschutz.html` wurde nicht mitgeliefert, und der
einzige Weg nach außen führt zur Unterstützungsseite und nicht zur Erklärung.
Zugleich sagt „Über diese Software“ zu, die Anwendung übertrage keine Daten —
eine Zusage, deren Beleg der Nutzende nicht erreichen konnte.

## Warum aus derselben Quelle und nicht daneben

Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es gibt
sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits
eingetreten — die veröffentlichte Seite stand am 30.08.2026 auf Fassung 1.1,
während `docs/datenschutz.md` bei 1.5 war. Der Erzeuger der HTML-Fassung hatte
gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch tut.

Deshalb wird hier nichts abgeschrieben. Dieses Werkzeug ruft **denselben**
Markdown-Umsetzer auf wie `datenschutz_html.py` — es gibt genau einen Parser
für dieses Dokument — und setzt dessen HTML in Blöcke um, die die Anwendung
ohne `dangerouslySetInnerHTML` darstellen kann.

## Warum über das HTML und nicht über das Markdown

Der Umweg ist Absicht. Ein zweiter Markdown-Parser wäre eine zweite Meinung
darüber, was im Dokument steht — und die erste ist sorgfältig gegen fünf
Auszeichnungsformen abgesichert, an denen sie ausdrücklich abbricht
(Blockzitat, Aufzählung mit `*`, vierte Überschriftenebene, verschachtelte
Liste, Kursivsatz; siehe Befund H41). Diese Absicherung gilt hier mit, ohne
dass sie ein zweites Mal geschrieben werden müsste.

Der Tagvorrat der Ausgabe ist klein und geschlossen — nachgezählt am
erzeugten Dokument vom 30.08.2026: `h1`, `h2`, `h3`, `p`, `ul`, `li`, `pre`,
`code`, `strong`, `a`. Alles andere lässt dieses Werkzeug abbrechen.

## Die Wache

`content/datenschutz.json` trägt die Prüfsumme des Veröffentlichungsteils von
`docs/datenschutz.md`. `app/tests/datenschutz.test.ts` rechnet sie nach und
wird rot, sobald jemand die Quelle ändert und diesen Erzeuger vergisst. Ein
erzeugtes Dokument, das niemand neu erzeugt, ist stiller falsch als ein
handgeschriebenes — weil niemand mehr hinsieht.

Aufruf: ``python tools/datenschutz_anwendung.py``
"""

from __future__ import annotations

import hashlib
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from datenschutz_html import kennung, umsetzen, veroeffentlichungsteil  # noqa: E402

WURZEL = Path(__file__).resolve().parent.parent
QUELLE = WURZEL / "docs" / "datenschutz.md"
ZIEL = WURZEL / "content" / "datenschutz.json"

#: Die Standtabelle am Ende der Erklärung führt beide Angaben. Sie werden
#: gelesen und nicht hier eingetragen: Eine Fassungsnummer an zwei Orten ist
#: genau die zweite Wahrheit, die dieses Werkzeug verhindern soll.
FASSUNG_MUSTER = re.compile(r"^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|", re.M)
STAND_MUSTER = re.compile(r"^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|", re.M)

#: Genau die Elemente, die `datenschutz_html.umsetzen()` erzeugt.
BLOCKTAGS = {"h1", "h2", "h3", "p", "ul", "pre"}
TEILTAGS = {"strong", "code", "a"}


class Umsetzer(HTMLParser):
    """Setzt das erzeugte HTML in Blöcke um und bricht bei allem Fremden ab."""

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.bloecke: list[dict[str, object]] = []
        self._block: str | None = None
        self._teile: list[dict[str, str]] = []
        self._punkte: list[list[dict[str, str]]] = []
        self._in_li = False
        self._teil: str | None = None
        self._ziel: str | None = None

    # ── Hilfen ───────────────────────────────────────────────────────────

    def _text_anhaengen(self, roh: str) -> None:
        if self._block is None and not self._in_li:
            if roh.strip():
                self._abbruch(f"Text außerhalb eines Blocks: {roh.strip()[:60]!r}")
            return
        art = self._teil or "text"
        eintrag: dict[str, str] = {"art": art, "text": roh}
        if art == "verweis":
            eintrag["ziel"] = self._ziel or ""
        self._teile.append(eintrag)

    def _abbruch(self, was: str) -> None:
        sys.exit(f"ABBRUCH: {was}. Der Umsetzer kennt diese Form nicht.")

    @staticmethod
    def _zusammenfassen(teile: list[dict[str, str]]) -> list[dict[str, str]]:
        """Benachbarte gleichartige Teile verschmelzen, Leeres entfällt."""
        fertig: list[dict[str, str]] = []
        for teil in teile:
            if teil["text"] == "":
                continue
            if (
                fertig
                and fertig[-1]["art"] == teil["art"] == "text"
            ):
                fertig[-1]["text"] += teil["text"]
            else:
                fertig.append(dict(teil))
        return fertig

    # ── HTMLParser ───────────────────────────────────────────────────────

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        werte = dict(attrs)
        if tag in BLOCKTAGS:
            self._block = tag
            self._teile = []
            if tag == "ul":
                self._punkte = []
            return
        if tag == "li":
            self._in_li = True
            self._teile = []
            return
        if tag in TEILTAGS:
            if tag == "code" and self._block == "pre":
                return  # <pre><code> – der Inhalt ist reiner Text
            self._teil = {"strong": "stark", "code": "kennzeichnung", "a": "verweis"}[tag]
            self._ziel = werte.get("href")
            return
        self._abbruch(f"unbekanntes Element <{tag}>")

    def handle_endtag(self, tag: str) -> None:
        if tag in TEILTAGS:
            if tag == "code" and self._block == "pre":
                return
            self._teil = None
            self._ziel = None
            return
        if tag == "li":
            self._punkte.append(self._zusammenfassen(self._teile))
            self._teile = []
            self._in_li = False
            return
        if tag == "ul":
            self.bloecke.append({"art": "liste", "punkte": self._punkte})
            self._block = None
            return
        if tag == "pre":
            self.bloecke.append(
                {"art": "code", "text": "".join(t["text"] for t in self._teile)}
            )
            self._block = None
            self._teile = []
            return
        if tag in {"h1", "h2", "h3"}:
            teile = self._zusammenfassen(self._teile)
            text = "".join(t["text"] for t in teile)
            self.bloecke.append(
                {
                    "art": "ueberschrift",
                    "ebene": int(tag[1]),
                    "text": text,
                    "kennung": kennung(text),
                }
            )
            self._block = None
            self._teile = []
            return
        if tag == "p":
            self.bloecke.append({"art": "absatz", "teile": self._zusammenfassen(self._teile)})
            self._block = None
            self._teile = []
            return
        self._abbruch(f"unbekanntes schließendes Element </{tag}>")

    def handle_data(self, data: str) -> None:
        self._text_anhaengen(data)


def pruefsumme(text: str) -> str:
    """SHA-256 über den Veröffentlichungsteil, mit Zeilenenden vereinheitlicht.

    Ohne die Vereinheitlichung wäre die Wache auf einem Arbeitsbaum mit CRLF
    dauerhaft und grundlos rot – und eine grundlos rote Wache wird
    abgeschaltet.
    """
    return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest()


def main() -> None:
    markdown = QUELLE.read_text(encoding="utf-8")
    teil = veroeffentlichungsteil(markdown)

    fassung = FASSUNG_MUSTER.search(markdown)
    stand = STAND_MUSTER.search(markdown)
    if fassung is None or stand is None:
        sys.exit(
            "ABBRUCH: Fassung oder Stand nicht in der Standtabelle gefunden. "
            "Beide werden gelesen und nicht eingetragen; ohne sie wüsste die "
            "Anwendung nicht, welche Fassung sie zeigt."
        )

    umsetzer = Umsetzer()
    umsetzer.feed(umsetzen(teil))
    umsetzer.close()

    if not umsetzer.bloecke:
        sys.exit("ABBRUCH: kein einziger Block entstanden.")

    inhalt = {
        "quellpruefsumme": pruefsumme(teil),
        "fassung": fassung.group(1),
        "stand": stand.group(1),
        "bloecke": umsetzer.bloecke,
    }

    ZIEL.parent.mkdir(parents=True, exist_ok=True)
    with ZIEL.open("w", encoding="utf-8", newline="\n") as datei:
        json.dump(inhalt, datei, ensure_ascii=False, indent=2)
        datei.write("\n")

    zahl = len(umsetzer.bloecke)
    print(f"Geschrieben: {ZIEL.relative_to(WURZEL)} ({zahl} Blöcke, Fassung {inhalt['fassung']}).")


if __name__ == "__main__":
    main()
