# v1.05_upload  -  UI-Abgleich mit der Homepage energie-durchdacht.at
#                  (Header, Footer, Brand-CSS, Cancel-Endpunkt,
#                   /datenschutz auf zentrale Seite)
# Basiert auf v1.04
# 2026-04-29
"""
EEG-Assistent  –  Version 1 (Upload & ZIP-Download)
Keine persistenten Daten am Server. Jeder Nutzer bekommt eine UUID-Session
mit eigenem Temp-Verzeichnis. Nach dem ZIP-Download wird alles gelöscht.
"""

import io, json, logging, os, queue, re, shutil, sys, threading, time, uuid, zipfile
from datetime import datetime, timedelta
from functools import wraps
from pathlib import Path

from flask import (Flask, Response, jsonify, redirect,
                   render_template, request, send_file, make_response)
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix

# Eigenes Verzeichnis in Pfad aufnehmen (main.py liegt jetzt im selben Ordner)
sys.path.insert(0, os.path.dirname(__file__))

import session_manager as sm
import licensing

# Lizenz-Datenbank anlegen + tägliches Backup starten (idempotent).
licensing.ensure_ready()

app = Flask(__name__)
# v1.04: hinter Apache als Reverse Proxy. Wertet die X-Forwarded-*-
# Header aus, damit url_for() den Pfad-Prefix kennt und Cookies auf
# der korrekten Domain landen.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
app.secret_key = os.urandom(24)

# Harte Obergrenze für ein einzelnes Upload-Request. EDA-Dateien sind selten
# größer als 5–10 MB, 100 MB sollten für jede realistische Kombination reichen
# und schützen vor DoS durch übergroße Uploads.
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024
# Statische Dateien (CSS/JS) 7 Tage cachen; Browser muss bei Änderungen
# nicht neu laden – Versionierung via ?v=... im HTML-Link falls nötig.
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 7  # 7 Tage in Sekunden
# Templates bei jeder Anfrage neu einlesen – kein Neustart nach index.html-Änderungen nötig.
app.config["TEMPLATES_AUTO_RELOAD"] = True


@app.after_request
def _add_cache_headers(response):
    """Cache-Control für statische Dateien setzen; SSE-Streams explizit ausschließen."""
    if request.path.startswith("/static/"):
        response.cache_control.max_age = 60 * 60 * 24 * 7
        response.cache_control.public = True
    return response


@app.errorhandler(413)
def _too_large(_e):
    return jsonify({"error": "Upload zu groß (Limit: 100 MB pro Request)."}), 413

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)-8s %(message)s")
log = logging.getLogger("v1")
# ── v1.04: Vollstaendiger Python-Log nach <root>/logs/full.log ──────────────
#
# Faengt alles ein, was durch logging laeuft, plus jede SSE-emit()-Nachricht
# der beiden Worker. Hilft beim Debuggen ohne dass der User Konsole offen
# halten muss. DEBUG-Level, taegliche Rotation, 30 Tage Retention.
from logging.handlers import TimedRotatingFileHandler as _TRFH

_LOG_DIR  = Path(__file__).resolve().parent / "logs"
_LOG_DIR.mkdir(exist_ok=True)
_LOG_PATH = _LOG_DIR / "full.log"

_root_logger = logging.getLogger()
_root_logger.setLevel(logging.DEBUG)

_already = any(
    isinstance(h, _TRFH) and getattr(h, "baseFilename", "").endswith("full.log")
    for h in _root_logger.handlers
)
if not _already:
    _fh = _TRFH(_LOG_PATH, when="midnight", backupCount=30,
                encoding="utf-8", utc=False)
    _fh.setLevel(logging.DEBUG)
    _fh.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)-5s %(name)s [%(threadName)s]: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    ))
    _root_logger.addHandler(_fh)
    logging.getLogger("v1").info(
        f"Full-Log aktiv: {_LOG_PATH} (DEBUG-Level, 30 Tage Retention)"
    )

_sse_log = logging.getLogger("sse")


def _mirror_emit(message: str, level: str = "INFO") -> None:
    """Spiegelt eine SSE-Zeile in den Python-Logger, damit sie in full.log landet."""
    level_map = {
        "DEBUG":    logging.DEBUG,
        "INFO":     logging.INFO,
        "WARNING":  logging.WARNING,
        "ERROR":    logging.ERROR,
        "CRITICAL": logging.CRITICAL,
    }
    try:
        _sse_log.log(level_map.get(str(level).upper(), logging.INFO), message)
    except Exception:
        pass
# ── Ende v1.04 Log-Block ────────────────────────────────────────────────────



# ── Pfade ─────────────────────────────────────────────────────────────────────
V1_DIR          = Path(__file__).parent
SEPA_TPL_DIR    = V1_DIR / "sepa_templates"
DEFAULT_VORLAGE = SEPA_TPL_DIR / "Rechnungsvorlage_Standard.xlsx"
SMTP_CONFIG_FILE = V1_DIR / "smtp_config.json"


# ── SMTP-Konfiguration lesen / schreiben ──────────────────────────────────────
def _smtp_cfg_read() -> dict:
    """Liest SMTP-Config aus smtp_config.json; Fallback auf Umgebungsvariablen."""
    cfg = {}
    if SMTP_CONFIG_FILE.exists():
        try:
            cfg = json.loads(SMTP_CONFIG_FILE.read_text(encoding="utf-8"))
        except Exception:
            pass
    # Env-Vars als Fallback (werden von Datei überschrieben falls vorhanden)
    username = cfg.get("username") or os.environ.get("EEG_SMTP_USERNAME", "")
    return {
        "server":       cfg.get("server")       or os.environ.get("EEG_SMTP_SERVER", ""),
        "port":         cfg.get("port")         or os.environ.get("EEG_SMTP_PORT", ""),
        "mode":         cfg.get("mode")         or os.environ.get("EEG_SMTP_MODE", "ssl"),
        "username":     username,
        "password":     cfg.get("password")     or os.environ.get("EEG_SMTP_PASSWORD", ""),
        "from_address": cfg.get("from_address") or os.environ.get("EEG_SMTP_FROM", "") or username,
    }


def _smtp_cfg_write(data: dict) -> None:
    """Speichert SMTP-Config in smtp_config.json."""
    SMTP_CONFIG_FILE.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

# ── Standard Config.csv (Spaltenmappings – für alle EEGs gleich) ──────────────
STANDARD_CONFIG_CSV = """\
File Mitgliederliste;;
Bezeichnung vom Tabellenblatt für die Mitgliederverwaltung;TB_Mitglied;Mitglieder
Nummer vom Mitglied;Mitglied_ID;ID
Vorname vom Mitglied;Mitglied_Vorname;Vorname
Nachname vom Mitglied;Mitglied_Nachname;Nachname
Geschlecht vom Mitglied;Mitglied_Sex;Sex
Strasse vom Mitglied;Mitglied_Adresse;Adresse
Postleitzahl vom Mitglied;Mitglied_PLZ;PLZ
Wohnort vom Mitglied;Mitglied_Ort;Ort
E-Mail-Adresse vom Mitglied;Mitglied_Mail;Mail
IBAN vom Konto des Mitglieds;Mitglied_IBAN;IBAN
BIC vom Konto des Mitglieds;Mitglied_BIC;BIC
Firmenname vom Mitglied für die Rechnungslegung;Mitglied_Firmenname;Rechnungsname
Zählpunktnummer vom Mitglied für den Bezug;Mitglied_CONSUMPTION;CONSUMPTION
Zählpunktnummer vom Mitglied für die Einspeisung;Mitglied_GENERATION;GENERATION
Steuersatz vom Mitglied (0/20/13 – leer = 0);Mitglied_Steuersatz;Steuersatz
UID-Nummer vom Mitglied (optional, Format ATU + 8 Ziffern);Mitglied_UID;UID_Nummer
Eintrittsdatum des Mitglieds (TT.MM.JJJJ, optional – auch SEPA-Mandatsdatum);Mitglied_Eintrittsdatum;Mitglied seit
;;
EDA Report-FILE;;
Bezeichnung vom Tabellenblatt mit der Übersicht im EDA-Report;TB_EDA;Übersicht
Schlagwort im EDA-Report um die Kopfzeile für die Abrechnung zu finden;EDA_Header;Zählpunkt
Energierichtung Bezug;EDA_direction_C;CONSUMPTION
Energierichtung Einspeisung;EDA_direction_G;GENERATION
Überschrift Zählpunkt;EDA_ZP;Zählpunkt
Überschrift Energierichtung;EDA_direction;Energierichtung
Überschrift Zeitraum von;EDA_time_from;Zeitraum von
Überschrift Zeitraum bis;EDA_time_to;Zeitraum bis
Überschrift Eigendeckung;EDA_Eigendeckung;Eigendeckung gemeinschaftliche Erzeugung [KWH]
Überschrift Erzeugung;EDA_Erzeugung;Erzeugung lt. Messung entsprechend dem Teilnahmefaktor und EC-ID [KWH]
Überschrift Restüberschuss;EDA_Overflow;Restüberschuss bei EG und je ZP [KWH]
Überschrift Gesamterzeugung;EDA_Gesamterzeugung;Gesamte gemeinschaftliche Erzeugung [KWH]
Überschrift Gesamtverbrauch;EDA_Gesamtverbrauch;Gesamtverbrauch lt. Messung (bei Teilnahme gem. Erzeugung) [KWH]
;;
Buchhaltung;;
Bezeichnung vom Tabellenblatt für Rechnugnsnummern;TB_Rechnungsnummer;Rechnungsnummern
;;
Rechnungsvorlage;;
Bezeichnung vom Tabellenblatt für Vorlage;TB_Vorlage;Vorlage
"""

DEFAULT_EMAIL_VORLAGE = {
    "betreff": "Rechnung {Periode} – {Vereinsname}",
    "anrede_m": "Lieber",
    "anrede_w": "Liebe",
    "anrede_default": "Liebe/r",
    "body": (
        "{Anrede} {Name},\n\n"
        "im Anhang befindet sich Ihre Rechnung von {Vereinsname} für die Periode {Periode}.\n"
        "Der Betrag von {Betrag} € wird am {Datum} {Aktion}.\n\n"
        "Bei Fragen stehen wir gerne zur Verfügung.\n\n"
        "Freundliche Grüße,\n{Vereinsname}"
    ),
}


# ── Hilfsfunktionen: Sicherheit & Datei-Suche ────────────────────────────────

# SID ist eine UUID4 aus session_manager.create() – alles andere weisen wir
# sofort ab. Verhindert, dass ein Angreifer mit "../" oder beliebigen Zeichen
# auf fremde Temp-Verzeichnisse zielen kann.
_SID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")


def _valid_sid(sid) -> bool:
    return isinstance(sid, str) and bool(_SID_RE.match(sid))


# ── Licensing-Helper ─────────────────────────────────────────────────────────

LICENSE_COOKIE = "license_session"


def _client_ip() -> str:
    """Holt die Client-IP, auch hinter Reverse-Proxy (X-Forwarded-For)."""
    fwd = request.headers.get("X-Forwarded-For", "")
    if fwd:
        return fwd.split(",")[0].strip()
    return request.remote_addr or ""


# ── Phase 7: In-Memory Sliding-Window-Rate-Limit ─────────────────────────────
#
# Ergänzung zu `licensing.check_rate_limit` (die nur fehlgeschlagene Logins
# über die DB zählt). Hier haben wir einen leichtgewichtigen Burst-Schutz
# für erfolgreiche Aufrufe (z.B. verhindern, dass jemand /api/v1/download
# in einer Schleife mit einer gültigen Session spammt).

_RATE_BUCKETS: dict[str, list[float]] = {}
_RATE_BUCKETS_LOCK = threading.Lock()


def _rate_limit(key: str, max_calls: int, window_s: int) -> bool:
    """Gibt True zurück, wenn der Aufruf erlaubt ist, sonst False.
    Implementiert als Sliding-Window (Liste von Timestamps pro Key)."""
    if not key:
        return True
    now = time.time()
    cutoff = now - window_s
    with _RATE_BUCKETS_LOCK:
        times = _RATE_BUCKETS.get(key, [])
        # Alte Einträge verwerfen
        times = [t for t in times if t >= cutoff]
        if len(times) >= max_calls:
            _RATE_BUCKETS[key] = times
            return False
        times.append(now)
        _RATE_BUCKETS[key] = times
        # GC – wenn die Map riesig wird, räumen wir auf
        if len(_RATE_BUCKETS) > 10_000:
            dead = [k for k, v in _RATE_BUCKETS.items() if not v or v[-1] < cutoff]
            for k in dead:
                _RATE_BUCKETS.pop(k, None)
    return True


def _rate_limit_response(retry_after: int = 60):
    resp = jsonify({"error": "Zu viele Anfragen. Bitte kurz warten.",
                    "reason": "rate_limited"})
    resp.status_code = 429
    resp.headers["Retry-After"] = str(retry_after)
    return resp


