"""Anonymisiert eine QAF-Arbeitsmappe auf XML-Ebene, statt sie neu zu schreiben.

Das Original wird ausschliesslich GELESEN. Die Ausgabe ist eine neue Datei.

── Warum eine zweite Fassung neben anonymise_workbook.py ───────────────────────
Die openpyxl-Fassung schreibt die Mappe komplett neu. Zwei Folgen, die sich
nicht wegkonfigurieren lassen:

1. **Formeln verlieren ihr gecachtes Ergebnis.** openpyxl kennt entweder
   Formeln oder Werte, nie beides. Die geschriebene Datei traegt `<f>` ohne
   `<v>`. Der QAF-Parser liest Werte und rechnet nicht — in der Fixture blieben
   deshalb 19 von 20 Kennzahlen leer, der Angebotspreis war `null` statt einer
   Zahl. Eine Fixture, die genau die Groessen nicht traegt, um die es geht,
   prueft nichts.
2. **Der Container schrumpft.** Aus 71 ZIP-Eintraegen wurden 35: `customXml/*`
   und die Erweiterungen zu bedingter Formatierung und Datenpruefung fallen weg.

Diese Fassung arbeitet deshalb am ZIP: jeder Eintrag wird unveraendert
uebernommen, ausser den Blatt-XMLs und `sharedStrings.xml`. Dort wird gezielt
ersetzt. In `<c>`-Elementen liegen `<f>` (Formel) und `<v>` (gecachtes
Ergebnis) nebeneinander — beides bleibt erhalten, nur der Zahlenwert darin
wechselt.

── Die Entscheidung zu den Formel-Caches ───────────────────────────────────────
Ein gecachtes Ergebnis DARF nicht im Original stehen bleiben: es ist ein echter
Kalkulationswert. Es soll aber zu den ersetzten Eingaben passen, sonst schliesst
keine Reconciliation mehr und die Fixture taugt nur noch fuer Strukturtests.

Zwei Betriebsarten, beide ausdruecklich benannt statt still gewaehlt:

  --caches=recompute  Eingaben ersetzen, danach die Mappe neu rechnen lassen und
                      die Ergebnisse als Cache schreiben. Arithmetisch konsistent.
                      Setzt voraus, dass die Formel-Engine die Mappe versteht.
  --caches=replace    Eingaben UND Caches deterministisch ersetzen. Kein
                      Originalwert bleibt stehen, die Arithmetik der Mappe
                      stimmt danach aber nicht mehr. Nur mit
                      `arithmetic_consistency: false` im Manifest zu verwenden.

Die Ersetzungsregel selbst ist dieselbe wie in anonymise_workbook.py und wird
von dort importiert — zwei Regeln waeren zwei Wahrheiten.
"""

from __future__ import annotations

import argparse
import hashlib
import html
import json
import re
import shutil
import sys
import zipfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from anonymise_workbook import TextAnonymiser, is_characteristic, replace_number  # noqa: E402

# Ein <c>-Element mit allem, was drinsteht. Bewusst nicht ueber einen XML-Parser:
# jeder Round-Trip durch ElementTree schreibt Namespaces, Attributreihenfolge und
# Selbstschliessungen um. Was hier nicht getroffen wird, bleibt Byte fuer Byte
# stehen — genau das ist der Zweck dieser Fassung.
# `attrs` MUSS nicht-gierig sein. Mit `[^>]*` verschluckt der Ausdruck bei einer
# selbstschliessenden Zelle `<c r="G82" s="516"/>` den Schraegstrich (er ist kein
# `>`), faellt dann in den `>`-Zweig und zieht alles bis zum naechsten `</c>` als
# Inhalt ein — also die FOLGENDEN Zellen. Deren `t`-Attribut wird nie gesehen,
# ihre Werte nie ersetzt, und die Ausgabe bleibt trotzdem wohlgeformt, weil der
# Text unveraendert wieder zusammengesetzt wird. Gefunden hat das die
# Leck-Pruefung: 435 Materialbezeichnungen standen noch im Klartext.
# Nach `<c` MUSS ein Trenner folgen. Ohne diese Bedingung trifft das Muster
# auch `<cols>`, `<conditionalFormatting>`, `<controls>` — alles, was mit
# `<c` beginnt. Der Ausdruck sucht dann das naechste `</c>` (das `</cols>`
# NICHT ist) und verschluckt dabei echte Zellen. Deren Werte wurden mit der
# Koordinate des falschen Elements ersetzt — unter anderem Zellen mit
# t="s", deren <v> ein INDEX in die Zeichenkettentabelle ist. Ein ersetzter
# Index zeigt ins Leere, und ExcelJS bricht mit "Cannot read properties of
# undefined (reading 'richText')" ab. Genau daran sind die 9.1-Fixtures
# gescheitert, waehrend die 8.8-Mappe es ueberlebte.
ZELLE = re.compile(r"<c(?=[ />])(?P<attrs>[^>]*?)(?:/>|>(?P<inhalt>.*?)</c>)", re.S)
# `<v>` kann Attribute tragen: `<v xml:space="preserve">   Text</v>`. Ohne die
# Attributgruppe blieben genau die Zellen unangetastet, die fuehrende
# Leerzeichen bewahren — auf der HICE-Mappe die Kopfzeilen-Texte.
WERT = re.compile(r"<v(?P<vattrs>\s[^>]*)?>(?P<wert>[^<]*)</v>", re.S)
FORMEL = re.compile(r"<f[ >]", re.S)
INLINE_TEXT = re.compile(r"(<is>.*?<t[^>]*>)(?P<text>[^<]*)(</t>)", re.S)
SI_TEXT = re.compile(r"(<t[^>]*>)(?P<text>[^<]*)(</t>)", re.S)
ATTR_R = re.compile(r'\br="([A-Z]+\d+)"')
ATTR_T = re.compile(r'\bt="([a-zA-Z]+)"')
# Formeltext samt umschliessendem Tag, und darin die Zeichenketten-Literale.
# Excel schreibt ein doppeltes Anfuehrungszeichen als "" — deshalb erlaubt das
# innere Muster genau das als Bestandteil des Literals.
FORMEL_INHALT = re.compile(r"(<f(?:\s[^>]*)?>)([^<]*)(</f>)", re.S)
FORMEL_LITERAL = re.compile(r'"((?:[^"]|"")*)"')
# Werte, die als Attribut stehen: PivotCache-sharedItems (`<s v="…"/>`),
# Hyperlink-Beschriftungen (`display="…"`), externe Blattnamen (`val="…"`).
# `v=` traegt die PivotCache-sharedItems, `display=` die Hyperlink-Beschriftung.
# `val=` bewusst NICHT: das trifft Theme-Token (phClr, minor) und Stilnamen —
# Struktur, keine Fachwerte, und als Leck gemeldet reiner Laerm.
# `refreshedBy` traegt den Namen dessen, der den PivotCache zuletzt
# aktualisiert hat — ein echter Personenname, gefunden in der 8.8-Fixture.
# Die Wortgrenze `\b` allein reicht nicht: in `xmlns:v="urn:schemas-microsoft-com:vml"`
# steht vor dem `v` ein Doppelpunkt, also ein Nicht-Wortzeichen — `\b` passt, und
# der Ausdruck fasst die VML-Namensraumkonstante als Wertträger auf. Aufgefallen
# erst, als die Prüf-Funktionen von der Endungsprüfung auf `ist_text()` umgestellt
# wurden und damit auch `.vml` lasen: `pruefe_leck` meldete
# `urn:schemas-microsoft-com:vml` als Leck. Ein latenter Fehler, den erst die
# Korrektur an anderer Stelle sichtbar gemacht hat.
ATTR_WERT = re.compile(r'(?<![\w:.-])(?:v|display|refreshedBy)="([^"]{2,})"')
HYPERLINK_TEXT = re.compile(r'display="([^"]{2,})"')
KOPFZEILE = re.compile(r"(<(?:odd|even|first)(?:Header|Footer)>)([^<]+)(</)", re.S)
# Ein Feldcode (&L, &P, &"Schrift,Stil") oder ein Stueck Freitext dazwischen.
KOPFZEILEN_STUECK = re.compile(r'&"[^"]*"|&[A-Za-z0-9]|[^&]+')


