"""Baut aus den amtlichen Gesetzes-XML einen prüfbaren Fundstellenindex.

Warum das sein muss
-------------------
Die Erklärungstexte zu den 575 Prüfungsfragen nennen Fundstellen wie
„§ 12 Abs. 1 Nr. 3 Buchst. b WaffG". Eine erfundene oder verschobene
Fundstelle ist in diesem Fach kein Schönheitsfehler: Wer sich darauf verlässt,
lernt geltendes Recht falsch. Verlassen wird sich deshalb nicht auf Sorgfalt,
sondern auf eine maschinelle Prüfung – dieser Index ist ihre Grundlage.

Quelle
------
Ausschließlich das amtliche XML von gesetze-im-internet.de, herausgegeben vom
Bundesministerium der Justiz gemeinsam mit der juris GmbH. Die Dateien liegen
unverändert unter `content/gesetze/`; dieses Skript liest sie und schreibt
`content/gesetze/index.json`.

Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. Sie werden nicht mit der
Anwendung ausgeliefert – der Index dient allein der Prüfung im
Entwicklungsbaum.

Gliederung
----------
Das Gesetzes-XML gliedert Aufzählungen als `<DL><DT>1.</DT><DD>…</DD></DL>`,
beliebig tief verschachtelt („1." enthält „a)" enthält „aa)"). Der Index bildet
diese Struktur ab, damit sich eine Fundstelle bis auf die Buchstabenebene
prüfen lässt.

Anlagen (etwa die Begriffsbestimmungen in Anlage 1 WaffG) folgen einer eigenen
Systematik mit Abschnitten und Unterabschnitten. Für sie wird nur der Volltext
abgelegt; die Prüfung arbeitet dort mit Textsuche statt mit Struktur.

Aufruf
------
    python data-pipeline/gesetze_index.py
"""

from __future__ import annotations

import io
import json
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path

WURZEL = Path(__file__).resolve().parent.parent
GESETZE = WURZEL / 'content' / 'gesetze'
ZIEL = GESETZE / 'index.json'

# Kürzel -> (Dateiname, Bezeichnung, Kennung bei gesetze-im-internet.de)
QUELLEN: dict[str, tuple[str, str, str]] = {
    'WaffG': ('BJNR397010002.xml', 'Waffengesetz', 'waffg_2002'),
    'AWaffV': ('BJNR212300003.xml', 'Allgemeine Waffengesetz-Verordnung', 'awaffv'),
    'BeschG': ('BJNR400300002.xml', 'Beschussgesetz', 'beschg'),
    'BeschussV': ('BJNR147400006.xml', 'Beschussverordnung', 'beschussv'),
    'StGB': ('BJNR001270871.xml', 'Strafgesetzbuch', 'stgb'),
    'SprengG': ('BJNR027370976.xml', 'Sprengstoffgesetz', 'sprengg_1976'),
    '1. SprengV': ('BJNR021410977.xml', 'Erste Verordnung zum Sprengstoffgesetz', 'sprengv_1'),
}

#: „(1)" oder „(1a)" am Absatzanfang.
ABSATZ_MUSTER = re.compile(r'^\(\s*(\d+[a-z]?)\s*\)\s*', re.UNICODE)

#: Eine Nummernmarke: „1", „12", „3a". Ein blosser Buchstabe ist keine.
NUMMER_MARKE = re.compile(r'^\d+[a-z]?$', re.UNICODE)


@dataclass
class Absatz:
    nummer: str
    text: str
    #: Nummer -> darin enthaltene Buchstaben. Leer, wenn nicht gegliedert.
    gliederung: dict[str, list[str]] = field(default_factory=dict)


@dataclass
class Norm:
    """Ein Paragraf oder eine Anlage."""

    bezeichnung: str  # „§ 12" oder „Anlage 1"
    titel: str
    text: str
    absaetze: dict[str, Absatz] = field(default_factory=dict)
    ist_anlage: bool = False


def text_von(element: ET.Element | None) -> str:
    """Fließtext eines Elements mit lesbaren Zeilenumbrüchen."""
    if element is None:
        return ''

    teile: list[str] = []

    def sammeln(knoten: ET.Element) -> None:
        # <DT> trägt die Gliederungsmarke („1.", „a)") und beginnt eine Zeile.
        if knoten.tag in ('BR', 'DT'):
            teile.append('\n')
        if knoten.text:
            teile.append(knoten.text)
        for kind in knoten:
            sammeln(kind)
            if kind.tail:
                teile.append(kind.tail)

    sammeln(element)
    roh = ''.join(teile).replace('­', '').replace(' ', ' ')
    zeilen = [z.strip() for z in roh.split('\n')]
    return '\n'.join(z for z in zeilen if z)


def direkte_listen(element: ET.Element) -> list[ET.Element]:
    """Die obersten `<DL>` unterhalb von `element`.

    Sobald eine Liste gefunden ist, wird nicht weiter hineingestiegen: Ihre
    Untergliederung gehört zu ihr, nicht zur Ebene darüber.
    """
    gefunden: list[ET.Element] = []

    def gehe(knoten: ET.Element) -> None:
        for kind in knoten:
            if kind.tag == 'DL':
                gefunden.append(kind)
            else:
                gehe(kind)

    gehe(element)
    return gefunden


