"""Orchestrator für das EEG-Abrechnungssystem.

Enthält die Klasse Abrechnungssystem sowie globale Hilfsfunktionen
(validate_params, validate_member_row, build_ust_hints).
Die eigentliche Fachlogik liegt in den core/-Modulen.
"""

import json
import logging
import math
import os
import random
from collections import OrderedDict
from datetime import datetime, date, timedelta
from typing import List, Dict

import pandas as pd
from openpyxl import load_workbook, Workbook
from openpyxl.styles import Font, PatternFill, Alignment

# tkinter nur im __main__-Block benötigt (GUI-Dateiauswahl)
try:
    import tkinter as tk
    from tkinter import filedialog
except ImportError:
    tk = None
    filedialog = None

# ── Re-Exporte aus core.models (Rückwärtskompatibilität) ──────────────────────
from core.models import (
    SEX_MALE, SEX_FEMALE, DEFAULT_DATE,
    RECHNUNG_COLUMNS, _AV_KONSTANTEN,
    Mitglied, validate_member_row, build_ust_hints,
)
from core.utils import mask_iban, iban_valid, vereinsname_to_from_email, mask_name_for_log, mask_filename_for_log

# ── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)


# ════════════════════════════════════════════════════════════════════════════
# Globale Hilfsfunktionen
# ════════════════════════════════════════════════════════════════════════════

def _iban_valid(iban: str) -> bool:
    """Prüft die IBAN-Prüfsumme nach ISO 7064 MOD-97-10."""
    return iban_valid(iban)


def validate_params(params):
    """Prüft die Vereinsparameter (aus Formular oder Parameter.csv).

    Gibt eine Liste von Dicts zurück – je Fehler einen:
      {"field": "<form-feld-id>", "fehler": "<menschliche Meldung>"}
    """
    def _s(v):
        if v is None:
            return ""
        return str(v).strip()

    errors = []

    abbuchung_modus = _s(params.get("abbuchung_modus")) or "ueberweisung"

    # ── 1. Pflichtfelder ──────────────────────────────────────────────────
    for label, field, val in [
        ("Vereinsname", "vereinsname",    _s(params.get("vereinsname"))),
        ("Straße",      "vereinsstrasse", _s(params.get("vereinsstrasse"))),
        ("Ort",         "vereinsort",     _s(params.get("vereinsort"))),
        ("ZVR-Zahl",    "zvr",            _s(params.get("zvr"))),
    ]:
        if not val:
            errors.append({"field": field, "fehler": f"{label} fehlt"})

    if abbuchung_modus == "eigenes_konto":
        for label, field, val in [
            ("IBAN",        "sepa_iban",        _s(params.get("sepa_iban"))),
            ("BIC",         "sepa_bic",         _s(params.get("sepa_bic"))),
            ("Creditor-ID", "sepa_creditor_id", _s(params.get("sepa_creditor_id"))),
        ]:
            if not val:
                errors.append({"field": field, "fehler": f"{label} fehlt"})

    # ── 2. PLZ: Ort beginnt mit 4-stelliger PLZ ───────────────────────────
    ort = _s(params.get("vereinsort"))
    if ort:
        parts = ort.split(None, 1)
        plz = parts[0] if parts else ""
        if len(plz) != 4 or not plz.isdigit():
            errors.append({"field": "vereinsplz",
                           "fehler": "PLZ muss 4-stellig sein (z. B. 3340)"})

    # ── 3–5. IBAN / BIC / Creditor-ID Format ─────────────────────────────
    if abbuchung_modus == "eigenes_konto":
        iban = _s(params.get("sepa_iban"))
        if iban and not _iban_valid(iban):
            errors.append({"field": "sepa_iban",
                           "fehler": "Ungültige IBAN (Prüfsumme stimmt nicht)"})

        bic = _s(params.get("sepa_bic")).replace(" ", "")
        if bic and (len(bic) not in (8, 11) or not bic.isalnum() or not bic.isupper()):
            errors.append({"field": "sepa_bic",
                           "fehler": "Ungültiger BIC (8 oder 11 Zeichen, Großbuchstaben)"})

        cid = _s(params.get("sepa_creditor_id")).replace(" ", "")
        if cid and (not cid.isalnum() or len(cid) > 35):
            errors.append({"field": "sepa_creditor_id",
                           "fehler": "Ungültige Creditor-ID (alphanumerisch, max. 35 Zeichen)"})

    # ── 6. Tarife müssen > 0 sein ─────────────────────────────────────────
    for key, label in [("tarif_einspeisung", "Tarif Einspeisung"),
                       ("tarif_bezug",       "Tarif Bezug")]:
        raw = _s(params.get(key)).replace(",", ".")
        if not raw:
            errors.append({"field": key, "fehler": f"{label} fehlt"})
            continue
        try:
            if float(raw) <= 0:
                errors.append({"field": key, "fehler": f"{label} muss größer 0 sein"})
        except ValueError:
            errors.append({"field": key, "fehler": f"{label} ist keine gültige Zahl"})

    # ── 7. Fälligkeit: ganze Zahl > 0 ─────────────────────────────────────
    faell = _s(params.get("faelligkeit"))
    if not faell:
        errors.append({"field": "faelligkeit", "fehler": "Fälligkeit fehlt"})
    else:
        try:
            if int(faell) <= 0:
                errors.append({"field": "faelligkeit",
                               "fehler": "Fälligkeit muss größer 0 sein"})
        except ValueError:
            errors.append({"field": "faelligkeit",
                           "fehler": "Fälligkeit muss eine ganze Zahl sein"})

    # ── 8. USt / Kleinunternehmer-Regelung ────────────────────────────────
    ku_raw = params.get("kleinunternehmer")
    if isinstance(ku_raw, bool):
        ku_norm = "Ja" if ku_raw else "Nein"
    else:
        ku_str = _s(ku_raw).lower()
        if ku_str in ("", "ja", "j", "yes", "y", "true", "1", "wahr"):
            ku_norm = "Ja"
        elif ku_str in ("nein", "n", "no", "false", "0", "falsch"):
            ku_norm = "Nein"
        else:
            errors.append({"field": "kleinunternehmer",
                           "fehler": "Kleinunternehmer muss Ja oder Nein sein"})
            ku_norm = "Ja"

    uid_val = _s(params.get("uid_nummer")).upper().replace(" ", "")
    if ku_norm == "Nein":
        if not uid_val:
            errors.append({"field": "uid_nummer",
                           "fehler": "UID-Nummer fehlt (Pflicht wenn nicht Kleinunternehmer)"})
        elif not (uid_val.startswith("ATU") and len(uid_val) == 11 and uid_val[3:].isdigit()):
            errors.append({"field": "uid_nummer",
                           "fehler": "UID-Nummer muss im Format ATU + 8 Ziffern sein"})
    elif uid_val:
        if not (uid_val.startswith("ATU") and len(uid_val) == 11 and uid_val[3:].isdigit()):
            errors.append({"field": "uid_nummer",
                           "fehler": "UID-Nummer muss im Format ATU + 8 Ziffern sein"})

    return errors


# ════════════════════════════════════════════════════════════════════════════
# Hauptklasse
# ════════════════════════════════════════════════════════════════════════════