def blatt_namen(z: zipfile.ZipFile) -> dict[str, str]:
    """Ordnet sheetN.xml den Blattnamen zu.

    Der Blattname geht in den Streuwert der Ersetzung ein — dieselbe Zelle in
    zwei Blaettern muss unterschiedliche Werte bekommen, sonst waere die
    Ersetzung ueber Blattgrenzen hinweg raterbar.
    """
    wb = z.read("xl/workbook.xml").decode("utf-8", "replace")
    rels = z.read("xl/_rels/workbook.xml.rels").decode("utf-8", "replace")
    id_zu_ziel = dict(re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', rels))
    zuordnung: dict[str, str] = {}
    for name, rid in re.findall(r'<sheet[^>]*name="([^"]*)"[^>]*r:id="([^"]+)"', wb):
        ziel = id_zu_ziel.get(rid, "")
        if ziel:
            pfad = "xl/" + ziel.lstrip("/").removeprefix("xl/")
            zuordnung[pfad] = name
    return zuordnung


def ersetze_blatt(xml: str, blatt: str, seed: str, anon: TextAnonymiser,
                  caches: str, statistik: dict, original_zahlen: set[float],
                  original_texte: set[str]) -> str:
    """Ersetzt Zahlen und Inline-Texte in einem Blatt-XML."""

    def pro_zelle(treffer: re.Match) -> str:
        ganze = treffer.group(0)
        attrs = treffer.group("attrs") or ""
        inhalt = treffer.group("inhalt")
        if inhalt is None:  # <c .../> — leere Zelle
            return ganze

        koord_treffer = ATTR_R.search(attrs)
        koord = koord_treffer.group(1) if koord_treffer else "?"
        typ_treffer = ATTR_T.search(attrs)
        typ = typ_treffer.group(1) if typ_treffer else ""

        ist_formel = bool(FORMEL.search(inhalt))

        if typ == "s":
            # Verweis in die gemeinsame Zeichenkettentabelle — dort ersetzt.
            return ganze
        if typ == "inlineStr":
            def pro_inline(m: re.Match) -> str:
                text = m.group("text")
                original_texte.add(text)
                neu = anon.pseudonym(text)
                if neu == text:
                    statistik["texte_beschriftung"] += 1
                else:
                    statistik["texte_ersetzt"] += 1
                return m.group(1) + neu + m.group(3)
            return "<c" + attrs + ">" + INLINE_TEXT.sub(pro_inline, inhalt) + "</c>"
        if typ == "str":
            # Gecachtes TEXT-Ergebnis einer Formel. Zuerst uebersprungen, weil
            # keine Kalkulationszahl — auf der HICE-Mappe standen dort aber 435
            # Materialbezeichnungen im Klartext ("Neodym Dom CN - ECN*",
            # "Ferrochrom low ..."). Ein Formelergebnis ist genauso ein Wert wie
            # eine getippte Zelle, nur eine Ebene tiefer.
            def pro_str(m: re.Match) -> str:
                text = m.group("wert")
                if text.strip() == "":
                    return m.group(0)
                original_texte.add(text)
                neu = anon.pseudonym(text)
                if neu == text:
                    statistik["texte_beschriftung"] += 1
                else:
                    statistik["texte_ersetzt"] += 1
                return f"<v{m.group('vattrs') or ''}>{_xml_escape(neu)}</v>"
            return "<c" + attrs + ">" + WERT.sub(pro_str, inhalt) + "</c>"
        if typ in ("e", "b"):
            # Fehlerwert und Wahrheitswert tragen keine Geschaeftsinformation.
            return ganze

        if ist_formel:
            # Zeichenketten-Literale IM Formeltext: `IF($I12="Stück (Kaufteil)", …)`
            # oder `="Lithiumhydroxid - "&A1`. Sie sind echte Werte und blieben
            # sonst im Klartext stehen — die openpyxl-Fassung hat Formeln
            # vollstaendig unangetastet gelassen und diese Klasse damit nie
            # gesehen. Ersetzt wird ueber dieselbe Abbildung wie ueberall sonst:
            # gleicher Text, gleiches Pseudonym. Der Vergleich in der Formel
            # trifft danach dieselbe Zelle wie vorher, die Logik bleibt heil.
            inhalt = FORMEL_INHALT.sub(
                lambda mf: mf.group(1) + FORMEL_LITERAL.sub(
                    lambda ml: '"' + _xml_escape(anon.pseudonym(ml.group(1))) + '"',
                    mf.group(2)) + mf.group(3),
                inhalt)

        def pro_wert(m: re.Match) -> str:
            roh = m.group("wert").strip()
            if roh == "":
                return m.group(0)
            try:
                zahl = float(roh)
            except ValueError:
                return m.group(0)

            original_zahlen.add(zahl)

            if ist_formel:
                if caches == "recompute":
                    # Cache faellt weg, die Engine schreibt ihn neu.
                    statistik["caches_entfernt"] += 1
                    return ""
                if zahl == 0:
                    statistik["zahlen_null"] += 1
                    return m.group(0)
                statistik["caches_ersetzt"] += 1
                return (f"<v{m.group('vattrs') or ''}>"
                        f"{_formatiere(replace_number(zahl, seed, blatt, koord))}</v>")

            if zahl == 0:
                statistik["zahlen_null"] += 1
                return m.group(0)
            statistik["zahlen_ersetzt"] += 1
            return (f"<v{m.group('vattrs') or ''}>"
                    f"{_formatiere(replace_number(zahl, seed, blatt, koord))}</v>")

        return "<c" + attrs + ">" + WERT.sub(pro_wert, inhalt) + "</c>"

    xml = ZELLE.sub(pro_zelle, xml)

    # Ausserhalb der Zellen, aber in derselben Datei: Hyperlink-Beschriftungen
    # und Kopf-/Fusszeilen. Beides liegt nicht in einem <c>-Element und wurde
    # deshalb nie angefasst — in der HICE-Mappe stand dort eine BMW-interne
    # Adresse.
    def pro_beschriftung(m: re.Match) -> str:
        wert = m.group(1)
        original_texte.add(wert)
        neu = anon.pseudonym(wert)
        if neu != wert:
            statistik["texte_ersetzt"] += 1
        return f'display="{_xml_escape(neu)}"'

    def pro_kopfzeile(m: re.Match) -> str:
        # Kopf- und Fusszeilen sind kein reiner Text: `&L`, `&P`, `&"Arial,Fett"`
        # sind Feldcodes von Excel. Wer die Zeile als Ganzes pseudonymisiert,
        # zerstoert das Format — openpyxl meldet dann "Cannot parse header or
        # footer so it will be ignored". Ersetzt werden nur die Freitextstuecke
        # zwischen den Codes.
        def pro_stueck(s: re.Match) -> str:
            stueck = s.group(0)
            if stueck.startswith("&") or not re.search(r"[A-Za-zÄÖÜäöüß]{3,}", stueck):
                return stueck
            original_texte.add(stueck)
            neu = anon.pseudonym(stueck)
            if neu != stueck:
                statistik["texte_ersetzt"] += 1
            return neu

        # Erst entschluesseln: im XML steht `&amp;L`, nicht `&L`. Ohne diesen
        # Schritt liest der Tokenizer `&a` als Feldcode und der Rest `mp;L…`
        # wird zu Freitext — die Zeile ist danach kaputt und openpyxl meldet
        # 'Cannot parse header or footer'.
        inhalt_neu = KOPFZEILEN_STUECK.sub(pro_stueck, html.unescape(m.group(2)))
        return m.group(1) + _xml_escape(inhalt_neu) + m.group(3)

    xml = HYPERLINK_TEXT.sub(pro_beschriftung, xml)
    return KOPFZEILE.sub(pro_kopfzeile, xml)


def _formatiere(wert: float) -> str:
    """Schreibt eine Zahl so, wie Excel sie im XML fuehrt (kein Exponent, kein .0)."""
    if float(wert).is_integer():
        return str(int(wert))
    return repr(round(float(wert), 10)).rstrip("0").rstrip(".")


SI_BLOCK = re.compile(r"<si>(.*?)</si>", re.S)


def ersetze_shared_strings(xml: str, anon: TextAnonymiser, statistik: dict,
                           original_texte: set[str]) -> str:
    """Ersetzt je `<si>`-Eintrag, nicht je `<t>`-Element.

    Excel zerlegt formatierten Text in Laeufe: `Bezeichnung` und ` des Bauteils`
    stehen als zwei `<r><t>` im selben `<si>`. Wer Lauf fuer Lauf entscheidet,
    fragt fuer Bruchstuecke nach einer Beschriftung, die nur als Ganzes eine ist
    — auf der HICE-Mappe blieben so drei Fragmente uebrig, waehrend dieselbe
    Beschriftung an anderer Stelle korrekt stehen blieb.

    Ist der zusammengesetzte Text eine Beschriftung, bleiben alle Laeufe. Sonst
    traegt der erste Lauf das Pseudonym und die uebrigen werden geleert; die
    Zeichenkette als Ganzes stimmt damit, die Lauf-Formatierung geht verloren.
    Das ist fuer eine Fixture der richtige Tausch.
    """

    def pro_si(block: re.Match) -> str:
        inhalt = block.group(1)
        stuecke = SI_TEXT.findall(inhalt)
        if not stuecke:
            return block.group(0)
        ganzer_text = "".join(s[1] for s in SI_TEXT.finditer(inhalt).__iter__() for _ in [0]) \
            if False else "".join(m.group("text") for m in SI_TEXT.finditer(inhalt))
        if ganzer_text.strip() == "":
            return block.group(0)

        original_texte.add(ganzer_text)
        pseudonym = anon.pseudonym(ganzer_text)
        if pseudonym == ganzer_text:
            statistik["texte_beschriftung"] += 1
            return block.group(0)

        statistik["texte_ersetzt"] += 1
        # Formatierte Laeufe werden zu EINEM schlichten Textknoten
        # zusammengefasst. Die erste Fassung liess die uebrigen Laeufe mit
        # leerem <t> stehen — ExcelJS bricht darauf mit "Cannot read properties
        # of undefined (reading 'richText')" ab, und der QAF-Parser konnte die
        # erzeugten Vorlagen-Fixtures gar nicht lesen. Die Lauf-Formatierung
        # geht dabei verloren; fuer eine Fixture ist das der richtige Tausch,
        # der Text als Ganzes stimmt.
        return f"<si><t xml:space=\"preserve\">{_xml_escape(pseudonym)}</t></si>"

    return SI_BLOCK.sub(pro_si, xml)


def _xml_escape(text: str) -> str:
    return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))



# Eintraege ausserhalb der Blaetter, die Fachwerte oder Personendaten tragen.
# Bewusst eine Positivliste: eine pauschale Ersetzung ueber styles.xml oder
# theme1.xml wuerde die Datei zerlegen.
WEITERE_WERTTRAEGER = re.compile(
    r"^(?:docProps/(?:core|app|custom)\.xml"
    r"|customXml/item\d+\.xml"
    r"|xl/pivotCache/pivotCache\w+\d*\.xml"
    r"|xl/externalLinks/externalLink\d+\.xml"
    # Purview-Empfindlichkeitskennzeichen: traegt Tenant- und Label-GUIDs
    # der erzeugenden Organisation. Kam bisher unveraendert durch, weil die
    # Datei in keiner Liste stand.
    r"|docMetadata/LabelInfo\.xml)$")
ELEMENT_TEXT = re.compile(r">([^<>&]{2,})<")
# Nur diese Felder der Dokument-Eigenschaften tragen Freitext. Alles andere
# dort ist typisiert (Datum, Wahrheitswert, Zaehler, GUID, Anwendungsname)
# und darf nicht zu einem Pseudonym werden.
DOCPROPS_FREITEXT = re.compile(
    r"(<(?:dc:title|dc:creator|dc:subject|dc:description|cp:lastModifiedBy"
    r"|cp:keywords|cp:category|Company|Manager)>)([^<]+)(</)")
# Zeitstempel, GUID, reine Ziffern: Struktur, kein Personenbezug.
# Dokument-Kennungen: identifizieren keine Person, ordnen die Datei aber
# eindeutig ihrer Quelle zu. In einer Fixture, die aus einer vertraulichen
# Mappe entsteht, ist das ein Herkunftsnachweis, den niemand braucht.
# `uri` und `ds:uri` bleiben: das sind Schema-Adressen, in jeder Datei dieses
# Typs identisch, also Struktur.
DOKUMENT_KENNUNG = re.compile(
    r'\b(xr:uid|xr2:uid|xr9:uid|xr10:uidLastSave|documentId|ds:itemID'
    # SharePoint-Kennungen der erzeugenden Organisation: Term-Set, Site,
    # Liste, Web, Feld. Die identifizieren nicht das Dokument, sondern den
    # Mandanten — und stehen in den customXml-Teilen der Vorlagen.
    r'|ma:fieldId|ma:sspId|ma:termSetId|ma:anchorId|ma:list|ma:web)="([^"]+)"')

# Der Nachtrag zur Namensliste darueber, und der Grund, warum es ihn braucht.
#
# `DOKUMENT_KENNUNG` ist eine feste ATTRIBUTNAMEN-Liste. Sie stammt aus der Zeit
# vor der Umstellung auf "nach der Form entscheiden, nicht nach dem Namen" — und
# sie blieb dabei unangetastet, weil sie schon da war. Ergebnis: `ma:contentTypeID`
# (`0x0101...`, Hex ohne Bindestriche), `ma:versionID` und `ma:fieldsID` (je 32
# Hexzeichen ohne Bindestriche) standen in ALLEN VIER Fixtures byte-identisch aus
# der Quelle. Drei davon waren bereits gemergt. Es ist die sechste Leck-Klasse,
# und keines der fuenf Post-Mortems hat sie gefunden — weil alle fuenf die neuen
# Pruefungen betrachteten und keines die alte Regel danebeb.
#
# Weder `GUID_ATTRIBUT` (verlangt Bindestrich-Format) noch `KENNUNGSWERT` (dito,
# plus `0x` mit mindestens 10 Stellen) griffen: die 32er-Hexwerte haben keine
# Bindestriche, und `0x0101...` traf die Laengenbedingung nur zufaellig nicht.
#
# Dieses Muster entscheidet ueber die FORM des Werts und ignoriert den
# Attributnamen. Bewusst auf `customXml/` beschraenkt: dort ist eine lange
# Hexfolge eine Kennung des Mandanten. In `xl/` koennte sie ein Stil- oder
# Formatschluessel sein, und ein Pseudonym dafuer wuerde die Mappe beschaedigen.
KENNUNGSFORM = re.compile(
    r'\b([\w:]+)="((?:0x[0-9A-Fa-f]{8,})'
    r'|(?:\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?)'
    r'|(?:[0-9A-Fa-f]{32,}))"')