def _license_session_id() -> str | None:
    return request.cookies.get(LICENSE_COOKIE)


def require_license(fn):
    """Endpoint-Schutz: Abbruch mit 401 wenn keine gültige Lizenz-Session.
    Hängt die Kontodaten als `request.license` an (dict aus licensing)."""
    @wraps(fn)
    def wrapper(*args, **kwargs):
        sid = _license_session_id()
        info = licensing.get_session(sid) if sid else None
        if info is None:
            resp = jsonify({"error": "Kein Zugangscode aktiv.",
                            "need_login": True})
            resp.status_code = 401
            # Falls ein altes/ungültiges Cookie existierte, aufräumen
            resp.delete_cookie(LICENSE_COOKIE, path="/")
            return resp
        request.license = info
        return fn(*args, **kwargs)
    return wrapper


def _set_license_cookie(resp, session_id: str):
    """Setzt den License-Cookie mit sicheren Defaults.
    `secure` wird automatisch auf True gesetzt, wenn über HTTPS angefragt wurde."""
    resp.set_cookie(
        LICENSE_COOKIE,
        session_id,
        httponly=True,
        samesite="Lax",
        secure=bool(request.is_secure),
        max_age=licensing.SESSION_TTL_SECONDS,
        path="/",
    )
    return resp


# ── Admin-Web-UI (Phase 6) ───────────────────────────────────────────────────
#
# Separate Auth gegenüber Kunden: DB-Tabelle `admins` (siehe licensing.py,
# Anlage via admin_cli.py). Admin-Sessions halten wir in einem In-Memory-Dict
# (Neustart = alle Admins müssen neu einloggen). Cookie ist HttpOnly +
# SameSite=Strict, damit CSRF keine Chance hat. IP-Rate-Limit erbt von
# licensing.check_rate_limit.

ADMIN_COOKIE       = "admin_session"
ADMIN_SESSION_TTL  = 8 * 3600          # 8 Stunden
_ADMIN_SESSIONS    = {}                 # token -> {username, expires, ip}
_ADMIN_SESSIONS_LOCK = threading.Lock()


def _admin_gc() -> None:
    """Entfernt abgelaufene Admin-Sessions (bei jedem Access aufgerufen)."""
    now = datetime.now()
    with _ADMIN_SESSIONS_LOCK:
        dead = [t for t, s in _ADMIN_SESSIONS.items() if s["expires"] < now]
        for t in dead:
            _ADMIN_SESSIONS.pop(t, None)


def _admin_create_session(username: str, ip: str) -> str:
    """Legt eine neue Admin-Session an und gibt das Token zurück."""
    token = uuid.uuid4().hex + uuid.uuid4().hex    # 64 hex chars
    with _ADMIN_SESSIONS_LOCK:
        _ADMIN_SESSIONS[token] = {
            "username": username,
            "expires":  datetime.now() + timedelta(seconds=ADMIN_SESSION_TTL),
            "ip":       ip,
        }
    return token


def _admin_session_for(token: str | None):
    """Gibt die Admin-Session zurück (verlängert TTL) oder None."""
    if not token:
        return None
    _admin_gc()
    with _ADMIN_SESSIONS_LOCK:
        s = _ADMIN_SESSIONS.get(token)
        if s and s["expires"] >= datetime.now():
            s["expires"] = datetime.now() + timedelta(seconds=ADMIN_SESSION_TTL)
            return s
        _ADMIN_SESSIONS.pop(token, None)
    return None


def _admin_destroy_session(token: str | None) -> None:
    if not token:
        return
    with _ADMIN_SESSIONS_LOCK:
        _ADMIN_SESSIONS.pop(token, None)


def _set_admin_cookie(resp, token: str):
    resp.set_cookie(
        ADMIN_COOKIE,
        token,
        httponly=True,
        samesite="Strict",
        secure=bool(request.is_secure),
        max_age=ADMIN_SESSION_TTL,
        path="/admin",
    )
    return resp


def require_admin(fn):
    """Endpoint-Schutz für das Admin-Panel. 401 + need_login wenn keine Session."""
    @wraps(fn)
    def wrapper(*args, **kwargs):
        tok = request.cookies.get(ADMIN_COOKIE)
        s   = _admin_session_for(tok)
        if s is None:
            resp = jsonify({"error": "Admin-Login erforderlich.",
                            "need_login": True})
            resp.status_code = 401
            resp.delete_cookie(ADMIN_COOKIE, path="/admin")
            return resp
        request.admin_user = s["username"]
        return fn(*args, **kwargs)
    return wrapper


def _safe_filename(name: str, default: str = "upload.bin") -> str:
    """secure_filename entfernt Pfad-Trenner und gefährliche Zeichen.
    Fällt auf `default` zurück, wenn danach nichts übrig bleibt (z.B. '../')."""
    s = secure_filename(name or "")
    return s if s else default


def _safe_periode(periode: str) -> str:
    """Periode darf als Ordnername und in Dateinamen erscheinen – hier auf
    ASCII-Wortzeichen + '-' + '_' reduzieren. Leere Werte werden gefiltert,
    damit kein '..' oder Separator-Missbrauch möglich ist."""
    s = re.sub(r"[^A-Za-z0-9._-]+", "_", str(periode or "").strip())
    s = s.strip("._-")
    return s or "Abrechnung"


_EXCEL_EXTS = (".xlsx", ".xlsm")


def _find_mitglieder_file(uploads: Path):
    """Findet die Mitglieder-Datei im uploads/-Ordner (xlsx/xlsm mit 'mitglied' im Namen).
    Fallback: erste xlsx/xlsm ohne 'rc' am Anfang (RC = EDA-Report)."""
    if not uploads.is_dir():
        return None
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() in _EXCEL_EXTS and "mitglied" in f.name.lower():
            return f
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() in _EXCEL_EXTS and not f.name.lower().startswith("rc"):
            return f
    return None


def _find_vorlage_file(uploads: Path):
    """Findet eine hochgeladene Rechnungsvorlage (xlsx/xls mit 'vorlage' im Namen)."""
    if not uploads.is_dir():
        return None
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() in (".xlsx", ".xls") and "vorlage" in f.name.lower():
            return f
    return None


def _find_param_file(uploads: Path):
    """Findet eine hochgeladene Parameter.json."""
    if not uploads.is_dir():
        return None
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() == ".json" and "parameter" in f.name.lower():
            return f
    return None


def _find_config_file(uploads: Path):
    """Findet eine hochgeladene Config.csv."""
    if not uploads.is_dir():
        return None
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() == ".csv" and "config" in f.name.lower():
            return f
    return None


def _find_eda_files(uploads: Path):
    """Liefert sortierte Liste aller EDA-Report-Dateien (RC*.xlsx/.xls)."""
    if not uploads.is_dir():
        return []
    return sorted(
        f for f in uploads.iterdir()
        if f.suffix.lower() in (".xlsx", ".xls") and f.name.lower().startswith("rc")
    )


def _find_letzter_lauf_file(uploads: Path):
    """Findet eine hochgeladene Abrechnungsinfo.json (Dateiname enthält 'abrechnung')."""
    if not uploads.is_dir():
        return None
    for f in sorted(uploads.iterdir()):
        if f.suffix.lower() == ".json" and "abrechnung" in f.name.lower():
            return f
    return None


# Erkennt Datums-Range im EDA-Dateinamen:
# RC100418_2025-04-01T00_00-2025-04-30T23_45.xlsx
_EDA_DATE_RE = re.compile(
    r'_(\d{4}-\d{2}-\d{2})T\d{2}[_:]\d{2}-(\d{4}-\d{2}-\d{2})T\d{2}[_:]\d{2}',
    re.IGNORECASE,
)

def _parse_eda_period(eda_files: list) -> tuple:
    """Parst Abrechnungszeitraum aus EDA-Dateinamen.
    Gibt (periode_von, periode_bis) als 'YYYY-MM-DD'-Strings zurück, oder (None, None)."""
    starts, ends = [], []
    for f in eda_files:
        m = _EDA_DATE_RE.search(Path(f).name)
        if m:
            starts.append(m.group(1))
            ends.append(m.group(2))
    if not starts:
        return None, None
    return min(starts), max(ends)