# Eine brauchbare Gliederungsmarke: Ziffer mit optionalem Kleinbuchstaben
# (»1«, »3a«) oder ein bis zwei Kleinbuchstaben (»a«, »aa«).
MARKE_MUSTER = re.compile(r'^(?:\d{1,3}[a-z]?|[a-z]{1,2})$')

# Was beim Lesen des amtlichen XML nicht aufging. Wird von `main()` gemeldet:
# Ein Absatz mit unlesbarer Gliederung fällt sonst niemandem auf, und an ihm
# scheitern anschliessend richtige Zitate.
MARKENBEFUNDE: list[str] = []


def liste_lesen(dl: ET.Element, ort: str = '') -> list[tuple[str, list[str]]]:
    """Eine `<DL>` als [(Marke, Marken der Untergliederung)].

    ## Warum Marken geprüft werden

    Das amtliche XML ist nicht überall sauber. Gemessen an der ausgelieferten
    Fassung: `BeschG § 11 Abs. 3` trägt ein leeres `<DT />`, weil die »1.«
    versehentlich **vor** der Liste im Absatztext steht (»wenn % 1.«); ebenso
    `StGB § 46 Abs. 2`. `BeschussV § 39 Abs. 2` liefert »-«, `BeschG § 2
    Abs. 7` ein »*«.

    Bis Fassung 0.24.1 wanderten diese Marken ungeprüft in den Index. Zwei
    Folgen: `gesetz.py --gliederung` stürzte an der leeren Marke ab, und die
    Fundstellenprüfung wies **richtige** Zitate ab – »§ 11 Abs. 3 Nr. 1
    BeschG« gibt es, der Index kannte die Nummer aber als Leerzeichenkette.

    Eine leere Marke wird deshalb aus ihrer **Stelle** in der Liste
    hergeleitet, aber nur, wenn das eindeutig ist: Die Liste muss aufsteigend
    numerisch sein und die hergeleitete Nummer darf noch nicht vorkommen.
    Jeder Fall wird gemeldet – geraten wird hier nichts stumm.
    """
    eintraege: list[tuple[str, list[str]]] = []
    marke: str | None = None
    stelle = 0

    for kind in dl:
        if kind.tag == 'DT':
            marke = ''.join(kind.itertext()).strip().rstrip('.)')
        elif kind.tag == 'DD' and marke is not None:
            stelle += 1
            unter: list[str] = []
            for tiefer in direkte_listen(kind):
                unter.extend(m for m, _ in liste_lesen(tiefer, ort))

            # Anlagen bleiben aussen vor: Fuer sie legt der Index nur den
            # Volltext ab (siehe Modulkopf), ihre Gliederung wird verworfen.
            # Sie zaehlen dutzendweise Punkte wie »3.4.1« und Kuerzel wie
            # »SW« auf – als Befund waere das nur Rauschen, das die neun
            # echten Faelle in den Paragrafen zudeckt.
            if not MARKE_MUSTER.match(marke) and not ort.lower().startswith('anlage'):
                ersatz = str(stelle)
                schon = {m for m, _ in eintraege}
                if not marke and ersatz not in schon:
                    MARKENBEFUNDE.append(
                        f'{ort}: leere Gliederungsmarke an Stelle {stelle} – '
                        f'als »{ersatz}« gefuehrt (amtliches XML unvollstaendig)'
                    )
                    marke = ersatz
                else:
                    MARKENBEFUNDE.append(
                        f'{ort}: unbrauchbare Gliederungsmarke {marke!r} an Stelle '
                        f'{stelle} – nicht in den Index aufgenommen'
                    )
                    marke = None
                    continue

            eintraege.append((marke, unter))
            marke = None

    return eintraege


def falten(gliederung: dict[str, list[str]]) -> dict[str, list[str]]:
    """Schlägt Buchstaben auf Nummernebene der vorangehenden Nummer zu.

    Das Gesetzes-XML setzt Untergliederungen nicht immer in das `<DD>` ihrer
    Nummer; in § 12 Abs. 4 WaffG etwa stehen die Buchstaben zu Nr. 3 als
    eigene Liste daneben. Ohne diese Korrektur entstünde daraus eine
    „Nr. a", die es nicht gibt – und eine Fundstelle darauf käme durch die
    Prüfung.

    Steht dagegen gar keine Nummer voran, ist die Buchstabengliederung echt:
    § 3a Abs. 2 SprengG zählt unmittelbar mit „a)" und „b)" auf.
    """
    ergebnis: dict[str, list[str]] = {}
    letzte_nummer: str | None = None

    for marke, unter in gliederung.items():
        if NUMMER_MARKE.match(marke):
            ergebnis[marke] = list(unter)
            letzte_nummer = marke
        elif letzte_nummer is not None:
            ergebnis[letzte_nummer].append(marke)
            ergebnis[letzte_nummer].extend(unter)
        else:
            ergebnis[marke] = list(unter)

    return ergebnis