# Generisch: jedes Attribut, dessen WERT wie eine GUID aussieht. Trifft die
# Zeichnungs-Kennungen (`id="{GUID}"` in xl/drawings/*), die als Fingerabdruck
# des Ursprungsdokuments taugen.
# Ausgenommen sind Schema-Adressen: `uri`, `xmlns*`, `namespace`,
# `targetNamespace` enthalten zwar GUIDs, sind aber fuer jedes Dokument
# desselben Typs gleich — also Struktur, kein Herkunftsnachweis.
GUID_ATTRIBUT = re.compile(
    r'\b(?!uri|xmlns|namespace|targetNamespace)(\w+(?::\w+)?)='
    r'"(\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?)"')


# Die Standard-FMTID der Office-Dokumenteigenschaften. Steht in praktisch
# jeder xlsx-Datei und ist damit Struktur, kein Herkunftsnachweis.
BEKANNTE_GUIDS = {
    "D5CDD505-2E9C-101B-9397-08002B2CF9AE",
    # Der eigene Platzhalter fuer die Purview-Kennungen. Ohne ihn hier hashte
    # die nachfolgende allgemeine Passage ihn noch einmal: aus dem sichtbar
    # neutralen {00000000-...} wurde ein zufaellig aussehender Wert, der in
    # allen drei Fixtures gleich ist — ein selbst erzeugter Fingerabdruck,
    # der die Dateien miteinander verknuepft.
    "00000000-0000-0000-0000-000000000000",
}

# GUIDs, die INNERHALB eines laengeren Werts stehen, etwa
# `name="MSIP_Label_{GUID}_Enabled"`. Ein Muster, das den ganzen Wert als GUID
# verlangt, trifft das nicht.
GUID_EINGEBETTET = re.compile(
    r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")
SCHEMA_ATTRIBUT = re.compile(r'\b(?:uri|xmlns(?::\w+)?|namespace|targetNamespace)="[^"]*"')


def neutralisiere_kennungen(xml: str, seed: str, statistik: dict,
                            schema_schuetzen: bool = True) -> str:
    """Ersetzt Dokument-GUIDs deterministisch, unter Erhalt der Eindeutigkeit.

    Gleiche Kennung bleibt gleich, verschiedene bleiben verschieden — sonst
    braechen Verweise zwischen den Teilen der Mappe.

    `schema_schuetzen=False` fuer die `customXml/`-Teile: dort ist der
    Namensraum KEINE Formatkonstante, sondern die GUID der SharePoint-Liste
    beziehungsweise des Inhaltstyps der erzeugenden Organisation. Fuer
    `uri="{78C0D931-...}"` in den Zeichnungen gilt das Gegenteil — das ist eine
    Office-Erweiterungskennung und in jeder Datei dieselbe. Weil beide Seiten
    denselben Seed benutzen, bleiben `xmlns` in `item2.xml` und `ds:uri` in
    `itemProps2.xml` auch nach der Ersetzung aufeinander abgebildet.
    """
    def pro_kennung(m: re.Match) -> str:
        roh = m.group(2)
        if roh.strip("{}").upper() in BEKANNTE_GUIDS:
            return m.group(0)
        h = hashlib.sha256((seed + "|kennung|" + roh).encode()).hexdigest()
        neu = f"{{{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}}}".upper()
        statistik["kennungen_neutralisiert"] += 1
        return f'{m.group(1)}="{neu}"'
    xml = DOKUMENT_KENNUNG.sub(pro_kennung, xml)
    xml = GUID_ATTRIBUT.sub(pro_kennung, xml)
    if not schema_schuetzen:
        # Nur fuer `customXml/`: jeder Attributwert, der die FORM einer Kennung
        # hat, unabhaengig vom Attributnamen. `schema_schuetzen=False` wird
        # ausschliesslich fuer diese Eintraege gesetzt.
        def pro_form(m: "re.Match[str]") -> str:
            roh = m.group(2)
            if roh.strip("{}").upper() in BEKANNTE_GUIDS:
                return m.group(0)
            h = hashlib.sha256((seed + "|kennung|" + roh).encode()).hexdigest()
            statistik["kennungen_neutralisiert"] += 1
            if roh.lower().startswith("0x"):
                # Laenge und Praefix erhalten: der Wert bleibt ein gueltiger
                # SharePoint-Inhaltstyp-Schluessel, nur ein anderer.
                return f'{m.group(1)}="0x{h[:len(roh) - 2].upper()}"'
            if "-" in roh:
                neu = f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}".upper()
                return f'{m.group(1)}="{{{neu}}}"' if roh.startswith("{") else f'{m.group(1)}="{neu}"'
            return f'{m.group(1)}="{h[:len(roh)].upper()}"'
        xml = KENNUNGSFORM.sub(pro_form, xml)

    # Schema-Adressen vor der Ersetzung herausnehmen und danach zurueck: ihre
    # GUIDs gehoeren zum Namensraum und sind in jeder Datei desselben Typs gleich.
    schutz: list[str] = []

    def merken(m: re.Match) -> str:
        schutz.append(m.group(0))
        return f"\x00{len(schutz) - 1}\x00"

    if schema_schuetzen:
        xml = SCHEMA_ATTRIBUT.sub(merken, xml)

    def pro_eingebettet(m: re.Match) -> str:
        roh = m.group(0)
        if roh.upper() in BEKANNTE_GUIDS:
            return roh
        h = hashlib.sha256((seed + "|kennung|" + roh).encode()).hexdigest()
        statistik["kennungen_neutralisiert"] += 1
        return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}".upper()

    xml = GUID_EINGEBETTET.sub(pro_eingebettet, xml)
    return re.sub(r"\x00(\d+)\x00", lambda m: schutz[int(m.group(1))], xml)


LABEL_GUID = re.compile(r'\b(id|siteId)="([^"]{8,})"')
NEUTRALE_GUID = "{00000000-0000-0000-0000-000000000000}"
VT_WERT = re.compile(r"(<vt:(?:lpwstr|i4|filetime|bool)>)([^<]+)(</vt:)")
# Kennungen, die in Metadaten stehen: GUID mit und ohne Klammern sowie die
# hexadezimalen SharePoint-Inhaltstyp-Kennungen (0x0101...).
KENNUNGSWERT = re.compile(
    r"\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?"
    r"|0x[0-9A-Fa-f]{10,}")
# Zeitstempel der Dokumenteigenschaften und feste Aufzaehlungswerte des
# Purview-Kennzeichens: beides Struktur, kein Inhalt.
ZEITSTEMPEL = re.compile(r"\d{4}-\d{2}-\d{2}T[\d:.]+Z?")
AUFZAEHLUNG = frozenset({"Privileged", "Standard", "Manual", "Automatic"})
STRUKTURWERT = re.compile(
    r"[\d.,:/+-]+|\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{4,}\}?|\d{4}-\d{2}-\d{2}T[\d:.Z+-]+"
    r"|true|false|0x[0-9A-Fa-f]+")
# Externe Ziele in den Beziehungs-Dateien: dort steht die Adresse des
# Servers, von dem die Mappe ihre Verknuepfungen zieht — eine
# BMW-interne URL ist ein Betriebsinternum und gehoert nicht in eine
# Fixture, die im Repository landet.
# Binaereintraege ohne Fachbedeutung fuer den Parser, die aber Herkunft
# tragen. Vollstaendig genullt statt ersetzt — siehe anonymise_xml.
INERTE_BINAERDATEN = re.compile(r"^xl/printerSettings/printerSettings\d+\.bin$")
EXTERNES_ZIEL = re.compile(r'(Target=")([^"]+)("[^>]*TargetMode="External")')
NEUTRALES_ZIEL = "https://beispiel.invalid/extern"
# Eine reine Ziffernfolge ist in den Metadaten KEINE Struktur, sondern eine
# Kennung — die SharePoint-AccountId des Bearbeiters stand als solche
# unveraendert in allen drei Fixtures. Der Fix "Ziffern-Ausnahme nur bis acht
# Stellen" wurde damals nur im Blattpfad nachgezogen; hier galt weiter "alle
# Ziffern sind Struktur", und eine zweistellige Kennung waere ohnehin
# durchgerutscht.
REINE_ZIFFERN = re.compile(r"\d+")

# --- Inhaltsklassen: was in keiner Fixture stehen darf ---------------------
#
# Alle drei Datenvorfaelle hatten dieselbe Bauart: entschieden wurde nach
# HERKUNFT ("hat der Ersetzer diesen Wert angefasst?") und nach FUNDORT
# ("steht er in einem Eintrag der Positivliste?"). Beide Kriterien teilen sich
# die blinden Flecken mit der Ersetzung — was kein Muster erfasst, ist damit
# automatisch auch von der Pruefung ausgenommen, und die meldet null.
#
# Diese Klassen entscheiden nach der FORM des Inhalts. Sie fragen den
# Anonymisierer nichts und laufen ueber die fertige AUSGABE: jeder ZIP-Eintrag,
# jeder Attributwert, jeder Textknoten. Was hier anschlaegt, ist ein Befund,
# unabhaengig davon, ob irgendein Muster den Wert haette sehen sollen.
#
# Die Hostliste ist die einzige Ausnahme und faellt bewusst in die sichere
# Richtung: ein unbekannter Host wird gemeldet, nicht durchgelassen.
UNBEDENKLICHE_HOSTS = frozenset({
    "schemas.openxmlformats.org", "schemas.microsoft.com", "www.w3.org",
    "purl.org", "docs.oasis-open.org", "go.microsoft.com", "www.iso.org",
    "schemas.opendocumentformat.org", "dublincore.org", "www.dublincore.org",
    "beispiel.invalid",
})
ADRESSE = re.compile(
    r"(?:https?://|ftps?://|www\.)[^\s\"'<>]+|\\\\[\w.-]+\\[^\s\"'<>]+", re.I)
# Ein Rechnername OHNE Schema faellt durch `ADRESSE` und damit durch jede
# Stufe: Ersetzung wie Pruefung. Im `.rels`-Target wurde das Ziel korrekt
# neutralisiert, im `display`-Attribut desselben Hyperlinks stand der echte
# Host weiter im Klartext. Mindestens drei Namensteile, damit gewoehnlicher
# Text mit Punkten nicht anschlaegt.
# Die ENDUNG muss kleingeschrieben sein, der Rest nicht. Mit `re.I` ueber das
# ganze Muster traf `eu` auch das grossgeschriebene `EU` in internen
# Klassifikationscodes der Form `BMW.Region.EU.<Land>.<Suffix>` — rund 100
# Laendercodes einer Dropdown-Liste wurden dadurch zu Pseudonymen, statt als
# Vorlagenvokabular stehen zu bleiben. Kein Leck (die sichere Richtung), aber
# die Vorlagentreue war zerstoert, und ein Test auf die Standortcodes waere
# still gebrochen.
#
# Echte Rechnernamen schreiben ihre Endung klein — `bmwgroup.net`,
# `europe.bmw.corp`, `vts4.bmwgroup.net`. Der Namensteil davor darf beliebige
# Gross-/Kleinschreibung haben, damit `SMUC1234.europe.bmw.corp` weiter
# anschlaegt.
#
# **Bekannte Luecke:** ein durchgehend grossgeschriebener Rechnername
# (`SERVER.EUROPE.BMW.CORP`) faellt damit durch. Windows- und
# NetBIOS-Konvention schreibt Namen durchaus so. In den drei Quellen kommt keine
# solche Form vor (byte-weise geprueft), und die Stelle mit der hoechsten
# Risikoflaeche dafuer — `xl/printerSettings/*.bin` — wird unbedingt genullt,
# unabhaengig von diesem Muster. Die Luecke ist damit latent, nicht aktiv. Sie
# hier zu schliessen kostet die 96 Laendercodes wieder; wer sie schliessen will,
# braucht ein anderes Unterscheidungsmerkmal als die Schreibweise.
RECHNERNAME = re.compile(
    r"\b[A-Za-z0-9][\w-]*(?:\.[A-Za-z0-9][\w-]*)+"
    r"\.(?:corp|local|intranet|lan|net|com|org|de|eu|io|gov|edu)\b")
