"""Zentrale Parteinamen-Auflösung für den GWÖ-Antragsprüfer.

Single Source of Truth für die Mappings, die heute (vor #55) an mindestens
6 Stellen redundant codiert sind:

- Vier nahezu identische ``_normalize_fraktion()``-Methoden in
  ``app.parlamente`` (PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter,
  PARLISAdapter)
- Der ``partei != "GRÜNE"``-Hack in ``app.embeddings`` Z. ~496
- Implizite Annahmen in ``WAHLPROGRAMME``-Keys und ``PROGRAMME``-Metadaten

Konzept:

- ``PARTEIEN`` ist eine kuratierte Tabelle (kanonisch + Aliase + optionaler
  ``bundesland_scope`` + langer Anzeigename + Sonderrolle für Regierungs-
  Strukturen)
- ``normalize_partei(raw, *, bundesland=None)`` löst einen einzelnen
  Roh-String auf den kanonischen Key auf
- ``extract_fraktionen(text, *, bundesland=None)`` zerlegt einen freien
  Urheber-Text (komma-separierte Listen, MdL-mit-Klammerpartei,
  HTML-Reste) in eine Liste kanonischer Keys — der Funnel für die vier
  alten Adapter-Helper
- ``display_name(canonical, *, long=False)`` liefert die Anzeigeform für
  UI/PDF/Reports

Backwards-Kompatibilität: die kanonischen Keys sind exakt die Strings,
die heute in der DB stehen ("CDU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE",
"BSW", "SSW", "Landesregierung", "FREIE WÄHLER", "BiW", "FW", "CSU"). Das
heißt, kein Migrations-Schritt ist nötig — bestehende Assessments und
Embeddings bleiben lesbar.

Die "Freie Wähler"-Disambiguierung (BVB-FW-BB ≠ FW-Bayern ≠ FW-RLP) ist
hier dokumentiert und der Mapper trägt sie als Daten — die programmatische
Auflösung greift, sobald die jeweiligen Wahlprogramme als separate
``PROGRAMME``-Einträge existieren.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Optional

# ─────────────────────────────────────────────────────────────────────────────
# Tabelle
# ─────────────────────────────────────────────────────────────────────────────


@dataclass(frozen=True)
class Partei:
    canonical: str                        # Lookup-Key, identisch zu DB-Schreibweise
    display_long: str                     # Für UI/PDF
    aliases: tuple[str, ...]              # alle bekannten Schreibweisen aus HTML/JSON/LLM
    bundesland_scope: Optional[str] = None  # None = bundesweit; sonst BL-Code
    is_government: bool = False           # für Landesregierung-Marker


# Reihenfolge ist Sortierung — bei mehrdeutigen Aliasen gewinnt der erste
# Treffer. Spezifische Einträge (mit ``bundesland_scope``) müssen vor den
# generischen stehen, damit ``FREIE WÄHLER`` in BB als BVB-FW erkannt wird
# bevor es als generisches FREIE WÄHLER landet.
PARTEIEN: tuple[Partei, ...] = (
    # ── Etablierte Bundesparteien ────────────────────────────────────────
    Partei("CDU", "CDU", ("CDU", "Christlich Demokratische Union")),
    Partei("CSU", "CSU",
           ("CSU", "Christlich-Soziale Union", "Christlich Soziale Union"),
           bundesland_scope="BY"),
    Partei("SPD", "SPD", ("SPD", "Sozialdemokratische Partei")),
    Partei("GRÜNE", "BÜNDNIS 90/DIE GRÜNEN",
           ("GRÜNE", "Grüne", "GRUENE", "Gruene",
            "Bündnis 90/Die Grünen", "BÜNDNIS 90", "B90/Grüne", "Bündnis90",
            # Sachsen-spezifischer Eigenname der Fraktion
            "BÜNDNISGRÜNE", "Bündnisgrüne")),
    Partei("FDP", "FDP", ("FDP", "F.D.P.", "F. D. P.", "F.D.P", "FDP-DVP")),
    Partei("LINKE", "DIE LINKE",
           ("LINKE", "Die Linke", "DIE LINKE", "LL/PDS", "Linkspartei")),
    Partei("AfD", "AfD",
           ("AfD", "AFD", "Alternative für Deutschland")),
    Partei("BSW", "BSW",
           ("BSW", "Bündnis Sahra Wagenknecht",
            "Bündnis Sahra Wagenknecht – Vernunft und Gerechtigkeit")),
    # ── Bundesland-spezifische Parteien ──────────────────────────────────
    Partei("SSW", "SSW",
           ("SSW", "Südschleswigscher Wählerverband"),
           bundesland_scope="SH"),
    Partei("BiW", "BÜRGER IN WUT",
           ("BiW", "Bürger in Wut", "BIW"),
           bundesland_scope="HB"),
    # ── Freie-Wähler-Familie (kontextsensitiv) ───────────────────────────
    # Reihenfolge: spezifische Scopes zuerst. ``BVB-FW`` ist im BB-Landtag
    # eine eigenständige Partei (Brandenburger Vereinigte Bürgerbewegung),
    # programmatisch nicht identisch mit den FW-Landesvereinigungen in BY
    # oder RP. Solange wir dafür kein eigenes Programm indexieren, bleibt
    # ``BVB-FW`` ein nominelles Mapping.
    Partei("BVB-FW", "BVB / FREIE WÄHLER",
           ("BVB", "BVB/FW", "BVB / FREIE WÄHLER", "FREIE WÄHLER", "FW", "Freie Wähler"),
           bundesland_scope="BB"),
    Partei("FW-BAYERN", "FREIE WÄHLER Bayern",
           ("FW", "FREIE WÄHLER", "Freie Wähler"),
           bundesland_scope="BY"),
    Partei("FW-SL", "Freie Wähler Saarland",
           ("FW", "FREIE WÄHLER", "Freie Wähler"),
           bundesland_scope="SL"),
    # Bundesweit-Default für FW (z.B. RP — der Landesverband der
    # Bundesvereinigung). Letzte Position, damit oben spezifische Scopes
    # vorrangig matchen.
    Partei("FREIE WÄHLER", "FREIE WÄHLER",
           ("FW", "FREIE WÄHLER", "Freie Wähler")),
)


# Regierung/Verwaltung — keine Partei, aber wir müssen sie aus Urheber-
# Texten als Marker extrahieren können (wenn das Ministerium als Antrag-
# steller auftaucht, ist das eine Regierungsdrucksache).
_GOVERNMENT_MARKER_RE = re.compile(
    r"LANDESREGIERUNG|SENAT VON BERLIN|REGIERENDE[RN]?\s+BÜRGERMEISTER"
    r"|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT",
    re.IGNORECASE,
)

GOVERNMENT_KEY = "Landesregierung"


# ─────────────────────────────────────────────────────────────────────────────
# Lookup-Tabellen werden einmalig aus PARTEIEN abgeleitet
# ─────────────────────────────────────────────────────────────────────────────


def _build_canonical_index() -> dict[str, Partei]:
    return {p.canonical: p for p in PARTEIEN}


_CANONICAL_INDEX: dict[str, Partei] = _build_canonical_index()


def all_canonical_keys() -> list[str]:
    """Alle bekannten kanonischen Partei-Keys + Government-Marker."""
    return [p.canonical for p in PARTEIEN] + [GOVERNMENT_KEY]


# ─────────────────────────────────────────────────────────────────────────────
# Regex-Patterns pro Alias — vorab kompiliert für die Volltext-Extraktion
# ─────────────────────────────────────────────────────────────────────────────


def _alias_to_pattern(alias: str) -> re.Pattern[str]:
    """Konvertiert einen Alias in eine fallunabhängige Wortgrenzen-Regex.

    Punkte und Schrägstriche werden escaped, dazwischen optional Whitespace
    erlaubt — ``F.D.P.`` matched dann ``F. D. P.``, ``FDP``, ``F.D.P``.
    Letzteres ist die historische SH/HB-Schreibweise. ``LL/PDS`` matched
    sich selbst und nichts anderes.
    """
    # Escape, dann Whitespace zwischen einzelnen Tokens flexibilisieren
    escaped = re.escape(alias)
    # Punkte: optional, Whitespace daneben optional
    flex = escaped.replace(r"\.", r"\.?\s*")
    return re.compile(rf"(?<![A-Za-zÄÖÜäöüß]){flex}(?![A-Za-zÄÖÜäöüß])", re.IGNORECASE)


@dataclass(frozen=True)
class _CompiledAlias:
    canonical: str
    bundesland_scope: Optional[str]
    pattern: re.Pattern[str]


def _build_compiled_aliases() -> list[_CompiledAlias]:
    out: list[_CompiledAlias] = []
    for p in PARTEIEN:
        for alias in p.aliases:
            out.append(_CompiledAlias(
                canonical=p.canonical,
                bundesland_scope=p.bundesland_scope,
                pattern=_alias_to_pattern(alias),
            ))
    return out


_COMPILED_ALIASES: list[_CompiledAlias] = _build_compiled_aliases()


# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────


def normalize_partei(raw: str, *, bundesland: Optional[str] = None) -> Optional[str]:
    """Lese einen einzelnen Roh-String → kanonischer Key.

    ``bundesland`` ist Pflicht, wenn der Roh-String nur durch Bundesland
    disambiguiert werden kann (z.B. ``"FREIE WÄHLER"`` → BVB-FW in BB,
    FW-BAYERN in BY, FREIE WÄHLER in RP). Bei generischen Strings wie
    ``"CDU"`` ist ``bundesland`` egal.

    Returns ``None`` wenn nichts matched — der Caller entscheidet, ob
    das ein Skip oder ein Hard-Fail ist.
    """
    if not raw:
        return None

    # Government-Marker zuerst, weil "Ministerium der Finanzen" weder
    # Partei-Alias ist noch eines werden soll
    if _GOVERNMENT_MARKER_RE.search(raw):
        return GOVERNMENT_KEY

    # Suche alle Treffer; bei mehreren wähle den, der zum Bundesland-Scope
    # passt (oder den ersten generischen wenn keiner spezifisch passt).
    candidates: list[_CompiledAlias] = []
    for ca in _COMPILED_ALIASES:
        if ca.pattern.search(raw):
            candidates.append(ca)

    if not candidates:
        return None

    if bundesland:
        # Erst spezifischer Match
        for ca in candidates:
            if ca.bundesland_scope == bundesland:
                return ca.canonical
    # Sonst erster generischer (bundesland_scope is None)
    for ca in candidates:
        if ca.bundesland_scope is None:
            return ca.canonical
    # Fallback: erster überhaupt — kann passieren bei BL-spezifischer
    # Partei in falschem BL (z.B. "SSW" in einem MV-Antrag, was Unsinn
    # wäre, aber wir liefern dann SSW zurück und der Caller logged es)
    return candidates[0].canonical


def extract_fraktionen(text: str, *, bundesland: Optional[str] = None) -> list[str]:
    """Zerlege einen freien Urheber-Text in eine Liste kanonischer Keys.

    Ersetzt die vier ``_normalize_fraktion()``-Methoden der Adapter
    (PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter).
    Findet alle Partei-Aliase im Text, dedupliziert, behält die Reihenfolge
    des ersten Vorkommens.

    ``bundesland`` ist nötig, damit FW-Familien-Disambiguierung greift —
    eine BB-Drucksache mit Urheber ``"FREIE WÄHLER"`` wird zu ``"BVB-FW"``,
    eine BY-Drucksache zu ``"FW-BAYERN"``, eine RP-Drucksache bleibt
    ``"FREIE WÄHLER"``.
    """
    if not text:
        return []

    out: list[str] = []
    seen: set[str] = set()

    # Government-Marker als erstes prüfen — wenn es ein Ministerium ist,
    # überspringen wir Parteien-Matching nicht (z.B. "Antrag der Fraktion
    # CDU und der Landesregierung" enthält beide), aber Landesregierung
    # wird jedenfalls aufgenommen.
    if _GOVERNMENT_MARKER_RE.search(text):
        out.append(GOVERNMENT_KEY)
        seen.add(GOVERNMENT_KEY)

    # Pro Alias prüfen und mit Bundesland-Scope-Vorrang sortieren.
    # Strategie: pro Partei-Familie wählen wir den passendsten Scope.
    matches_by_canonical: dict[str, list[_CompiledAlias]] = {}
    for ca in _COMPILED_ALIASES:
        if ca.pattern.search(text):
            matches_by_canonical.setdefault(ca.canonical, []).append(ca)

    # Für jede Partei: wähle die richtige Scope-Variante. FW-Familie ist
    # der Spezialfall — alle vier Einträge (BVB-FW/FW-BAYERN/FW-SL/
    # FREIE WÄHLER) haben überlappende Aliase, aber nur einer soll am
    # Ende im Output landen. Wir gruppieren über die Aliase und wählen
    # nach Bundesland.
    fw_aliases = {"FW", "FREIE WÄHLER", "Freie Wähler"}
    fw_family: list[str] = []
    for canonical, aliases in matches_by_canonical.items():
        if any(a in fw_aliases for ca in aliases for a in [ca.pattern.pattern]):
            # Approximation — wir wissen, dass alle FW-Familien-Patterns
            # auf die gleichen Strings matchen
            fw_family.append(canonical)

    # Tatsächliche FW-Familien-Detektion: schauen, welche der Partei-Keys
    # zur FW-Familie gehören (statisch)
    FW_CANONICAL_FAMILY = {"BVB-FW", "FW-BAYERN", "FW-SL", "FREIE WÄHLER"}
    fw_in_match = FW_CANONICAL_FAMILY & set(matches_by_canonical.keys())
    if fw_in_match:
        # Wähle den passenden FW-Eintrag nach Bundesland
        chosen_fw: Optional[str] = None
        if bundesland:
            for ca in PARTEIEN:
                if ca.canonical in fw_in_match and ca.bundesland_scope == bundesland:
                    chosen_fw = ca.canonical
                    break
        if not chosen_fw:
            # generischer Fallback (bundesland_scope is None)
            for ca in PARTEIEN:
                if ca.canonical in fw_in_match and ca.bundesland_scope is None:
                    chosen_fw = ca.canonical
                    break
        if not chosen_fw:
            # Notfall: ersten nehmen
            chosen_fw = sorted(fw_in_match)[0]
        if chosen_fw not in seen:
            out.append(chosen_fw)
            seen.add(chosen_fw)
        # Andere FW-Familien-Mitglieder aus dem Match-Dict entfernen
        for k in list(matches_by_canonical.keys()):
            if k in FW_CANONICAL_FAMILY and k != chosen_fw:
                del matches_by_canonical[k]

    # Verbleibende Parteien in der Reihenfolge ihrer Tabellen-Position
    for p in PARTEIEN:
        if p.canonical in matches_by_canonical and p.canonical not in seen:
            # Bundesland-Scope-Filter: BL-spezifische Parteien dürfen nur
            # auftauchen, wenn der Antrag aus diesem BL stammt (oder kein
            # BL angegeben wurde — dann tolerant)
            if p.bundesland_scope is not None and bundesland is not None:
                if p.bundesland_scope != bundesland:
                    continue
            out.append(p.canonical)
            seen.add(p.canonical)

    return out


def display_name(canonical: str, *, long: bool = False) -> str:
    """Render einen kanonischen Key für Anzeige in UI/PDF/Reports.

    Mit ``long=True`` der lange offizielle Name (z.B. ``"BÜNDNIS 90/DIE
    GRÜNEN"`` für ``"GRÜNE"``), sonst der kanonische Key selbst (kurz
    und vertraut).
    """
    if canonical == GOVERNMENT_KEY:
        return "Landesregierung"
    p = _CANONICAL_INDEX.get(canonical)
    if p is None:
        return canonical  # unbekannt — Pass-Through statt Fehler
    return p.display_long if long else p.canonical
