"""Führt einzeln erarbeitete Erklärungen zu `content/erklaerungen.json` zusammen.

Die Erklärungen entstehen abschnittweise in Teilstücken. Dieses Skript legt
sie zusammen, setzt den Kopf mit dem tatsächlichen Gesetzesstand und schreibt
das Ergebnis in einer festen Reihenfolge – so bleibt der Unterschied zwischen
zwei Ständen im Versionsverlauf lesbar und besteht nicht aus umsortierten
Zeilen.

Ein Teilstück ist eine JSON-Datei mit denselben Einträgen wie `zuFrage`,
entweder unmittelbar::

    { "I.1-01": { … }, "I.1-02": { … } }

oder in einen Umschlag gepackt::

    { "zuFrage": { "I.1-01": { … } } }

Aufruf
------
    python data-pipeline/erklaerungen_zusammenfuehren.py <datei-oder-verzeichnis> …

Vorhandene Erklärungen bleiben erhalten; gleichnamige Einträge aus einem
Teilstück ersetzen sie. Mit `--nur-neu` bleibt der Bestand unangetastet und
es werden ausschließlich fehlende Einträge ergänzt.

Nach dem Zusammenführen läuft die Prüfung – ohne sie wird nicht geschrieben.
"""

from __future__ import annotations

import argparse
import io
import json
import sys
from pathlib import Path

WURZEL = Path(__file__).resolve().parent.parent
GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json'
KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json'
ZIEL = WURZEL / 'content' / 'erklaerungen.json'
ZWEIFEL = WURZEL / 'docs' / 'erklaerungen-zweifel.md'

#: Ablage der Redaktionsvermerke - **nicht** ausgeliefert.
#:
#: Sie braucht einen eigenen Ort, und das ist der Kern der Sache. Bis Fassung
#: 0.24.1 wurden die Vermerke aus dem Bestand herausgenommen und nur nach
#: `ZWEIFEL` geschrieben; jener Lauf ueberschrieb die Datei vollstaendig, und
#: beim naechsten Durchgang war der Vermerk nirgends mehr - weder im Bestand
#: noch in der Datei. Sechs von zwoelf gingen so verloren, nachweisbar in der
#: Projektgeschichte.
#:
#: `content/erklaerungen.json` kommt als Ablage nicht in Frage: Sie wird
#: ausgeliefert, und die Vermerke sollen Lernende ausdruecklich nicht
#: erreichen. Deshalb diese Datei hier neben dem Werkzeug.
ZWEIFEL_ABLAGE = WURZEL / 'data-pipeline' / 'zweifel.json'

#: Muss Wort fuer Wort dem meta.hinweis in content/erklaerungen.json
#: entsprechen - ein Zusammenfuehren schreibt diesen Text dorthin zurueck und
#: wuerde eine Praezisierung sonst stillschweigend wieder einkassieren.
HINWEIS = (
    'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Fragenkatalogs des '
    'Bundesverwaltungsamtes. Jede Fundstelle wird von '
    'data-pipeline/pruefe_erklaerungen.py gegen den amtlichen Gesetzestext von '
    'gesetze-im-internet.de geprueft: nachgewiesen werden Existenz der Norm und '
    'jede angegebene Feinstelle (Absatz, Nummer, Buchstabe, Anlagen-Stelle; '
    'Satzangaben als Plausibilitaetsgrenze, da das amtliche XML Saetze nicht '
    'auszeichnet). Ob eine Norm die Aussage inhaltlich traegt, prueft die '
    'Redaktion, nicht die Maschine.'
)


def teilstuecke_einlesen(orte: list[str]) -> dict[str, dict]:
    """Liest alle angegebenen Dateien und Verzeichnisse ein."""
    gesammelt: dict[str, dict] = {}

    dateien: list[Path] = []
    for ort in orte:
        pfad = Path(ort)
        if pfad.is_dir():
            dateien.extend(sorted(pfad.glob('*.json')))
        elif pfad.is_file():
            dateien.append(pfad)
        else:
            raise SystemExit(f'Nicht gefunden: {ort}')

    for datei in dateien:
        if datei.resolve() == ZIEL.resolve():
            continue  # das Ziel ist kein Teilstück
        roh = json.load(io.open(datei, encoding='utf-8'))
        eintraege = roh.get('zuFrage') if isinstance(roh, dict) and 'zuFrage' in roh else roh
        if not isinstance(eintraege, dict):
            raise SystemExit(f'{datei}: erwartet wird ein Objekt mit Frage-Kennungen.')

        for frage_id, erklaerung in eintraege.items():
            if frage_id in gesammelt:
                print(f'  Hinweis: {frage_id} kommt mehrfach vor – {datei.name} gilt.')
            gesammelt[frage_id] = erklaerung
        print(f'  {datei.name}: {len(eintraege)} Erklärungen')

    return gesammelt