# Rufnummern. Datenvorfall 1 war genau das, und bis hierher hatte diese Klasse
# kein eigenes Muster — sie wurde damals nur zufaellig von der
# Ziffern-Ausnahme mitbehandelt, und zwar falsch.
#
# Bewusst ZWEI enge Muster statt eines breiten. Ein generisches
# "Ziffernfolge mit Trennzeichen, mindestens acht Ziffern" ergab auf den drei
# Fixtures 331 Fehlalarme — GUID-Bruchstuecke der Form `NN-NNNN-NNNN`. Von
# `letzte_sicherung` ersetzt haette das die Dateien zerlegt. Beide Muster unten
# ergeben gemessen NULL Fehlalarme:
#
#   TEL_INTERNATIONAL  verlangt eine Landesvorwahl (`+49 ...`) oder die
#                      Null-in-Klammern-Schreibweise (`(0)`)
#   TEL_MIT_KONTEXT    verlangt ein Kontextwort davor (Tel, Telefon, Phone,
#                      Fax, Mobil, Handy, Durchwahl)
#
# **Bekannte Luecke:** eine nackte Ortsnummer ohne Kontextwort und ohne
# Vorwahl (`089/1234567` allein in einer Zelle) faellt durch. Sie von einer
# Bestellnummer oder Materialnummer zu unterscheiden, geht ueber die Form
# nicht — und ein Muster, das sie faengt, faengt auch die GUID-Bruchstuecke.
# In den drei Quellen kommt keine Rufnummer vor (geprueft), die Luecke ist
# also latent, nicht aktiv.
TEL_INTERNATIONAL = re.compile(r"(?:\+\d{1,3}[\s/()-]{1,3}|\(0\)?\s?)\d[\d\s/-]{5,}\d")
TEL_MIT_KONTEXT = re.compile(
    r"\b(?:tel|telefon|fon|rufnummer|phone|fax|mobil|mobile|handy|durchwahl)\b"
    r"[^\d]{0,12}\d[\d\s/()-]{5,}\d", re.I)
# Die fuehrende `\b` ist nicht kosmetisch. Ohne sie traf ein blosses `t` in der
# Kontextliste das Schluss-`t` von "Gesamt." und meldete "Gesamt. 1234567
# Stueck" als Rufnummer. `T.` als Kurzform aus Signaturen ist deshalb NICHT in
# der Liste: sie laesst sich nicht sicher von einer Einheit oder einem
# abgekuerzten Wort trennen. Gemessen: mit `t` ein Fehlalarm auf den Quellen,
# ohne `t` null.
#
# Ebenfalls nicht erfasst: die alte Auslandsschreibweise `0049 89 ...` ohne
# `+`. Sie faellt in dieselbe Luecke wie die nackte Ortsnummer.


def _ist_rufnummer(text: str) -> bool:
    for muster in (TEL_INTERNATIONAL, TEL_MIT_KONTEXT):
        treffer = muster.search(text)
        if treffer and len(re.sub(r"\D", "", treffer.group(0))) >= 7:
            return True
    return False


VERBOTENE_INHALTE: tuple[tuple[str, "re.Pattern[str]"], ...] = (
    ("Postfach", re.compile(r"[\w.+-]+@[\w-]+\.[A-Za-z]{2,}")),
    ("Anmeldung", re.compile(r"(?<![\\\w])[A-Za-z][\w-]{2,}\\[A-Za-z][\w.-]{2,}(?![\w-])")),
    ("Anrede", re.compile(r"\b(?:Herr|Frau|Hr|Fr|Mr|Mrs|Ms|Dear)\.?\s+[A-ZÄÖÜ][\wäöüß-]{2,}")),
)


def _host(adresse: str) -> str:
    ohne = re.sub(r"^[a-z]+://", "", adresse, flags=re.I).lstrip("\\")
    return re.split(r"[/\\]", ohne)[0].lower().strip()


def verbotener_inhalt(text: str) -> str | None:
    """Nennt die Inhaltsklasse, wenn der Text nicht in eine Fixture gehoert.

    Unbedenkliche Adressen werden vor den uebrigen Klassen herausgeschnitten,
    nicht nur uebersprungen: `\\\\beispiel.invalid\\ablage\\...` — der eigene
    Ersatzpfad — sieht sonst wie eine Anmeldung `invalid\\ablage` aus, und die
    Pruefung meldet 89 Treffer auf ihre eigene Ausgabe.
    """
    teile: list[str] = []
    letzte = 0
    for treffer in ADRESSE.finditer(text):
        if _host(treffer.group(0)) not in UNBEDENKLICHE_HOSTS:
            return "Adresse"
        teile.append(text[letzte:treffer.start()])
        letzte = treffer.end()
    teile.append(text[letzte:])
    rest = " ".join(teile)
    for klasse, muster in VERBOTENE_INHALTE:
        if muster.search(rest):
            return klasse
    for treffer in RECHNERNAME.finditer(rest):
        if treffer.group(0).lower() not in UNBEDENKLICHE_HOSTS:
            return "Rechnername"
    if _ist_rufnummer(rest):
        return "Rufnummer"
    return None


def ist_text(rohdaten: bytes) -> bool:
    """Entscheidet ueber den Inhalt, nicht ueber die Dateiendung.

    Dieselbe Lehre wie beim Zaehlen von Dateiformaten: der Container
    entscheidet, nicht der Name. `.vml` ist Text und wurde als Binaerdatei
    behandelt, `.bin` ist Binaerdatei und wurde als "kein Text" abgetan.
    """
    try:
        rohdaten.decode("utf-8")
    except UnicodeDecodeError:
        return False
    return True


def zeichenketten_aus_binaerdaten(rohdaten: bytes) -> list[str]:
    """Lesbare Zeichenketten aus einem nicht-XML-Eintrag — ASCII und UTF-16LE.

    `pruefe_ausgabe` hat solche Eintraege bis hierher mit der Begruendung
    uebersprungen, sie traegen "kein Text, keine Werte". Fuer Bilder und
    Schriften stimmt das; fuer `xl/printerSettings/*.bin` nicht. Dort steht
    die DEVMODE-Struktur des Druckers, und darin standen interne
    Server-Rechnernamen und ein Anmeldename — byte-identisch mit der Quelle,
    in allen drei Fixtures, durch jede Stufe der Kette hindurch.
    """
    ketten = [m.group().decode("ascii")
              for m in re.finditer(rb"[\x20-\x7e]{4,}", rohdaten)]
    ketten += [m.group().decode("utf-16-le", "replace")
               for m in re.finditer(rb"(?:[\x20-\x7e]\x00){4,}", rohdaten)]
    return ketten


# Quellverweise in Formeln, definierte Bereiche und Adressfelder. Formeltext
# hat der Anonymisierer nie angefasst — dort standen 47 Verweise auf einen
# internen Dateiserver und ein echter Windows-Anmeldename im Pfad, quer durch
# alle drei Fixtures. Ein Ordnerpfad enthaelt Leerzeichen, deshalb reicht das
# allgemeine Adressmuster hier nicht: der Verweis wird ueber seine
# Anfuehrungszeichen gefasst.
QUELLVERWEIS = re.compile(r"'([^']*(?:\\\\[\w.-]+\\|[A-Za-z]:\\)[^']*)'")
NACKTER_PFAD = re.compile(r"(?:\\\\[\w.-]+\\|[A-Za-z]:\\)[^\s\"'<>]*")


def neutralisiere_adressen(xml: str, seed: str, statistik: dict) -> str:
    """Ersetzt Pfade und Adressen deterministisch, formerhaltend.

    Formerhaltend heisst: aus einem Quellverweis wird wieder ein
    Quellverweis. Wer ihn durch ein Wortpseudonym ersetzt, macht aus
    `VLOOKUP($D23, '\\\\server\\[Mappe.xlsx]Blatt'!$A:$B, 2, 0)` eine
    ungueltige Formel — und die Fixture soll gerade zeigen, dass Formeln und
    ihre gecachten Ergebnisse erhalten bleiben.
    """
    def marke(roh: str) -> str:
        return hashlib.sha256((seed + "|pfad|" + roh).encode()).hexdigest()[:8]

    def pro_verweis(m: re.Match) -> str:
        statistik["adressen_neutralisiert"] += 1
        return f"'\\\\beispiel.invalid\\ablage\\[mappe-{marke(m.group(1))}.xlsx]Blatt'"

    def pro_pfad(m: re.Match) -> str:
        statistik["adressen_neutralisiert"] += 1
        return f"\\\\beispiel.invalid\\ablage\\{marke(m.group(0))}"

    def pro_adresse(m: re.Match) -> str:
        roh = m.group(0)
        if _host(roh) in UNBEDENKLICHE_HOSTS:
            return roh
        statistik["adressen_neutralisiert"] += 1
        return f"https://beispiel.invalid/{marke(roh)}"

    xml = QUELLVERWEIS.sub(pro_verweis, xml)
    xml = NACKTER_PFAD.sub(pro_pfad, xml)
    return ADRESSE.sub(pro_adresse, xml)


def letzte_sicherung(xml: str, seed: str, gesperrt: "re.Pattern[str] | None",
                     statistik: dict) -> str:
    """Letzter Durchgang ueber jeden Eintrag: was eine verbotene Inhaltsklasse
    trifft oder eine Personenwortmarke traegt, wird ersetzt — egal wo es steht.

    Der Grund steht in den Blaettern selbst. Bis hierher ersetzte der
    Anonymisierer auf einem Blatt `<t>`-Text und `<v>`-Werte; ein Name in einem
    ATTRIBUT desselben Blattes (Gueltigkeitsmeldung, Hyperlink-Hinweis,
    Kopfzeile) kam unveraendert durch. Dieselbe Bauart wie alle Vorfaelle
    davor: eine Stelle, an die keine Regel gedacht hat.

    Diese Sicherung listet keine Stellen mehr auf. Sie prueft jeden Wert und
    ersetzt den, der nicht in eine Fixture gehoert. Damit kann `pruefe_ausgabe`
    nichts mehr finden, was hier nicht schon behandelt waere — die Pruefung ist
    danach eine Regressionssperre, keine Entdeckung. Entdeckt wird durch ein
    unabhaengiges Review; das ist Verfahren, nicht Werkzeug.
    """
    def ersatz(wert: str) -> str:
        h = hashlib.sha256((seed + "|sicherung|" + wert).encode()).hexdigest()[:10]
        statistik["sicherung_ersetzt"] += 1
        return f"anonymisiert-{h}"

    def betroffen(wert: str) -> bool:
        if verbotener_inhalt(wert) is not None:
            return True
        return gesperrt is not None and gesperrt.search(wert) is not None

    def pro_attribut(m: re.Match) -> str:
        return f'="{ersatz(m.group(1))}"' if betroffen(m.group(1)) else m.group(0)

    def pro_text(m: re.Match) -> str:
        return f">{ersatz(m.group(1))}<" if betroffen(m.group(1)) else m.group(0)

    xml = ATTRIBUT_BELIEBIG.sub(pro_attribut, xml)
    return TEXTKNOTEN.sub(pro_text, xml)