def _setup_temp_structure(td: str, cfg: dict) -> None:
    """Legt Config/ + uploads/ im Temp-Verzeichnis an und schreibt alle Konfig-Dateien."""
    td = Path(td)
    config_dir = td / "Config"
    config_dir.mkdir(exist_ok=True)
    (td / "uploads").mkdir(exist_ok=True)
    # Rechnungen-Ordner vorab anlegen (main.py erwartet ihn beim Kassabuch-Erstellen)
    (td / "Rechnungen").mkdir(exist_ok=True)
    (td / "Rechnungen" / "Report").mkdir(exist_ok=True)

    # Config.csv (Standard-Spaltenmappings)
    custom_config = cfg.get("_config_csv_override")
    (config_dir / "Config.csv").write_text(
        custom_config if custom_config else STANDARD_CONFIG_CSV,
        encoding="utf-8-sig"
    )

    # Parameter.csv aus Formular-Daten generieren
    mitglieder_file = cfg.get("mitglieder_filename", "Mitglieder.xlsm")
    kassabuch_name  = "Kassabuch.xlsx"
    # Legacy XLSX-Vorlage: Default-Wert, wird ab D6 nicht mehr genutzt
    # (DOCX-Template in v1_upload/sepa_templates/ ist der aktive Weg).
    vorlage_name    = cfg.get("vorlage_filename", "")
    logo_filename   = cfg.get("logo_filename", "")
    accent_color    = cfg.get("accent_color", "")

    def _num(val, default: str) -> str:
        """Normalisiert eine Zahl: Komma als Dezimaltrenner.
        main.py erwartet Strings wie '0,12'."""
        try:
            return f"{float(str(val).replace(',', '.')):.4f}".rstrip('0').rstrip('.').replace('.', ',')
        except (ValueError, TypeError):
            return default

    param_data = {
        "Tarif_Einspeisung":   _num(cfg.get("tarif_einspeisung", "0,10"), "0,10"),
        "Tarif_Bezug":         _num(cfg.get("tarif_bezug", "0,12"), "0,12"),
        "Fälligkeit":          cfg.get("faelligkeit", "14"),
        "File_Mitglieder":     mitglieder_file,
        "Vereinsname":         cfg.get("vereinsname", ""),
        "Vereinsstrasse":      cfg.get("vereinsstrasse", ""),
        "Vereinsort":          cfg.get("vereinsort", ""),
        "ZVR":                 cfg.get("zvr", ""),
        "SEPA_IBAN":           cfg.get("sepa_iban", ""),
        "SEPA_BIC":            cfg.get("sepa_bic", ""),
        "SEPA_Creditor_ID":    cfg.get("sepa_creditor_id", ""),
        "Bank":                cfg.get("bank", ""),
        "Kleinunternehmer":    cfg.get("kleinunternehmer", "Ja"),
        "UID_Nummer":          cfg.get("uid_nummer", ""),
        "RC_Kostentraeger":    cfg.get("rc_kostentraeger", "Mitglied"),
        "Abbuchung_Modus":     cfg.get("abbuchung_modus", "ueberweisung"),
        "AV_Email":            cfg.get("av_email", ""),
        "File_Rechnungsnummer": kassabuch_name,
        "Logo_Filename":       logo_filename,
        "Accent_Color":        accent_color,
        "MAIL_Betreff":        cfg.get("mail_betreff", "EEG Abrechnung {Periode}"),
        "MAIL_Body":           cfg.get("mail_body", ""),
        "SMTP_Server":         cfg.get("smtp_server", ""),
        "SMTP_Mode":           cfg.get("smtp_mode", ""),
        "SMTP_User":           cfg.get("smtp_user", ""),
        "SMTP_Pass":           cfg.get("smtp_pass", ""),
        "Mail_ReplyTo":        cfg.get("mail_reply_to", ""),
    }
    (config_dir / "Parameter.json").write_text(
        json.dumps(param_data, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

    # Rechnungsvorlage (XLSX) — nur noch Legacy: kopieren wenn explizit hochgeladen,
    # sonst KEIN Default mehr. Das DOCX-Template wird direkt aus
    # v1_upload/sepa_templates/ von main.py gelesen (siehe render_invoice_docx).
    if vorlage_name:
        vorlage_src = Path(td / "uploads" / vorlage_name)
        if vorlage_src.exists():
            shutil.copy(vorlage_src, config_dir / vorlage_name)

    # Logo-Datei (falls hochgeladen) nach Config/ kopieren
    if logo_filename:
        logo_src = Path(td / "uploads" / logo_filename)
        if logo_src.exists():
            shutil.copy(logo_src, config_dir / logo_filename)

    # SEPA-XML-Templates aus dem gebündelten Verzeichnis kopieren
    for xml_file in SEPA_TPL_DIR.glob("SEPA_*.xml"):
        shutil.copy(xml_file, config_dir / xml_file.name)

    # E-Mail-Vorlage – Betreff und Body aus Konfiguration übernehmen falls vorhanden
    email_vorlage = dict(DEFAULT_EMAIL_VORLAGE)
    if cfg.get("mail_betreff"):
        email_vorlage["betreff"] = cfg["mail_betreff"]
    if cfg.get("mail_body"):
        # \n in CSV ist als literales \n gespeichert → in echte Newlines wandeln
        email_vorlage["body"] = cfg["mail_body"].replace(r"\n", "\n")
    (config_dir / "email_vorlage.json").write_text(
        json.dumps(email_vorlage, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )


def _parse_parameter_json(content: str) -> dict:
    """Liest Parameter.json-Inhalt und gibt ein Dict mit den Formularwerten zurück."""
    result = {}
    mapping = {
        "Tarif_Einspeisung":  "tarif_einspeisung",
        "Tarif_Bezug":        "tarif_bezug",
        "Fälligkeit":         "faelligkeit",
        "Vereinsname":        "vereinsname",
        "Vereinsstrasse":     "vereinsstrasse",
        "Vereinsort":         "vereinsort",
        "SEPA_IBAN":          "sepa_iban",
        "SEPA_BIC":           "sepa_bic",
        "SEPA_Creditor_ID":   "sepa_creditor_id",
        "Bank":               "bank",
        "Kleinunternehmer":   "kleinunternehmer",
        "UID_Nummer":         "uid_nummer",
        "RC_Kostentraeger":   "rc_kostentraeger",
        "Logo_Filename":      "logo_filename",
        "Accent_Color":       "accent_color",
        "Abbuchung_Modus":    "abbuchung_modus",
        "AV_Email":           "av_email",
        "MAIL_Betreff":       "mail_betreff",
        "MAIL_Body":          "mail_body",
        "SMTP_Server":        "smtp_server",
        "SMTP_Mode":          "smtp_mode",
        "SMTP_User":          "smtp_user",
        "SMTP_Pass":          "smtp_pass",
        "Mail_ReplyTo":       "mail_reply_to",
    }
    try:
        data = json.loads(content)
        for k, v in data.items():
            if k in mapping:
                result[mapping[k]] = "" if v is None else str(v)
    except Exception:
        pass
    return result


# Phase 4b: Alle bekannten Module (muss synchron zu licensing_schema.sql
# `packages` bleiben).  Wird verwendet, wenn der modules-Parameter fehlt,
# damit alte Clients weiterhin den kompletten Lieferumfang bekommen.
ALL_MODULES = ("pdfs", "report", "kassabuch", "sepa", "rechnungsversand", "abrechnungsverein")


def _build_zip(td: str, periode: str, modules=None) -> bytes:
    """Packt nur die Output-Dateien der ausgewählten Module in ein ZIP.

    Args:
        td:       Session-Tempordner.
        periode:  Abrechnungsperiode (Ordnername unterhalb von Rechnungen/).
        modules:  Iterable mit Modul-Keys ("pdfs", "report", "kassabuch",
                  "sepa"). ``None`` bedeutet "alle Module" (Rückwärts-
                  kompatibilität zu Clients ohne Modul-Auswahl).
    """
    mods = set(ALL_MODULES) if modules is None else {m for m in modules if m}
    td = Path(td)
    buf = io.BytesIO()
    rechnungen_base = td / "Rechnungen"

    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        # PDFs aus Rechnungen/<periode>/ – Verzeichnisstruktur beibehalten,
        # damit die relativen Hyperlinks im Kassabuch nach der Extraktion stimmen.
        # Kassabuch liegt in Rechnungen/, Hyperlinks zeigen auf <periode>/Datei.pdf
        if "pdfs" in mods:
            periode_dir = rechnungen_base / periode
            if periode_dir.exists():
                for f in sorted(periode_dir.rglob("*")):
                    if f.is_file():
                        zf.write(f, f"Rechnungen/{periode}/{f.relative_to(periode_dir)}")

        # Kassabuch.xlsx in Rechnungen/ – gleicher Pfad wie auf dem Server,
        # damit die relativen Hyperlinks zu den PDFs nach der Extraktion stimmen.
        if "kassabuch" in mods:
            kassabuch = rechnungen_base / "Kassabuch.xlsx"
            if kassabuch.exists():
                zf.write(kassabuch, "Rechnungen/Kassabuch.xlsx")

        # SEPA XML-Dateien (auch wenn nur Abrechnungsverein-Modul aktiv)
        if "sepa" in mods or "abrechnungsverein" in mods:
            sepa_dir = rechnungen_base / "SEPA"
            for f in sorted(sepa_dir.glob("*.xml")) if sepa_dir.exists() else []:
                zf.write(f, f"SEPA/{f.name}")

        # Gemeinschaftsbericht (liegt in Rechnungen/Report/)
        if "report" in mods:
            report_dir = rechnungen_base / "Report"
            if report_dir.exists():
                for f in sorted(report_dir.glob("*.pdf")):
                    zf.write(f, f"Gemeinschaftsbericht/{f.name}")

        # Abrechnungsinfo.json – immer im ZIP (oberste Ebene)
        ainfo = td / "Abrechnungsinfo.json"
        if ainfo.exists():
            zf.write(ainfo, "Abrechnungsinfo.json")

    return buf.getvalue()


# ── Log-Handler für SSE ───────────────────────────────────────────────────────

class _QueueHandler(logging.Handler):
    """Schreibt Log-Records in eine Queue. Bei voller Queue wird die Nachricht
    verworfen – so kann der main.py-Logger nie blockieren, auch wenn der SSE-
    Client nicht mitliest."""

    def __init__(self, q: queue.Queue):
        super().__init__()
        self.q = q

    def emit(self, record):
        try:
            self.q.put({
                "level":   record.levelname,
                "time":    datetime.now().strftime("%H:%M:%S"),
                "message": self.format(record),
            }, timeout=0.5)
        except queue.Full:
            pass
        except Exception:
            # Logging darf niemals die Anwendung abschießen
            self.handleError(record)


# ── v1.01: NaN-Safety fuer SSE-JSON-Stream ──────────────────────────────────
import math as _math

def _sanitize_json_value(v):
    """Ersetzt NaN/Infinity rekursiv durch 0.0 bzw. None und wandelt Sentinels
    in JSON-sichere Werte um. Wird vor json.dumps() aufgerufen, damit der
    Browser nie ein literales `NaN` im Stream sieht (ungueltiges JSON)."""
    if isinstance(v, float):
        if _math.isnan(v) or _math.isinf(v):
            return 0.0
        return v
    if isinstance(v, bool):   # bool ist Subclass von int – vor int pruefen
        return v
    if isinstance(v, int):
        return v
    if v is None:
        return None
    if isinstance(v, str):
        return v
    if isinstance(v, dict):
        return {str(k): _sanitize_json_value(val) for k, val in v.items()}
    if isinstance(v, (list, tuple, set)):
        return [_sanitize_json_value(x) for x in v]
    # pandas NaT, numpy-Typen, Decimal, datetime, etc. → String-Fallback
    try:
        return str(v)
    except Exception:
        return ""


def _safe_sse_json(payload) -> str:
    """json.dumps mit allow_nan=False + vorab-Sanitisierung.
    Wirft nie – im Fehlerfall kommt ein minimaler Fehler-Frame raus, der sich
    vom Browser weiterhin parsen laesst."""
    try:
        return json.dumps(_sanitize_json_value(payload),
                          ensure_ascii=False, allow_nan=False)
    except (TypeError, ValueError) as e:
        return json.dumps({"ok": False,
                           "error": f"Serialisierungs-Fehler: {type(e).__name__}: {e}"},
                          ensure_ascii=False, allow_nan=False)




# ── Routes ────────────────────────────────────────────────────────────────────

# v1.04: "/" rendert direkt den Wizard, keine Redirects.
# Der alte Pfad bleibt als Alias fuer Bookmarks aelterer Nutzer.
@app.route("/")
@app.route("/EEG-Assistent/abrechnung")
def index():
    return render_template("index.html")


# ── Lizenz-Endpunkte (Login/Logout/Status/Pakete) ────────────────────────────

# HTTP-Status pro LicenseError-Reason (UI-stabil, maschinenlesbar)
_LICENSE_HTTP = {
    "invalid":      400,
    "rate_limited": 429,
    "locked":       409,
    "disabled":     403,
    "expired":      403,
    "no_tokens":    402,
}


@app.route("/api/v1/login", methods=["POST"])
def api_login():
    """Login mit UID + Browser-Fingerprint.
    Body: {"uid": "EEGA-XXXX-XXXX-XXXX", "fingerprint": "<hex>"}
    Erfolg: Cookie wird gesetzt + Kontostand zurückgegeben.
    """
    body        = request.json or {}
    uid         = (body.get("uid") or "").strip()
    fingerprint = (body.get("fingerprint") or "").strip()
    ip          = _client_ip()
    ua          = request.headers.get("User-Agent", "")

    # Phase 7: Burst-Schutz unabhängig von DB-Fehlversuchs-Zählung.
    # 20 Login-Versuche pro IP pro Minute.
    if not _rate_limit(f"login_ip:{ip}", max_calls=20, window_s=60):
        log.info(f"Login-Burst-Limit aktiv für IP {ip}")
        return _rate_limit_response(60)

    try:
        info = licensing.login(uid, fingerprint, ip, ua)
    except licensing.LicenseError as e:
        status = _LICENSE_HTTP.get(e.reason, 400)
        log.info(f"Login abgelehnt ({e.reason}) von {ip}")
        resp_data = {"error": str(e), "reason": e.reason}
        # Bei Lock: Ablaufzeit der aktiven Session mitliefern → Frontend-Countdown
        if e.reason == "locked":
            try:
                resp_data["lock_expires_at"] = licensing.get_lock_expires(
                    licensing._normalize_uid(uid))
            except Exception:
                pass
        return jsonify(resp_data), status
    except Exception as e:
        log.exception("Login-Fehler")
        return jsonify({"error": "Interner Fehler beim Login.",
                        "reason": "server_error"}), 500

    resp = jsonify({"ok": True, "account": info})
    _set_license_cookie(resp, info["session_id"])
    return resp


@app.route("/api/v1/logout", methods=["POST"])
def api_logout():
    """Session-Lock freigeben und Cookie löschen."""
    sid = _license_session_id()
    if sid:
        try:
            licensing.logout(sid)
        except Exception:
            log.exception("Logout-Fehler")
    resp = jsonify({"ok": True})
    resp.delete_cookie(LICENSE_COOKIE, path="/")
    return resp


@app.route("/api/v1/session", methods=["GET"])
def api_session_status():
    """Liefert den aktuellen Lizenz-Status (für Seiten-Reload / Header-Refresh).
    Antwort: {"logged_in": bool, "account": {…}} — kein Fehler wenn nicht eingeloggt.
    """
    sid  = _license_session_id()
    info = licensing.get_session(sid) if sid else None
    if info is None:
        resp = jsonify({"logged_in": False})
        if sid:
            resp.delete_cookie(LICENSE_COOKIE, path="/")
        return resp
    # TTL wurde durch get_session() verlängert → Cookie-Ablauf auch frischen
    resp = jsonify({"logged_in": True, "account": info})
    _set_license_cookie(resp, info["session_id"])
    return resp


@app.route("/api/v1/packages", methods=["GET"])
def api_packages():
    """Öffentlicher Paket-Katalog (Admin bearbeitbar). Für Schritt 3 im UI."""
    try:
        pkgs = licensing.list_packages(only_active=True)
    except Exception:
        log.exception("Paket-Katalog konnte nicht geladen werden")
        return jsonify({"error": "Paket-Katalog nicht verfügbar."}), 500
    return jsonify({"packages": pkgs})


# ── Schritt 0: Session anlegen ────────────────────────────────────────────────

@app.route("/api/v1/session/new", methods=["POST"])
@require_license
def api_new_session():
    sid = sm.create()
    return jsonify({"sid": sid})


@app.route("/api/v1/upload", methods=["POST"])
@require_license
def api_upload():
    sid = request.form.get("sid")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400

    td        = Path(sm.temp_dir(sid))
    uploads   = td / "uploads"
    uploads.mkdir(parents=True, exist_ok=True)
    saved     = []
    rejected  = []

    for field, fobj in request.files.items():
        if not fobj or not fobj.filename:
            continue
        fname = _safe_filename(fobj.filename)
        if not fname or fname in {".", ".."}:
            rejected.append(fobj.filename)
            continue
        dest = uploads / fname
        # Absicherung: dest muss innerhalb von uploads/ liegen
        try:
            dest.resolve().relative_to(uploads.resolve())
        except ValueError:
            rejected.append(fobj.filename)
            continue
        try:
            fobj.save(str(dest))
        except OSError as e:
            log.warning(f"[{sid[:8]}] Upload fehlgeschlagen ({fname}): {e}")
            rejected.append(fobj.filename)
            continue
        saved.append(fname)
        log.info(f"[{sid[:8]}] Upload: {fname}")

    sm.update(sid, status="uploading")
    resp = {"saved": saved}
    if rejected:
        resp["rejected"] = rejected
    return jsonify(resp)


@app.route("/api/v1/remove-file", methods=["POST"])
@require_license
def api_remove_file():
    """Löscht eine einzelne Datei aus dem Upload-Ordner der Session."""
    body     = request.json or {}
    sid      = body.get("sid")
    filename = body.get("filename", "")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400
    uploads = Path(sm.temp_dir(sid)) / "uploads"
    safe    = _safe_filename(filename)
    if safe:
        target = uploads / safe
        try:
            target.resolve().relative_to(uploads.resolve())
            if target.exists():
                target.unlink()
                log.info(f"[{sid[:8]}] Datei entfernt: {safe}")
        except (ValueError, OSError) as e:
            log.warning(f"[{sid[:8]}] Datei konnte nicht entfernt werden ({safe}): {e}")
    return jsonify({"ok": True})


# ── Schritt 1a: Hochgeladene Mitgliederdatei sofort validieren ──────────────
#
# Nutzt die Standard-Spaltennamen aus STANDARD_CONFIG_CSV (ID, Vorname, …) und
# ruft dieselben Regeln wie main.py auf (Funktion main.validate_member_row).
# Läuft OHNE Abrechnungssystem-Initialisierung – d.h. ohne Config.csv und
# Parameter.csv. Fehler können also sofort nach dem Upload gemeldet werden,
# noch bevor der Nutzer das Konfig-Formular ausfüllt.

_STANDARD_MITGLIEDER_SHEET = "Mitglieder"
_STANDARD_COLS = {
    "ID":           "ID",
    "Vorname":      "Vorname",
    "Nachname":     "Nachname",
    "Sex":          "Sex",
    "Adresse":      "Adresse",
    "PLZ":          "PLZ",
    "Ort":          "Ort",
    "Mail":         "Mail",
    "IBAN":         "IBAN",
    "BIC":          "BIC",
    "CONSUMPTION":  "CONSUMPTION",
    "GENERATION":   "GENERATION",
}


def _is_empty(v):
    """True wenn Zellwert als leer gilt (None, leer, NaN)."""
    if v is None:
        return True
    s = str(v).strip()
    return s == "" or s.lower() == "nan"


@app.route("/api/v1/validate-uploaded-members", methods=["POST"])
@require_license
def api_validate_uploaded_members():
    """Prüft die hochgeladene Mitgliederdatei direkt nach dem Upload.
    Setzt die Standard-Spaltennamen voraus (wie in Mitglieder_Vorlage.xlsx).

    Response:
      { "ok": true,  "count": 42, "errors": [] }
      { "ok": false, "count": 42, "errors": [{zeile, id, name, fehler}, …] }
      { "ok": false, "error": "…" }  – bei strukturellen Problemen
    """
    body = request.json or {}
    sid  = body.get("sid")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400

    uploads = Path(sm.temp_dir(sid)) / "uploads"
    if not uploads.is_dir():
        return jsonify({"ok": False, "error": "Kein uploads/-Ordner in der Session."})

    # Wenn ein konkreter Dateiname mitgeschickt wird, diesen bevorzugen
    # (verhindert Race-Condition wenn alte Dateien noch nicht gelöscht wurden)
    requested_filename = _safe_filename(body.get("filename", ""))
    if requested_filename and (uploads / requested_filename).exists():
        mitglieder_file = uploads / requested_filename
    else:
        mitglieder_file = _find_mitglieder_file(uploads)
    if mitglieder_file is None:
        return jsonify({"ok": False, "error": "Keine Mitgliederdatei im Upload gefunden."})

    # Prüfen ob die hochgeladene Datei identisch mit der leeren Vorlage ist
    import hashlib
    _vorlage_path = SEPA_TPL_DIR / "Mitglieder_Vorlage.xlsx"
    if _vorlage_path.exists():
        _vorlage_hash = hashlib.md5(_vorlage_path.read_bytes()).hexdigest()
        _upload_hash  = hashlib.md5(mitglieder_file.read_bytes()).hexdigest()
        if _vorlage_hash == _upload_hash:
            return jsonify({
                "ok":    False,
                "error": "Es wurde die leere Vorlage hochgeladen. "
                         "Bitte die echte Mitgliederliste hochladen – nicht die Vorlage.",
            })

    try:
        from openpyxl import load_workbook
        wb = load_workbook(str(mitglieder_file), data_only=True, read_only=True)
    except Exception as e:
        return jsonify({"ok": False, "error": f"Datei konnte nicht geöffnet werden: {e}"})

    if _STANDARD_MITGLIEDER_SHEET not in wb.sheetnames:
        wb.close()
        return jsonify({
            "ok": False,
            "error": f"Tabellenblatt '{_STANDARD_MITGLIEDER_SHEET}' nicht gefunden "
                     f"(vorhanden: {', '.join(wb.sheetnames)}).",
        })

    ws = wb[_STANDARD_MITGLIEDER_SHEET]
    rows_iter = ws.iter_rows(values_only=True)
    try:
        headers = next(rows_iter)
    except StopIteration:
        wb.close()
        return jsonify({"ok": False, "error": "Datei enthält keine Daten."})

    col_idx = {}
    for i, h in enumerate(headers):
        if h is None:
            continue
        col_idx[str(h).strip()] = i

    missing_cols = [label for label in _STANDARD_COLS.values() if label not in col_idx]
    if missing_cols:
        wb.close()
        return jsonify({
            "ok": False,
            "error": f"Fehlende Spalte(n) in '{_STANDARD_MITGLIEDER_SHEET}': "
                     f"{', '.join(missing_cols)}. Bitte Überschriften aus der Mitglieder_Vorlage.xlsx verwenden.",
        })

    # Optionale Spalten prüfen – kein Fehler, nur Hinweis
    _OPTIONAL_COLS = {
        "Steuersatz":    "Unternehmen mit Reverse Charge (Werte: 0 = Privat, 20 = Unternehmer, 13 = Landwirt)",
        "Rechnungsname": "Firmenname für die Rechnung (statt Vor-/Nachname)",
    }
    hints = [f'Spalte "{col}" fehlt - {desc}' for col, desc in _OPTIONAL_COLS.items() if col not in col_idx]

    from main import validate_member_row

    errors = []
    checked = 0
    for row_num, row in enumerate(rows_iter, start=2):
        if row is None:
            continue
        def cell(label):
            idx = col_idx[label]
            return row[idx] if idx < len(row) else None

        consumption = cell("CONSUMPTION")
        generation  = cell("GENERATION")
        if _is_empty(consumption) and _is_empty(generation):
            continue

        checked += 1

        plz_raw = cell("PLZ")
        if plz_raw is None or (isinstance(plz_raw, float) and plz_raw != plz_raw):
            plz_str = ""
        else:
            try:
                plz_str = str(int(plz_raw))
            except (ValueError, TypeError):
                plz_str = str(plz_raw).strip()

        # Steuersatz-Spalte ist optional (fehlt in älteren Vorlagen)
        steuer_raw = cell("Steuersatz") if "Steuersatz" in col_idx else None

        err = validate_member_row(
            member_id  = cell("ID"),
            vorname    = cell("Vorname"),
            nachname   = cell("Nachname"),
            mail       = cell("Mail"),
            iban       = cell("IBAN"),
            bic        = cell("BIC"),
            plz        = plz_str,
            ort        = cell("Ort"),
            strasse    = cell("Adresse"),
            sex        = cell("Sex"),
            steuersatz = steuer_raw,
        )
        if err:
            vorname  = cell("Vorname") or ""
            nachname = cell("Nachname") or ""
            name = f"{vorname} {nachname}".strip() or "(ohne Namen)"
            errors.append({
                "zeile":  row_num,
                "id":     str(cell("ID") or "").strip(),
                "name":   name,
                "fehler": err,
            })

    wb.close()

    if checked == 0:
        return jsonify({
            "ok":    False,
            "error": "Keine Mitglieder mit Verbrauchsdaten (CONSUMPTION oder GENERATION) gefunden. "
                     "Bitte die echte Mitgliederliste hochladen – nicht die leere Vorlage.",
        })

    return jsonify({
        "ok":      len(errors) == 0,
        "count":   checked,
        "datei":   mitglieder_file.name,
        "errors":  errors,
        "hints":   hints,
    })



# ── Schritt 1c: EDA-Dateien vorab prüfen ─────────────────────────────────────
#
# EDA-Reports haben ein Pivot-Format (Blatt 2 = Energiedaten):
#   Zeile idx 1: MeteringPointId | ZP1 | MM | ZP1 | MM | ZP2 | ...
#   Zeile idx 3: Energydirection | CONSUMPTION | ... | GENERATION | ...
#   Zeile idx 13: MeterCode | <Code1> | ... | <Code2> | ...
#   Zeile idx 15: Spaltensumme | <Wert> | ...
# Mindestens 16 Zeilen erforderlich.

@app.route("/api/v1/validate-eda", methods=["POST"])
@require_license
def api_validate_eda():
    """Prüft EDA-Datei(en) auf korrektes Format und gleicht Zählpunkte
    mit der Mitgliederliste ab (falls vorhanden)."""
    body = request.json or {}
    sid  = body.get("sid")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400

    uploads = Path(sm.temp_dir(sid)) / "uploads"

    MEMBER_HINTS = {"mitglied"}
    eda_files = [
        f for f in uploads.glob("*.xlsx")
        if not any(h in f.name.lower() for h in MEMBER_HINTS)
        and f.suffix.lower() != ".json"
    ]
    if not eda_files:
        return jsonify({"ok": False, "errors": ["Keine EDA-Datei gefunden"], "warnings": []})

    # Pflicht-MeterCodes die in Zeile idx 13 vorhanden sein müssen
    REQUIRED_METER_CODES = [
        "Eigendeckung gemeinschaftliche Erzeugung [KWH]",
        "Erzeugung lt. Messung entsprechend dem Teilnahmefaktor und EC-ID [KWH]",
        "Restüberschuss bei EG und je ZP [KWH]",
        "Gesamte gemeinschaftliche Erzeugung [KWH]",
        "Gesamtverbrauch lt. Messung (bei Teilnahme gem. Erzeugung) [KWH]",
    ]

    errors   = []
    warnings = []
    eda_zps  = set()

    for eda_path in eda_files:
        fname = eda_path.name
        try:
            import openpyxl
            wb = openpyxl.load_workbook(str(eda_path), read_only=True, data_only=True)
        except Exception as e:
            errors.append(f"{fname}: Datei konnte nicht geöffnet werden ({e})")
            continue

        if len(wb.sheetnames) < 2:
            errors.append(f"{fname}: Weniger als 2 Tabellenblätter — Blatt 2 (Energiedaten) fehlt")
            wb.close()
            continue

        rows = list(wb.worksheets[1].iter_rows(values_only=True))
        wb.close()

        if len(rows) < 16:
            errors.append(f"{fname}: Blatt 2 hat nur {len(rows)} Zeilen — EDA-Format erwartet mindestens 16")
            continue

        # Pflicht-Zeilenschlüssel prüfen (erste Spalte jeder Schlüsselzeile)
        key_checks = [
            (1,  "MeteringPointId"),
            (3,  "Energydirection"),
            (13, "MeterCode"),
            (15, "Spaltensumme"),
        ]
        row_ok = True
        for idx, expected_prefix in key_checks:
            cell = str(rows[idx][0]).strip() if rows[idx] and rows[idx][0] else ""
            if not cell.startswith(expected_prefix):
                errors.append(
                    f"{fname}: Zeile {idx+1} soll mit '{expected_prefix}' beginnen, gefunden: '{cell[:40]}'")
                row_ok = False
        if not row_ok:
            continue

        # ZP-IDs aus Zeile idx 1 extrahieren (alles außer Schlüsselwort, MM, TOTAL)
        row_zp = rows[1]
        SKIP = {"MeteringPointId", "MM", "TOTAL", "", "None"}
        file_zps = set()
        for v in row_zp[1:]:
            s = str(v).strip() if v is not None else ""
            if s and s not in SKIP:
                file_zps.add(s)
                eda_zps.add(s)

        if not file_zps:
            errors.append(f"{fname}: Keine Zählpunkte in Zeile 2 gefunden")
            continue

        # MeterCodes aus Zeile idx 13 prüfen
        row_code = rows[13]
        present_codes = {str(v).strip() for v in row_code if v and str(v).strip()}
        missing_codes = [mc for mc in REQUIRED_METER_CODES if mc not in present_codes]
        if missing_codes:
            errors.append(f"{fname}: Fehlende MeterCodes: {', '.join(missing_codes)}")
            continue

        log.info(f"[{sid[:8]}] EDA-Check OK: {fname} — {len(file_zps)} Zählpunkte")

    if errors:
        return jsonify({"ok": False, "errors": errors, "warnings": warnings})

    # ── Mitglieder ↔ EDA Abgleich (wenn Mitgliederdatei vorhanden) ───────────
    try:
        mitglieder_files = [
            f for f in uploads.glob("*.xls*")
            if any(h in f.name.lower() for h in MEMBER_HINTS)
        ]
        if mitglieder_files and eda_zps:
            import openpyxl as _opx
            wb_m = _opx.load_workbook(str(mitglieder_files[0]), read_only=True, data_only=True)
            if "Mitglieder" in wb_m.sheetnames:
                ws_m = wb_m["Mitglieder"]
                header_m = [str(c.value).strip() if c.value else "" for c in next(ws_m.iter_rows(max_row=1))]
                cons_idx = header_m.index("CONSUMPTION") if "CONSUMPTION" in header_m else None
                gen_idx  = header_m.index("GENERATION")  if "GENERATION"  in header_m else None

                member_zps = set()
                for row in ws_m.iter_rows(min_row=2, values_only=True):
                    if cons_idx is not None and row[cons_idx]:
                        member_zps.add(str(row[cons_idx]).strip())
                    if gen_idx is not None and row[gen_idx]:
                        member_zps.add(str(row[gen_idx]).strip())
                wb_m.close()

                eda_ohne_mitglied = eda_zps - member_zps
                if eda_ohne_mitglied:
                    errors.append(
                        f"EDA enthält Zählpunkte ohne Mitglied (Abrechnung würde abbrechen): "
                        f"{', '.join(sorted(eda_ohne_mitglied))}"
                    )

                mitglied_ohne_eda = member_zps - eda_zps
                if mitglied_ohne_eda:
                    warnings.append(
                        f"Mitglieder ohne EDA-Eintrag (werden mit 0 kWh abgerechnet): "
                        f"{', '.join(sorted(mitglied_ohne_eda))}"
                    )
    except Exception as e:
        warnings.append(f"Mitglieder-Abgleich übersprungen ({e})")

    ok = len(errors) == 0
    return jsonify({"ok": ok, "errors": errors, "warnings": warnings,
                    "eda_zp_count": len(eda_zps)})


# ── Schritt 1b: Parameter.csv parsen (aus hochgeladenem CSV) ──────────────────

@app.route("/api/v1/parse-config", methods=["POST"])
@require_license
def api_parse_config():
    sid = (request.json or {}).get("sid") or request.form.get("sid")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400

    td      = Path(sm.temp_dir(sid))
    uploads = td / "uploads"

    param_file = _find_param_file(uploads)
    if not param_file:
        return jsonify({"found": False, "values": {}})

    try:
        content = param_file.read_text(encoding="utf-8-sig", errors="replace")
    except OSError as e:
        return jsonify({"found": False, "values": {},
                        "error": f"Parameter.json konnte nicht gelesen werden: {e}"})
    values = _parse_parameter_json(content)

    # Optional: Config.csv-Override merken. Über sm.update() gehen, damit
    # die Mutation unter dem Session-Lock passiert.
    config_file = _find_config_file(uploads)
    if config_file:
        try:
            override = config_file.read_text(encoding="utf-8-sig", errors="replace")
        except OSError:
            override = None
        if override:
            s = sm.get(sid)
            new_data = dict((s or {}).get("data") or {})
            new_data["_config_csv_override"] = override
            sm.update(sid, data=new_data)

    return jsonify({"found": True, "values": values})


# ── Schritt 2: Konfiguration speichern ────────────────────────────────────────

@app.route("/api/v1/config", methods=["POST"])
@require_license
def api_save_config():
    body = request.json or {}
    sid  = body.get("sid")
    if not _valid_sid(sid) or not sm.get(sid):
        return jsonify({"error": "Ungültige Session"}), 400

    td      = Path(sm.temp_dir(sid))
    uploads = td / "uploads"

    # Mitglieder- und Vorlage-Datei zentral finden
    mitglieder_path = _find_mitglieder_file(uploads)
    if not mitglieder_path:
        return jsonify({"error": "Keine Mitglieder-Datei gefunden"}), 400

    vorlage_path = _find_vorlage_file(uploads)

    cfg = dict(body)
    cfg["mitglieder_filename"] = mitglieder_path.name
    if vorlage_path:
        cfg["vorlage_filename"] = vorlage_path.name

    # Periode defensiv bereinigen (wird Ordnername + Teil des ZIP-Dateinamens)
    periode_safe = _safe_periode(body.get("periode", ""))

    # Config.csv-Override aus früherer parse-config ggf. übernehmen
    current = sm.get(sid) or {}
    override = (current.get("data") or {}).get("_config_csv_override")
    if override:
        cfg["_config_csv_override"] = override

    try:
        _setup_temp_structure(str(td), cfg)
    except OSError as e:
        log.exception(f"[{sid[:8]}] Setup fehlgeschlagen")
        return jsonify({"error": f"Konfiguration konnte nicht geschrieben werden: {e}"}), 500

    # Mitglieder-Datei ins Root des temp_dir kopieren (main.py sucht per find_file)
    dst = td / mitglieder_path.name
    if mitglieder_path.exists() and not dst.exists():
        shutil.copy(mitglieder_path, dst)

    # EDA-Dateien merken
    eda_files = [str(f) for f in _find_eda_files(uploads)]

    # Abrechnungszeitraum aus EDA-Dateinamen parsen
    periode_von, periode_bis = _parse_eda_period(eda_files)

    # Letzter Rechnungslauf: 1) Datei, 2) manuelle Eingabe im Body
    letzter_lauf = None
    letzter_lauf_file = _find_letzter_lauf_file(uploads)
    if letzter_lauf_file:
        try:
            letzter_lauf = json.loads(letzter_lauf_file.read_text(encoding="utf-8"))
            log.info(f"[{sid[:8]}] Abrechnungsinfo.json eingelesen: "
                     f"letzter Lauf {letzter_lauf.get('periode','?')}, "
                     f"letzte Nr {letzter_lauf.get('letzte_rechnungsnr','?')}")
        except Exception as e_ll:
            log.warning(f"[{sid[:8]}] Abrechnungsinfo.json konnte nicht gelesen werden: {e_ll}")
    elif body.get("letzter_lauf_manual"):
        # Manuelle Eingabe aus dem Formular (Tab „Manuell eingeben")
        ll_m = body["letzter_lauf_manual"]
        if isinstance(ll_m, dict) and (ll_m.get("letzte_rechnungsnr") or ll_m.get("periode_bis")):
            letzter_lauf = ll_m
            log.info(f"[{sid[:8]}] Letzter Lauf manuell: "
                     f"Nr={ll_m.get('letzte_rechnungsnr','?')}, "
                     f"bis={ll_m.get('periode_bis','?')}")

    sm.update(sid, status="configured", data={
        **((sm.get(sid) or {}).get("data") or {}),
        "eda_files":      eda_files,
        "periode":        periode_safe,
        "rechnungsdatum": body.get("rechnungsdatum", ""),
        "periode_von":    periode_von,
        "periode_bis":    periode_bis,
        "letzter_lauf":   letzter_lauf,
    })

    return jsonify({
        "ok":        True,
        "eda_files": [Path(f).name for f in eda_files],
        "periode":   periode_safe,
    })


# ── Schritt 3: Vorschau als SSE-Stream (mit Live-Log) ─────────────────────────

@app.route("/api/v1/preview-stream")
@require_license
def api_preview_stream():
    """
    Führt system.prepare() in einem Worker-Thread aus und streamt den Fortschritt
    als Server-Sent Events. Abschluss-Nachricht: event: done, data: <result-json>
    """
    sid = request.args.get("sid")
    if not _valid_sid(sid):
        return jsonify({"error": "Ungültige Session-ID"}), 400
    s = sm.get(sid)
    if not s:
        return jsonify({"error": "Session nicht gefunden"}), 404

    data      = s.get("data") or {}
    eda_files = data.get("eda_files") or []
    periode   = data.get("periode", "")
    td        = sm.temp_dir(sid)

    # Backpressure: wenn der Client nicht mehr liest, soll der Worker nicht
    # unbegrenzt RAM belegen. Bei voller Queue wird im emit() einfach
    # verworfen statt zu blockieren (Log-Nachrichten sind nicht kritisch).
    q             = queue.Queue(maxsize=5000)
    prep_done_evt = threading.Event()
    result_holder = {}

    # Heartbeat-Thread: alle 2 Sek ein kurzes Lebenszeichen (falls Worker still)
    def heartbeat():
        while not prep_done_evt.is_set():
            try:
                q.put({"type": "keepalive"}, timeout=1.0)
            except queue.Full:
                pass
            prep_done_evt.wait(timeout=2.0)

    threading.Thread(target=heartbeat, daemon=True, name="preview-hb").start()

    # ── Worker-Thread: eigentliche Vorbereitung ──────────────────────────────
    def emit(msg: str, level: str = "INFO"):
        _mirror_emit(msg, level)   # v1.04: in full.log spiegeln
        try:
            q.put({"level": level,
                   "time":  datetime.now().strftime("%H:%M:%S"),
                   "message": msg}, timeout=1.0)
        except queue.Full:
            # Client liest nicht mit → Log-Nachricht verwerfen, nicht blocken
            pass

    def worker():
        import traceback as tb_mod

        def fail(msg: str, exc=None):
            """Einheitliche Fehlerbehandlung: Log + result_holder setzen.

            Bei SystemExit wird KEIN Traceback angezeigt – die eigentliche
            Fehlermeldung wurde vorher bereits via logger.error() in die
            SSE-Queue geschrieben (durch den _QueueHandler am Root-Logger).
            """
            prep_done_evt.set()
            is_systemexit = isinstance(exc, SystemExit)
            if not is_systemexit:
                emit(f"❌ {msg}", "ERROR")
            if exc is not None and not is_systemexit:
                exc_type = type(exc).__name__
                exc_str  = str(exc) or repr(exc)
                emit(f"   {exc_type}: {exc_str}", "ERROR")
                for line in tb_mod.format_exc().splitlines():
                    if line.strip():
                        emit(f"   {line}", "ERROR")
            full_err = f"{msg}" + (
                f" — {type(exc).__name__}: {str(exc) or repr(exc)}"
                if exc and not is_systemexit else ""
            )
            result_holder.update({"ok": False, "error": full_err})

        # main.py-Logs (logger.error/warning/info) ins Browser-Log weiterleiten –
        # damit die saubere "ABBRUCH"-Meldung sichtbar wird statt nur der Traceback.
        _log_handler = _QueueHandler(q)
        _log_handler.setFormatter(logging.Formatter("%(message)s"))
        _root_logger = logging.getLogger()
        _root_logger.addHandler(_log_handler)

        try:
            # ── Preflight-Checks ─────────────────────────────────────────────
            td_path = Path(td)
            config_csv   = td_path / "Config" / "Config.csv"
            param_csv    = td_path / "Config" / "Parameter.json"

            emit("🔍 Konfiguration wird geprüft …")
            if not config_csv.exists():
                fail(f"Config/Config.csv fehlt in {td_path.name} – Konfiguration unvollständig.")
                return
            if not param_csv.exists():
                fail(f"Config/Parameter.json fehlt in {td_path.name} – Konfiguration unvollständig.")
                return

            # Mitglieder-Datei prüfen
            xlsm_files = [f for f in td_path.iterdir()
                          if f.suffix.lower() in (".xlsm", ".xlsx") and "mitglied" in f.name.lower()]
            if not xlsm_files:
                # Fallback: xlsm/xlsx die nicht RC* heißen
                xlsm_files = [f for f in td_path.iterdir()
                              if f.suffix.lower() in (".xlsm", ".xlsx")
                              and not f.name.lower().startswith("rc")]
            if not xlsm_files:
                fail("Keine Mitglieder-Datei (.xlsm/.xlsx) im Arbeitsverzeichnis gefunden.")
                return
            emit(f"   ✓ Mitglieder: {xlsm_files[0].name}")

            # EDA-Dateien prüfen
            for ef in eda_files:
                if not Path(ef).exists():
                    fail(f"EDA-Datei nicht gefunden: {Path(ef).name}")
                    return
            emit(f"   ✓ EDA-Dateien: {len(eda_files)} vorhanden")

            # ── Abrechnungssystem initialisieren ─────────────────────────────
            emit("🔧 Abrechnungssystem wird initialisiert …")
            from main import Abrechnungssystem
            system = Abrechnungssystem(td)

            vereinsname = system.config.get("Vereinsname", "")
            if vereinsname:
                emit(f"🏢 Verein: {vereinsname}")

            # Optionales Rechnungsdatum
            rd_str = data.get("rechnungsdatum", "")
            if rd_str:
                try:
                    rd = datetime.strptime(rd_str, "%Y-%m-%d")
                    system.datum_rechnung  = rd
                    system.datum_abbuchung = rd + timedelta(
                        days=int(system.config.get("Fälligkeit", 14)))
                    emit(f"📅 Rechnungsdatum: {rd.strftime('%d.%m.%Y')}")
                except Exception:
                    pass

            # ── Mitglieder + EDA einlesen ─────────────────────────────────────
            emit("📋 Mitgliederliste wird gelesen …")
            n_eda = len(eda_files)
            emit(f"⚡ {n_eda} EDA-Datei{'en' if n_eda != 1 else ''} werden eingelesen:")
            for f in eda_files:
                emit(f"   • {Path(f).name}")
            emit("🧮 Kosten werden berechnet – das kann etwas dauern …")

            members = system.prepare(eda_files, periode)
            # v1.01: Defensiv gegen NaN/Inf aus pandas – darf nie in JSON
            members = [_sanitize_json_value(m) for m in (members or [])]
            prep_done_evt.set()

            invoiced = [m for m in members if m.get("has_invoice")]
            total_k  = sum(m["kosten"] for m in invoiced)
            ls_count = sum(1 for m in invoiced if m.get("kosten", 0) > 0)
            gs_count = sum(1 for m in invoiced if m.get("kosten", 0) < 0)

            emit(f"✅ {len(members)} Mitglieder geladen, {len(invoiced)} mit Rechnung.")
            if ls_count or gs_count:
                emit(f"   ↳ Lastschriften: {ls_count}  |  Gutschriften: {gs_count}")

            # ── Letzter-Lauf-Prüfungen ─────────────────────────────────────────
            letzter_lauf = data.get("letzter_lauf")
            curr_von = data.get("periode_von")
            curr_bis = data.get("periode_bis")
            current_year = system.datum_rechnung.year

            if letzter_lauf:
                prev_bis   = letzter_lauf.get("periode_bis")
                prev_label = letzter_lauf.get("periode", prev_bis or "?")
                prev_datum = letzter_lauf.get("abrechnungsdatum", "?")

                # Doppelte Periode
                if prev_bis and curr_bis and prev_bis == curr_bis:
                    emit(f"⚠ Zeitraum bereits abgerechnet: {prev_label} "
                         f"(letzter Lauf: {prev_datum})", level="WARNING")

                # Lückenprüfung
                elif prev_bis and curr_von:
                    try:
                        from datetime import date as _date, timedelta as _td
                        prev_bis_d  = _date.fromisoformat(prev_bis)
                        curr_von_d  = _date.fromisoformat(curr_von)
                        expected    = prev_bis_d + _td(days=1)
                        if curr_von_d > expected:
                            gap_von = expected.strftime("%d.%m.%Y")
                            gap_bis = (curr_von_d - _td(days=1)).strftime("%d.%m.%Y")
                            emit(f"⚠ Abrechnungslücke: Zeitraum {gap_von}–{gap_bis} "
                                 f"wurde nicht abgerechnet.", level="WARNING")
                    except Exception:
                        pass

                # Nächste Rechnungsnummer
                letzte_nr_str = letzter_lauf.get("letzte_rechnungsnr", "")
                letzte_laufnr = int(letzter_lauf.get("letzte_laufnr", 0) or 0)
                if letzte_nr_str.startswith(str(current_year)) and letzte_laufnr > 0:
                    emit(f"🔢 Rechnungsnummern: Weiterführung ab "
                         f"{current_year}-{letzte_laufnr+1:04d} "
                         f"(letzter Lauf endete bei {letzte_nr_str})")
                else:
                    emit(f"🔢 Rechnungsnummern: Neuer Jahreskreis – beginnt bei "
                         f"{current_year}-0001")
            elif curr_von:
                emit(f"🔢 Rechnungsnummern: beginnen bei {current_year}-0001 "
                     f"(kein letzter Lauf hochgeladen)")

            sm.update(sid, system=system)
            result_holder.update({
                "ok":      True,
                "members": members,
                "totals":  {"count": len(invoiced), "kosten": round(total_k, 2)},
            })

        except BaseException as e:
            fail("Unerwarteter Fehler während der Vorschau", e)
        finally:
            # Log-Handler wieder abhängen
            _root_logger.removeHandler(_log_handler)
            # Kurz warten damit alle emit()-Messages sicher in der Queue sind
            # bevor __PREVIEW_DONE__ gesendet wird
            import time
            time.sleep(0.1)
            q.put({"message": "__PREVIEW_DONE__"})

    threading.Thread(target=worker, daemon=True, name="preview-worker").start()

    # ── SSE-Generator ────────────────────────────────────────────────────────
    def stream():
        while True:
            try:
                item = q.get(timeout=30)
            except queue.Empty:
                yield "data: {\"type\":\"keepalive\"}\n\n"
                continue

            if item.get("message") == "__PREVIEW_DONE__":
                yield f"event: done\ndata: {_safe_sse_json(result_holder)}\n\n"
                break
            else:
                yield f"data: {_safe_sse_json(item)}\n\n"

    return Response(
        stream(),
        mimetype="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# ── Schritt 4: Verarbeitung (SSE-Stream) ──────────────────────────────────────

@app.route("/api/v1/process")
@require_license
def api_process():
    # Phase 7: Burst-Schutz pro Lizenz-UID (Angriff wie „endlose Verarbeitung starten")
    lic_key = (request.license or {}).get("uid_masked", "")
    if lic_key and not _rate_limit(f"process_uid:{lic_key}", max_calls=20, window_s=60):
        log.info(f"Process-Burst-Limit aktiv für {lic_key}")
        return _rate_limit_response(30)

    sid = request.args.get("sid")
    if not _valid_sid(sid):
        return jsonify({"error": "Ungültige Session-ID"}), 400
    s = sm.get(sid)
    if not s:
        return jsonify({"error": "Session nicht gefunden"}), 404

    system = s.get("system")
    if system is None:
        return jsonify({"error": "Erst /api/v1/preview aufrufen"}), 400

    # Phase 4b: Modul-Auswahl aus Query-String lesen + validieren.
    # Query: ?modules=pdfs,report,kassabuch,sepa  (kommasepariert)
    # Fehlt der Parameter → alle Module (Rückwärtskompatibilität).
    modules_raw = request.args.get("modules", "").strip()
    if modules_raw:
        requested = [m.strip() for m in modules_raw.split(",") if m.strip()]
        try:
            valid_keys = {p["key"] for p in licensing.list_packages(only_active=True)}
        except Exception:
            log.exception("Paket-Katalog nicht ladbar in api_process")
            return jsonify({"error": "Paket-Katalog nicht verfügbar."}), 500
        unknown = [m for m in requested if m not in valid_keys]
        if unknown:
            return jsonify({
                "error": f"Unbekannte(s) Modul(e): {', '.join(unknown)}"
            }), 400
        # Dedup + sortiert (stabile Reihenfolge für Logs)
        modules = sorted(set(requested))
        if not modules:
            return jsonify({"error": "Mindestens ein Modul auswählen."}), 400
    else:
        modules = list(ALL_MODULES)

    # Modul-Auswahl in der Session hinterlegen – /api/v1/download liest sie
    # und validiert sie beim atomaren Token-Abzug in Phase 5.
    sm.update(sid, modules=modules)

    data    = s.get("data") or {}
    periode = _safe_periode(data.get("periode", ""))
    td      = sm.temp_dir(sid)

    # Eigene Queue pro Request (keine geteilte session-Queue, die u.U.
    # noch Nachrichten von einem früheren Lauf hält).
    q = queue.Queue(maxsize=5000)

    def emit(message: str, level: str = "INFO"):
        _mirror_emit(message, level)   # v1.04: in full.log spiegeln
        try:
            q.put({"level": level,
                   "time":  datetime.now().strftime("%H:%M:%S"),
                   "message": message}, timeout=1.0)
        except queue.Full:
            pass

    def run():
        import traceback as tb_mod

        handler = _QueueHandler(q)
        handler.setFormatter(logging.Formatter("%(message)s"))
        root_logger = logging.getLogger()
        root_logger.addHandler(handler)
        success = False
        try:
            mods = set(modules)
            emit(f"🧩 Ausgewählte Module: {', '.join(sorted(mods))}")

            # PDFs generieren
            if "pdfs" in mods:
                # Startnummer aus letztem Lauf übernehmen (falls gleicher Jahrgang)
                _start_nr = 0
                _ll = data.get("letzter_lauf")
                if _ll:
                    _letzte_nr_str = _ll.get("letzte_rechnungsnr", "")
                    _letzte_laufnr = int(_ll.get("letzte_laufnr", 0) or 0)
                    if (_letzte_laufnr > 0 and
                            _letzte_nr_str.startswith(str(system.datum_rechnung.year))):
                        _start_nr = _letzte_laufnr
                emit("📄 Rechnungs-PDFs werden erstellt …")
                system.generate_pdfs(start_rechnungsnummer=_start_nr)
            else:
                emit("⏭ PDFs übersprungen (nicht ausgewählt).")

            # Gemeinschaftsbericht (nicht fatal, wenn er scheitert)
            if "report" in mods:
                try:
                    from report import generate_community_report
                    rp_folder = Path(td) / "Rechnungen" / "Report"
                    rp_folder.mkdir(parents=True, exist_ok=True)
                    rp_path = str(rp_folder / f"Gemeinschaftsbericht_{periode}.pdf")
                    generate_community_report(system, rp_path)
                    emit("📊 Gemeinschaftsbericht erstellt.")
                except Exception as e_rep:
                    emit(f"⚠ Gemeinschaftsbericht konnte nicht erstellt werden: "
                         f"{type(e_rep).__name__}: {e_rep}", level="WARNING")
            else:
                emit("⏭ Gemeinschaftsbericht übersprungen (nicht ausgewählt).")

            # SEPA XML (zuerst: setzt MsgIds + Ctrlsums, die das Kassabuch für
            # die Sammelzeilen übernimmt)
            if "sepa" in mods or "abrechnungsverein" in mods:
                emit("💳 SEPA-XML wird erstellt …")
                system.build_sepa_xml()
            else:
                emit("⏭ SEPA-XML übersprungen (nicht ausgewählt).")

            # Kassabuch (nach SEPA: Sammelzeilen übernehmen MsgIds, wenn SEPA lief.
            # Ohne SEPA laufen nur die Einzel-Zeilen ohne Sammelzeile.)
            if "kassabuch" in mods:
                emit("📒 Kassabuch wird geschrieben …")
                system.write_kassabuch()
            else:
                emit("⏭ Kassabuch übersprungen (nicht ausgewählt).")

            # Rechnungsversand: Mails werden NACH dem ZIP-Download versendet
            # (Sicherheit: erst ZIP gesichert, dann Mails raus)
            if "rechnungsversand" in mods:
                if "pdfs" not in mods:
                    emit("⚠ Rechnungsversand übersprungen – PDFs wurden nicht erstellt "
                         "(Modul 'PDF-Rechnungen' nicht ausgewählt).", level="WARNING")
                else:
                    n_mails = sum(1 for m in system.mitglieder if m.Kosten != 0)
                    emit(f"📧 Rechnungsversand aktiv – {n_mails} Mail(s) werden nach "
                         "dem ZIP-Download automatisch versendet.")
            else:
                emit("⏭ Rechnungsversand übersprungen (nicht ausgewählt).")

            # Abrechnungsverein – kein eigener Generierungsschritt,
            # nur Bestätigung dass der Modus aktiv ist
            if "abrechnungsverein" in mods:
                modus = (system.config.get("Abbuchung_Modus", "ueberweisung")
                         .strip().lower())
                if modus == "abrechnungsverein":
                    emit("🏦 Abrechnungsverein-Modus aktiv – Abbuchung durch "
                         "'Verein zur Abrechnung von Energiegemeinschaften'.")
                else:
                    emit(f"⚠ Modul 'Abrechnungsverein' gewählt, aber Parameter.json hat "
                         f"Abbuchung_Modus='{modus}'. Bitte Parameter prüfen.",
                         level="WARNING")

            sm.update(sid, status="done")
            success = True

        except BaseException as e:
            is_systemexit = isinstance(e, SystemExit)
            if not is_systemexit:
                # v1.04: zusaetzlich den ganzen Stack ueber den Logger loggen
                logging.getLogger("process-worker").exception(
                    "Abrechnungs-Verarbeitung abgebrochen"
                )
                emit(f"❌ {type(e).__name__}: {str(e) or repr(e)}", level="ERROR")
                for line in tb_mod.format_exc().splitlines():
                    if line.strip():
                        emit(f"   {line}", level="ERROR")
            # Bei SystemExit wurde die Ursache meist schon von main.py geloggt.
        finally:
            root_logger.removeHandler(handler)
            try:
                q.put({"message": "__DONE__", "success": success}, timeout=2.0)
            except queue.Full:
                pass

    threading.Thread(target=run, daemon=True, name="process-worker").start()

    def stream():
        while True:
            try:
                item = q.get(timeout=25)
            except queue.Empty:
                yield "data: {\"type\": \"keepalive\"}\n\n"
                continue
            yield f"data: {_safe_sse_json(item)}\n\n"
            if item.get("message") == "__DONE__":
                break

    return Response(
        stream(),
        mimetype="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# -- Schritt 4b: ZIP herunterladen + Session aufraeumen --

@app.route("/api/v1/download")
@require_license
def api_download():
    # Phase 7: Burst-Schutz pro UID für Downloads (schützt vor Replay- und
    # Flood-Angriffen, obwohl Tokens atomar abgezogen werden).
    lic_key = (request.license or {}).get("uid_masked", "")
    if lic_key and not _rate_limit(f"download_uid:{lic_key}", max_calls=10, window_s=60):
        log.info(f"Download-Burst-Limit aktiv für {lic_key}")
        return _rate_limit_response(30)

    sid = request.args.get("sid")
    if not _valid_sid(sid):
        return jsonify({"error": "Ungueltige Session-ID"}), 400
    s = sm.get(sid)
    if not s:
        return jsonify({"error": "Session nicht gefunden oder bereits heruntergeladen"}), 404
    if s.get("status") != "done":
        return jsonify({"error": "Verarbeitung noch nicht abgeschlossen"}), 400

    td      = sm.temp_dir(sid)
    periode = _safe_periode((s.get("data") or {}).get("periode", "Abrechnung"))
    data    = s.get("data") or {}

    # Phase 4b: Modul-Auswahl aus der Session lesen.  Wurde sie bei
    # /api/v1/process gesetzt, bestimmt sie auch hier, was ins ZIP kommt.
    modules = s.get("modules") or list(ALL_MODULES)

    # Abrechnungsinfo.json für nächsten Lauf schreiben
    sys_obj_pre = s.get("system")
    if sys_obj_pre is not None:
        try:
            _pv, _pb = _parse_eda_period(data.get("eda_files", []))
            _letzte_laufnr = getattr(sys_obj_pre, "rechnungsnummer", 0) or 0
            _letzte_rn = (
                f"{sys_obj_pre.datum_rechnung.year}-{_letzte_laufnr:04d}"
                if _letzte_laufnr else ""
            )
            _lauf_info = {
                "version":          "1",
                "vereinsname":      sys_obj_pre.config.get("Vereinsname", ""),
                "periode":          periode,
                "periode_von":      _pv,
                "periode_bis":      _pb,
                "abrechnungsdatum": datetime.now().strftime("%Y-%m-%d"),
                "anzahl_rechnungen": sum(
                    1 for m in sys_obj_pre.mitglieder if m.Kosten != 0
                ),
                "letzte_rechnungsnr": _letzte_rn,
                "letzte_laufnr":    _letzte_laufnr,
            }
            (Path(td) / "Abrechnungsinfo.json").write_text(
                json.dumps(_lauf_info, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
        except Exception as e_ai:
            log.warning(f"[{sid[:8]}] Abrechnungsinfo.json konnte nicht geschrieben werden: {e_ai}")

    # Phase 5: ZIP zuerst bauen (billig, keine DB-Aenderungen).
    try:
        zip_bytes = _build_zip(td, periode, modules)
    except Exception as e:
        log.exception(f"[{sid[:8]}] ZIP-Fehler")
        return jsonify({"error": str(e)}), 500

    # Phase 5: Tokens atomar abziehen.
    license_sid = _license_session_id() or ""
    try:
        consume_info = licensing.consume_tokens(
            session_id  = license_sid,
            modules     = modules,
            ip          = _client_ip(),
            fingerprint = "",
            user_agent  = request.headers.get("User-Agent", ""),
            periode     = periode,
        )
    except licensing.LicenseError as e:
        status = _LICENSE_HTTP.get(e.reason, 400)
        log.info(f"[{sid[:8]}] Token-Abzug abgelehnt: {e.reason} -- {e}")
        return jsonify({"error": str(e), "reason": e.reason}), status
    except Exception:
        log.exception(f"[{sid[:8]}] Token-Abzug fehlgeschlagen")
        return jsonify({"error": "Token-Abzug fehlgeschlagen.",
                        "reason": "server_error"}), 500

    log.info(f"[{sid[:8]}] Download: {consume_info['tokens_consumed']} Token(s) "
             f"abgezogen, {consume_info['remaining_tokens']} uebrig "
             f"(Kunde: {consume_info.get('customer_name','?')}).")

    mods_set = set(modules)
    sys_obj  = s.get("system")   # EEGSystem-Instanz aus der Session

    def _finalize_after_download():
        """Mailversand (falls aktiv) + Session-Cleanup nach dem ZIP-Download."""
        # Rechnungs-E-Mails an Mitglieder
        if "rechnungsversand" in mods_set and "pdfs" in mods_set and sys_obj is not None:
            sent = 0; errors = 0
            for m in sys_obj.mitglieder:
                if m.Kosten != 0:
                    try:
                        sys_obj.send_email(m)
                        sent += 1
                    except Exception as e_m:
                        _name = getattr(m, "Mail", None) or getattr(m, "Name", "?")
                        log.warning(f"[{sid[:8]}] Mail an {_name} fehlgeschlagen: "
                                    f"{type(e_m).__name__}: {e_m}")
                        errors += 1
            suffix = f", {errors} Fehler" if errors else ""
            log.info(f"[{sid[:8]}] Mailversand abgeschlossen: {sent} gesendet{suffix}.")
        # SEPA-Dateien an Abrechnungsverein senden (feste Adresse)
        _AV_EMAIL = "jakob@energie-durchdacht.at"
        if "abrechnungsverein" in mods_set and sys_obj is not None:
            try:
                sys_obj.send_sepa_email(_AV_EMAIL, periode)
            except Exception as e_av:
                log.warning(f"[{sid[:8]}] SEPA-Versand an {_AV_EMAIL} fehlgeschlagen: "
                            f"{type(e_av).__name__}: {e_av}")
        sm.destroy(sid)

    threading.Thread(target=_finalize_after_download, daemon=True).start()

    filename = f"EEG_Abrechnung_{periode}_{datetime.now().strftime('%Y%m%d')}.zip"
    resp = send_file(
        io.BytesIO(zip_bytes),
        mimetype="application/zip",
        as_attachment=True,
        download_name=filename,
    )
    resp.delete_cookie(LICENSE_COOKIE, path="/")
    return resp


# -- Admin-Web-UI (Phase 6) --

@app.route("/admin")
@app.route("/admin/")
def admin_index():
    """Liefert das Admin-Panel. Auth passiert client-seitig via /admin/api/session."""
    return render_template("admin.html")


@app.route("/admin/api/session", methods=["GET"])
def admin_api_session():
    """Status-Check: ist diese Browser-Session eingeloggt?"""
    tok = request.cookies.get(ADMIN_COOKIE)
    s   = _admin_session_for(tok)
    if s is None:
        resp = jsonify({"logged_in": False})
        if tok:
            resp.delete_cookie(ADMIN_COOKIE, path="/admin")
        return resp
    return jsonify({"logged_in": True, "username": s["username"]})


@app.route("/admin/api/login", methods=["POST"])
def admin_api_login():
    """Admin-Login. Body: {username, password}. Rate-Limit pro IP."""
    body     = request.json or {}
    username = (body.get("username") or "").strip()
    password = body.get("password") or ""
    ip       = _client_ip()

    # Phase 7: Burst-Schutz vor der DB-Prüfung (billiger, schützt auch vor
    # erfolgreichen Credential-Stuffing-Runs).
    if not _rate_limit(f"admin_login_ip:{ip}", max_calls=15, window_s=300):
        return _rate_limit_response(60)

    try:
        licensing.check_rate_limit(ip)
    except licensing.LicenseError as e:
        return jsonify({"error": str(e), "reason": e.reason}), 429

    if not username or not password:
        return jsonify({"error": "Username und Passwort sind Pflicht."}), 400

    ok = licensing.verify_admin(username, password, ip)
    if not ok:
        try:
            licensing._record_failed_login(ip, f"admin:{username}"[:40], "admin_invalid")
        except Exception:
            log.exception("Fehler beim Loggen eines Admin-Fehlversuchs")
        log.info(f"Admin-Login fehlgeschlagen ({username!r}) von {ip}")
        return jsonify({"error": "Username oder Passwort falsch.",
                        "reason": "invalid"}), 401

    token = _admin_create_session(username, ip)
    log.info(f"Admin-Login: {username} von {ip}")
    resp = jsonify({"ok": True, "username": username})
    _set_admin_cookie(resp, token)
    return resp


@app.route("/admin/api/logout", methods=["POST"])
def admin_api_logout():
    tok = request.cookies.get(ADMIN_COOKIE)
    _admin_destroy_session(tok)
    resp = jsonify({"ok": True})
    resp.delete_cookie(ADMIN_COOKIE, path="/admin")
    return resp


@app.route("/admin/api/accounts", methods=["GET"])
@require_admin
def admin_api_accounts_list():
    include_disabled = request.args.get("include_disabled", "1") != "0"
    try:
        accts = licensing.list_accounts(include_disabled=include_disabled)
    except Exception:
        log.exception("Admin: Konten-Liste fehlgeschlagen")
        return jsonify({"error": "Konten konnten nicht geladen werden."}), 500
    return jsonify({"accounts": accts})


@app.route("/admin/api/accounts", methods=["POST"])
@require_admin
def admin_api_accounts_create():
    body            = request.json or {}
    customer_name   = (body.get("customer_name") or "").strip()
    customer_email  = (body.get("customer_email") or "").strip() or None
    device_name     = (body.get("device_name") or "").strip() or None
    notes           = (body.get("notes") or "").strip() or None
    try:
        initial_tokens = int(body.get("initial_tokens") or 0)
    except (TypeError, ValueError):
        return jsonify({"error": "initial_tokens muss eine Zahl sein."}), 400

    if not customer_name:
        return jsonify({"error": "customer_name ist Pflicht."}), 400
    if initial_tokens < 0:
        return jsonify({"error": "initial_tokens darf nicht negativ sein."}), 400

    try:
        uid = licensing.create_account(
            customer_name, customer_email, device_name,
            initial_tokens, notes=notes, admin_user=request.admin_user,
        )
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception:
        log.exception("Admin: create_account fehlgeschlagen")
        return jsonify({"error": "Konto konnte nicht angelegt werden."}), 500

    log.info(f"Admin {request.admin_user}: Konto angelegt ({uid}, {customer_name}, {initial_tokens} Tokens)")
    return jsonify({"uid": uid, "customer_name": customer_name,
                    "initial_tokens": initial_tokens})


@app.route("/admin/api/accounts/<uid>/status", methods=["POST"])
@require_admin
def admin_api_account_status(uid):
    body   = request.json or {}
    status = (body.get("status") or "").strip()
    if status not in ("active", "disabled", "expired"):
        return jsonify({"error": "status muss active/disabled/expired sein."}), 400
    try:
        licensing.set_status(uid, status, admin_user=request.admin_user)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception:
        log.exception("Admin: set_status fehlgeschlagen")
        return jsonify({"error": "Status konnte nicht gesetzt werden."}), 500
    return jsonify({"ok": True, "uid": uid, "status": status})


@app.route("/admin/api/accounts/<uid>/unlock", methods=["POST"])
@require_admin
def admin_api_account_unlock(uid):
    """Device-Lock sofort aufheben – für Support-Fälle (z.B. Browser-Crash)."""
    try:
        deleted = licensing.unlock_device(uid, admin_user=request.admin_user)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception:
        log.exception("Admin: unlock_device fehlgeschlagen")
        return jsonify({"error": "Entsperren fehlgeschlagen."}), 500
    log.info(f"Admin {request.admin_user}: Gerät für {uid} entsperrt "
             f"({deleted} Session(s) gelöscht)")
    return jsonify({"ok": True, "uid": uid, "deleted_sessions": deleted})


@app.route("/admin/api/accounts/<uid>/tokens", methods=["POST"])
@require_admin
def admin_api_account_tokens(uid):
    body   = request.json or {}
    try:
        delta = int(body.get("delta"))
    except (TypeError, ValueError):
        return jsonify({"error": "delta muss eine Ganzzahl sein."}), 400
    reason = (body.get("reason") or "").strip() or "admin_adjust"
    try:
        info = licensing.adjust_tokens(uid, delta, reason,
                                       admin_user=request.admin_user)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception:
        log.exception("Admin: adjust_tokens fehlgeschlagen")
        return jsonify({"error": "Token-Anpassung fehlgeschlagen."}), 500
    return jsonify(info)


@app.route("/admin/api/accounts/<uid>/usage", methods=["GET"])
@require_admin
def admin_api_account_usage(uid):
    try:
        entries = licensing.get_usage_log(uid=uid, limit=200)
    except Exception:
        log.exception("Admin: get_usage_log fehlgeschlagen")
        return jsonify({"error": "Usage-Log konnte nicht geladen werden."}), 500
    return jsonify({"entries": entries})


@app.route("/admin/api/packages", methods=["GET"])
@require_admin
def admin_api_packages_list():
    try:
        pkgs = licensing.list_packages(only_active=False)
    except Exception:
        log.exception("Admin: list_packages fehlgeschlagen")
        return jsonify({"error": "Pakete konnten nicht geladen werden."}), 500
    return jsonify({"packages": pkgs})


@app.route("/admin/api/packages/<key>", methods=["POST"])
@require_admin
def admin_api_package_update(key):
    body      = request.json or {}
    cost      = body.get("cost")
    is_active = body.get("is_active")
    try:
        cost_i = int(cost) if cost is not None else None
    except (TypeError, ValueError):
        return jsonify({"error": "cost muss eine Ganzzahl sein."}), 400
    try:
        pkg = licensing.update_package(
            key, cost=cost_i, is_active=is_active,
            admin_user=request.admin_user,
        )
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception:
        log.exception("Admin: update_package fehlgeschlagen")
        return jsonify({"error": "Paket konnte nicht aktualisiert werden."}), 500
    return jsonify(pkg)


@app.route("/admin/api/accounts/<uid>/export", methods=["GET"])
@require_admin
def admin_api_account_export(uid):
    """DSGVO Art. 15 – Recht auf Auskunft.
    Liefert alle zu einem Konto gespeicherten Daten als JSON-Download.
    """
    try:
        data = licensing.export_account_data(uid)
    except ValueError as e:
        return jsonify({"error": str(e)}), 404
    except Exception:
        log.exception("Admin: export_account_data fehlgeschlagen")
        return jsonify({"error": "Export fehlgeschlagen."}), 500
    log.info(f"Admin {request.admin_user}: DSGVO-Auskunft zu {uid} exportiert")
    resp = make_response(json.dumps(data, ensure_ascii=False, indent=2, default=str))
    resp.headers["Content-Type"] = "application/json; charset=utf-8"
    resp.headers["Content-Disposition"] = f'attachment; filename="{uid}.json"'
    return resp


@app.route("/admin/api/accounts/<uid>", methods=["DELETE"])
@require_admin
def admin_api_account_delete(uid):
    """DSGVO Art. 17 – Recht auf Löschung.
    Anonymisiert das Konto und löscht das komplette Nutzungsprotokoll.
    """
    try:
        result = licensing.delete_account(uid, admin_user=request.admin_user)
    except ValueError as e:
        return jsonify({"error": str(e)}), 404
    except Exception:
        log.exception("Admin: delete_account fehlgeschlagen")
        return jsonify({"error": "Löschung fehlgeschlagen."}), 500
    return jsonify(result)


@app.route("/datenschutz")
def datenschutz_page():
    """v1.07b: Datenschutzerklärung direkt aus dem Template rendern (DSGVO Art. 15/17)."""
    return render_template("datenschutz.html")


# ── v1.07a: Muster-Downloads aus dem muster/-Ordner im App-Verzeichnis ───────
_MUSTER = Path(__file__).parent / "muster"

@app.route("/api/v1/beispiel-ergebnis")
def api_beispiel_ergebnis():
    """Vollständiges Beispiel-ZIP (liegt fertig im muster/-Ordner).
    Keine Authentifizierung nötig – Vorab-Einblick für neue Nutzer."""
    f = _MUSTER / "EEG_Abrechnung_Beispiel.zip"
    if not f.is_file():
        return jsonify({"error": "Beispiel-ZIP nicht gefunden."}), 404
    return send_file(f, mimetype="application/zip",
                     as_attachment=True, download_name="Beispiel-Ergebnis-EEG.zip")

@app.route("/api/v1/muster/rechnung")
def api_muster_rechnung():
    """Muster-Rechnung als PDF."""
    f = _MUSTER / "Rechnung_Beispiel.pdf"
    if not f.is_file():
        return jsonify({"error": "Muster nicht gefunden."}), 404
    return send_file(f, mimetype="application/pdf", as_attachment=False)

@app.route("/api/v1/muster/sepa")
def api_muster_sepa():
    """Muster-SEPA-Lastschrift-XML."""
    f = _MUSTER / "SEPA_Lastschrift_Beispiel.xml"
    if not f.is_file():
        return jsonify({"error": "Muster nicht gefunden."}), 404
    return send_file(f, mimetype="application/xml",
                     as_attachment=True, download_name="Muster-SEPA-Lastschrift.xml")

@app.route("/api/v1/muster/kassabuch")
def api_muster_kassabuch():
    """Muster-Kassabuch als XLSX."""
    f = _MUSTER / "Kassabuch_Beispiel.xlsx"
    if not f.is_file():
        return jsonify({"error": "Muster nicht gefunden."}), 404
    return send_file(f,
                     mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                     as_attachment=True, download_name="Muster-Kassabuch-EEG.xlsx")

@app.route("/api/v1/muster/bericht")
def api_muster_bericht():
    """Muster-Gemeinschaftsbericht als PDF."""
    f = _MUSTER / "Gemeinschaftsbericht_Beispiel.pdf"
    if not f.is_file():
        return jsonify({"error": "Muster nicht gefunden."}), 404
    return send_file(f, mimetype="application/pdf",
                     as_attachment=True, download_name="Muster-Gemeinschaftsbericht-EEG.pdf")


# ── v1.05: Cancel-Session-Endpunkt fuer den "Zurueck/Abbrechen"-Button ──────
@app.route("/api/v1/cancel-session", methods=["POST"])
def api_cancel_session():
    """Loescht Tempordner und zerstoert Session.
    Wird vom Wizard aufgerufen, wenn der User abbricht.
    Antwortet immer 200 (Frontend macht den Redirect so oder so)."""
    sid = request.args.get("sid", "")
    if _valid_sid(sid):
        try:
            sm.destroy(sid)
        except Exception:
            log.exception("cancel-session: destroy fehlgeschlagen")
    return jsonify({"ok": True})

@app.route("/admin/api/audit", methods=["GET"])
@require_admin
def admin_api_audit():
    try:
        limit = min(int(request.args.get("limit", 200)), 1000)
    except ValueError:
        limit = 200
    try:
        entries = licensing.list_admin_audit(limit=limit)
    except Exception:
        log.exception("Admin: list_admin_audit fehlgeschlagen")
        return jsonify({"error": "Audit-Log konnte nicht geladen werden."}), 500
    return jsonify({"entries": entries})


# ── Admin: SMTP-Konfiguration ────────────────────────────────────────────────

@app.route("/admin/api/smtp", methods=["GET"])
@require_admin
def admin_api_smtp_get():
    """Gibt die aktuelle SMTP-Konfiguration zurück (Passwort wird maskiert)."""
    cfg = _smtp_cfg_read()
    return jsonify({
        "server":       cfg["server"],
        "port":         cfg["port"],
        "mode":         cfg["mode"],
        "username":     cfg["username"],
        "password":     "••••••••" if cfg["password"] else "",
        "from_address": cfg["from_address"],
        "configured":   bool(cfg["server"] and cfg["username"]),
    })


@app.route("/admin/api/smtp", methods=["POST"])
@require_admin
def admin_api_smtp_post():
    """Speichert SMTP-Konfiguration in smtp_config.json."""
    body = request.json or {}
    current = _smtp_cfg_read()
    # Passwort nur übernehmen wenn nicht maskiert
    pw = body.get("password", "")
    if "•" in pw:
        pw = current["password"]  # unverändertes Passwort beibehalten
    mode = body.get("mode", "ssl").strip().lower()
    data = {
        "server":       body.get("server", "").strip(),
        "port":         body.get("port", "465" if mode == "ssl" else "587").strip(),
        "mode":         mode,
        "username":     body.get("username", "").strip(),
        "password":     pw,
        "from_address": body.get("from_address", "").strip(),
    }
    try:
        _smtp_cfg_write(data)
    except Exception as e:
        log.exception("SMTP-Config konnte nicht gespeichert werden")
        return jsonify({"ok": False, "error": str(e)}), 500
    return jsonify({"ok": True})


# -- Vorlagen-Downloads --

@app.route("/api/v1/validate-params", methods=["POST"])
@require_license
def api_validate_params():
    """Prueft die Vereinsparameter. Erwartet ein JSON-Dict mit den Frontend-Feld-Keys."""
    body = request.json or {}
    try:
        from main import validate_params
        errors = validate_params(body)
    except Exception as e:
        logging.getLogger("app").exception("validate_params fehlgeschlagen")
        return jsonify({"ok": False,
                        "errors": [{"field": "",
                                    "fehler": f"Fehler bei der Pruefung: {e}"}]})
    return jsonify({"ok": len(errors) == 0, "errors": errors})


# ── Test-Mail ────────────────────────────────────────────────────────────────

@app.route("/admin/api/test-mail", methods=["POST"])
@require_admin
def api_test_mail():
    """Sendet eine Test-E-Mail über die server-seitig konfigurierten SMTP-Zugangsdaten.

    SMTP-Konfiguration über Umgebungsvariablen am Server:
        EEG_SMTP_SERVER    – Hostname des SMTP-Servers
        EEG_SMTP_PORT      – Port (Default: 465)
        EEG_SMTP_MODE      – "ssl" oder "starttls" (Default: "ssl")
        EEG_SMTP_USERNAME  – Benutzername / Absenderadresse
        EEG_SMTP_PASSWORD  – Passwort
        EEG_MAIL_DOMAIN    – Domain für From-Adressen (Default: energie-durchdacht.at)

    Erwartet optional JSON: { "to": "empfaenger@example.com" }
    Fehlt "to", wird die Test-Mail an EEG_SMTP_USERNAME gesendet.
    """
    import smtplib, email.utils as _eu
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText

    import email.utils as _eu2

    cfg         = _smtp_cfg_read()
    smtp_mode   = cfg["mode"].strip().lower()
    smtp_server = cfg["server"].strip()
    smtp_port   = int(cfg["port"]) if cfg["port"] else (465 if smtp_mode == "ssl" else 587)
    smtp_user    = cfg["username"].strip()
    smtp_pass    = cfg["password"]
    smtp_from    = cfg["from_address"] or smtp_user   # noreply@ oder fallback auf smtp_user

    if not smtp_server or not smtp_user:
        return jsonify({
            "ok": False,
            "error": "SMTP nicht konfiguriert. Bitte zuerst im Admin-Bereich unter 'System' die SMTP-Daten eintragen."
        }), 500

    body       = request.json or {}
    to_addr    = (body.get("to") or smtp_from).strip()
    from_addr  = _eu2.formataddr(("EEG-Assistent", smtp_from))

    msg = MIMEMultipart()
    msg["From"]       = from_addr
    msg["To"]         = to_addr
    msg["Subject"]    = "[TEST] EEG-Assistent – SMTP-Verbindungstest"
    msg["Message-ID"] = _eu.make_msgid()
    msg["Date"]       = _eu.formatdate()
    msg.attach(MIMEText(
        "Diese Test-E-Mail bestätigt, dass der EEG-Assistent erfolgreich "
        f"über {smtp_server}:{smtp_port} ({smtp_mode.upper()}) "
        "E-Mails versenden kann.\n\nKeine Aktion erforderlich.",
        "plain", "utf-8"
    ))

    try:
        if smtp_mode == "starttls":
            with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as srv:
                srv.ehlo(); srv.starttls(); srv.ehlo()
                srv.login(smtp_user, smtp_pass)
                srv.send_message(msg)
        else:
            with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=10) as srv:
                srv.login(smtp_user, smtp_pass)
                srv.send_message(msg)
    except smtplib.SMTPAuthenticationError:
        return jsonify({"ok": False,
                        "error": "Authentifizierung fehlgeschlagen – Passwort oder Benutzername falsch."}), 200
    except smtplib.SMTPConnectError as e:
        return jsonify({"ok": False,
                        "error": f"Verbindung zu {smtp_server}:{smtp_port} fehlgeschlagen: {e}"}), 200
    except smtplib.SMTPException as e:
        return jsonify({"ok": False, "error": f"SMTP-Fehler: {e}"}), 200
    except OSError as e:
        return jsonify({"ok": False, "error": f"Netzwerkfehler: {e}"}), 200
    except Exception as e:
        log.exception("Test-Mail unbekannter Fehler")
        return jsonify({"ok": False, "error": f"Unbekannter Fehler: {e}"}), 200

    return jsonify({"ok": True, "message": f"Test-Mail gesendet von {from_addr} an {to_addr}."})


# ── Template-Downloads ────────────────────────────────────────────────────────

@app.route("/api/v1/template/mitglieder")
def api_template_mitglieder():
    """Mitglieder-Vorlage herunterladen (aus sepa_templates/)."""
    path = SEPA_TPL_DIR / "Mitglieder_Vorlage.xlsx"
    if not path.exists():
        return jsonify({"error": "Vorlage nicht gefunden"}), 404
    return send_file(
        path,
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        as_attachment=True,
        download_name="Mitglieder_Vorlage.xlsx",
    )


@app.route("/api/v1/template/vorlage")
def api_template_vorlage():
    """Rechnungs-Standardvorlage herunterladen."""
    path = DEFAULT_VORLAGE
    if not path.exists():
        return jsonify({"error": "Rechnungsvorlage nicht gefunden"}), 404
    return send_file(
        path,
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        as_attachment=True,
        download_name="Rechnungsvorlage_Standard.xlsx",
    )