#: Felder einer Fundstelle, die als Zeichenkette vorliegen müssen.
FUNDSTELLENFELDER = ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle')


def fundstellen_normalisieren(bestand: dict[str, dict]) -> int:
    """Macht aus Zahlen Zeichenketten.

    `"absatz": 2` statt `"absatz": "2"` ist eine naheliegende Verwechslung und
    inhaltlich harmlos – der Lader in der Anwendung weist sie aber ab, weil er
    auf den Typ prüft. Statt das jedes Mal von Hand zu berichtigen, wird hier
    einmal umgesetzt: an der Stelle, an der die Teilstücke ohnehin
    zusammengeführt werden.

    Bewusst nur Zahlen. Alles andere bleibt stehen und fällt der Prüfung auf.
    """
    geaendert = 0
    for erklaerung in bestand.values():
        for fundstelle in erklaerung.get('fundstellen') or []:
            if not isinstance(fundstelle, dict):
                continue
            for feld in FUNDSTELLENFELDER:
                wert = fundstelle.get(feld)
                if isinstance(wert, bool):
                    continue  # bool ist in Python eine Zahl – hier keine
                if isinstance(wert, (int, float)):
                    fundstelle[feld] = str(wert)
                    geaendert += 1
    return geaendert


def sortierschluessel(frage_id: str, reihenfolge: dict[str, int]) -> tuple[int, str]:
    """Katalogreihenfolge, Unbekanntes ans Ende."""
    return (reihenfolge.get(frage_id, len(reihenfolge)), frage_id)


def main() -> int:
    zerleger = argparse.ArgumentParser(description='Erklärungen zusammenführen.')
    zerleger.add_argument('quellen', nargs='+', help='Dateien oder Verzeichnisse')
    zerleger.add_argument(
        '--nur-neu',
        action='store_true',
        help='Vorhandene Einträge nicht ersetzen, nur fehlende ergänzen',
    )
    argumente = zerleger.parse_args()

    print('Teilstücke:')
    neue = teilstuecke_einlesen(argumente.quellen)

    bestand: dict[str, dict] = {}
    if ZIEL.exists():
        bestand = json.load(io.open(ZIEL, encoding='utf-8')).get('zuFrage') or {}

    vorher = len(bestand)
    ersetzt = 0
    for frage_id, erklaerung in neue.items():
        if frage_id in bestand:
            if argumente.nur_neu:
                continue
            ersetzt += 1
        bestand[frage_id] = erklaerung

    normalisiert = fundstellen_normalisieren(bestand)
    if normalisiert:
        print(f'  {normalisiert} Zahlangaben in Fundstellen zu Zeichenketten gemacht.')

    katalog = json.load(io.open(KATALOG, encoding='utf-8'))
    reihenfolge = {f['id']: i for i, f in enumerate(katalog['fragen'])}

    # Zweifel an der amtlichen Loesung sind eine Notiz fuer die Redaktion und
    # gehoeren nicht in die Auslieferung: Sie wuerden Lernende verunsichern,
    # ohne dass sie an der Pruefung etwas aendern koennten. Verloren gehen
    # duerfen sie trotzdem nicht - sie sind das Wertvollste am Durchgang.
    #
    # Genau das ist bis Fassung 0.24.1 geschehen. Hier stand `pop`: Der
    # Vermerk wurde aus dem Bestand HERAUSGENOMMEN und nur in die
    # Markdown-Datei geschrieben, die anschliessend vollstaendig ueberschrieben
    # wird. Beim naechsten Lauf war er im Bestand nicht mehr da, also stand er
    # auch nicht mehr in der Datei. Die Projektgeschichte zeigt es Schritt fuer
    # Schritt: erst I.1-83, dann nur noch I.2-21, dann nur noch vier aus
    # Kapitel II/III, zuletzt nur noch die sechs des letzten Durchgangs.
    # Sechs von zwoelf Vermerken waren damit fort.
    #
    # Jetzt bleibt der Vermerk im Bestand stehen; herausgefiltert wird er erst
    # beim Schreiben von erklaerungen.json (siehe `ohne_zweifel`).
    zweifel = zweifel_zusammenfuehren(bestand)
    zweifel_ablegen(zweifel)
    zweifel_schreiben(zweifel, katalog)
    gesetze = json.load(io.open(GESETZE, encoding='utf-8'))['gesetze']

    # Nur die Gesetze vermerken, die auch zitiert werden – ein Stand, auf den
    # sich nichts bezieht, wäre eine Behauptung ohne Gegenstand.
    zitiert = {
        f.get('gesetz')
        for e in bestand.values()
        for f in (e.get('fundstellen') or [])
        if isinstance(f, dict)
    }

    ergebnis = {
        'meta': {
            'version': 1,
            'stand': heute(),
            'hinweis': HINWEIS,
            'gesetzesstand': {
                k: gesetze[k]['stand'] for k in gesetze if k in zitiert
            },
        },
        'zuFrage': {
            frage_id: ohne_zweifel(bestand[frage_id])
            for frage_id in sorted(bestand, key=lambda k: sortierschluessel(k, reihenfolge))
        },
    }

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

    gesamt = len(katalog['fragen'])
    print(
        f'\nvorher {vorher}, neu {len(bestand) - vorher}, ersetzt {ersetzt} '
        f'-> {len(bestand)} von {gesamt} Fragen ({len(bestand) / gesamt * 100:.1f} %)'
    )
    print(f'geschrieben: {ZIEL.name}')
    print('\nJetzt prüfen mit: python data-pipeline/pruefe_erklaerungen.py')
    return 0