PERSONENFELD = re.compile(
    r"<(?:cp:lastModifiedBy|dc:creator|dc:lastModifiedBy|DisplayName|UserName"
    r"|Author)[^>]*>([^<]+)</")
NAMENSTOKEN = re.compile(r"[A-Za-zÄÖÜäöüßÀ-ÿ]{4,}")


def personen_token(src: Path) -> "re.Pattern[str] | None":
    """Wortmarken aus den Personenfeldern der Quelle — gesperrt fuer die
    GESAMTE Ausgabe, auch auf Blaettern.

    Der Nachname des letzten Bearbeiters wurde in `cp:lastModifiedBy` korrekt
    zu einem Pseudonym und blieb gleichzeitig in einer Redaktionsnotiz auf
    einem Blatt stehen: derselbe Wert, zwei Fundorte, zwei Entscheidungen. Wer
    einen Wert einmal als personenbezogen erkennt, darf ihn nirgends stehen
    lassen — auch nicht unter dem Titel "Formularvokabular".

    Faellt ein Token mit einem echten Vorlagenwort zusammen, wird das Wort
    ersetzt und der Parser erkennt die Mappe nicht mehr: die Abnahmetests
    schlagen fehl. Das ist die richtige Richtung — ein lautes Fehlschlagen
    statt eines stillen Lecks.
    """
    token: set[str] = set()
    with zipfile.ZipFile(src) as z:
        for name in z.namelist():
            if not name.startswith(("docProps/", "customXml/")):
                continue
            if not name.endswith(".xml"):
                continue
            xml = z.read(name).decode("utf-8", "replace")
            for m in PERSONENFELD.finditer(xml):
                token |= {t.lower() for t in NAMENSTOKEN.findall(m.group(1))}
    if not token:
        return None
    return re.compile(
        r"(?<![A-Za-zÄÖÜäöüß])(?:"
        + "|".join(re.escape(t) for t in sorted(token))
        + r")(?![A-Za-zÄÖÜäöüß])", re.I)


def ersetze_weiteren_eintrag(name: str, xml: str, anon: TextAnonymiser,
                             statistik: dict, original_texte: set[str],
                             seed_fuer_kennungen: str = "") -> str:
    """Pseudonymisiert Werte in Eintraegen, die keine Blaetter sind.

    Der PivotCache fuehrt seine Werte als ATTRIBUT (`<s v="Rohstoff"/>`), die
    Dokument-Eigenschaften als Elementtext, und `customXml/*` traegt
    SharePoint-Felder mit Namen von Bearbeitern. Alle drei kamen in der ersten
    Fassung unveraendert durch — 24 Eintraege Byte fuer Byte, darunter 17
    Rohstoffbezeichnungen im Klartext.

    **Nicht pauschal ueber alle Textknoten.** Die Dokument-Eigenschaften
    enthalten typisierte Felder: Zeitstempel (`dcterms:created`), Wahrheitswerte
    (`ScaleCrop`, `SharedDoc`), Zaehler und die erzeugende Anwendung. Werden die
    zu Pseudonymen, ist die Mappe nicht mehr OOXML-konform — `openpyxl` bricht
    dann beim Oeffnen mit `ValueError` ab, waehrend der ExcelJS-basierte
    QAF-Parser sie noch liest. Eine Fixture, die nur die Haelfte der Werkzeuge
    oeffnen kann, ist keine. Deshalb hier eine Positivliste der Freitextfelder.
    """
    def ersetze(wert: str) -> str:
        """In den Metadaten wird IMMER ersetzt, ohne Ruecksicht auf die
        Beschriftungsliste.

        Ein Formularwort kann auf einem Blatt Vokabular sein und im selben
        Dokument als `<Company>` eine Herkunftsangabe — dieselbe Zeichenkette,
        zwei Bedeutungen. Wer hier die Beschriftungsliste fragt, schuetzt die
        Herkunft mit dem Argument des Vokabulars: der reale Konzernname blieb
        so in `docProps/app.xml` stehen, obwohl er auf den Blaettern zu Recht
        bleiben durfte.

        Metadaten tragen kein Vokabular, das ein Parser braucht. Also weg
        damit, ausser es ist erkennbar Struktur.
        """
        original_texte.add(wert)
        roh = wert.strip()
        # In den Metadaten ist eine GUID KEIN Strukturmerkmal, sondern die
        # Kennung des Mandanten, der Liste oder des Inhaltstyps. Die
        # allgemeine STRUKTURWERT-Ausnahme (fuer Blattinhalte richtig) haette
        # sie hier geschuetzt — deshalb vorher abfangen und deterministisch
        # neutralisieren, damit Verweise untereinander halten.
        if KENNUNGSWERT.fullmatch(roh):
            h = hashlib.sha256((seed_fuer_kennungen + "|meta|" + roh).encode()).hexdigest()
            statistik["kennungen_neutralisiert"] += 1
            if roh.lower().startswith("0x"):
                return "0x" + h[:len(roh) - 2].upper()
            geklammert = roh.startswith("{")
            neu_guid = f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
            return f"{{{neu_guid}}}".upper() if geklammert else neu_guid
        if REINE_ZIFFERN.fullmatch(roh):
            # Ziffernweise ersetzen, nicht durch ein Wortpseudonym: typisierte
            # Felder muessen Zahlen bleiben, sonst oeffnet openpyxl die Mappe
            # nicht mehr. Deterministisch aus dem Seed, damit Verweise halten.
            h = hashlib.sha256((seed_fuer_kennungen + "|zahl|" + roh).encode()).hexdigest()
            statistik["kennungen_neutralisiert"] += 1
            return "".join(str(int(c, 16) % 10) for c in h)[:len(roh)]
        if STRUKTURWERT.fullmatch(roh):
            return wert
        neu_wert = anon.pseudonym_erzwungen(wert)
        if neu_wert != wert:
            statistik["texte_ersetzt"] += 1
        return neu_wert

    if name == "docMetadata/LabelInfo.xml":
        # Empfindlichkeitskennzeichen von Microsoft Purview. Es traegt zwei
        # GUIDs: `id` (das vergebene Label) und `siteId` (der Tenant der
        # erzeugenden Organisation). Beides identifiziert die Herkunft der
        # Datei eindeutig und hat in einer Fixture nichts zu suchen.
        # Elementtext gibt es hier nicht, die Werte stehen in Attributen —
        # deshalb greift weder ATTR_WERT noch ELEMENT_TEXT.
        def pro_guid(m: re.Match) -> str:
            ersetze(m.group(2))  # in die Pruefmenge aufnehmen
            return f'{m.group(1)}="{NEUTRALE_GUID}"'
        return LABEL_GUID.sub(pro_guid, xml)

    if name.startswith("docProps/"):
        def pro_freitext(m: re.Match) -> str:
            wert = m.group(2)
            if wert.strip() == "":
                return m.group(0)
            return m.group(1) + _xml_escape(ersetze(wert)) + m.group(3)
        xml = DOCPROPS_FREITEXT.sub(pro_freitext, xml)

        # docProps/custom.xml fuehrt seine Werte NICHT in den bekannten
        # Freitext-Tags, sondern als <property name="..."><vt:lpwstr>Wert</...>.
        # Dort stand die SharePoint-Dokument-GUID des Originals — eindeutig
        # rueckfuehrbar und in der ersten Fixture unveraendert enthalten.
        if name == "docProps/custom.xml":
            def pro_eigenschaft(m: re.Match) -> str:
                wert = m.group(2)
                if wert.strip() == "":
                    return m.group(0)
                return m.group(1) + _xml_escape(ersetze(wert)) + m.group(3)
            xml = VT_WERT.sub(pro_eigenschaft, xml)
        return xml

    def pro_attribut(m: re.Match) -> str:
        wert = m.group(1)
        return m.group(0).replace(f'"{wert}"', f'"{_xml_escape(ersetze(wert))}"')

    def pro_element(m: re.Match) -> str:
        wert = m.group(1)
        # Bezeichner nicht anfassen: Zeitstempel und GUIDs sind Struktur, und
        # ein Wortpseudonym daraus macht die Datei kaputt. Reine Ziffernfolgen
        # gehoeren NICHT mehr dazu — `<AccountId>` ist genau das und ist so in
        # allen drei Fixtures unveraendert gelandet. Darueber entscheidet
        # jetzt `ersetze`, das sie ziffernweise neutralisiert.
        roh = wert.strip()
        if roh == "" or (STRUKTURWERT.fullmatch(roh) and not REINE_ZIFFERN.fullmatch(roh)):
            return m.group(0)
        return ">" + _xml_escape(ersetze(wert)) + "<"

    xml = ATTR_WERT.sub(pro_attribut, xml)
    return ELEMENT_TEXT.sub(pro_element, xml)