class Abrechnungssystem:
    """Orchestrator für den EEG-Abrechnungsprozess.

    Fachlogik delegiert an core/-Module. Alle öffentlichen Methoden bleiben
    rückwärtskompatibel (gleiche Signaturen wie v1.07a).
    """

    def __init__(self, main_path: str):
        self.main_path = main_path
        self.config = self.load_config()
        self.mitglieder: List[Mitglied] = []
        self.datum_rechnung = datetime.now()
        self.datum_abbuchung = self.datum_rechnung + timedelta(
            days=int(self.config["Fälligkeit"])
        )
        self.folder_rechnungen = ""
        self.rechnungsnummer = 0
        # Gemeinschafts-KPIs (befüllt in read_eda_report)
        self.total_gesamterzeugung: float = 0.0
        self.total_gesamtverbrauch: float = 0.0
        self.total_restüberschuss:  float = 0.0
        self.total_erzeugung_tf:    float = 0.0
        self.total_eigendeckung:    float = 0.0
        # Stündliche KPI-Kurven (0–23 Uhr), None wenn keine Daten
        self.hourly_eigenverbrauch:   list = [None] * 24
        self.hourly_autarkiegrad:     list = [None] * 24
        self.hourly_avg_consumption:  list = [None] * 24
        self.hourly_avg_generation:   list = [None] * 24
        # Nacht-Jahresdauerlinie
        self.nighttime_verbrauch_sorted: list = []
        # SEPA MsgIds und CTRLSUMs (gesetzt in prepare())
        self._ls_msgid   = ""
        self._gs_msgid   = ""
        self._ls_ctrlsum = 0.0
        self._gs_ctrlsum = 0.0

    # ── Filesystem-Helpers ────────────────────────────────────────────────────

    def init_Files(self):
        """Legt Rechnungs- und SEPA-Ordner an."""
        self.folder_rechnungen = os.path.join(
            self.main_path, "Rechnungen", self.periode
        )
        self.folder_sepa = os.path.join(self.folder_rechnungen, "SEPA")
        self.create_folder(self.folder_rechnungen)
        self.create_folder(self.folder_sepa)

    def find_file_in_subfolders(self, root_path: str, filename: str) -> str:
        """Durchsucht rekursiv alle Unterordner nach filename. Gibt Pfad oder None zurück."""
        for root, dirs, files in os.walk(root_path):
            if filename in files:
                return os.path.join(root, filename)
        return None

    def create_folder(self, folder_name: str) -> None:
        """Erstellt einen Ordner mit Fehlerbehandlung."""
        try:
            parent_dir = os.path.dirname(folder_name)
            if not os.path.exists(parent_dir):
                raise FileNotFoundError(
                    f"Übergeordneter Pfad existiert nicht: {parent_dir}"
                )
            os.makedirs(folder_name, exist_ok=True)
            if not os.path.exists(folder_name):
                raise OSError(f"Konnte Ordner nicht erstellen: {folder_name}")
            if not os.access(folder_name, os.W_OK):
                raise PermissionError(f"Keine Schreibrechte für Ordner: {folder_name}")
            logger.info(f"Ordner erstellt: {folder_name}")
        except (FileNotFoundError, PermissionError, OSError, ValueError) as e:
            logger.error(f"Fehler beim Erstellen des Ordners: {e} – ABBRUCH")
            exit(1)
        except Exception as e:
            logger.error(f"Unbekannter Fehler beim Ordner erstellen: {e} – ABBRUCH")
            exit(1)

    # ── Config ────────────────────────────────────────────────────────────────

    def load_config(self) -> Dict[str, str]:
        """Lädt und validiert Config.csv + Parameter.json."""
        config = {}

        def validate_config_entries(entries: dict, source: str, required_groups: dict):
            """Validiert alle Einträge einer Config-Quelle."""
            for group_name, keys in required_groups.items():
                if group_name == "numerisch":
                    for key, validator in keys:
                        if key not in entries:
                            logger.error(f"Fehlender Eintrag in {source}: {key} – ABBRUCH")
                            exit(1)
                        try:
                            if not validator(entries[key]):
                                logger.error(
                                    f"Ungültiger Wert für {key} in {source} – ABBRUCH"
                                )
                                exit(1)
                        except (ValueError, AttributeError):
                            logger.error(
                                f"Ungültiger Wert für {key} in {source}: Keine gültige Zahl – ABBRUCH"
                            )
                            exit(1)
                elif group_name == "dateipfade":
                    for key in keys:
                        if key not in entries:
                            logger.error(f"Fehlender Eintrag in {source}: {key} – ABBRUCH")
                            exit(1)
                        expected_path = os.path.join(self.main_path, entries[key])
                        filename = os.path.basename(expected_path)
                        if not os.path.exists(expected_path):
                            found_path = self.find_file_in_subfolders(
                                self.main_path, filename
                            )
                            if found_path:
                                logger.warning(
                                    f"Datei nicht im erwarteten Pfad, aber gefunden in: "
                                    f"{found_path} (Key: {key})"
                                )
                            else:
                                logger.error(
                                    f"Datei nicht gefunden: {filename} (Key: {key}) – ABBRUCH"
                                )
                                exit(1)
                elif group_name == "iban_bic":
                    for key, validator in keys:
                        if key not in entries:
                            logger.error(f"Fehlender Eintrag in {source}: {key} – ABBRUCH")
                            exit(1)
                        if not validator(entries[key]):
                            logger.error(f"Ungültige {key} in {source} – ABBRUCH")
                            exit(1)
                elif group_name in ["creditor_id", "email", "boolean"]:
                    for entry in keys:
                        key, validator = entry if isinstance(entry, tuple) else (entry, None)
                        if key not in entries:
                            logger.error(f"Fehlender Eintrag in {source}: {key} – ABBRUCH")
                            exit(1)
                        if not str(entries[key]).strip():
                            logger.error(
                                f"Leerer Wert in {source} für Key: {key} – ABBRUCH"
                            )
                            exit(1)
                        if validator and not validator(entries[key]):
                            logger.error(
                                f"Ungültiger Wert für {key} in {source} – ABBRUCH"
                            )
                            exit(1)
                elif group_name == "freitext":
                    for key in keys:
                        if key not in entries:
                            logger.error(f"Fehlender Eintrag in {source}: {key} – ABBRUCH")
                            exit(1)
                        if not str(entries[key]).strip():
                            logger.error(
                                f"Leerer Wert in {source} für Key: {key} – ABBRUCH"
                            )
                            exit(1)

        config_path = os.path.join(self.main_path, "Config", "Config.csv")
        try:
            config_df = pd.read_csv(
                config_path, sep=";", header=None,
                names=["Bezeichnung", "Keywort", "Suchwort"],
            )
            config_df = config_df[
                config_df["Keywort"].notna()
                & (config_df["Keywort"].astype(str).str.strip() != "")
                & (config_df["Keywort"].astype(str).str.strip().str.lower() != "keywort")
            ]
            config.update(dict(zip(config_df["Keywort"], config_df["Suchwort"])))
        except Exception as e:
            logger.error(f"Fehler beim Lesen von Config.csv: {e} – ABBRUCH")
            exit(1)

        required_keys_config = {
            "freitext": [
                "TB_Mitglied", "Mitglied_ID", "Mitglied_Vorname", "Mitglied_Nachname",
                "Mitglied_Sex", "Mitglied_Adresse", "Mitglied_PLZ", "Mitglied_Ort",
                "Mitglied_Mail", "Mitglied_IBAN", "Mitglied_BIC", "Mitglied_Firmenname",
                "Mitglied_CONSUMPTION", "Mitglied_GENERATION",
                "EDA_direction_C", "EDA_direction_G", "EDA_Eigendeckung",
                "EDA_Erzeugung", "EDA_Overflow", "EDA_Gesamterzeugung",
                "EDA_Gesamtverbrauch", "TB_Rechnungsnummer", "TB_Vorlage",
            ],
        }
        validate_config_entries(config, "Config.csv", required_keys_config)

        parameter_path = os.path.join(self.main_path, "Config", "Parameter.json")
        try:
            with open(parameter_path, encoding="utf-8") as _pf:
                _param_data = json.load(_pf)
            config.update(
                {k: ("" if v is None else str(v)) for k, v in _param_data.items()}
            )
        except Exception as e:
            logger.error(f"Fehler beim Lesen der Parameter.json: {e} – ABBRUCH")
            exit(1)

        # Defaults
        config.setdefault("Kleinunternehmer", "Ja")
        config.setdefault("UID_Nummer", "")
        config.setdefault("Mitglied_Steuersatz", "Steuersatz")
        config.setdefault("Mitglied_UID", "UID_Nummer")
        config.setdefault("Mitglied_Eintrittsdatum", "Mitglied seit")
        config.setdefault("RC_Kostentraeger", "Mitglied")
        config.setdefault("Logo_Filename", "")
        config.setdefault("Accent_Color", "")

        # Kleinunternehmer normalisieren
        ku_raw = str(config.get("Kleinunternehmer", "Ja")).strip().lower()
        if ku_raw in ("ja", "j", "yes", "y", "true", "1", "wahr"):
            config["Kleinunternehmer"] = "Ja"
        elif ku_raw in ("nein", "n", "no", "false", "0", "falsch"):
            config["Kleinunternehmer"] = "Nein"
        else:
            logger.error(
                f"Ungültiger Wert für Kleinunternehmer: '{config['Kleinunternehmer']}' – ABBRUCH"
            )
            exit(1)

        if config["Kleinunternehmer"] == "Nein":
            uid_val = str(config.get("UID_Nummer", "")).strip().upper().replace(" ", "")
            if not uid_val:
                logger.error("UID-Nummer fehlt in Parameter.json – ABBRUCH")
                exit(1)
            if not (uid_val.startswith("ATU") and len(uid_val) == 11
                    and uid_val[3:].isdigit()):
                logger.error(f"Ungültige UID-Nummer: '{uid_val}' – ABBRUCH")
                exit(1)
            config["UID_Nummer"] = uid_val

        required_keys_parameter = {
            "numerisch": [
                ("Tarif_Bezug",        lambda x: float(x.replace(",", ".")) > 0),
                ("Tarif_Einspeisung",  lambda x: float(x.replace(",", ".")) > 0),
                ("Fälligkeit",         lambda x: int(x) > 0),
            ],
            "dateipfade": ["File_Mitglieder"],
            "iban_bic": (
                []
                if config.get("Abbuchung_Modus", "ueberweisung") in ("abrechnungsverein", "ueberweisung")
                else [
                    ("SEPA_IBAN", lambda x: len(x.replace(" ", "")) >= 15
                                            and x.replace(" ", "").isalnum()),
                    ("SEPA_BIC",  lambda x: len(x) in [8, 11] and x.isalnum()
                                            and x.isupper()),
                ]
            ),
            "creditor_id": (
                []
                if config.get("Abbuchung_Modus", "ueberweisung") in ("abrechnungsverein", "ueberweisung")
                else [("SEPA_Creditor_ID", lambda x: x.isalnum() and len(x) <= 35)]
            ),
            "email":    [],
            "freitext": [
                "Vereinsname", "Vereinsstrasse", "Vereinsort", "File_Rechnungsnummer",
            ],
        }
        validate_config_entries(config, "Parameter.json", required_keys_parameter)

        logger.info("Konfiguration erfolgreich geladen und geprüft.")
        return config

    # ── Mitglieder ────────────────────────────────────────────────────────────

    def init_mitglieder(self):
        """Lädt die Mitglieder aus der Excel-Datei."""
        excel_path = os.path.join(self.main_path, self.config["File_Mitglieder"])
        df = pd.read_excel(excel_path, sheet_name=self.config["TB_Mitglied"])
        for _, row in df.iterrows():
            if not (pd.notna(row[self.config["Mitglied_CONSUMPTION"]])
                    or pd.notna(row[self.config["Mitglied_GENERATION"]])):
                continue

            error_msg = self.validate_member_data(row, self.config)
            if error_msg:
                logger.error(f"{error_msg} – ABBRUCH")
                exit(1)

            vorname  = row.get(self.config["Mitglied_Vorname"])
            vorname  = "" if not pd.notna(vorname) else str(vorname)
            nachname = row.get(self.config["Mitglied_Nachname"])
            nachname = "" if not pd.notna(nachname) else str(nachname)
            name     = f"{vorname} {nachname}".strip() or "Unbekannt"

            steuer_col   = self.config.get("Mitglied_Steuersatz", "Steuersatz")
            steuer_raw   = row.get(steuer_col)
            try:
                steuersatz_val = (
                    int(float(str(steuer_raw).replace(",", ".")))
                    if pd.notna(steuer_raw) and str(steuer_raw).strip() != ""
                    else 0
                )
            except (ValueError, TypeError):
                steuersatz_val = 0
            if steuersatz_val not in (0, 13, 20):
                steuersatz_val = 0

            uid_col     = self.config.get("Mitglied_UID", "UID_Nummer")
            uid_raw     = row.get(uid_col)
            mitglied_uid = ""
            if pd.notna(uid_raw) and str(uid_raw).strip():
                mitglied_uid = str(uid_raw).strip().upper().replace(" ", "")

            _firmenname_raw = row.get(self.config["Mitglied_Firmenname"])
            _name4rechnung  = (
                name
                if (_firmenname_raw is None or not pd.notna(_firmenname_raw)
                    or not str(_firmenname_raw).strip())
                else str(_firmenname_raw).strip()
            )

            # Eintrittsdatum (= SEPA-Mandatsdatum) – optional
            _seit_col = self.config.get("Mitglied_Eintrittsdatum", "Mitglied seit")
            _seit_raw = row.get(_seit_col)
            _eintrittsdatum = DEFAULT_DATE
            if _seit_raw is not None and pd.notna(_seit_raw):
                try:
                    if isinstance(_seit_raw, (datetime, pd.Timestamp)):
                        _eintrittsdatum = pd.Timestamp(_seit_raw).to_pydatetime().replace(tzinfo=None)
                    else:
                        _eintrittsdatum = datetime.strptime(str(_seit_raw).strip(), "%d.%m.%Y")
                except (ValueError, TypeError):
                    logger.warning(f"Ungültiges Eintrittsdatum '{_seit_raw}' für {name} – ignoriert.")

            self.mitglieder.append(Mitglied(
                ID=str(int(float(str(row[self.config["Mitglied_ID"]])))),
                Name=name,
                Sex=(str(row[self.config["Mitglied_Sex"]]).strip().lower()
                     if row[self.config["Mitglied_Sex"]] else ""),
                Mail=row[self.config["Mitglied_Mail"]],
                Strasse=row[self.config["Mitglied_Adresse"]],
                PLZ=str(int(float(str(row[self.config["Mitglied_PLZ"]])))),
                Ort=row[self.config["Mitglied_Ort"]],
                IBAN=row[self.config["Mitglied_IBAN"]],
                BIC=row[self.config["Mitglied_BIC"]],
                ZP_Consumption=row[self.config["Mitglied_CONSUMPTION"]],
                ZP_Generation=row[self.config["Mitglied_GENERATION"]],
                Name4Rechnung=_name4rechnung,
                Steuersatz=steuersatz_val,
                UID=mitglied_uid,
                Eintrittsdatum=_eintrittsdatum,
            ))
        logger.info(f"{len(self.mitglieder)} Mitglieder geladen.")

    def validate_member_data(self, row: pd.Series, config: Dict[str, str]):
        """Prüft eine Mitgliederzeile gegen die Config-Spalten. Delegiert an validate_member_row."""
        try:
            plz_raw = row.get(config["Mitglied_PLZ"])
            try:
                plz = str(int(plz_raw)) if pd.notna(plz_raw) else ""
            except (ValueError, TypeError):
                plz = str(plz_raw) if pd.notna(plz_raw) else ""

            steuer_col = config.get("Mitglied_Steuersatz", "Steuersatz")
            uid_col    = config.get("Mitglied_UID", "UID_Nummer")

            return validate_member_row(
                member_id  = row.get(config["Mitglied_ID"]),
                vorname    = row.get(config["Mitglied_Vorname"]),
                nachname   = row.get(config["Mitglied_Nachname"]),
                mail       = row.get(config["Mitglied_Mail"]),
                iban       = row.get(config["Mitglied_IBAN"]),
                bic        = row.get(config["Mitglied_BIC"]),
                plz        = plz,
                ort        = row.get(config["Mitglied_Ort"]),
                strasse    = row.get(config["Mitglied_Adresse"]),
                sex        = row.get(config["Mitglied_Sex"]),
                steuersatz = row.get(steuer_col),
                uid        = row.get(uid_col),
            )
        except Exception as e:
            return f"Fehler beim Prüfen der Daten für Mitglied: {e}"

    def validate_all_members(self) -> list:
        """Prüft die komplette Mitgliederliste und gibt ALLE gefundenen Fehler zurück."""
        errors: list = []
        try:
            excel_path = os.path.join(self.main_path, self.config["File_Mitglieder"])
        except KeyError:
            return [{"zeile": 0, "id": "", "name": "",
                     "fehler": "File_Mitglieder fehlt in der Konfiguration"}]

        if not os.path.exists(excel_path):
            return [{"zeile": 0, "id": "", "name": "",
                     "fehler": f"Mitgliederdatei nicht gefunden: {os.path.basename(excel_path)}"}]

        try:
            df = pd.read_excel(excel_path, sheet_name=self.config["TB_Mitglied"])
        except Exception as e:
            return [{"zeile": 0, "id": "", "name": "",
                     "fehler": f"Mitgliederdatei konnte nicht gelesen werden: {e}"}]

        for idx, row in df.iterrows():
            try:
                has_c = pd.notna(row[self.config["Mitglied_CONSUMPTION"]])
                has_g = pd.notna(row[self.config["Mitglied_GENERATION"]])
            except KeyError as ke:
                errors.append({"zeile": int(idx) + 2, "id": "", "name": "",
                               "fehler": f"Spalte fehlt: {ke}"})
                continue
            if not (has_c or has_g):
                continue

            error_msg = self.validate_member_data(row, self.config)
            if error_msg:
                try:
                    vorname  = row.get(self.config["Mitglied_Vorname"], "") or ""
                    nachname = row.get(self.config["Mitglied_Nachname"], "") or ""
                    name     = f"{vorname} {nachname}".strip() or "Unbekannt"
                except Exception:
                    name = "Unbekannt"
                try:
                    member_id = str(row.get(self.config["Mitglied_ID"], "") or "")
                except Exception:
                    member_id = ""
                errors.append({
                    "zeile":  int(idx) + 2,
                    "id":     member_id,
                    "name":   name,
                    "fehler": error_msg,
                })
        return errors

    def _merge_duplicate_members(self) -> None:
        """Fasst Mitglieder mit gleichem Namen UND gleicher IBAN zusammen."""
        groups: OrderedDict = OrderedDict()
        for m in self.mitglieder:
            iban_clean = str(m.IBAN).replace(" ", "").upper() if m.IBAN else ""
            key = (m.Name.strip().lower(), iban_clean)
            if key not in groups:
                groups[key] = []
            groups[key].append(m)

        merged_count = 0
        new_mitglieder: List[Mitglied] = []

        for key, members in groups.items():
            if len(members) == 1:
                new_mitglieder.append(members[0])
                continue
            primary = members[0]
            for other in members[1:]:
                primary.Consumption += other.Consumption
                primary.Generation  += other.Generation
                if pd.isna(primary.ZP_Consumption) and not pd.isna(other.ZP_Consumption):
                    primary.ZP_Consumption = other.ZP_Consumption
                if pd.isna(primary.ZP_Generation) and not pd.isna(other.ZP_Generation):
                    primary.ZP_Generation = other.ZP_Generation
                for start_attr in ("Start_C", "Start_G"):
                    other_val = getattr(other, start_attr)
                    prim_val  = getattr(primary, start_attr)
                    if other_val and other_val != DEFAULT_DATE:
                        setattr(primary, start_attr,
                                other_val if prim_val == DEFAULT_DATE
                                else min(prim_val, other_val))
                for end_attr in ("Ende_C", "Ende_G"):
                    other_val = getattr(other, end_attr)
                    prim_val  = getattr(primary, end_attr)
                    if other_val and other_val != DEFAULT_DATE:
                        setattr(primary, end_attr,
                                other_val if prim_val == DEFAULT_DATE
                                else max(prim_val, other_val))
                merged_count += 1
                logger.info(
                    f"Zusammenfassung: {other.Name} (ID: {other.ID}) "
                    f"→ {primary.Name} (ID: {primary.ID})"
                )
            new_mitglieder.append(primary)

        if merged_count:
            logger.info(
                f"Mitglieder zusammengefasst: {len(self.mitglieder)} → "
                f"{len(new_mitglieder)} ({merged_count} zusammengefasst)"
            )
        self.mitglieder = new_mitglieder

    # ── EDA ───────────────────────────────────────────────────────────────────

    def read_eda_report(self, report_path: str):
        """Liest den EDA-Report. Delegiert an core.eda_reader."""
        from core.eda_reader import read_eda_report
        return read_eda_report(self, report_path)

    def check_consumption_generation(self, mitglieder) -> bool:
        """Prüft ob Consumption und Generation im Toleranzbereich liegen (±1%)."""
        total_C = sum(m.Consumption for m in mitglieder)
        total_G = sum(m.Generation  for m in mitglieder)
        if total_G == 0:
            logger.error("Generation ist 0 – Division durch Null vermieden!")
            return False
        deviation_percent = abs(total_C - total_G) / total_G * 100
        if deviation_percent > 1.0:
            logger.error(
                f"Abweichung Consumption/Generation: {deviation_percent:.2f}% (max ±1%)"
            )
            return False
        return True

    def update_member_from_eda_row(self, mitglied: Mitglied, row: pd.Series,
                                   config: Dict[str, str]) -> None:
        """Aktualisiert Verbrauchs-/Erzeugungsdaten eines Mitglieds aus einer EDA-Zeile."""
        if row[config["EDA_direction"]] == config["EDA_direction_C"]:
            mitglied.Consumption += row[config["EDA_Eigendeckung"]]
            mitglied.Start_C = (
                min(mitglied.Start_C, row[config["EDA_time_from"]])
                if mitglied.Start_C != DEFAULT_DATE
                else row[config["EDA_time_from"]]
            )
            mitglied.Ende_C = max(mitglied.Ende_C, row[config["EDA_time_to"]])
        elif row[config["EDA_direction"]] == config["EDA_direction_G"]:
            mitglied.Generation += (
                row[config["EDA_Erzeugung"]] - row[config["EDA_Overflow"]]
            )
            mitglied.Start_G = (
                min(mitglied.Start_G, row[config["EDA_time_from"]])
                if mitglied.Start_G != DEFAULT_DATE
                else row[config["EDA_time_from"]]
            )
            mitglied.Ende_G = max(mitglied.Ende_G, row[config["EDA_time_to"]])
        mitglied.DataCheck = True

    def validate_eda_row(self, row: pd.Series, config: Dict[str, str]):
        """Validiert eine einzelne EDA-Zeile."""
        try:
            zp = str(row[config["EDA_ZP"]])
            if not zp or not zp.strip():
                return "Zählpunkt (ZP) fehlt oder ist ungültig"
            direction = row[config["EDA_direction"]]
            if direction not in [config["EDA_direction_C"], config["EDA_direction_G"]]:
                return f"Ungültige Richtung '{direction}' für Zählpunkt {zp}"
            time_from = row[config["EDA_time_from"]]
            time_to   = row[config["EDA_time_to"]]
            if pd.isna(time_from) or pd.isna(time_to):
                return f"Ungültige Zeitstempel für Zählpunkt {zp}"
            eigendeckung = row[config["EDA_Eigendeckung"]]
            erzeugung    = row[config["EDA_Erzeugung"]]
            overflow     = row[config["EDA_Overflow"]]
            if any(pd.isna(val) for val in [eigendeckung, erzeugung, overflow]):
                return f"Fehlende numerische Werte für Zählpunkt {zp}"
            return None
        except Exception as e:
            return f"Fehler beim Prüfen der EDA-Daten: {e}"

    # ── Kosten ────────────────────────────────────────────────────────────────

    def calculate_kosten(self):
        """Berechnet die Kosten (Rechnung/Gutschrift) für alle Mitglieder."""
        tarif_bezug       = float(self.config["Tarif_Bezug"].replace(",", "."))
        tarif_einspeisung = float(self.config["Tarif_Einspeisung"].replace(",", "."))
        kleinunternehmer  = str(self.config.get("Kleinunternehmer", "Ja")).strip().lower() == "ja"
        rc_kostentraeger  = self.config.get("RC_Kostentraeger", "Mitglied").strip()

        for mitglied in self.mitglieder:
            try:
                if (kleinunternehmer and rc_kostentraeger == "Mitglied"
                        and mitglied.Steuersatz == 20):
                    tarif_einspeisung_eff = tarif_einspeisung * 6 / 7
                else:
                    tarif_einspeisung_eff = tarif_einspeisung
                mitglied.Kosten = round(
                    mitglied.Consumption * tarif_bezug
                    - mitglied.Generation * tarif_einspeisung_eff,
                    2,
                )
            except Exception as e:
                logger.error(
                    f"Fehler bei der Kostenberechnung für {mask_name_for_log(mitglied.Name)}: {e} – ABBRUCH"
                )
                exit(1)
        logger.info("Kosten für alle Mitglieder berechnet.")

    # ── Rechnungsnummern ──────────────────────────────────────────────────────

    def _generate_rechnungsnummer(self, rechnungsnummer_path: str) -> int:
        """Legt Kassabuch an (falls nötig) und gibt die letzte Rechnungsnummer des Jahres zurück."""
        from core.kassabuch import init_kassabuch
        return init_kassabuch(rechnungsnummer_path)

    def handle_rechnungsnummer(self, mitglied: Mitglied) -> "int | None":
        """Vergabe der Rechnungsnummer und Kassabuch-Eintrag. Gibt Zeilennummer zurück."""
        rn_path     = os.path.join(self.main_path, "Rechnungen", self.config["File_Rechnungsnummer"])
        description = self._get_rechnung_description(mitglied)
        if not description:
            return None

        abbuchung_modus = self.config.get(
            "Abbuchung_Modus", self.config.get("abbuchung_modus", "ueberweisung")
        ).strip().lower()

        if abbuchung_modus == "ueberweisung":
            doc_typ      = "RG" if mitglied.Kosten > 0 else "GS"
            vereinsname  = self.config.get("Vereinsname", "").strip()
            buchungstext = (
                f"{vereinsname} | {self.periode} | "
                f"{doc_typ} {mitglied.Rechnungsnummer} | {mitglied.Name}"
            )[:140]
        else:
            buchungstext = getattr(
                self, "_ls_msgid" if mitglied.Kosten > 0 else "_gs_msgid", ""
            )

        return self._update_rechnungsnummern_datei(rn_path, mitglied, description, buchungstext)

    def _get_rechnung_description(self, mitglied: Mitglied) -> str:
        """Erstellt die Beschreibungszeile (Zeitraum) für das Kassabuch."""
        start_c = mitglied.Start_C.date() if mitglied.Start_C.date() != DEFAULT_DATE.date() else None
        start_g = mitglied.Start_G.date() if mitglied.Start_G.date() != DEFAULT_DATE.date() else None
        ende_c  = mitglied.Ende_C.date()  if mitglied.Ende_C.date()  != DEFAULT_DATE.date() else None
        ende_g  = mitglied.Ende_G.date()  if mitglied.Ende_G.date()  != DEFAULT_DATE.date() else None
        if start_c and ende_c and start_g and ende_g:
            return (f"{min(start_c, start_g).strftime('%d.%m.%Y')} bis "
                    f"{min(ende_c, ende_g).strftime('%d.%m.%Y')}")
        elif start_c and ende_c:
            return f"{start_c.strftime('%d.%m.%Y')} bis {ende_c.strftime('%d.%m.%Y')}"
        elif start_g and ende_g:
            return f"{start_g.strftime('%d.%m.%Y')} bis {ende_g.strftime('%d.%m.%Y')}"
        return ""

    def _update_rechnungsnummern_datei(self, path: str, mitglied: Mitglied,
                                        description: str, msgid: str) -> int:
        """Delegiert Kassabuch-Zeileneintrag an core.kassabuch."""
        from core.kassabuch import update_kassabuch
        return update_kassabuch(
            path=path,
            mitglied=mitglied,
            description=description,
            msgid=msgid,
            config=self.config,
            datum_rechnung=self.datum_rechnung,
            datum_abbuchung=self.datum_abbuchung,
            folder_rechnungen=self.folder_rechnungen,
        )

    def _write_kassabuch_batch_summary(self, ls_rows: list, gs_rows: list) -> None:
        """Delegiert Kassabuch-Sammelzeilen an core.kassabuch."""
        from core.kassabuch import write_kassabuch_batch_summary
        rn_path = os.path.join(self.main_path, "Rechnungen", self.config["File_Rechnungsnummer"])
        write_kassabuch_batch_summary(
            path=rn_path,
            ls_rows=ls_rows,
            gs_rows=gs_rows,
            ls_msgid=self._ls_msgid,
            gs_msgid=self._gs_msgid,
            ls_ctrlsum=self._ls_ctrlsum,
            gs_ctrlsum=self._gs_ctrlsum,
            datum_abbuchung=self.datum_abbuchung,
        )

    # ── Rechnungs-Rendering ───────────────────────────────────────────────────

    def _build_docx_context(self, mitglied: Mitglied) -> dict:
        """Baut das Kontext-Dictionary für DOCX- und PDF-Templates."""
        kleinunt       = str(self.config.get("Kleinunternehmer", "Ja")).strip().lower() == "ja"
        verein_uid     = self.config.get("UID_Nummer", "")
        has_bezug      = mitglied.Consumption > 0
        has_einspeisung = mitglied.Generation > 0

        ust_hint_bezug, ust_hint_einspeisung = build_ust_hints(
            kleinunternehmer=kleinunt,
            verein_uid=verein_uid,
            mitglied_steuersatz=mitglied.Steuersatz,
            has_bezug=has_bezug,
            has_einspeisung=has_einspeisung,
        )

        tarif_b_num = float(str(self.config["Tarif_Bezug"]).replace(",", "."))
        tarif_e_num = float(str(self.config["Tarif_Einspeisung"]).replace(",", "."))
        tarif_bezug_str       = f"{tarif_b_num:.2f}".replace(".", ",")
        tarif_einspeisung_str = f"{tarif_e_num:.2f}".replace(".", ",")

        rc_kostentraeger   = self.config.get("RC_Kostentraeger", "Mitglied").strip()
        rc_mwst_betrag_str = None
        rc_mwst_satz_str   = None
        if (kleinunt and rc_kostentraeger == "Mitglied"
                and has_einspeisung and mitglied.Steuersatz == 20):
            _rc_mwst           = mitglied.Generation * tarif_e_num / 7
            rc_mwst_betrag_str = f"{_rc_mwst:.2f}".replace(".", ",")
            rc_mwst_satz_str   = "20"

        landwirt_mwst_betrag_str  = None
        landwirt_netto_tarif_str  = None
        landwirt_netto_betrag_str = None
        if has_einspeisung and mitglied.Steuersatz == 13:
            _lw_netto_tarif           = tarif_e_num / 1.13
            _lw_mwst                  = mitglied.Generation * tarif_e_num * (0.13 / 1.13)
            _lw_netto_betrag          = mitglied.Generation * _lw_netto_tarif
            landwirt_mwst_betrag_str  = f"{_lw_mwst:.2f}".replace(".", ",")
            landwirt_netto_tarif_str  = f"{_lw_netto_tarif:.4f}".replace(".", ",")
            landwirt_netto_betrag_str = f"{_lw_netto_betrag:.2f}".replace(".", ",")

        bezug_mwst_betrag_str  = None
        bezug_netto_tarif_str  = None
        bezug_netto_betrag_str = None
        if not kleinunt and has_bezug:
            _b_netto_tarif         = tarif_b_num / 1.2
            _b_mwst                = mitglied.Consumption * tarif_b_num * (0.2 / 1.2)
            _b_netto_betrag        = mitglied.Consumption * _b_netto_tarif
            bezug_mwst_betrag_str  = f"{_b_mwst:.2f}".replace(".", ",")
            bezug_netto_tarif_str  = f"{_b_netto_tarif:.4f}".replace(".", ",")
            bezug_netto_betrag_str = f"{_b_netto_betrag:.2f}".replace(".", ",")

        if has_bezug:
            kosten_c        = f"{mitglied.Consumption * tarif_b_num:.2f}".replace(".", ",")
            consumption_str = f"{mitglied.Consumption:.2f}".replace(".", ",")
            cons_von = mitglied.Start_C.strftime("%d.%m.%Y")
            cons_bis = mitglied.Ende_C.strftime("%d.%m.%Y")
        else:
            kosten_c        = "0,00"
            consumption_str = "0,00"
            cons_von = (mitglied.Start_G.strftime("%d.%m.%Y")
                        if mitglied.Start_G != DEFAULT_DATE else "")
            cons_bis = (mitglied.Ende_G.strftime("%d.%m.%Y")
                        if mitglied.Ende_G != DEFAULT_DATE else "")

        if has_einspeisung:
            kosten_g       = f"-{mitglied.Generation * tarif_e_num:.2f}".replace(".", ",")
            generation_str = f"{mitglied.Generation:.2f}".replace(".", ",")  # positiv: kWh-Menge ohne Vorzeichen
            gen_von = mitglied.Start_G.strftime("%d.%m.%Y")
            gen_bis = mitglied.Ende_G.strftime("%d.%m.%Y")
        else:
            kosten_g       = "0,00"
            generation_str = "0,00"
            gen_von = (mitglied.Start_C.strftime("%d.%m.%Y")
                       if mitglied.Start_C != DEFAULT_DATE else "")
            gen_bis = (mitglied.Ende_C.strftime("%d.%m.%Y")
                       if mitglied.Ende_C != DEFAULT_DATE else "")

        if mitglied.Sex == SEX_MALE:
            anrede = "Lieber"
        elif mitglied.Sex == SEX_FEMALE:
            anrede = "Liebe"
        else:
            anrede = "Liebe/r"

        zp_c = (mitglied.ZP_Consumption[-10:]
                if mitglied.ZP_Consumption and pd.notna(mitglied.ZP_Consumption) else "-")
        zp_g = (mitglied.ZP_Generation[-10:]
                if mitglied.ZP_Generation and pd.notna(mitglied.ZP_Generation) else "-")

        is_gutschrift = mitglied.Kosten < 0
        doc_typ       = "GUTSCHRIFT" if is_gutschrift else "RECHNUNG"
        gesamt        = f"{mitglied.Kosten:.2f}".replace(".", ",")
        mandat_ref    = f"EEG-{mitglied.ID}"

        return {
            "vereinsname":        self.config["Vereinsname"],
            "vereinsstrasse":     self.config["Vereinsstrasse"],
            "vereinsort":         self.config["Vereinsort"],
            "logo":               "",
            "anrede":             anrede,
            "name":               mitglied.Name,
            "anschrift":          mitglied.Name4Rechnung if mitglied.Name4Rechnung else mitglied.Name,
            "strasse":            mitglied.Strasse,
            "plz":                mitglied.PLZ,
            "ort":                mitglied.Ort,
            "rechnungsdatum":     self.datum_rechnung.strftime("%d.%m.%Y"),
            "faelligkeitsdatum":  self.datum_abbuchung.strftime("%d.%m.%Y"),
            "rechnungsnummer":    mitglied.Rechnungsnummer,
            "kontonummer":        mask_iban(mitglied.IBAN),
            "zp_consumption":     zp_c,
            "zp_generation":      zp_g,
            "consumption_von":    cons_von,
            "consumption_bis":    cons_bis,
            "consumption":        consumption_str,
            "tarif_bezug":        tarif_bezug_str,
            "kosten_consumption": kosten_c,
            "generation_von":     gen_von,
            "generation_bis":     gen_bis,
            "generation":         generation_str,
            "tarif_einspeisung":  tarif_einspeisung_str,
            "kosten_generation":  kosten_g,
            "gesamtkosten":       gesamt,
            "ust_hint_bezug":     ust_hint_bezug,
            "ust_hint_einspeisung": ust_hint_einspeisung,
            "doc_typ":            doc_typ,
            "is_gutschrift":      is_gutschrift,
            "mandat_ref":         mandat_ref,
            "mitglied_uid":           (mitglied.UID or "").strip(),
            "rc_mwst_betrag":         rc_mwst_betrag_str,
            "rc_mwst_satz":           rc_mwst_satz_str,
            "landwirt_mwst_betrag":   landwirt_mwst_betrag_str,
            "landwirt_netto_tarif":   landwirt_netto_tarif_str,
            "landwirt_netto_betrag":  landwirt_netto_betrag_str,
            "bezug_mwst_betrag":      bezug_mwst_betrag_str,
            "bezug_netto_tarif":      bezug_netto_tarif_str,
            "bezug_netto_betrag":     bezug_netto_betrag_str,
        }

    def render_invoice_pdf_html(self, mitglied: Mitglied,
                                output_path: str = None) -> "str | None":
        """Rendert Rechnung als PDF via ReportLab. Delegiert an core.invoice_pdf."""
        from core.invoice_pdf import render_invoice_pdf_html
        return render_invoice_pdf_html(self, mitglied, output_path)

    def render_invoice_docx(self, mitglied: Mitglied,
                            template_path: str = None,
                            output_path: str = None) -> str:
        """Rendert Rechnung als DOCX via docxtpl. Delegiert an core.invoice_docx."""
        from core.invoice_docx import render_invoice_docx
        return render_invoice_docx(self, mitglied, template_path, output_path)

    def fill_template(self, mitglied: Mitglied) -> str:
        """Füllt Legacy-Excel-Vorlage. Delegiert an core.invoice_docx."""
        from core.invoice_docx import fill_template
        return fill_template(self, mitglied)

    def _replace_placeholders(self, ws, mitglied: Mitglied) -> None:
        """Ersetzt [Platzhalter] in Excel-Vorlage. Delegiert an core.invoice_docx."""
        from core.invoice_docx import _replace_placeholders
        _replace_placeholders(ws, mitglied, self.config,
                              self.datum_rechnung, self.datum_abbuchung)

    def _build_logo_image(self, doc):
        """Erstellt InlineImage für Logo. Delegiert an core.invoice_docx."""
        from core.invoice_docx import _build_logo_image
        return _build_logo_image(doc, self)

    @staticmethod
    def _apply_accent_color(docx_path: str, hex_color: str) -> None:
        """Setzt Akzentfarbe im DOCX. Delegiert an core.invoice_docx."""
        from core.invoice_docx import _apply_accent_color
        _apply_accent_color(docx_path, hex_color)

    @staticmethod
    def _mask_iban(iban: str) -> str:
        """Maskiert IBAN für Anzeige. Delegiert an core.utils."""
        return mask_iban(iban)

    # ── PDF-Hilfsmethoden (COM / LibreOffice) ────────────────────────────────

    @staticmethod
    def _ensure_ms_pdf_printer() -> bool:
        """Stellt sicher, dass 'Microsoft Print to PDF' installiert ist."""
        import subprocess
        check = subprocess.run(
            ["powershell", "-NoProfile", "-Command",
             "Get-Printer -Name \"Microsoft Print to PDF\" -ErrorAction SilentlyContinue "
             "| Select-Object -ExpandProperty Name"],
            capture_output=True, text=True, timeout=15,
        )
        if "Microsoft Print to PDF" in check.stdout:
            return True
        logger.info("'Microsoft Print to PDF' nicht gefunden – versuche Installation …")
        subprocess.run(
            ["powershell", "-NoProfile", "-Command",
             "Start-Service -Name Spooler -ErrorAction SilentlyContinue"],
            capture_output=True, timeout=10,
        )
        add = subprocess.run(
            ["powershell", "-NoProfile", "-Command",
             "Add-Printer -Name \"Microsoft Print to PDF\" "
             "-DriverName \"Microsoft Print To PDF\" -PortName \"PORTPROMPT:\""],
            capture_output=True, text=True, timeout=20,
        )
        if add.returncode == 0:
            logger.info("'Microsoft Print to PDF' installiert.")
            return True
        logger.warning(f"Installation fehlgeschlagen: {add.stderr.strip()}")
        return False

    @staticmethod
    def _find_excel_printer():
        """Sucht einen verfügbaren Drucker für Excel-PDF-Export."""
        import subprocess, json as _json
        try:
            result = subprocess.run(
                ["powershell", "-NoProfile", "-Command",
                 "Get-Printer | Select-Object Name, PortName | ConvertTo-Json"],
                capture_output=True, text=True, timeout=15,
            )
            if result.returncode == 0 and result.stdout.strip():
                data = _json.loads(result.stdout)
                if isinstance(data, dict):
                    data = [data]
                preferred = fallback = None
                for p in data:
                    name = p.get("Name", "")
                    port = p.get("PortName", "PORTPROMPT:") or "PORTPROMPT:"
                    printer_str = f"{name} on {port}"
                    if any(kw in name.upper() for kw in ("PDF", "XPS")):
                        preferred = printer_str
                    if fallback is None:
                        fallback = printer_str
                if preferred or fallback:
                    return preferred or fallback
        except Exception:
            pass
        try:
            import win32print
            printers = win32print.EnumPrinters(
                win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS, None, 2
            )
            preferred = fallback = None
            for p in printers:
                name   = p[2]
                handle = win32print.OpenPrinter(name)
                try:
                    info = win32print.GetPrinter(handle, 2)
                finally:
                    win32print.ClosePrinter(handle)
                port        = info.get("pPortName", "PORTPROMPT:")
                printer_str = f"{name} on {port}"
                if any(kw in name.upper() for kw in ("PDF", "XPS")):
                    preferred = printer_str
                if fallback is None:
                    fallback = printer_str
            return preferred or fallback
        except Exception:
            pass
        return None

    def _create_pdf_excel_com(self, excel_path: str, output_pdf_path: str) -> bool:
        """PDF-Erstellung via Excel COM (win32com). Gibt True zurück wenn erfolgreich."""
        import win32com.client, pythoncom
        excel = workbook = None
        try:
            pythoncom.CoInitialize()
            if not os.path.exists(excel_path):
                raise FileNotFoundError(f"Excel-Datei nicht gefunden: {excel_path}")
            excel = win32com.client.Dispatch("Excel.Application")
            excel.Visible = False
            excel.DisplayAlerts = False
            excel.AutomationSecurity = 1
            printer = self._find_excel_printer()
            if not printer:
                self._ensure_ms_pdf_printer()
                printer = self._find_excel_printer()
            if printer:
                try:
                    excel.ActivePrinter = printer
                except Exception as pe:
                    logger.warning(f"Drucker konnte nicht gesetzt werden: {pe}")
            else:
                logger.warning("Kein Drucker verfügbar.")
            workbook = excel.Workbooks.Open(excel_path)
            workbook.ExportAsFixedFormat(0, output_pdf_path)
            workbook.Close(False)
            workbook = None
            return True
        finally:
            try:
                if workbook is not None:
                    workbook.Close(False)
            except Exception:
                pass
            try:
                if excel is not None:
                    excel.Quit()
            except Exception:
                pass
            try:
                pythoncom.CoUninitialize()
            except Exception:
                pass

    @staticmethod
    def _create_pdf_libreoffice(input_path: str, output_pdf_path: str) -> bool:
        """PDF-Erstellung via LibreOffice (headless). Format-agnostisch."""
        import subprocess, tempfile, shutil
        if os.name != "nt":
            input_path      = input_path.replace("\\", "/")
            output_pdf_path = output_pdf_path.replace("\\", "/")
        output_dir = os.path.dirname(output_pdf_path)

        lo_home = os.environ.get("EEG_LO_HOME", "/var/lib/eeg-lo-profile")
        if os.name != "nt" and not os.path.isdir(lo_home):
            lo_home = os.path.join(tempfile.gettempdir(), "eeg-lo-profile")
            try:
                os.makedirs(lo_home, exist_ok=True)
            except Exception:
                pass

        env = os.environ.copy()
        if os.name != "nt":
            env["HOME"] = lo_home
            env.setdefault("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
        user_install = "file://" + lo_home.replace(os.sep, "/")

        resolved = (shutil.which("soffice", path=env.get("PATH"))
                    or shutil.which("libreoffice", path=env.get("PATH")))
        lo_candidates = []
        if resolved:
            lo_candidates.append(resolved)
        lo_candidates.extend([
            "/usr/bin/soffice", "/usr/bin/libreoffice",
            "/usr/local/bin/soffice", "/usr/local/bin/libreoffice",
            "soffice", "libreoffice",
            r"C:\Program Files\LibreOffice\program\soffice.exe",
            r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
        ])
        seen = set()
        lo_candidates = [c for c in lo_candidates if not (c in seen or seen.add(c))]

        errs = []
        logger.info(f"LibreOffice-Kandidaten ({len(lo_candidates)}): {lo_candidates}")
        for soffice in lo_candidates:
            try:
                result = subprocess.run(
                    [soffice, "--headless",
                     "-env:UserInstallation=" + user_install,
                     "--convert-to", "pdf",
                     "--outdir", output_dir, input_path],
                    capture_output=True, text=True, timeout=180, env=env,
                )
                if result.returncode == 0:
                    lo_pdf = os.path.join(
                        output_dir,
                        os.path.splitext(os.path.basename(input_path))[0] + ".pdf",
                    )
                    if os.path.exists(lo_pdf):
                        if lo_pdf != output_pdf_path:
                            os.rename(lo_pdf, output_pdf_path)
                        return True
                    errs.append(f"[{soffice}] rc=0 aber kein PDF in {output_dir}")
                else:
                    errs.append(
                        f"[{soffice}] rc={result.returncode} stderr={result.stderr.strip()[:300]}"
                    )
            except (FileNotFoundError, subprocess.TimeoutExpired) as e:
                errs.append(f"[{soffice}] {type(e).__name__}: {e}")
            except Exception as e:
                errs.append(f"[{soffice}] {type(e).__name__}: {e}")
        for e in errs:
            logger.warning(f"LibreOffice-Fehler: {e}")
        return False

    def create_pdf(self, excel_path: str, output_pdf_path: str) -> bool:
        """Erstellt PDF aus Excel. Versucht: COM → LibreOffice → Fallback XLSX."""
        excel_path       = os.path.abspath(excel_path)
        output_pdf_path  = os.path.abspath(output_pdf_path)
        if os.name == "nt":
            excel_path      = excel_path.replace("/", "\\")
            output_pdf_path = output_pdf_path.replace("/", "\\")
        try:
            logger.info("PDF-Erstellung via Excel COM …")
            self._create_pdf_excel_com(excel_path, output_pdf_path)
            if os.path.exists(output_pdf_path):
                if os.path.exists(excel_path):
                    os.remove(excel_path)
                logger.info(f"PDF erstellt (Excel COM): {os.path.basename(output_pdf_path)}")
                return True
        except Exception as e:
            logger.warning(f"Excel COM fehlgeschlagen: {e}")
        try:
            logger.info("PDF-Erstellung via LibreOffice …")
            if self._create_pdf_libreoffice(excel_path, output_pdf_path):
                if os.path.exists(excel_path):
                    os.remove(excel_path)
                logger.info(f"PDF erstellt (LibreOffice): {os.path.basename(output_pdf_path)}")
                return True
            logger.warning("LibreOffice nicht gefunden oder fehlgeschlagen.")
        except Exception as e:
            logger.warning(f"LibreOffice fehlgeschlagen: {e}")
        logger.warning(
            f"PDF-Erstellung nicht möglich – XLSX bleibt erhalten: {os.path.basename(excel_path)}"
        )
        return False

    def create_pdf_from_docx(self, docx_path: str, output_pdf_path: str,
                              keep_docx: bool = False) -> bool:
        """Wandelt DOCX via LibreOffice in PDF um."""
        docx_path       = os.path.abspath(docx_path)
        output_pdf_path = os.path.abspath(output_pdf_path)
        if os.name == "nt":
            docx_path       = docx_path.replace("/", "\\")
            output_pdf_path = output_pdf_path.replace("/", "\\")
        try:
            logger.info("PDF-Erstellung via LibreOffice (DOCX → PDF) …")
            if self._create_pdf_libreoffice(docx_path, output_pdf_path):
                if not keep_docx and os.path.exists(docx_path):
                    try:
                        os.remove(docx_path)
                    except OSError as e:
                        logger.warning(f"DOCX konnte nicht gelöscht werden: {e}")
                logger.info(f"PDF erstellt: {os.path.basename(output_pdf_path)}")
                return True
            logger.warning("LibreOffice nicht gefunden oder fehlgeschlagen.")
        except Exception as e:
            logger.warning(f"LibreOffice-Konvertierung fehlgeschlagen: {e}")
        logger.warning(
            f"PDF-Erstellung nicht möglich – DOCX bleibt erhalten: "
            f"{os.path.basename(docx_path)}"
        )
        return False

    # ── Rollback ──────────────────────────────────────────────────────────────

    def rollback(self, created_mitglieder: list) -> None:
        """Löscht alle erstellten Rechnungsdateien und räumt leere Ordner auf."""
        for mitglied in created_mitglieder:
            base = os.path.join(
                self.folder_rechnungen,
                f"Rechnung_{mitglied.Rechnungsnummer}_{mitglied.Name4Rechnung}",
            )
            for ext in (".pdf", ".docx", ".xlsx"):
                path = base + ext
                if os.path.exists(path):
                    os.remove(path)
        if os.path.exists(self.folder_sepa) and not os.listdir(self.folder_sepa):
            os.rmdir(self.folder_sepa)
        if os.path.exists(self.folder_rechnungen) and not os.listdir(self.folder_rechnungen):
            os.rmdir(self.folder_rechnungen)
        logger.warning("Rollback abgeschlossen.")

    # ── Abrechnung pro Mitglied ───────────────────────────────────────────────

    def process_mitglied(self, mitglied: Mitglied) -> None:
        """Rechnungsnummer vergeben, PDF erstellen (HTML → DOCX-Fallback)."""
        try:
            # Rechnung erstellen wenn Kosten ≠ 0 ODER Energie getauscht wurde (0€-Gutschrift möglich)
            if mitglied.Kosten != 0 or mitglied.Generation > 0 or mitglied.Consumption > 0:
                self.rechnungsnummer += 1
                mitglied.Rechnungsnummer = (
                    f"{self.datum_rechnung.year}-{self.rechnungsnummer:04d}"
                )

                pdf_base = os.path.join(
                    self.folder_rechnungen,
                    f"Rechnung_{mitglied.Rechnungsnummer}_{mitglied.Name4Rechnung}.pdf",
                )

                # Priorität 1: ReportLab-PDF
                pdf_ok = self.render_invoice_pdf_html(mitglied, pdf_base) is not None

                if not pdf_ok:
                    # Priorität 2: DOCX → LibreOffice
                    logger.info("WeasyPrint nicht verfügbar – Fallback: DOCX + LibreOffice")
                    docx_path = self.render_invoice_docx(mitglied)
                    pdf_ok    = self.create_pdf_from_docx(docx_path, pdf_base)
                    if not pdf_ok:
                        logger.warning(
                            f"PDF fehlgeschlagen – DOCX bleibt: {os.path.basename(docx_path)}"
                        )

                logger.info(
                    f"Rechnung für {mask_name_for_log(mitglied.Name)} erstellt (Nr: {mitglied.Rechnungsnummer})."
                )
        except Exception as e:
            logger.error(f"Fehler bei {mask_name_for_log(mitglied.Name)}: {e}")
            raise

    # ── SEPA ──────────────────────────────────────────────────────────────────

    def _is_av_mode(self) -> bool:
        """True wenn Abbuchungsmodus = abrechnungsverein."""
        return self.config.get(
            "Abbuchung_Modus", self.config.get("abbuchung_modus", "ueberweisung")
        ).strip().lower() == "abrechnungsverein"

    def _sepa_ids(self) -> tuple:
        """Gibt (IBAN, BIC, Creditor_ID) zurück – AV-Modus: aus _AV_KONSTANTEN."""
        if self._is_av_mode():
            return (
                _AV_KONSTANTEN["iban"],
                _AV_KONSTANTEN["bic"],
                _AV_KONSTANTEN["creditor_id"],
            )
        return (
            self.config.get("SEPA_IBAN", ""),
            self.config.get("SEPA_BIC", ""),
            self.config.get("SEPA_Creditor_ID", ""),
        )

    def _validate_sepa_config(self) -> None:
        """Prüft SEPA-Konfigurationswerte. Delegiert an core.sepa_handler."""
        from core.sepa_handler import _validate_sepa_config
        _validate_sepa_config(self)

    def _create_sepa_payment(self, mitglied: Mitglied) -> dict:
        """Erstellt ein SEPA-Payment-Dict für ein Mitglied."""
        if not mitglied.IBAN or not mitglied.BIC:
            raise ValueError(f"Mitglied {mask_name_for_log(mitglied.Name)} hat keine gültige IBAN/BIC")
        return {
            "PmtInfId":       f"Lastschrift-{self.periode}",
            "name":           str(mitglied.Name),
            "IBAN":           str(mitglied.IBAN),
            "amount":         int(round(float(mitglied.Kosten) * 100)),
            "BIC":            mitglied.BIC,
            "mandate_id":     mitglied.ID,
            "mandate_date":   date.today(),
            "collection_date": self.datum_abbuchung.date(),
            "description":    f"{self.config['Vereinsname']}-{self.periode}-{mitglied.Rechnungsnummer}",
            "sequence":       "OOFF",
            "creditor_id":    self._sepa_ids()[2],
            "type":           "OOFF",
        }

    def _write_xml_file(self, output_path: str, xml_content: bytes) -> None:
        """Schreibt XML-Inhalt in eine Datei."""
        try:
            with open(output_path, "wb") as f:
                f.write(xml_content)
        except IOError as e:
            raise IOError(f"Fehler beim Schreiben der XML-Datei {output_path}: {e}")

    def create_sepa_xml_Lastschrift(self) -> None:
        """Erstellt SEPA-Lastschrift-XML. Delegiert an core.sepa_handler."""
        from core.sepa_handler import create_sepa_xml_lastschrift
        create_sepa_xml_lastschrift(self)

    def create_sepa_xml_Gutschrift(self) -> None:
        """Erstellt SEPA-Gutschrift-XML. Delegiert an core.sepa_handler."""
        from core.sepa_handler import create_sepa_xml_gutschrift
        create_sepa_xml_gutschrift(self)

    def _create_sepa_ct_object(self):
        """Delegiert an core.sepa_handler."""
        from core.sepa_handler import _create_sepa_ct_object
        return _create_sepa_ct_object(self)

    def _add_gutschrift_transactions(self, sepa_ct, gutschrift_mitglieder: list) -> None:
        """Delegiert an core.sepa_handler."""
        from core.sepa_handler import _add_gutschrift_transactions
        _add_gutschrift_transactions(sepa_ct, gutschrift_mitglieder, self)

    def _export_sepa_ct(self, sepa_ct, output_path: str) -> None:
        """Delegiert an core.sepa_handler."""
        from core.sepa_handler import _export_sepa_ct
        _export_sepa_ct(sepa_ct, output_path, self)

    def _validate_sepa_folder(self, sepa_folder: str) -> None:
        """Delegiert an core.sepa_handler."""
        from core.sepa_handler import _validate_sepa_folder
        _validate_sepa_folder(sepa_folder)

    # ── E-Mail ────────────────────────────────────────────────────────────────

    @staticmethod
    def _vereinsname_to_from_email(vereinsname: str,
                                    domain: str = "energie-durchdacht.at") -> str:
        """Delegiert an core.utils."""
        return vereinsname_to_from_email(vereinsname, domain)

    def send_email(self, mitglied: Mitglied, test_email: str = None):
        """Versendet die Rechnung per E-Mail. Delegiert an core.email_handler."""
        from core.email_handler import send_email
        send_email(self, mitglied, test_email)

    def send_sepa_email(self, to_addr: str, periode: str = ""):
        """Sendet SEPA-XMLs per E-Mail. Delegiert an core.email_handler."""
        from core.email_handler import send_sepa_email
        send_sepa_email(self, to_addr, periode)

    # ── EDA-Integrität ────────────────────────────────────────────────────────

    def _validate_eda_sheet_consistency(self, rows2: list, fname: str) -> None:
        """Prüft Spaltensummen, Zeitreihen-Vollständigkeit und Energiebilanz.
        Berechnet auch stündliche KPI-Kurven und die Nacht-Jahresdauerlinie."""
        from core.eda_reader import compute_hourly_kpis
        compute_hourly_kpis(self, rows2)

        IDX_ZP         = 1
        IDX_DIR        = 3
        IDX_CODE       = 13
        IDX_N_INTV     = 12
        IDX_SUM_PRE    = 15
        IDX_DATA_START = 16

        logger.info(f"Starte Integritätsprüfung EDA-Report: {fname}")

        if len(rows2) <= IDX_DATA_START:
            logger.warning("Integritätsprüfung: Blatt 2 enthält keine Datenzeilen.")
            return

        row_zp      = rows2[IDX_ZP]
        row_dir     = rows2[IDX_DIR]
        row_code    = rows2[IDX_CODE]
        row_n_intv  = rows2[IDX_N_INTV]
        row_sum_pre = rows2[IDX_SUM_PRE]

        dir_g_val = self.config.get("EDA_direction_G", "GENERATION")
        dir_c_val = self.config.get("EDA_direction_C", "CONSUMPTION")

        data_cols = []
        for ci in range(1, len(row_zp)):
            zp   = row_zp[ci]
            dirn = row_dir[ci]
            code = row_code[ci]
            pre  = row_sum_pre[ci]
            if (zp and zp not in ("MM", "TOTAL")
                    and dirn in (dir_c_val, dir_g_val)
                    and code and isinstance(pre, (int, float))):
                data_cols.append({
                    "idx": ci, "zp": zp, "dir": dirn, "code": code,
                    "pre_sum": pre, "n_expected": row_n_intv[ci],
                })

        if not data_cols:
            logger.warning("Integritätsprüfung: Keine Datenspalten gefunden.")
            return

        n_data_rows = len(rows2) - IDX_DATA_START

        def _parse_ts(v):
            if v is None:
                return None
            s = str(v).strip()
            for fmt in ("%d.%m.%Y %H:%M", "%d.%m.%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S"):
                try:
                    return datetime.strptime(s, fmt)
                except ValueError:
                    pass
            return None

        # Prüfung 2: Zeitreihen-Vollständigkeit
        errors_completeness: list = []
        n_expected_ref = data_cols[0]["n_expected"]
        if n_expected_ref and n_data_rows != n_expected_ref:
            errors_completeness.append(
                f"Anzahl Datenzeilen {n_data_rows} ≠ Z13-Angabe {n_expected_ref}"
            )
        timestamps = [_parse_ts(rows2[i][0]) for i in range(IDX_DATA_START, len(rows2))]
        valid_ts   = [t for t in timestamps if t is not None]
        interval   = timedelta(minutes=15)
        gap_count  = sum(
            1 for i in range(1, len(valid_ts))
            if valid_ts[i] - valid_ts[i - 1] != interval
        )
        if gap_count:
            errors_completeness.append(
                f"{gap_count} Zeitstempel-Lücke(n)/Sprünge im 15-Min-Raster"
            )
        missing_cols: list = []
        for col in data_cols:
            if col["pre_sum"] == 0:
                continue
            ci      = col["idx"]
            missing = sum(1 for r in rows2[IDX_DATA_START:] if r[ci] is None)
            if missing:
                missing_cols.append(
                    f"{col['zp']} | {col['dir']} | {col['code'][:50]}: {missing} Werte fehlen"
                )
        for msg in errors_completeness + missing_cols:
            logger.warning(f"Vollständigkeitsprüfung: {msg}")
        if not errors_completeness and not missing_cols:
            logger.info(
                f"Vollständigkeitsprüfung OK – {n_data_rows} Zeitintervalle, "
                f"{len(data_cols)} Spalten vollständig."
            )

        # Prüfung 1: Spaltensummen-Integrität
        TOLERANCE_COL = 0.001
        sum_errors: list = []
        for col in data_cols:
            ci       = col["idx"]
            computed = sum(
                r[ci] for r in rows2[IDX_DATA_START:]
                if isinstance(r[ci], (int, float))
            )
            diff = abs(computed - col["pre_sum"])
            if diff > TOLERANCE_COL:
                sum_errors.append(
                    f"{col['zp']} | {col['dir']} | {col['code'][:50]}: "
                    f"Z16={col['pre_sum']:.4f} ≠ Σ={computed:.4f} (Δ={diff:.4f} kWh)"
                )
        for msg in sum_errors:
            logger.warning(f"Spaltensummen-Abweichung: {msg}")
        if not sum_errors:
            logger.info(
                f"Spaltensummen-Integrität OK – alle {len(data_cols)} Spalten korrekt."
            )

        # Prüfung 3: Energiebilanz
        TOLERANCE_BALANCE = 1.0
        sum_c        = sum(m.Consumption for m in self.mitglieder)
        sum_g        = sum(m.Generation  for m in self.mitglieder)
        balance_diff = abs(sum_c - sum_g)
        if balance_diff >= TOLERANCE_BALANCE:
            logger.warning(
                f"Energiebilanz-Abweichung: Σ C={sum_c:.4f} kWh, "
                f"Σ G={sum_g:.4f} kWh, Δ={balance_diff:.4f} kWh"
            )
        else:
            logger.info(f"Energiebilanz OK – Δ={balance_diff:.6f} kWh.")

        # Nacht-Jahresdauerlinie
        code_ges_verb = self.config.get("EDA_Gesamtverbrauch", "")
        ges_verb_cols = [
            c["idx"] for c in data_cols
            if c["code"] == code_ges_verb and c["dir"] == dir_c_val
        ]
        try:
            from astral import LocationInfo
            from astral.sun import sun as _astral_sun

            _AT_LON: dict = {
                "wien": 16.37, "vienna": 16.37,
                "graz": 15.44, "linz": 14.29, "salzburg": 13.04,
                "innsbruck": 11.39, "klagenfurt": 14.31, "villach": 13.85,
                "wels": 14.02, "steyr": 14.42,
                "st. pölten": 15.62, "st pölten": 15.62, "sankt pölten": 15.62,
                "dornbirn": 9.74, "bregenz": 9.75, "feldkirch": 9.60,
                "leonding": 14.25, "traun": 14.24,
                "klosterneuburg": 16.32, "mödling": 16.29,
                "krems an der donau": 15.61, "krems": 15.61,
                "amstetten": 14.87, "waidhofen an der ybbs": 14.77,
                "waidhofen/ybbs": 14.77, "waidhofen": 14.77,
                "leoben": 15.09, "kapfenberg": 15.29,
                "bruck an der mur": 15.28, "wolfsberg": 14.84,
                "kufstein": 12.17, "imst": 10.74, "lienz": 12.77,
                "zell am see": 12.80, "bad ischl": 13.62, "gmunden": 13.80,
                "vöcklabruck": 13.66, "ried im innkreis": 13.49,
                "wiener neustadt": 16.24, "eisenstadt": 16.52,
                "horn": 15.66, "zwettl": 15.17, "melk": 15.33,
            }
            _VIENNA_LON = 16.37
            city_name   = str(self.config.get("Suncity", "Vienna")).strip()
            city_key    = city_name.lower().strip()
            city_lon    = _AT_LON.get(city_key)
            if city_lon is None:
                for k_city, lon in _AT_LON.items():
                    if k_city in city_key or city_key in k_city:
                        city_lon = lon
                        break
            if city_lon is None:
                city_lon = _VIENNA_LON

            _lon_offset = timedelta(minutes=((_VIENNA_LON - city_lon) * 4.0))
            _location   = LocationInfo("Vienna", "Austria", "Europe/Vienna", 48.2, _VIENNA_LON)
            _sun_cache: dict = {}

            def _get_sunrise_sunset(d):
                if d not in _sun_cache:
                    s  = _astral_sun(_location.observer, date=d, tzinfo=_location.timezone)
                    sr = s["sunrise"].replace(tzinfo=None) + _lon_offset
                    ss = s["sunset"].replace(tzinfo=None)  + _lon_offset
                    _sun_cache[d] = (sr, ss)
                return _sun_cache[d]

            nighttime_kw: list = []
            for row in rows2[IDX_DATA_START:]:
                ts = _parse_ts(row[0])
                if ts is None:
                    continue
                sr, ss = _get_sunrise_sunset(ts.date())
                if ts < sr or ts >= ss:
                    total_verb_kwh = sum(
                        row[ci] for ci in ges_verb_cols
                        if isinstance(row[ci], (int, float))
                    )
                    nighttime_kw.append(round(total_verb_kwh * 4.0, 4))

            nighttime_kw.sort(reverse=True)
            n_full = len(nighttime_kw)
            if n_full > 1000:
                step         = n_full / 1000
                nighttime_kw = [nighttime_kw[int(i * step)] for i in range(1000)]

            self.nighttime_verbrauch_sorted = nighttime_kw
            logger.info(
                f"Nacht-Jahresdauerlinie: {n_full} Nachtstunden-Intervalle"
                + (f", Peak {nighttime_kw[0]:.2f} kW" if nighttime_kw else "")
            )
        except Exception as _e_night:
            logger.warning(f"Nacht-Jahresdauerlinie: {_e_night}")
            self.nighttime_verbrauch_sorted = []

    # ── Workflow-Methoden (alte run() API + neue Phasen-API) ──────────────────

    def run(self, report_paths: str, periode: str):
        """Hauptablauf (Legacy): Alles in einem Durchgang."""
        try:
            self.periode = periode
            self.init_Files()
            self.init_mitglieder()

            for r_p in report_paths:
                self.read_eda_report(r_p)
            if not self.check_consumption_generation(self.mitglieder):
                logger.error("Abbruch: Consumption und Generation weichen zu stark ab!")
                exit(1)

            self.calculate_kosten()

            self.mitglieder.sort(key=lambda m: (
                0 if m.Kosten > 0 else (1 if m.Kosten < 0 else 2), m.ID
            ))

            rechnungsnummer_path = os.path.join(
                self.main_path, "Rechnungen", self.config["File_Rechnungsnummer"]
            )
            self.rechnungsnummer = self._generate_rechnungsnummer(rechnungsnummer_path)

            created_rechnungen = []
            try:
                for mitglied in self.mitglieder:
                    created_rechnungen.append(mitglied)
                    self.process_mitglied(mitglied)
            except Exception as e:
                logger.error(f"Fehler: {e}. Setze alle Änderungen zurück...")
                self.rollback(created_rechnungen)
                raise

            self.create_sepa_xml_Lastschrift()
            self.create_sepa_xml_Gutschrift()

            for mitglied in self.mitglieder:
                if mitglied.Kosten != 0 or mitglied.Generation > 0 or mitglied.Consumption > 0:
                    if self.config.get("Send_Mail", "False") == "True":
                        self.send_email(mitglied)
                    self.handle_rechnungsnummer(mitglied)
            logger.info("Fertig! Rechnungen erstellt.")

        except Exception as e:
            logger.error(f"Fehler: {e}", exc_info=True)
            raise

    def prepare(self, report_paths: list, periode: str) -> list:
        """Phase 1: Mitglieder + EDA einlesen, Kosten berechnen. Keine Dateien werden geschrieben."""
        self.periode = periode
        self.init_Files()
        self.init_mitglieder()

        for r_p in report_paths:
            self.read_eda_report(r_p)

        try:
            ok = self.check_consumption_generation(self.mitglieder)
            if not ok:
                logger.warning("Consumption und Generation weichen stark ab.")
        except Exception as e_bal:
            logger.warning(f"Bilanz-Check fehlgeschlagen: {e_bal}")

        self.calculate_kosten()

        result = []
        for m in self.mitglieder:
            try:
                consumption = float(getattr(m, "Consumption", 0) or 0)
            except Exception:
                consumption = 0.0
            try:
                generation = float(getattr(m, "Generation", 0) or 0)
            except Exception:
                generation = 0.0
            try:
                kosten = float(getattr(m, "Kosten", 0) or 0)
            except Exception:
                kosten = 0.0
            result.append({
                "id":          getattr(m, "ID", ""),
                "name":        getattr(m, "Name", "") or "",
                "mail":        getattr(m, "Mail", "") or "",
                "iban":        getattr(m, "IBAN", "") or "",
                "zp_c":        getattr(m, "ZP_Consumption", "") or "",
                "zp_g":        getattr(m, "ZP_Generation", "") or "",
                "consumption": round(consumption, 3),
                "generation":  round(generation, 3),
                "kosten":      round(kosten, 2),
                "steuersatz":  int(getattr(m, "Steuersatz", 0) or 0),
                "has_invoice": abs(kosten) > 0.005,
            })

        self._prepared = True
        return result

    def generate_pdfs(self, start_rechnungsnummer: int = 0) -> None:
        """Phase 2: Rechnungsnummern vergeben + PDF pro Mitglied."""
        if not getattr(self, "_prepared", False):
            raise RuntimeError("generate_pdfs() ohne vorheriges prepare() aufgerufen.")

        if start_rechnungsnummer > 0:
            self.rechnungsnummer = start_rechnungsnummer
            logger.info(
                f"Rechnungsnummern-Start: {start_rechnungsnummer} "
                f"(nächste: {self.datum_rechnung.year}-{start_rechnungsnummer+1:04d})"
            )
        else:
            rn_path = os.path.join(
                self.main_path, "Rechnungen", self.config["File_Rechnungsnummer"]
            )
            self.rechnungsnummer = self._generate_rechnungsnummer(rn_path)

        self.mitglieder.sort(key=lambda m: (
            0 if m.Kosten > 0 else (1 if m.Kosten < 0 else 2), m.ID
        ))

        created = []
        try:
            for m in self.mitglieder:
                created.append(m)
                self.process_mitglied(m)
        except Exception:
            logger.error("Fehler in generate_pdfs – rollback.")
            try:
                self.rollback(created)
            except Exception as e_rb:
                logger.error(f"Rollback fehlgeschlagen: {e_rb}")
            raise

    def build_sepa_xml(self) -> None:
        """Phase 3: SEPA-Lastschrift- und -Gutschrift-XML schreiben."""
        if not getattr(self, "_prepared", False):
            raise RuntimeError("build_sepa_xml() ohne vorheriges prepare() aufgerufen.")
        self.create_sepa_xml_Lastschrift()
        self.create_sepa_xml_Gutschrift()

    def write_kassabuch(self) -> None:
        """Phase 4: Kassabuch-Einträge + Sammelzeilen schreiben."""
        if not getattr(self, "_prepared", False):
            raise RuntimeError("write_kassabuch() ohne vorheriges prepare() aufgerufen.")

        for attr in ("_ls_msgid", "_gs_msgid"):
            if not hasattr(self, attr):
                setattr(self, attr, "")
        for attr in ("_ls_ctrlsum", "_gs_ctrlsum"):
            if not hasattr(self, attr):
                setattr(self, attr, 0.0)

        ls_rows, gs_rows = [], []
        for m in self.mitglieder:
            try:
                if m.Kosten == 0:
                    continue
                row_nr = self.handle_rechnungsnummer(m)
                if row_nr is None:
                    continue
                (ls_rows if m.Kosten > 0 else gs_rows).append(row_nr)
            except Exception as e_hr:
                logger.error(
                    f"Kassabuch-Zeile für {getattr(m, 'Name', '?')} fehlgeschlagen: {e_hr}"
                )

        if self._ls_ctrlsum > 0 or self._gs_ctrlsum > 0:
            try:
                self._write_kassabuch_batch_summary(ls_rows, gs_rows)
            except Exception as e_bs:
                logger.warning(f"Kassabuch-Sammelzeilen konnten nicht geschrieben werden: {e_bs}")
        else:
            logger.info("Kassabuch: SEPA nicht mitgelaufen – Sammelzeilen übersprungen.")