def zweifel_schreiben(zweifel: dict[str, str], katalog: dict) -> None:
    """Haelt Zweifel an der amtlichen Loesung als Liste zur Durchsicht fest."""
    if not zweifel:
        return

    fragen = {f['id']: f for f in katalog['fragen']}
    zeilen = [
        '# Zweifel an amtlichen Loesungen',
        '',
        'Beim Schreiben der Erklaerungen aufgefallen: Stellen, an denen der',
        'amtliche Fragenkatalog von der abgerufenen Fassung des Gesetzes',
        'abweicht oder in sich unstimmig wirkt.',
        '',
        'Diese Vermerke werden **nicht** ausgeliefert. Die Erklaerungen folgen',
        'durchgaengig der amtlichen Loesung - in der Pruefung wird danach',
        'bewertet. Die Liste dient der Redaktion und einer moeglichen Rueckmeldung',
        'an das Bundesverwaltungsamt.',
        '',
        'Maschinell erzeugt von data-pipeline/erklaerungen_zusammenfuehren.py.',
        '',
    ]
    for frage_id in sorted(zweifel, key=lambda k: (fragen.get(k, {}).get('seite', 0), k)):
        frage = fragen.get(frage_id, {})
        zeilen.append(f'## {frage_id} (amtliche Nr. {frage.get("amtliche_nummer", "?")})')
        zeilen.append('')
        zeilen.append(f'**Frage:** {frage.get("frage", {}).get("text", "")}')
        zeilen.append('')
        zeilen.append(zweifel[frage_id])
        zeilen.append('')

    with io.open(ZWEIFEL, 'w', encoding='utf-8', newline='\n') as datei:
        datei.write('\n'.join(zeilen))
    print(f'{len(zweifel)} Zweifel vermerkt in {ZWEIFEL.name}')


def zweifel_zusammenfuehren(bestand: dict[str, dict]) -> dict[str, str]:
    """Die Vermerke aus der Ablage und aus dem frisch Eingelesenen.

    Was in einem Teilstueck steht, gewinnt: Wer einen Vermerk neu formuliert,
    will die neue Fassung. Was nur in der Ablage steht, bleibt erhalten - das
    ist der ganze Zweck der Ablage.
    """
    gesammelt: dict[str, str] = {}
    if ZWEIFEL_ABLAGE.exists():
        gesammelt.update(json.load(io.open(ZWEIFEL_ABLAGE, encoding='utf-8')).get('zuFrage') or {})
    for frage_id, erklaerung in bestand.items():
        vermerk = erklaerung.get('zweifel')
        if vermerk:
            gesammelt[frage_id] = vermerk
    return gesammelt


def zweifel_ablegen(zweifel: dict[str, str]) -> None:
    """Schreibt die Ablage - die Quelle, aus der die Liste jedesmal entsteht."""
    ergebnis = {
        'hinweis': (
            'Redaktionsvermerke zu Zweifeln an amtlichen Loesungen. Wird NICHT '
            'ausgeliefert. Erzeugt und fortgeschrieben von '
            'data-pipeline/erklaerungen_zusammenfuehren.py; die lesbare Fassung '
            'steht in docs/erklaerungen-zweifel.md.'
        ),
        'zuFrage': dict(sorted(zweifel.items())),
    }
    with io.open(ZWEIFEL_ABLAGE, 'w', encoding='utf-8', newline='\n') as datei:
        json.dump(ergebnis, datei, ensure_ascii=False, indent=1)
        datei.write('\n')


def ohne_zweifel(erklaerung: dict) -> dict:
    """Die Erklaerung, wie sie ausgeliefert wird - ohne den Redaktionsvermerk.

    Herausgefiltert statt herausgenommen: Im Bestand bleibt der Vermerk
    stehen, damit er den naechsten Durchgang ueberlebt. Siehe `main()`.
    """
    return {schluessel: wert for schluessel, wert in erklaerung.items() if schluessel != 'zweifel'}


def heute() -> str:
    """Datum als ISO-Zeichenkette."""
    from datetime import date

    return date.today().isoformat()


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