def anonymise_xml(src: Path, dst: Path, labels: set[str], seed: str,
                  caches: str) -> dict:
    anon = TextAnonymiser(labels, seed)
    statistik = {"zahlen_ersetzt": 0, "zahlen_null": 0, "caches_ersetzt": 0,
                 "caches_entfernt": 0, "texte_ersetzt": 0, "texte_beschriftung": 0,
                 "externe_ziele_neutralisiert": 0, "kennungen_neutralisiert": 0,
                 "adressen_neutralisiert": 0, "sicherung_ersetzt": 0,
                 "binaerdaten_genullt": 0,
                 "eintraege_unveraendert": 0}
    original_zahlen: set[float] = set()
    original_texte: set[str] = set()
    gesperrte_token = personen_token(src)

    dst.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(src) as quelle:
        namen = blatt_namen(quelle)
        with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as ziel:
            for eintrag in quelle.infolist():
                rohdaten = quelle.read(eintrag.filename)
                if re.match(r"xl/worksheets/sheet\d+\.xml$", eintrag.filename):
                    blatt = namen.get(eintrag.filename, eintrag.filename)
                    xml = rohdaten.decode("utf-8")
                    xml = ersetze_blatt(xml, blatt, seed, anon, caches, statistik,
                                        original_zahlen, original_texte)
                    rohdaten = xml.encode("utf-8")
                elif eintrag.filename == "xl/sharedStrings.xml":
                    xml = rohdaten.decode("utf-8")
                    xml = ersetze_shared_strings(xml, anon, statistik, original_texte)
                    rohdaten = xml.encode("utf-8")
                elif eintrag.filename.endswith(".rels"):
                    xml = rohdaten.decode("utf-8")
                    neu_xml = EXTERNES_ZIEL.sub(
                        lambda m: m.group(1) + NEUTRALES_ZIEL + m.group(3), xml)
                    if neu_xml != xml:
                        statistik["externe_ziele_neutralisiert"] += 1
                    rohdaten = neu_xml.encode("utf-8")
                elif WEITERE_WERTTRAEGER.match(eintrag.filename):
                    xml = rohdaten.decode("utf-8")
                    xml = ersetze_weiteren_eintrag(eintrag.filename, xml, anon,
                                                   statistik, original_texte, seed)
                    rohdaten = xml.encode("utf-8")
                elif INERTE_BINAERDATEN.match(eintrag.filename):
                    # Druckereinstellungen: die DEVMODE-Struktur traegt den
                    # Rechnernamen des Druckservers und den Anmeldenamen
                    # dessen, der zuletzt gedruckt hat. Fuer den QAF-Parser
                    # bedeutet sie nichts — weder ExcelJS noch openpyxl lesen
                    # sie. Deshalb vollstaendig genullt statt zeichenweise
                    # ersetzt: an einer Binaerstruktur herumzuschneiden waere
                    # ein weiteres Muster, das man richtig raten muss.
                    rohdaten = b"\x00" * len(rohdaten)
                    statistik["binaerdaten_genullt"] += 1
                else:
                    statistik["eintraege_unveraendert"] += 1
                    # Jeder Eintrag, der KEIN Text ist, wird geprueft statt
                    # angenommen. Findet sich dort etwas, bricht der Lauf ab:
                    # eine unbekannte Binaerstruktur blind zu nullen kann die
                    # Mappe zerstoeren, sie stehen zu lassen war der Fehler.
                    if not ist_text(rohdaten):
                        for kette in zeichenketten_aus_binaerdaten(rohdaten):
                            klasse = verbotener_inhalt(kette)
                            if klasse is None and gesperrte_token is not None:
                                klasse = "Personenwortmarke" if gesperrte_token.search(kette) else None
                            if klasse is not None:
                                raise SystemExit(
                                    f"{eintrag.filename}: {klasse} in einem "
                                    f"Binaereintrag, den das Werkzeug nicht "
                                    f"behandeln kann. Erst entscheiden, wie "
                                    f"dieser Eintragstyp neutralisiert wird — "
                                    f"nicht blind nullen, nicht stehen lassen.")
                # Zeitstempel und Kompression des Originals uebernehmen, damit die
                # Ausgabe reproduzierbar ist und nicht bei jedem Lauf anders aussieht.
                #
                # ABER: einen FRISCHEN ZipInfo bauen, nicht den gelesenen
                # weiterreichen. Der traegt CRC-32, komprimierte und
                # unkomprimierte Groesse des ORIGINALS; mit veraendertem Inhalt
                # stimmen die nicht mehr, und die Datei ist beschaedigt.
                # Python meldet das erst beim Lesen ("Bad CRC-32"), Excel und
                # ExcelJS reagieren unterschiedlich — mal geht es gut, mal
                # bricht der Parser mit einem Folgefehler ab. Genau daran sind
                # die 9.1-Fixtures gescheitert.
                # Dokument-Kennungen ueberall neutralisieren, auch in
                # Eintraegen, die sonst unveraendert durchgehen.
                # Ueber den INHALT entscheiden, nicht ueber die Endung. Nach
                # `.xml`/`.rels` zu gehen liess `xl/drawings/vmlDrawing*.vml`
                # aussen vor — eine Textdatei, die Beschriftungen von
                # Kommentarfeldern traegt und deshalb sehr wohl Namen enthalten
                # kann. Bilder und Schriften scheitern zuverlaessig an der
                # UTF-8-Dekodierung und bleiben damit weiterhin aussen vor.
                if ist_text(rohdaten):
                    text_neu = neutralisiere_kennungen(
                        rohdaten.decode("utf-8"), seed, statistik,
                        schema_schuetzen=not eintrag.filename.startswith("customXml/"))
                    text_neu = neutralisiere_adressen(text_neu, seed, statistik)
                    text_neu = letzte_sicherung(text_neu, seed, gesperrte_token,
                                                statistik)
                    rohdaten = text_neu.encode("utf-8")

                info = zipfile.ZipInfo(eintrag.filename, date_time=eintrag.date_time)
                info.compress_type = eintrag.compress_type
                info.external_attr = eintrag.external_attr
                info.internal_attr = eintrag.internal_attr
                info.create_system = eintrag.create_system
                ziel.writestr(info, rohdaten)

    # Die Vergleichsmenge UNABHAENGIG von der Ersetzung aufbauen. Bisher kannte
    # die Pruefung nur, was derselbe Lauf angefasst hatte: faellt eine Datei aus
    # der Positivliste (Tippfehler, neues Namensmuster), verschwindet ihr Inhalt
    # nicht nur aus der Ersetzung, sondern auch aus der Pruefung — und die
    # meldet 0 Lecks, waehrend Klartext stehen bleibt. Genau so ist der
    # PivotCache durchgerutscht, nur eine Ebene tiefer.
    metadaten_texte = metadaten_der_quelle(src)
    original_texte |= alle_texte_der_quelle(src)
    lecks, kollisionen = pruefe_leck(src, dst, original_zahlen, original_texte, anon)
    # Metadaten getrennt pruefen: ein Wort kann auf einem Blatt Vokabular
    # sein und in den Metadaten Herkunft. Wer beides in einen Topf wirft,
    # meldet jedes Formularwort als Leck und die Pruefung wird unbrauchbar.
    lecks += pruefe_metadaten(dst, metadaten_texte)
    # Die einzige Pruefung, die den Ersetzer nicht befragt: sie entscheidet
    # nach der Form des Inhalts und liest jeden Eintrag der Ausgabe.
    lecks += pruefe_ausgabe(dst, gesperrte_token)
    lecks += pruefe_unveraenderte_kennungen(src, dst)
    statistik["kollisionen_geprueft"] = kollisionen
    return {
        "statistik": statistik,
        "lecks": lecks,
        "zip_eintraege_quelle": len(zipfile.ZipFile(src).namelist()),
        "zip_eintraege_ziel": len(zipfile.ZipFile(dst).namelist()),
        "sha256": hashlib.sha256(dst.read_bytes()).hexdigest(),
    }


WERT_ODER_FORMEL = re.compile(r"<(?:v|f)(?:\s[^>]*)?>([^<]*)</(?:v|f)>", re.S)
TEXT_INHALT = re.compile(r"<t(?:\s[^>]*)?>([^<]*)</t>", re.S)
ZAHL_IN_FORMEL = re.compile(r"(?<![\w.$!:])(\d+(?:\.\d+)?)(?![\w.])")


def _zellwerte(pfad: Path) -> dict[str, float]:
    """Alle Zahlwerte je Blatt und Koordinate, direkt aus der XML.

    Formelzellen liefern ihren gecachten Wert mit — anders als bei einer
    Bibliotheks-Sicht, die entweder Formel ODER Wert zeigt. Genau diese Zellen
    sind der Grund fuer diese Fassung, also muessen sie auch in der Pruefung
    vorkommen.
    """
    werte: dict[str, float] = {}
    with zipfile.ZipFile(pfad) as z:
        namen = blatt_namen(z)
        for name in z.namelist():
            if not re.match(r"xl/worksheets/sheet\d+\.xml$", name):
                continue
            blatt = namen.get(name, name)
            xml = z.read(name).decode("utf-8", "replace")
            for treffer in ZELLE.finditer(xml):
                inhalt = treffer.group("inhalt")
                if inhalt is None:
                    continue
                attrs = treffer.group("attrs") or ""
                typ_treffer = ATTR_T.search(attrs)
                if typ_treffer and typ_treffer.group(1) in ("s", "str", "e", "b", "inlineStr"):
                    continue
                koord_treffer = ATTR_R.search(attrs)
                if not koord_treffer:
                    continue
                wert_treffer = WERT.search(inhalt)
                if not wert_treffer:
                    continue
                try:
                    werte[f"{blatt}!{koord_treffer.group(1)}"] = float(wert_treffer.group("wert"))
                except ValueError:
                    continue
    return werte


def _wertbereiche(dst: Path) -> tuple[list[str], list[tuple[str, str]]]:
    """Liest aus der Ausgabe nur die Stellen, die Fachwerte tragen koennen.

    Bewusst NICHT die ganze Datei: `xl/theme/theme1.xml` fuehrt Farbanteile wie
    `val="105000"`, und `xr:uid`-Attribute enthalten lange Ziffernfolgen. Eine
    Suche darueber meldet Treffer, die keine sind — und eine Pruefung, die
    Fehlalarme produziert, wird abgeschaltet. Genau daran ist die erste Fassung
    dieser Funktion aufgefallen.
    """
    werte: list[str] = []
    formeln: list[tuple[str, str]] = []
    with zipfile.ZipFile(dst) as z:
        for name in z.namelist():
            # JEDER XML-Eintrag, nicht nur Blaetter und sharedStrings. Die
            # erste Fassung sah genau die Dateien nicht, die niemand ersetzt:
            # der PivotCache trug 17 Rohstoffbezeichnungen im Klartext und
            # docProps/customXml die Namen der Bearbeiter — gemeldet wurden
            # trotzdem 0 Lecks. Eine Pruefung, die ihren eigenen Blindfleck
            # nicht kennt, ist schlimmer als keine.
            # Ueber den INHALT entscheiden, nicht ueber die Endung. Dieselbe
            # Lehre wie in der Ersetzung, dort schon nachgezogen: `.vml` ist Text
            # und traegt Beschriftungen, heisst aber nicht `.xml`.
            rohdaten = z.read(name)
            if not ist_text(rohdaten):
                continue
            xml = rohdaten.decode("utf-8")
            werte.extend(WERT_ODER_FORMEL.findall(xml))
            werte.extend(TEXT_INHALT.findall(xml))
            # Symmetrie zur Quelle: wo dort freier Elementtext eingesammelt
            # wird, muss er hier auch gesucht werden. Ohne diese Zeile bleibt
            # ein Leck in docProps unsichtbar, obwohl der Wert in der
            # Vergleichsmenge steht — die Gegenprobe (docProps aus der
            # Positivliste nehmen) blieb genau daran still.
            if name.startswith("docProps/"):
                werte.extend(m.group(2) for m in DOCPROPS_FREITEXT.finditer(xml))
                werte.extend(m.group(2) for m in VT_WERT.finditer(xml))
            elif name.startswith("docMetadata/"):
                werte.extend(m.group(1) for m in ELEMENT_TEXT.finditer(xml))
            elif name.startswith("customXml/"):
                werte.extend(m.group(1) for m in ELEMENT_TEXT.finditer(xml))
            # PivotCache und aehnliche Strukturen fuehren Werte in
            # ATTRIBUTEN (`<s v="Text"/>`), nicht als Elementinhalt.
            werte.extend(m.group(1) for m in ATTR_WERT.finditer(xml))
            for m in re.finditer(r"<f(?:\s[^>]*)?>([^<]*)</f>", xml, re.S):
                formeln.append((name, m.group(1)))
    # Als LISTE zurueckgeben, nicht als verketteter Text: eine Beschriftung
    # kann selbst einen Zeilenumbruch enthalten (" Indexbezeichnung\n (siehe
    # Anfrageunterlagen)"). Wer zum Vergleich an "\n" trennt, zerlegt sie in
    # Bruchstuecke und meldet jedes davon als Leck.
    return werte, formeln