def gliederung_von(element: ET.Element, ort: str = '') -> dict[str, list[str]]:
    """Gliederung eines Absatz-Blocks als Marke -> Untermarken."""
    gliederung: dict[str, list[str]] = {}
    for dl in direkte_listen(element):
        for marke, unter in liste_lesen(dl, ort):
            # Bei mehreren Listen im selben Absatz gewinnt die erste Nennung;
            # doppelte Marken kommen im Gesetzestext nicht vor.
            gliederung.setdefault(marke, unter)
    return falten(gliederung)


def absaetze_lesen(bloecke: list[tuple[str, dict[str, list[str]]]]) -> dict[str, Absatz]:
    """Ordnet die `<P>`-Blöcke ihren Absatznummern zu.

    Ein Absatz kann sich über mehrere `<P>` erstrecken. Ein Block ohne eigene
    Nummer gehört deshalb zum zuletzt begonnenen Absatz.
    """
    absaetze: dict[str, Absatz] = {}
    laufend: str | None = None

    for text, gliederung in bloecke:
        treffer = ABSATZ_MUSTER.match(text)
        if treffer:
            laufend = treffer.group(1)
            absaetze[laufend] = Absatz(
                nummer=laufend,
                text=text[treffer.end() :],
                gliederung=dict(gliederung),
            )
        elif laufend is not None:
            absaetze[laufend].text += '\n' + text
            for marke, unter in gliederung.items():
                absaetze[laufend].gliederung.setdefault(marke, unter)

    return absaetze


def norm_lesen(norm: ET.Element) -> Norm | None:
    md = norm.find('metadaten')
    if md is None:
        return None

    bezeichnung = (md.findtext('enbez') or '').strip()
    if not bezeichnung:
        return None

    inhalt = norm.find('textdaten/text/Content')
    bloecke: list[tuple[str, dict[str, list[str]]]] = []
    if inhalt is not None:
        for block in inhalt:
            text = text_von(block)
            if text:
                bloecke.append((text, gliederung_von(block, bezeichnung)))

    ist_anlage = bezeichnung.lower().startswith('anlage')

    return Norm(
        bezeichnung=bezeichnung,
        titel=(md.findtext('titel') or '').strip(),
        text='\n'.join(t for t, _ in bloecke),
        absaetze={} if ist_anlage else absaetze_lesen(bloecke),
        ist_anlage=ist_anlage,
    )


def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]:
    wurzel = ET.parse(pfad).getroot()
    kopf: dict[str, str] = {}
    normen: dict[str, Norm] = {}

    for norm_element in wurzel.findall('norm'):
        md = norm_element.find('metadaten')
        if md is None:
            continue

        # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes.
        if not kopf:
            stand = md.find('standangabe/standkommentar')
            kopf = {
                'jurabk': (md.findtext('jurabk') or '').strip(),
                'amtabk': (md.findtext('amtabk') or '').strip(),
                'langtitel': (md.findtext('langue') or '').strip(),
                'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(),
                'stand': (stand.text or '').strip() if stand is not None else '',
            }

        norm = norm_lesen(norm_element)
        if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage):
            normen[norm.bezeichnung] = norm

    return kopf, normen


def abzugsdatum(pfad: Path) -> str:
    """Wann dieser Abzug gezogen wurde – das Datum der XML-Datei.

    **Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die
    XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur,
    wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext
    darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte
    die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends
    stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist.
    Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug
    **sechs Jahre**.

    Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die
    allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund,
    sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts`
    schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und
    in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein
    Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat.
    """
    return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat()

def main() -> int:
    gesetze: dict[str, object] = {}

    for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items():
        pfad = GESETZE / datei
        if not pfad.exists():
            print(f'FEHLT: {kuerzel} ({datei})')
            return 1

        kopf, normen = gesetz_lesen(pfad)
        paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage}
        anlagen = {b: n for b, n in normen.items() if n.ist_anlage}

        gesetze[kuerzel] = {
            'bezeichnung': bezeichnung,
            'langtitel': kopf.get('langtitel', ''),
            'stand': kopf.get('stand', ''),
            'ausfertigung': kopf.get('ausfertigung', ''),
            'quelle': f'https://www.gesetze-im-internet.de/{kennung}/',
            'abzug': abzugsdatum(pfad),
            'normen': {
                b: {
                    'titel': n.titel,
                    'ist_anlage': n.ist_anlage,
                    'absaetze': {
                        a.nummer: {'gliederung': a.gliederung, 'text': a.text}
                        for a in n.absaetze.values()
                    },
                    'text': n.text,
                }
                for b, n in normen.items()
            },
        }

        gegliedert = sum(
            1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung
        )
        print(
            f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, '
            f'{gegliedert:4} gegliederte Absaetze'
        )

    ergebnis = {
        'hinweis': (
            'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de '
            '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - '
            'neu erzeugen mit: python data-pipeline/gesetze_index.py'
        ),
        'gesetze': gesetze,
    }

    with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus:
        json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1)

    print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)')

    # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit
    # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern
    # anschliessend richtige Zitate.
    if MARKENBEFUNDE:
        print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):')
        for befund in MARKENBEFUNDE:
            print(f'  {befund}')

    return 0


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