def vokabular_der_blaetter(src: Path) -> set[str]:
    """Nur was auf den Blaettern steht — NIE die Dokument-Metadaten.

    Der Blanko-Modus schuetzt Vokabular vor dem Ersetzen. In der ersten Fassung
    nahm er dafuer `alle_texte_der_quelle()`, und das liest auch `docProps`,
    `customXml` und `docMetadata`. Damit galten Autorname, Bearbeiter,
    Firmenname und ein vollstaendiger SharePoint-Personendatensatz (Login,
    E-Mail, Abteilung) als "Formularvokabular" und blieben unveraendert in den
    erzeugten Fixtures stehen. Aus einer Verbesserung wurde ein groesseres Leck
    als das, was sie beheben sollte.

    Ein leeres Formular traegt sein Vokabular auf den BLAETTERN. Alles andere
    ist Herkunft und gehoert ersetzt.

    Aber auch das reichte nicht: "steht auf einem Blatt" hiess bis hierher
    "ist Formularvokabular", und damit standen eine vollstaendige interne
    SharePoint-Adresse und eine Redaktionsnotiz mit dem Nachnamen des
    Bearbeiters unter Vokabularschutz — beide Werte hatte derselbe Lauf an
    anderer Stelle korrekt ersetzt. Deshalb gilt der Schutz jetzt nur noch
    fuer Texte, die keiner verbotenen Inhaltsklasse angehoeren und keine
    Personenwortmarke enthalten.
    """
    gesperrt = personen_token(src)

    def unbedenklich(text: str) -> bool:
        if verbotener_inhalt(text) is not None:
            return False
        return gesperrt is None or gesperrt.search(text) is None

    texte: set[str] = set()
    with zipfile.ZipFile(src) as z:
        for name in z.namelist():
            if not (re.match(r"xl/worksheets/sheet\d+\.xml$", name)
                    or name == "xl/sharedStrings.xml"):
                continue
            xml = z.read(name).decode("utf-8", "replace")
            if name == "xl/sharedStrings.xml":
                for block in SI_BLOCK.finditer(xml):
                    texte.add("".join(m.group("text")
                                      for m in SI_TEXT.finditer(block.group(1))))
            else:
                texte.update(m.group(1) for m in TEXT_INHALT.finditer(xml))
                texte.update(m.group("wert") for m in WERT.finditer(xml)
                             if not m.group("wert").replace(".", "").isdigit())
    return {x for x in texte if x.strip() and unbedenklich(x)}


def alle_texte_der_quelle(src: Path) -> set[str]:
    """Jeder Text der Quelle, unabhaengig davon, ob ihn jemand ersetzt hat.

    Liest ALLE XML- und .rels-Eintraege: Elementinhalte, `<t>`-Knoten und die
    Werte in Attributen. Das ist die Menge, gegen die geprueft wird — sie darf
    nicht davon abhaengen, was die Ersetzung zufaellig erfasst hat.
    """
    texte: set[str] = set()
    with zipfile.ZipFile(src) as z:
        for name in z.namelist():
            rohdaten = z.read(name)
            if not ist_text(rohdaten):
                continue
            xml = rohdaten.decode("utf-8")
            if name == "xl/sharedStrings.xml":
                # Je <si> den ZUSAMMENGESETZTEN Text: Excel zerlegt formatierte
                # Beschriftungen in Laeufe, und ein Bruchstueck ist keine
                # Beschriftung mehr — dieselbe Falle wie beim Ersetzen.
                for block in SI_BLOCK.finditer(xml):
                    texte.add("".join(m.group("text")
                                      for m in SI_TEXT.finditer(block.group(1))))
            else:
                texte.update(m.group(1) for m in TEXT_INHALT.finditer(xml))
            texte.update(m.group(1) for m in ATTR_WERT.finditer(xml))
            # Freier Elementtext nur dort, wo Personen- und Dokumentdaten
            # stehen. Ueberall sonst waere er Struktur: `<f>SUMIF(...)</f>`,
            # Zellbezuege, Stil-Token. Die als Leck zu melden, macht die
            # Pruefung unbrauchbar — 200 Fehlalarme im ersten Versuch.
            # In den Dokument-Eigenschaften genau die Felder, die laut Regel
            # Freitext tragen — dieselbe Positivliste, nach der auch ersetzt
            # wird. Zeitstempel, Wahrheitswerte und GUIDs sind dort Struktur;
            # sie als Kandidaten zu fuehren, erzeugt nur Fehlalarme ueber
            # Werte, die absichtlich stehen bleiben.
            if name.startswith("docProps/"):
                texte.update(m.group(2) for m in DOCPROPS_FREITEXT.finditer(xml))
                # Die Pruefung darf nicht denselben Zuschnitt haben wie die
                # Ersetzung: custom.xml traegt seine Werte woanders.
                texte.update(m.group(2) for m in VT_WERT.finditer(xml)
                             if not STRUKTURWERT.fullmatch(m.group(2).strip()))
            elif name.startswith("docMetadata/"):
                texte.update(m.group(1) for m in ELEMENT_TEXT.finditer(xml)
                             if not STRUKTURWERT.fullmatch(m.group(1).strip()))
            elif name.startswith("customXml/"):
                texte.update(m.group(1) for m in ELEMENT_TEXT.finditer(xml)
                             if not STRUKTURWERT.fullmatch(m.group(1).strip()))
    return {x for x in texte if x.strip()}


METADATEN = ("docProps/", "customXml/", "docMetadata/")


def metadaten_der_quelle(src: Path) -> set[str]:
    """Freitext aus den Metadaten-Teilen — Autor, Bearbeiter, Firma,
    SharePoint-Felder. Das ist Herkunft, kein Vokabular."""
    texte: set[str] = set()
    with zipfile.ZipFile(src) as z:
        for name in z.namelist():
            if not name.startswith(METADATEN) or not name.endswith(".xml"):
                continue
            xml = z.read(name).decode("utf-8", "replace")
            if name == "docProps/app.xml":
                # Dort ist fast alles Struktur: TitlesOfParts spiegelt Blaetter
                # und Bereiche, HeadingPairs sind Excels eigene Ueberschriften,
                # Application/AppVersion sind Programmangaben. Freitext tragen
                # nur Company und Manager — und genau die werden ersetzt.
                texte.update(m.group(2) for m in DOCPROPS_FREITEXT.finditer(xml))
                continue
            for muster in (DOCPROPS_FREITEXT, VT_WERT):
                texte.update(m.group(2) for m in muster.finditer(xml))
            texte.update(m.group(1) for m in ELEMENT_TEXT.finditer(xml))
    # Excel spiegelt Blattnamen und definierte Bereiche in docProps/app.xml.
    # Als Kandidaten gefuehrt erzeugen sie nur Rauschen — 54 bis 66 Meldungen,
    # praktisch alle davon. Eine Pruefung mit dieser Trefferzahl findet den
    # einen echten Fund nicht mehr.
    struktur = set(blatt_namen(zipfile.ZipFile(src)).values())
    with zipfile.ZipFile(src) as z:
        wb = z.read("xl/workbook.xml").decode("utf-8", "replace")
        struktur |= set(re.findall(r'<definedName[^>]*name="([^"]+)"', wb))
    return {x.strip() for x in texte
            if x.strip() and len(x.strip()) >= 4
            and x.strip() not in struktur
            and x.strip() not in AUFZAEHLUNG
            and not STRUKTURWERT.fullmatch(x.strip())
            and not ZEITSTEMPEL.fullmatch(x.strip())}


def pruefe_metadaten(dst: Path, kandidaten: set[str]) -> list[str]:
    """Kein Metadaten-Wert der Quelle darf in den Metadaten der Ausgabe stehen.

    Bewusst NUR gegen die Metadaten geprueft: derselbe Text darf auf einem
    Blatt weiterhin vorkommen, wenn er dort Formularvokabular ist.
    """
    with zipfile.ZipFile(dst) as z:
        inhalt = "\n".join(
            z.read(n).decode("utf-8", "replace")
            for n in z.namelist()
            if n.startswith(METADATEN) and n.endswith(".xml"))
    return [f"Metadatum steht noch in der Ausgabe: {k[:40]}"
            for k in kandidaten if _xml_escape(k) in inhalt]


ATTRIBUT_BELIEBIG = re.compile(r'=\s*"([^"]*)"')
TEXTKNOTEN = re.compile(r">([^<>]+)<")


KENNUNG_IRGENDWO = re.compile(
    r"(?:0x[0-9A-Fa-f]{8,})"
    r"|(?:\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?)"
    r"|(?:\b[0-9A-Fa-f]{32,}\b)")


def pruefe_unveraenderte_kennungen(src: Path, dst: Path) -> list[str]:
    """Jede Kennung, die in Quelle UND Ausgabe steht, ist ein Befund.

    Das ist die Klassen-Luecke, die die sechste Leck-Klasse durchgelassen hat.
    `pruefe_ausgabe()` prueft namensfrei ueber jeden Eintrag und jedes Attribut,
    kennt aber nur Inhaltsklassen wie Adresse, Postfach oder Anrede — und ein
    unveraenderter Kennungswert ist keine davon. Die andere Pruefung,
    `pruefe_leck()`, sammelt ihre Kandidaten ueber `ATTR_WERT`, also ueber drei
    feste Attributnamen; `ma:contentTypeID` steht dort nicht.

    Beide Pruefungen zusammen hatten damit ein Loch in der Groesse dieser
    Funktion.

    **Warum nicht einfach `ATTR_WERT` namensfrei machen.** Gemessen: eine
    namensfreie Sammlung ALLER Attributwerte ergibt auf der HICE-Mappe 6194
    zusaetzliche Kandidaten — Zahlenformate (`#,##0.000000`), Stil-Token,
    Zellbezuege. Die Pruefung waere wieder so verrauscht, dass sie den einen
    echten Fund nicht mehr zeigt; genau daran ist eine fruehere Fassung
    gescheitert. Diese Funktion ist deshalb namensfrei UND auf die Form einer
    Kennung beschraenkt: lange Hexfolgen mit und ohne Bindestriche, `0x`-Praefix.
    Solche Werte sind selten und spezifisch.
    """
    def kennungen(p: Path) -> set[str]:
        gefunden: set[str] = set()
        with zipfile.ZipFile(p) as z:
            for name in z.namelist():
                rohdaten = z.read(name)
                if not ist_text(rohdaten):
                    continue
                text = rohdaten.decode("utf-8")
                # Schema-Positionen herausschneiden — aber NICHT in `customXml/`.
                #
                # Das ist eine Ausnahme nach FUNDORT, und die braucht eine
                # Begruendung, weil genau dieses Kriterium fuenf Vorfaelle
                # verursacht hat. Die erste Fassung dieser Funktion schnitt
                # `uri`, `xmlns*`, `namespace` und `targetNamespace` UEBERALL
                # heraus, mit dem Argument: dort sieht die OOXML-Spezifikation
                # selbst Konstanten vor.
                #
                # Das trifft fuer `xl/*` zu (feste Schema-URIs, in allen vier
                # unabhaengigen Dokumenten identisch) und fuer `customXml/`
                # NICHT: dort sind dieselben Positionen tenant-spezifische
                # SharePoint-Kennungen — Listen-, Termset- und Site-GUIDs.
                #
                # Der Beweis stand im eigenen Werkzeug: `neutralisiere_kennungen`
                # setzt fuer `customXml/` ausdruecklich `schema_schuetzen=False`,
                # weil die Ersetzung sie dort anfassen MUSS. Meine Pruefung
                # blendete genau diese Stellen aus. Was die Ersetzung zu Recht
                # ersetzt, war fuer die Pruefung unsichtbar — vier konkrete
                # Kennungen, gefunden von einem Review, nicht von mir.
                #
                # Dieselbe Bauart wie die fuenf Vorfaelle: eine Annahme ueber
                # einen Dateibereich, nie gegen die Realitaet geprueft, obwohl
                # das Werkzeug an anderer Stelle wusste, dass sie dort nicht
                # gilt. Die Unterscheidung, die traegt, existierte bereits —
                # ich habe sie nur nicht uebernommen.
                if not name.startswith("customXml/"):
                    text = SCHEMA_ATTRIBUT.sub("", text)
                for treffer in KENNUNG_IRGENDWO.finditer(text):
                    wert = treffer.group(0)
                    if wert.strip("{}").upper() in BEKANNTE_GUIDS:
                        continue
                    gefunden.add(wert.upper())
        return gefunden

    gemeinsam = kennungen(src) & kennungen(dst)
    return [f"Kennung unveraendert aus der Quelle: {k[:20]}…" for k in sorted(gemeinsam)]


def pruefe_ausgabe(dst: Path, gesperrt: "re.Pattern[str] | None") -> list[str]:
    """Prueft die fertige Ausgabe nach Inhaltsform — jeder Eintrag, jedes
    Attribut, jeder Textknoten. Ohne Positivliste, ohne Attributnamen, ohne
    Ruecksicht darauf, was der Ersetzer angefasst hat.

    Zwei Gegenproben haben diese Fassung erzwungen: ein erfundener
    Personendatensatz als Wert eines BELIEBIGEN Attributs (nicht `v`,
    `display` oder `refreshedBy`) in einem Eintrag AUS der Positivliste, und
    derselbe Datensatz als Elementtext in einem Eintrag AUSSERHALB davon.
    Beide kamen unveraendert durch, und die Pruefung meldete `"lecks": []`.
    Beide Blindstellen entstanden dadurch, dass Ersetzung und Pruefung
    dieselbe Kandidatenauswahl benutzt haben.
    """
    befunde: list[str] = []
    with zipfile.ZipFile(dst) as z:
        for name in z.namelist():
            rohdaten = z.read(name)
            try:
                xml = rohdaten.decode("utf-8")
            except UnicodeDecodeError:
                # NICHT ueberspringen. Die alte Fassung tat das mit der
                # Begruendung "Bilder, Schriften: kein Text, keine Werte" —
                # eine Annahme ueber den Inhalt, die fuer Druckereinstellungen
                # falsch ist und deren Leck deshalb durch jede Stufe kam.
                for kette in zeichenketten_aus_binaerdaten(rohdaten):
                    klasse = verbotener_inhalt(kette)
                    if klasse is not None:
                        befunde.append(f"{klasse} in {name} (binaer): {kette[:40]}")
                    elif gesperrt is not None and gesperrt.search(kette):
                        befunde.append(f"Personenwortmarke in {name} (binaer): {kette[:40]}")
                continue
            werte = [m.group(1) for m in ATTRIBUT_BELIEBIG.finditer(xml)]
            werte += [m.group(1) for m in TEXTKNOTEN.finditer(xml)]
            for wert in werte:
                klasse = verbotener_inhalt(wert)
                if klasse is not None:
                    befunde.append(f"{klasse} in {name}: {wert[:40]}")
                elif gesperrt is not None and gesperrt.search(wert):
                    befunde.append(f"Personenwortmarke in {name}: {wert[:40]}")
    return befunde


def pruefe_leck(src: Path, dst: Path, original_zahlen: set[float],
                original_texte: set[str], anon: TextAnonymiser) -> tuple[list[str], int]:
    """Kein charakteristischer Originalwert darf in der Ausgabe stehen — auch
    nicht als Konstante INNERHALB einer Formel.

    Der zweite Teil ist der Grund fuer diese Fassung. Die openpyxl-Pruefung
    liest Zellwerte ueber die Bibliothek und ueberspringt Formelzellen
    ausdruecklich (`if v.startswith("=") : continue`). Eine hartcodierte
    Konstante in `=SUM(D10:D20)*105000` ist damit unsichtbar gewesen, obwohl sie
    ein echter Kalkulationswert sein kann. Hier wird der Formeltext mitgeprueft.
    """
    lecks: list[str] = []
    kandidaten = {z for z in original_zahlen if is_characteristic(z)}
    # Welche Texte bleiben absichtlich stehen, entscheidet der Anonymisierer
    # selbst — nicht eine zweite Fassung derselben Regel. Eine eigene
    # Label-Liste hier hat auf der HICE-Mappe 24 Vorlagenwoerter als Leck
    # gemeldet, die genau nach Vorschrift stehen geblieben waren.
    texte = {t for t in original_texte
             if anon.pseudonym(t) != t
             and not ZEITSTEMPEL.fullmatch(t.strip())
             and t.strip() not in AUFZAEHLUNG
             # Wahrheitswerte sind Struktur, kein Geschaeftsinhalt. Ein echter
             # Wert ist nie exakt "true" oder "false" — der Ausschluss kann
             # also nichts verdecken.
             and t.strip().lower() not in ("true", "false")
             and len(t.strip()) >= 4
             and not re.fullmatch(r"[\d\s.,:/-]+", t.strip())}

    inhalt, formeln = _wertbereiche(dst)
    kollisionen = 0

    # (1) Zellgenau: was im Original in einer Zelle stand, darf in derselben
    #     Zelle der Ausgabe nicht mehr stehen. Diese Pruefung ist exakt und
    #     kennt keine Fehlalarme.
    fuer_zelle_original = _zellwerte(src)
    fuer_zelle_ausgabe = _zellwerte(dst)
    for schluessel, alt in fuer_zelle_original.items():
        if alt == 0:
            continue
        neu = fuer_zelle_ausgabe.get(schluessel)
        if neu is not None and neu == alt:
            lecks.append(f"unveraendert {schluessel}: {alt}")

    # (2) Verschoben: ein charakteristischer Wert taucht an einer STELLE auf, an
    #     der im Original etwas anderes stand. Nur dann ist es ein Leck — steht
    #     derselbe Wert dort, wo er herkommt, hat ihn (1) schon gemeldet; steht
    #     er nirgends an einer fremden Stelle mit passender Herkunft, ist es eine
    #     Kollision der Ersetzung und kein Geheimnisverlust.
    #     Ohne diese Unterscheidung meldete die Pruefung auf der HICE-Mappe 598
    #     Treffer, von denen praktisch alle erzeugte Zahlen waren.
    ausgabe_nach_wert: dict[float, set[str]] = {}
    for schluessel, wert in fuer_zelle_ausgabe.items():
        ausgabe_nach_wert.setdefault(wert, set()).add(schluessel)
    for zahl in kandidaten:
        for schluessel in ausgabe_nach_wert.get(zahl, set()):
            if fuer_zelle_original.get(schluessel) == zahl:
                continue  # von (1) erfasst
            kollisionen += 1

    # Vollstaendige Werte vergleichen, nicht Teilzeichenketten: das Wort
    # "Vorrichtung" steckt im Blattnamen "SBM-Vorrichtungen-FWZ", der stehen
    # bleiben MUSS — eine Teilstring-Suche meldet das als Leck und die
    # Pruefung verliert ihre Aussagekraft.
    vorhandene_werte = {w.strip() for w in inhalt}
    for text in texte:
        if text.strip() in vorhandene_werte:
            lecks.append(f"Text steht noch in der Ausgabe: {text[:40]}")

    # Konstanten in Formeln getrennt ausweisen: sie sind nicht dasselbe wie ein
    # stehengebliebener Zellwert und brauchen eine eigene Entscheidung.
    for name, formel in formeln:
        for roh in ZAHL_IN_FORMEL.findall(formel):
            try:
                zahl = float(roh)
            except ValueError:
                continue
            if zahl in kandidaten:
                lecks.append(f"Konstante in Formel ({name}): {roh} in '{formel[:48]}'")

    return lecks, kollisionen


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--src", required=True, type=Path)
    p.add_argument("--dst", required=True, type=Path)
    p.add_argument("--seed", required=True)
    p.add_argument("--labels", type=Path,
                   help="Datei mit einer Beschriftung je Zeile, die stehen bleibt")
    p.add_argument("--caches", choices=["recompute", "replace"], default="replace",
                   help="Wie mit gecachten Formelergebnissen verfahren wird")
    p.add_argument("--blanko-vorlage", action="store_true",
                   help=("Quelle ist ein leeres Formular: das gesamte Vokabular "
                         "gilt als Beschriftung und bleibt stehen, ersetzt werden "
                         "nur Zahlen"))
    p.add_argument("--report", type=Path, help="Bericht als JSON")
    args = p.parse_args()

    labels: set[str] = set()
    if args.labels and args.labels.exists():
        roh = args.labels.read_text()
        if args.labels.suffix == ".json":
            # Format von scripts/qaf-v2/anonymise/__tests__/export-labels.test.ts
            daten = json.loads(roh)
            labels = set(daten["labels"] if isinstance(daten, dict) else daten)
        else:
            labels = {z.strip() for z in roh.splitlines() if z.strip()}

    if args.blanko_vorlage:
        # Ein leeres Formular traegt keine Lieferantendaten — es traegt das
        # Vokabular, an dem der Parser die Familie erkennt. Wird das ersetzt,
        # ist die Mappe kein QAF mehr; genau daran sind die ersten Versuche
        # gescheitert. Also: alle Texte der Quelle gelten als Beschriftung,
        # ersetzt werden nur Zahlen.
        #
        # Der Modus ist ausdruecklich zu waehlen und nicht zu erraten. Wer ihn
        # auf eine ausgefuellte Mappe anwendet, behaelt deren Texte — deshalb
        # steht er nicht als Vorgabe.
        labels |= vokabular_der_blaetter(args.src)

    # Blattnamen bleiben immer stehen: der Parser findet die Mappe ueber sie.
    # Ein pseudonymisiertes "Fertigungskosten" waere keine QAF-Mappe mehr.
    # Dasselbe gilt fuer definierte Bereiche (`QAF_MAPPING_SBM_3`) und die
    # Namen der Eigenschaften-Listen: Struktur der Vorlage, keine Kundendaten.
    # Ohne sie in der Beschriftungsliste meldet die Pruefung sie als Leck, und
    # eine Ersetzung machte die Bezuege in den Formeln kaputt.
    with zipfile.ZipFile(args.src) as z:
        labels |= set(blatt_namen(z).values())
        wb = z.read("xl/workbook.xml").decode("utf-8", "replace")
        labels |= set(re.findall(r'<definedName[^>]*name="([^"]+)"', wb))
        for eintrag in z.namelist():
            if eintrag.startswith("customXml/") or eintrag.startswith("docProps/"):
                xml = z.read(eintrag).decode("utf-8", "replace")
                labels |= {m.group(1) for m in re.finditer(r"<([A-Za-z_][\w.-]*)[ />]", xml)}

    if args.caches == "recompute":
        # Der Modus entfernt die Caches — neu gerechnet wird bisher nirgends.
        # Das Ergebnis waere exakt der Zustand, den dieses Werkzeug beheben
        # soll: 1 von 20 Kennzahlen. Lieber hart abbrechen als eine Fixture
        # ausliefern, die still nichts taugt.
        print("--caches=recompute ist vorbereitet, aber nicht verdrahtet: die "
              "Caches wuerden entfernt und niemand rechnet sie neu. Das Ergebnis "
              "waere eine Fixture mit 1 von 20 Kennzahlen. Bis der Rechenschritt "
              "steht, nur --caches=replace verwenden.", file=sys.stderr)
        return 2

    bericht = anonymise_xml(args.src, args.dst, labels, args.seed, args.caches)
    text = json.dumps(bericht, indent=2, ensure_ascii=False)
    if args.report:
        args.report.write_text(text + "\n")
    print(text)
    return 1 if bericht["lecks"] else 0


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