#!/usr/bin/env python3
"""
TESM (TimEShepManager) — Web-App zur Überwachung und Steuerung von
PoE-Devices über Aruba-Switche.

Funktional identisch zur Ursprungsversion (Login, Dashboard, Device-/
Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend.
"""
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, send_file, abort
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from flask_sock import Sock
from simple_websocket.errors import ConnectionClosed
from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, serialization
from cryptography import x509
from datetime import datetime, timedelta
import base64, csv, io, ipaddress, logging, math, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
import paramiko
import yaml
import ssl
from ldap3 import Server, Connection, Tls, ALL, SIMPLE, BASE
from ldap3.core.exceptions import (
    LDAPException,
    LDAPSocketOpenError,
    LDAPSocketReceiveError,
    LDAPSocketSendError,
    LDAPResponseTimeoutError,
)
from ldap3.utils.conv import escape_filter_chars
import urllib.request
import urllib.error
import licensing

LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"


class _SendAllSocketProxy:
    """Leitet alles an das echte Socket weiter, außer .send() -> .sendall()."""

    def __init__(self, sock):
        object.__setattr__(self, "_sock", sock)

    def send(self, data):
        return self._sock.sendall(data)

    def __getattr__(self, name):
        return getattr(self._sock, name)


def _patch_simple_websocket_partial_writes():
    """
    simple-websocket (genutzt von Flask-Sock) schreibt WebSocket-Frames per
    `socket.send()`, das laut Doku aber nur *irgendeine* Anzahl Bytes
    schreiben muss — nicht zwingend alle. Folgen zwei Frames sehr schnell
    aufeinander (z.B. eine Nachricht direkt gefolgt vom Schließen der
    Verbindung, wie beim SSH-Terminal), kann ein unvollständig geschriebenes
    Frame den Frame-Stream für den Client zerstören ("Invalid frame header").
    Patch: das Socket-Objekt jeder Verbindung durch einen Proxy ersetzen,
    dessen .send() intern .sendall() aufruft. Rein additiv/defensiv — falls
    die Bibliothek ihre interne Struktur ändert, wird der Patch übersprungen
    statt den Start zu blockieren.
    """
    try:
        from simple_websocket.ws import Base

        original_init = Base.__init__

        def patched_init(self, *args, **kwargs):
            original_init(self, *args, **kwargs)
            if self.sock is not None and not isinstance(self.sock, _SendAllSocketProxy):
                self.sock = _SendAllSocketProxy(self.sock)

        Base.__init__ = patched_init
    except Exception:
        pass


_patch_simple_websocket_partial_writes()

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json"))
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")

TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm")
TESM_LIVE_LOG_PATH = os.path.join(TESM_LOG_DIR, "live.log")
TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log")
TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log")
TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")

# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der
# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows).
# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel
# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt,
# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller
# Export-und-Löschen-Funktion unter Verlauf.
AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive")
AUDIT_LOG_ARCHIVE_THRESHOLD = 20000
AUDIT_LOG_ARCHIVE_TARGET = 15000
AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024  # 1 GiB frei
try:
    os.makedirs(TESM_LOG_DIR, exist_ok=True)
    # War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
    # tesm-check.service) als root lief. Ein world-writable Verzeichnis lässt
    # logrotate die Rotation aber aus Sicherheitsgründen komplett verweigern
    # ("insecure permissions") -- live reproduziert: ohne Rotation blieb
    # live.log unbegrenzt wachsen, siehe auch "su root root" in
    # _write_logrotate_config als zusätzliche Absicherung.
    os.chmod(TESM_LOG_DIR, 0o755)
except OSError:
    pass
SWITCH_DEFAULT_SSH_PORT = 22
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}

NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_NGINX_CONFIG", "/etc/nginx/sites-available/tesm")
TESM_SSL_DIR = os.environ.get("TESM_SSL_DIR", "/etc/tesm/ssl")
TESM_SSL_CERT_PATH = os.path.join(TESM_SSL_DIR, "cert.pem")
TESM_SSL_KEY_PATH = os.path.join(TESM_SSL_DIR, "key.pem")
TESM_ACME_WEBROOT = os.environ.get("TESM_ACME_WEBROOT", "/var/www/tesm-acme")
CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh"
try:
    # Bewusst AUSSERHALB von /srv/tesm -- das rsync --delete beim Deployen
    # (siehe install.sh) würde ein dort abgelegtes Zertifikat sonst bei
    # jedem Update wieder löschen. Analog zu /etc/nginx/ selbst: liegt
    # außerhalb des App-Verzeichnisses, übersteht also jedes Update.
    os.makedirs(TESM_SSL_DIR, exist_ok=True)
    os.chmod(TESM_SSL_DIR, 0o700)
    os.makedirs(TESM_ACME_WEBROOT, exist_ok=True)
except OSError:
    pass


def _le_cert_path(domain):
    return f"/etc/letsencrypt/live/{domain}/fullchain.pem"


def _le_key_path(domain):
    return f"/etc/letsencrypt/live/{domain}/privkey.pem"


def _write_certbot_deploy_hook():
    """certbot führt nach JEDER erfolgreichen Erneuerung (egal für welche
    Domain) automatisch alle ausführbaren Skripte in diesem Verzeichnis aus
    -- ohne diesen Hook würde nginx nach einer automatischen Verlängerung
    weiter das alte (bald ablaufende) Zertifikat verwenden, da es die
    Datei nicht von selbst neu einliest. Läuft wie _write_logrotate_config
    bei jedem App-Start neu, damit ein frisch installiertes certbot den
    Hook auch ohne manuellen Aufruf der NGINX-Seite bekommt."""
    try:
        os.makedirs(os.path.dirname(CERTBOT_DEPLOY_HOOK_PATH), exist_ok=True)
        with open(CERTBOT_DEPLOY_HOOK_PATH, "w", encoding="utf-8") as f:
            f.write("#!/bin/bash\nsystemctl reload nginx\n")
        os.chmod(CERTBOT_DEPLOY_HOOK_PATH, 0o755)
    except OSError:
        pass


_write_certbot_deploy_hook()

CREDENTIAL_CATEGORIES = [
    ("switch", "Switch"),
    ("linux", "Linux-Client"),
    ("windows", "Windows-Client"),
    ("router", "Router/Sonstiges"),
]
CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES}
DEVICE_MAINTENANCE_CATEGORY = "linux"
os.makedirs(AVATAR_DIR, exist_ok=True)

app = Flask(__name__)
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024

try:
    _app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
    _app_log_handler.setLevel(logging.WARNING)
    _app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S"))
    app.logger.addHandler(_app_log_handler)
    app.logger.setLevel(logging.WARNING)
except OSError:
    pass


@app.errorhandler(413)
def _handle_request_too_large(_e):
    """Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
    ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
    App-Look ausliefern. request.referrer statt einer festen Route, damit
    das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
    Seite trifft."""
    max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
    flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
    return redirect(request.referrer or url_for("index"))


def _load_or_create_secret() -> str:
    env_secret = os.environ.get("TESM_SECRET_KEY")
    if env_secret:
        return env_secret
    if os.path.exists(SECRET_KEY_PATH):
        with open(SECRET_KEY_PATH, "r") as f:
            return f.read().strip()
    new_secret = secrets.token_hex(32)
    with open(SECRET_KEY_PATH, "w") as f:
        f.write(new_secret)
    return new_secret


def _load_or_create_fernet() -> Fernet:
    if os.path.exists(FERNET_KEY_PATH):
        with open(FERNET_KEY_PATH, "rb") as f:
            key = f.read()
    else:
        key = Fernet.generate_key()
        with open(FERNET_KEY_PATH, "wb") as f:
            f.write(key)
    return Fernet(key)


app.secret_key = _load_or_create_secret()
bcrypt = Bcrypt(app)
fernet = _load_or_create_fernet()
sock = Sock(app)

login_manager = LoginManager()
login_manager.login_view = "login"
login_manager.init_app(app)


PERMISSION_ROW_TYPES = [
    ("view", "R", "Lesen (Read)"),
    ("create", "W", "Schreiben/Anlegen (Write)"),
    ("edit", "E", "Ändern inkl. Löschen (Edit)"),
    ("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"),
    ("export", "X", "Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)"),
]

PERMISSIONS = {
    "devices_group": {
        "label": "Geräte",
        "view_key": "devices_group.view",
        "children": {
            "devices": {
                "label": "Clients",
                "rows": {
                    "view": "devices.view",
                    "create": "devices.create",
                    "edit": "devices.edit",
                    "extra": "devices.restart",
                },
            },
            "switches": {
                "label": "Switche",
                "rows": {
                    "view": "switches.view",
                    "create": "switches.create",
                    "edit": "switches.edit",
                },
            },
            "credentials": {
                "label": "Zugangsdaten",
                "rows": {
                    "view": "credentials.view",
                    "create": "credentials.create",
                    "edit": "credentials.edit",
                },
            },
            "maintenance": {
                "label": "Wartung",
                "rows": {
                    "view": "maintenance.view",
                    "edit": "maintenance.edit",
                },
            },
        },
    },
    "logs_group": {
        "label": "Logs",
        "view_key": "logs_group.view",
        "children": {
            "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
            "logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}},
            "logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}},
            "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
        },
    },
    "settings_group": {
        "label": "Einstellungen",
        "view_key": "settings_group.view",
        "children": {
            "users": {
                "label": "Benutzer",
                "rows": {
                    "view": "users.view",
                    "create": "users.create",
                    "edit": "users.edit",
                },
            },
            "groups": {
                "label": "Gruppen",
                "rows": {
                    "view": "groups.view",
                    "create": "groups.create",
                    "edit": "groups.edit",
                },
            },
            "settings_system": {
                "label": "Systemeinstellungen",
                "rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
            },
            "settings_importexport": {
                "label": "Im-/Export",
                "rows": {
                    "view": "settings_importexport.view",
                    "edit": "settings_importexport.edit",
                    "export": "settings_importexport.export",
                },
            },
            "settings_dhcp": {
                "label": "DHCP",
                "rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"},
            },
            "settings_ldap": {
                "label": "LDAP",
                "rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"},
            },
            "settings_nginx": {
                "label": "NGINX",
                "rows": {"view": "settings_nginx.view", "edit": "settings_nginx.edit"},
            },
            "papierkorb": {
                "label": "Papierkorb",
                "rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"},
            },
        },
    },
    "fileshare_group": {
        "label": "Dateifreigaben",
        "view_key": "fileshare_group.view",
        "children": {
            "fileshare": {
                "label": "Dateifreigaben",
                "rows": {
                    "view": "fileshare.view",
                    "create": "fileshare.create",
                    "edit": "fileshare.edit",
                },
            },
        },
    },
}

PERMISSION_LABELS = {
    "devices_group.view": "Geräte-Bereich anzeigen",
    "logs_group.view": "Logs-Bereich anzeigen",
    "settings_group.view": "Einstellungen-Bereich anzeigen",
    "devices.view": "Clients lesen", "devices.create": "Clients anlegen",
    "devices.edit": "Clients ändern (inkl. Aktivieren/Deaktivieren, Löschen)",
    "devices.restart": "PoE-Neustart über das Dashboard ausführen (automatische Neustarts bei Ausfall bleiben unberührt)",
    "switches.view": "Switche lesen", "switches.create": "Switche anlegen",
    "switches.edit": "Switche ändern (inkl. Löschen)",
    "credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
    "credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
    "maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
    "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
    "papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)",
    "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
    "logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog lesen",
    "logs_kea.view": "Kea-DHCP-Log lesen",
    "logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)",
    "logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen",
    "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
    "users.edit": "Benutzer ändern (inkl. Löschen)",
    "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
    "groups.edit": "Gruppen ändern (inkl. Löschen)",
    "settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
    "settings_importexport.view": "Im-/Export-Seite ansehen",
    "settings_importexport.edit": "Daten importieren",
    "settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
    "settings_dhcp.view": "DHCP-Status und -Konfiguration lesen",
    "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben",
    "settings_ldap.view": "LDAP/AD-Konfiguration lesen",
    "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
    "settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
    "settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
    "fileshare_group.view": "Dateifreigaben-Bereich anzeigen",
    "fileshare.view": "Dateifreigaben lesen (Browsen/Herunterladen der über LDAP-Gruppen zugewiesenen Freigaben -- OHNE dieses Recht wird für den Nutzer nichts gemountet)",
    "fileshare.create": "Dateien in Freigaben hochladen",
    "fileshare.edit": "Dateien in Freigaben überschreiben, umbenennen oder löschen",
}

ALL_PERMISSION_KEYS = []
PERMISSION_PARENT_GROUP = {}
for _group in PERMISSIONS.values():
    ALL_PERMISSION_KEYS.append(_group["view_key"])
    for _child in _group["children"].values():
        for _key in _child["rows"].values():
            ALL_PERMISSION_KEYS.append(_key)
            PERMISSION_PARENT_GROUP[_key] = _group["view_key"]

GROUP_ROW_TYPES = {}
for _group_key, _group in PERMISSIONS.items():
    _used_row_keys = {"view"}
    for _child in _group["children"].values():
        _used_row_keys.update(_child["rows"].keys())
    GROUP_ROW_TYPES[_group_key] = [rt for rt in PERMISSION_ROW_TYPES if rt[0] in _used_row_keys]

DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = [
    "devices_group.view", "devices.view", "switches.view",
    "logs_group.view", "logs_live.view",
]

NAV_ITEMS = [
    {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
    {"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
        {"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
        {"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
        {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
        {"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"},
    ]},
    {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
    {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
    {"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
    {"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
        {"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
        {"key": "settings_license", "label": "Lizenz", "icon": "key", "endpoint": "settings_license"},
        {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
        {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
        {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
        {"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"},
        {"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"},
    ]},
    {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
        {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
        {"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
        {"key": "logs_activity", "label": "Auditlog", "icon": "history", "endpoint": "activity_log"},
        {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
    ]},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}


def _nav_key_visible(key, user):
    if key == "index":
        return True
    if key == "devices":
        return user.can_view_devices
    if key == "switches":
        return user.can_manage_switches
    if key == "credentials":
        return user.can_manage_credentials
    if key == "maintenance":
        return user.can_view_maintenance and module_licensed("maintenance")
    if key == "papierkorb":
        return user.can_view_papierkorb
    if key == "users":
        return user.can_manage_users
    if key == "groups":
        return user.can_manage_groups
    if key == "fileshare":
        return (
            user.has_permission("fileshare.view")
            and bool(_current_fileshare_mounts())
            and module_licensed("fileshare")
        )
    if key == "settings_system":
        return user.can_view_settings_system
    if key == "settings_license":
        # Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
        # muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
        # jemals eine erste Lizenz hochladen/aktivieren.
        return user.can_view_settings_system
    if key == "settings_importexport":
        return user.can_view_settings_importexport
    if key == "settings_dhcp":
        return user.can_view_settings_dhcp and module_licensed("dhcp")
    if key == "settings_ldap":
        return user.can_view_settings_ldap
    if key == "settings_nginx":
        return user.has_permission("settings_nginx.view")
    if key == "logs_live":
        return user.can_view_live_log
    if key == "logs_history":
        return user.can_view_log_history
    if key == "logs_activity":
        return user.can_view_activity_log
    if key == "logs_kea":
        return user.can_view_kea_log and module_licensed("dhcp")
    return False


def _ordered_nav_items():
    """Komplette Navbar-Struktur (Top-Level + Unterpunkte je Gruppe) in der
    gespeicherten Reihenfolge, OHNE Sichtbarkeitsfilter — Grundlage sowohl
    für die Sidebar (dort wird danach noch nach Berechtigung gefiltert) als
    auch für den Reihenfolge-Editor auf der Account-Seite (der die
    komplette Navbar zeigen soll, unabhängig davon, was der bearbeitende
    Admin selbst sehen dürfte — als Admin ohnehin alles)."""
    try:
        stored_order = json.loads(get_setting("nav_order", "") or "[]")
    except (ValueError, TypeError):
        stored_order = []
    ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
    ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]

    try:
        stored_child_order = json.loads(get_setting("nav_child_order", "") or "{}")
    except (ValueError, TypeError):
        stored_child_order = {}

    items = []
    for k in ordered_keys:
        item = NAV_ITEMS_BY_KEY[k]
        if item.get("children"):
            children_by_key = {c["key"]: c for c in item["children"]}
            stored = stored_child_order.get(k, [])
            child_keys = [ck for ck in stored if ck in children_by_key]
            child_keys += [c["key"] for c in item["children"] if c["key"] not in child_keys]
            items.append({**item, "children": [children_by_key[ck] for ck in child_keys]})
        else:
            items.append(item)
    return items


@app.context_processor
def inject_asset_version():
    """Cache-Busting für statische Dateien (CSS/JS): hängt die letzte
    Änderungszeit der Datei als ?v=... an, damit Browser nach einem Update
    (z.B. app.js) nicht stillschweigend eine veraltete, gecachte Version
    weiterverwenden — sonst können neue Funktionen scheinbar "nicht
    funktionieren", obwohl der Code auf dem Server längst aktuell ist."""
    def asset_url(filename):
        try:
            mtime = int(os.path.getmtime(os.path.join(app.static_folder, filename)))
        except OSError:
            mtime = 0
        return f"{url_for('static', filename=filename)}?v={mtime}"
    return {"asset_url": asset_url}


@app.context_processor
def inject_current_year():
    """Für die Copyright-Zeile im Sidebar-Footer (siehe base.html)."""
    return {"current_year": datetime.now().year}


def _read_tesm_version():
    """Liest die Versionsnummer aus VERSION (im Repo neben app.py, wird von
    install.sh 1:1 nach /srv/tesm mitkopiert). Fehlt die Datei (z.B. lokale
    Entwicklung ohne vollständigen Checkout), lieber "unbekannt" anzeigen
    als die App deswegen abstürzen zu lassen."""
    try:
        with open(os.path.join(BASE_DIR, "VERSION"), "r", encoding="utf-8") as f:
            return f.read().strip() or "unbekannt"
    except OSError:
        return "unbekannt"


TESM_VERSION = _read_tesm_version()


@app.context_processor
def inject_tesm_version():
    """Für die Versionsanzeige im Sidebar-Footer (siehe base.html) — beim
    Modulimport einmalig gelesen (Neustart des Dienstes nach einem Update
    erforderlich, damit eine neue Versionsnummer sichtbar wird — geschieht
    ohnehin bei jedem install.sh/update.sh-Lauf)."""
    return {"tesm_version": TESM_VERSION}


@app.context_processor
def inject_nav():
    if not current_user.is_authenticated:
        return {}
    nav_items_ordered = []
    for item in _ordered_nav_items():
        if item.get("children"):
            visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
            if visible_children:
                nav_items_ordered.append({**item, "children": visible_children})
        elif _nav_key_visible(item["key"], current_user):
            nav_items_ordered.append(item)
    return {"nav_items_ordered": nav_items_ordered}


class User(UserMixin):
    def __init__(self, id_, username, is_admin, permissions=None, group_names=None,
                 first_name=None, last_name=None, avatar_filename=None, auth_source="local"):
        self.id = id_
        self.username = username
        self.is_admin = bool(is_admin)
        self.permissions = permissions or set()
        self.group_names = group_names or ""
        self.first_name = first_name
        self.last_name = last_name
        self.avatar_filename = avatar_filename
        self.auth_source = auth_source

    @property
    def is_ldap_user(self):
        return self.auth_source == "ldap"

    def has_permission(self, key):
        """Admins dürfen immer alles. Sonst muss das Recht selbst gesetzt
        sein UND — falls es ein übergeordnetes Bereichs-Recht gibt (siehe
        PERMISSION_PARENT_GROUP) — dieses ebenfalls: das übergeordnete Recht
        wirkt als Kill-Switch für den ganzen Bereich, selbst wenn ein
        einzelnes Kind-Recht noch gesetzt ist."""
        if self.is_admin:
            return True
        if key not in self.permissions:
            return False
        parent = PERMISSION_PARENT_GROUP.get(key)
        return parent is None or parent in self.permissions

    def has_any_permission(self, keys):
        return any(self.has_permission(k) for k in keys)

    @property
    def can_view_devices(self):
        return self.has_permission("devices.view")

    @property
    def can_manage_switches(self):
        return self.has_any_permission(
            ["switches.view", "switches.create", "switches.edit"]
        )

    @property
    def can_manage_credentials(self):
        return self.has_any_permission(
            ["credentials.view", "credentials.create", "credentials.edit"]
        )

    @property
    def can_view_maintenance(self):
        return self.has_any_permission(["maintenance.view", "maintenance.edit"])

    @property
    def can_run_maintenance(self):
        return self.has_permission("maintenance.edit")

    @property
    def can_view_papierkorb(self):
        """Papierkorb: eigener, globaler Bereich unter Einstellungen, komplett
        unabhängig von den Leserechten der einzelnen Ressourcen (Geräte/
        Switche/Zugangsdaten/Benutzer/Gruppen) -- vorher implizit sichtbar
        für jeden mit irgendeiner der fünf Ressourcen-Leserechte, wodurch
        praktisch jeder Benutzer den Papierkorb sehen konnte (Standardgruppe
        hat bereits devices.view). papierkorb.view zeigt jetzt ALLE fünf
        Kategorien auf einmal, unabhängig davon, ob der Benutzer die
        jeweilige aktive Ressource überhaupt sehen darf."""
        return self.has_permission("papierkorb.view")

    @property
    def can_edit_papierkorb(self):
        """Wiederherstellen/endgültig Löschen im Papierkorb: eigenes,
        globales Recht (papierkorb.edit) -- vorher an das jeweilige
        Ressourcen-Recht gekoppelt (z.B. switches.edit für Switche im
        Papierkorb). Wer den Papierkorb bearbeiten darf, darf jetzt JEDEN
        Eintrag darin wiederherstellen oder endgültig löschen, unabhängig
        vom sonstigen Bearbeiten-Recht der jeweiligen Ressource."""
        return self.has_permission("papierkorb.edit")

    @property
    def can_view_live_log(self):
        return self.has_permission("logs_live.view")

    @property
    def can_view_log_history(self):
        """Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite
        Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt,
        damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne
        automatisch auch durch ältere Logstände blättern zu können."""
        return self.has_permission("logs_history.view")

    @property
    def can_manage_log_history(self):
        """Zusätzlich zum reinen Lesen (can_view_log_history): archivierte
        Auditlog-Tage als ZIP exportieren und danach vom Server löschen --
        bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist,
        die über reines Einsehen des Verlaufs hinausgeht."""
        return self.has_permission("logs_history.edit")

    @property
    def can_view_activity_log(self):
        return self.has_permission("logs_activity.view")

    @property
    def can_view_kea_log(self):
        return self.has_permission("logs_kea.view")

    @property
    def can_manage_users(self):
        return self.has_any_permission(
            ["users.view", "users.create", "users.edit"]
        )

    @property
    def can_manage_groups(self):
        return self.has_any_permission(
            ["groups.view", "groups.create", "groups.edit"]
        )

    @property
    def can_view_settings_system(self):
        return self.has_permission("settings_system.view")

    @property
    def can_view_settings_importexport(self):
        return self.has_any_permission([
            "settings_importexport.view", "settings_importexport.edit", "settings_importexport.export",
        ])

    @property
    def can_view_settings_dhcp(self):
        return self.has_permission("settings_dhcp.view")

    @property
    def can_view_settings_ldap(self):
        return self.has_any_permission(["settings_ldap.view", "settings_ldap.edit"])

    @property
    def full_name(self):
        parts = [p for p in (self.first_name, self.last_name) if p]
        return " ".join(parts)

    @property
    def display_name(self):
        return self.full_name or self.username

    @property
    def avatar_url(self):
        if self.avatar_filename:
            return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}")
        return None


def get_db_connection():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


DHCP_STANDARD_OPTIONS = [
    (4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
    (7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
    (8, "cookie-servers", "ipv4-address", "Cookie-Server"),
    (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
    (10, "impress-servers", "ipv4-address", "Impress-Server"),
    (11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
    (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
    (14, "merit-dump", "string", "Crash-Dump-Datei"),
    (16, "swap-server", "ipv4-address", "Swap-Server"),
    (17, "root-path", "string", "Root-Pfad (NFS)"),
    (18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
    (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
    (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
    (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
    (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
    (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
    (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
    (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
    (26, "interface-mtu", "uint16", "MTU des Interfaces"),
    (27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
    (28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
    (29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
    (30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
    (31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
    (32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
    (33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
    (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
    (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
    (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
    (37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
    (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
    (40, "nis-domain", "string", "NIS-Domain"),
    (41, "nis-servers", "ipv4-address", "NIS-Server"),
    (42, "ntp-servers", "ipv4-address", "NTP-Server"),
    (44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
    (45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
    (46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
    (47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
    (48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
    (49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
    (60, "vendor-class-identifier", "string", "Vendor Class Identifier"),
    (64, "nisplus-domain-name", "string", "NIS+-Domain"),
    (65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
    (66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
    (67, "boot-file-name", "string", "Bootdatei-Name"),
    (68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
    (69, "smtp-server", "ipv4-address", "SMTP-Server"),
    (70, "pop-server", "ipv4-address", "POP3-Server"),
    (71, "nntp-server", "ipv4-address", "NNTP-Server"),
    (72, "www-server", "ipv4-address", "WWW-Server"),
    (73, "finger-server", "ipv4-address", "Finger-Server"),
    (74, "irc-server", "ipv4-address", "IRC-Server"),
    (75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
    (76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
    (118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"),
    (119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
]


def _ensure_schema():
    """Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer
    älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen.
    Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und
    jeder Benutzer ohne Gruppe ihr zugeordnet ist."""
    conn = get_db_connection()
    conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
    conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS group_permissions (
            group_id INTEGER NOT NULL,
            permission TEXT NOT NULL,
            PRIMARY KEY (group_id, permission),
            FOREIGN KEY (group_id) REFERENCES groups(id)
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS user_groups (
            user_id INTEGER NOT NULL,
            group_id INTEGER NOT NULL,
            PRIMARY KEY (user_id, group_id),
            FOREIGN KEY (user_id) REFERENCES users(id),
            FOREIGN KEY (group_id) REFERENCES groups(id)
        )
    """)

    existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()}
    if "is_default" not in existing_cols:
        conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
    if "is_system" not in existing_cols:
        conn.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0")

    user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
    if "first_name" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT")
    if "last_name" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT")
    if "avatar_filename" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT")
    if "auth_source" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'")
    if "email" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
    if "is_locked" not in user_cols:
        conn.execute("ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")

    conn.execute("""
        CREATE TABLE IF NOT EXISTS service_accounts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            purpose TEXT UNIQUE NOT NULL,
            username TEXT NOT NULL,
            password TEXT NOT NULL
        )
    """)

    conn.execute("""
        CREATE TABLE IF NOT EXISTS ldap_group_mappings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ad_group_dn TEXT UNIQUE NOT NULL,
            ad_group_name TEXT NOT NULL,
            app_group_id INTEGER NOT NULL,
            FOREIGN KEY (app_group_id) REFERENCES groups(id)
        )
    """)

    conn.execute("""
        CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ad_group_dn TEXT NOT NULL,
            ad_group_name TEXT NOT NULL,
            share_label TEXT NOT NULL,
            share_unc TEXT NOT NULL,
            UNIQUE(ad_group_dn, share_unc)
        )
    """)

    conn.execute("""
        CREATE TABLE IF NOT EXISTS audit_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ts TEXT NOT NULL,
            username TEXT NOT NULL,
            action TEXT NOT NULL,
            target TEXT,
            details TEXT
        )
    """)

    device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
    if "rpi_ip" in device_cols and "ip" not in device_cols:
        conn.execute("ALTER TABLE devices RENAME COLUMN rpi_ip TO ip")
        device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}

    if "last_modified_by" not in device_cols:
        conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT")
    if "last_modified_at" not in device_cols:
        conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT")

    conn.execute("""
        CREATE TABLE IF NOT EXISTS credentials (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT UNIQUE NOT NULL,
            username TEXT NOT NULL,
            password TEXT NOT NULL,
            category TEXT NOT NULL DEFAULT 'switch'
        )
    """)
    credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()}
    if "category" not in credential_cols:
        conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'")
    switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
    if "credential_id" not in switch_cols:
        conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
    if "last_modified_by" not in switch_cols:
        conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT")
    if "last_modified_at" not in switch_cols:
        conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT")
    if "ssh_port" not in switch_cols:
        conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER")

    device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
    if "credential_id" not in device_ssh_cols:
        conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER")
    if "ssh_port" not in device_ssh_cols:
        conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER")

    for _table in ("devices", "switches", "credentials", "users", "groups"):
        _cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({_table})").fetchall()}
        if "deleted_at" not in _cols:
            conn.execute(f"ALTER TABLE {_table} ADD COLUMN deleted_at TEXT")

    conn.execute("""
        CREATE TABLE IF NOT EXISTS dhcp_option_defs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            code INTEGER NOT NULL UNIQUE,
            name TEXT NOT NULL UNIQUE,
            type TEXT NOT NULL,
            description TEXT,
            is_standard INTEGER NOT NULL DEFAULT 0
        )
    """)
    option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()}
    if "is_standard" not in option_def_cols:
        conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS dhcp_option_values (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            option_def_id INTEGER NOT NULL,
            device_mac TEXT NOT NULL DEFAULT '',
            value TEXT NOT NULL,
            FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id),
            UNIQUE (option_def_id, device_mac)
        )
    """)
    _migration_key_std_opts = "_seeded_dhcp_standard_options_v1"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone():
        for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
            conn.execute(
                "INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
                "VALUES (?, ?, ?, ?, 1)",
                (code, name, opt_type, description),
            )
        conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,))
    _migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone():
        for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
            conn.execute(
                "INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
                "VALUES (?, ?, ?, ?, 1)",
                (code, name, opt_type, description),
            )
        conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,))
    _migration_key_std_opts_v3 = "_removed_dhcp_standard_option_hostname_v3"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v3,)).fetchone():
        stale = conn.execute("SELECT id FROM dhcp_option_defs WHERE name='host-name' AND is_standard=1").fetchone()
        if stale:
            conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (stale["id"],))
            conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (stale["id"],))
        conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v3,))

    conn.execute("""
        CREATE TABLE IF NOT EXISTS dhcp_manual_reservations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            mac TEXT NOT NULL UNIQUE,
            ip TEXT NOT NULL,
            name TEXT NOT NULL
        )
    """)

    conn.execute("""
        CREATE TABLE IF NOT EXISTS dhcp_subnets (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            interface TEXT NOT NULL,
            range_start TEXT NOT NULL,
            range_end TEXT NOT NULL,
            gateway TEXT,
            dns TEXT,
            sort_order INTEGER NOT NULL DEFAULT 0,
            enabled INTEGER NOT NULL DEFAULT 1
        )
    """)
    dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()}
    if "enabled" not in dhcp_subnet_cols:
        conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
    _migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone():
        legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone()
        legacy_end = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_end'").fetchone()
        if legacy_start and legacy_end and legacy_start["value"] and legacy_end["value"]:
            legacy_interface = conn.execute("SELECT value FROM settings WHERE key='dhcp_interface'").fetchone()
            legacy_gateway = conn.execute("SELECT value FROM settings WHERE key='dhcp_gateway'").fetchone()
            legacy_dns = conn.execute("SELECT value FROM settings WHERE key='dhcp_dns'").fetchone()
            conn.execute(
                "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
                "VALUES (?, ?, ?, ?, ?, 0)",
                (
                    (legacy_interface["value"] if legacy_interface else None) or "eth0",
                    legacy_start["value"], legacy_end["value"],
                    legacy_gateway["value"] if legacy_gateway else None,
                    legacy_dns["value"] if legacy_dns else None,
                ),
            )
        conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,))

    if "username" in switch_cols and "password" in switch_cols:
        legacy_switches = conn.execute(
            "SELECT hostname, username, password FROM switches WHERE credential_id IS NULL"
        ).fetchall()
        for sw in legacy_switches:
            base_name = f"{sw['hostname']} (migriert)"
            final_name, suffix = base_name, 1
            while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone():
                suffix += 1
                final_name = f"{base_name} {suffix}"
            cur = conn.execute(
                "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
                (final_name, sw["username"], sw["password"]),
            )
            conn.execute(
                "UPDATE switches SET credential_id=? WHERE hostname=?",
                (cur.lastrowid, sw["hostname"]),
            )

    _migration_key = "_migrated_permission_hierarchy_v1"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone():
        toggle_groups = conn.execute(
            "SELECT DISTINCT group_id FROM group_permissions WHERE permission='devices.toggle'"
        ).fetchall()
        for row in toggle_groups:
            conn.execute(
                "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, 'devices.edit')",
                (row["group_id"],),
            )
        conn.execute("DELETE FROM group_permissions WHERE permission='devices.toggle'")

        _switch_to_cred = {
            "switches.view": "credentials.view",
            "switches.create": "credentials.create",
            "switches.edit": "credentials.edit",
            "switches.delete": "credentials.delete",
        }
        for switch_perm, cred_perm in _switch_to_cred.items():
            rows = conn.execute(
                "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (switch_perm,)
            ).fetchall()
            for row in rows:
                conn.execute(
                    "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
                    (row["group_id"], cred_perm),
                )

        _parent_backfill = [
            ("devices_group.view", [
                "devices.view", "devices.create", "devices.edit", "devices.delete", "devices.restart",
                "switches.view", "switches.create", "switches.edit", "switches.delete",
                "credentials.view", "credentials.create", "credentials.edit", "credentials.delete",
            ]),
            ("logs_group.view", ["logs_live.view", "logs_activity.view"]),
            ("settings_group.view", [
                "users.view", "users.create", "users.edit", "users.delete",
                "groups.view", "groups.create", "groups.edit", "groups.delete",
                "settings_system.view", "settings_system.edit",
                "settings_importexport.view", "settings_importexport.edit",
            ]),
        ]
        for parent_key, child_keys in _parent_backfill:
            placeholders = ",".join("?" * len(child_keys))
            rows = conn.execute(
                f"SELECT DISTINCT group_id FROM group_permissions WHERE permission IN ({placeholders})",
                child_keys,
            ).fetchall()
            for row in rows:
                conn.execute(
                    "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
                    (row["group_id"], parent_key),
                )

        conn.execute(
            "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,)
        )

    _migration_key_v2 = "_migrated_permission_delete_fold_v2"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone():
        _delete_to_edit = {
            "devices.delete": "devices.edit",
            "switches.delete": "switches.edit",
            "credentials.delete": "credentials.edit",
            "users.delete": "users.edit",
            "groups.delete": "groups.edit",
        }
        for delete_perm, edit_perm in _delete_to_edit.items():
            rows = conn.execute(
                "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (delete_perm,)
            ).fetchall()
            for row in rows:
                conn.execute(
                    "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
                    (row["group_id"], edit_perm),
                )
            conn.execute("DELETE FROM group_permissions WHERE permission=?", (delete_perm,))

        conn.execute(
            "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
        )

    _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
    if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
        for stale_log in glob.glob("/var/log/rpi-*.log"):
            try:
                os.remove(stale_log)
            except OSError:
                pass
        conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_old_logs,))

    cur = conn.execute(
        "INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)",
        (DEFAULT_GROUP_NAME,),
    )
    default_group_id = cur.lastrowid if cur.rowcount > 0 else conn.execute(
        "SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,)
    ).fetchone()["id"]
    conn.execute("UPDATE groups SET is_default=1, is_system=1 WHERE id=?", (default_group_id,))
    conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default_group_id,))
    conn.executemany(
        "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
        [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS],
    )

    orphan_users = conn.execute("""
        SELECT users.id FROM users
        LEFT JOIN user_groups ON user_groups.user_id = users.id
        WHERE users.is_admin = 0 AND user_groups.user_id IS NULL AND users.deleted_at IS NULL
    """).fetchall()
    conn.executemany(
        "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
        [(u["id"], default_group_id) for u in orphan_users],
    )

    conn.commit()
    conn.close()


_ensure_schema()


def _build_user(row):
    """Baut ein vollständiges User-Objekt inkl. effektiver Rechte aus allen
    Gruppen, denen der Benutzer angehört (Vereinigung, nicht Schnittmenge)."""
    conn = get_db_connection()
    group_rows = conn.execute("""
        SELECT g.id, g.name FROM groups g
        JOIN user_groups ug ON ug.group_id = g.id
        WHERE ug.user_id = ? AND g.deleted_at IS NULL
        ORDER BY g.name ASC
    """, (row["id"],)).fetchall()

    permissions = set()
    if group_rows:
        placeholders = ",".join("?" * len(group_rows))
        perm_rows = conn.execute(
            f"SELECT DISTINCT permission FROM group_permissions WHERE group_id IN ({placeholders})",
            [g["id"] for g in group_rows],
        ).fetchall()
        permissions = {p["permission"] for p in perm_rows}
    conn.close()

    group_names = ", ".join(g["name"] for g in group_rows)
    return User(
        row["id"], row["username"], row["is_admin"], permissions, group_names,
        first_name=row["first_name"] if "first_name" in row.keys() else None,
        last_name=row["last_name"] if "last_name" in row.keys() else None,
        avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None,
        auth_source=row["auth_source"] if "auth_source" in row.keys() else "local",
    )


def _append_changes_log(ts, who, action, target, details):
    """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
    logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle
    bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
    Logs → Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt
    dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
    Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
    nicht gefährden — daher bewusst best-effort mit breitem except."""
    try:
        line = f"{ts} [{who}] {action}"
        if target:
            line += f" — {target}"
        if details:
            line += f": {details}"
        with open(TESM_CHANGES_LOG_PATH, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except OSError:
        pass


def log_action(action, target=None, details=None):
    """Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
    PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
    who = current_user.username if current_user.is_authenticated else "system"
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    conn = get_db_connection()
    conn.execute(
        "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
        (ts, who, action, target, details),
    )
    conn.commit()
    conn.close()
    _append_changes_log(ts, who, action, target, details)


def log_action_system(action, target=None, details=None):
    """Wie log_action(), aber ohne Abhängigkeit von current_user — für
    Code, der außerhalb eines Request-Kontexts läuft (z.B. der
    Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user
    nicht auflösbar ist und einen AttributeError werfen würde)."""
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    conn = get_db_connection()
    conn.execute(
        "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
        (ts, "system", action, target, details),
    )
    conn.commit()
    conn.close()
    _append_changes_log(ts, "system", action, target, details)


def _format_audit_line(ts, who, action, target, details):
    """Gleiches Zeilenformat wie _append_changes_log(), damit ein
    archivierter Auditlog-Tag optisch identisch zu changes.log aussieht."""
    line = f"{ts} [{who}] {action}"
    if target:
        line += f" — {target}"
    if details:
        line += f": {details}"
    return line


def _audit_archive_file_path(day_str):
    """day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten
    DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei
    _resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig."""
    return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log")


def _audit_archive_files():
    """Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage,
    neueste zuerst, mit Größe für die Dateiliste."""
    if not os.path.isdir(AUDIT_ARCHIVE_DIR):
        return []
    out = []
    for name in os.listdir(AUDIT_ARCHIVE_DIR):
        m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name)
        if not m:
            continue
        full_path = os.path.join(AUDIT_ARCHIVE_DIR, name)
        try:
            size = os.path.getsize(full_path)
        except OSError:
            continue
        out.append({"filename": name, "day": m.group(1), "size": size})
    out.sort(key=lambda f: f["day"], reverse=True)
    return out


def _resolve_audit_archive_file(filename):
    """Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt
    ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von
    AUDIT_ARCHIVE_DIR."""
    if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename):
        return None
    full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename)
    if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR):
        return None
    if not os.path.isfile(full_path):
        return None
    return full_path


def _log_disk_space_warning():
    """None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs
    Verlauf-Template. Bewusst nur eine Warnung (kein automatisches
    Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung
    über den Export-und-Löschen-Button."""
    try:
        free = shutil.disk_usage(TESM_LOG_DIR).free
    except OSError:
        return None
    if free >= LOG_DISK_SPACE_WARNING_BYTES:
        return None
    free_mb = free // (1024 * 1024)
    return (
        f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). "
        f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und "
        f"dabei vom Server gelöscht werden, um Platz zu schaffen."
    )


def _archive_old_audit_log_rows():
    """Hält die audit_log-Tabelle auf eine vernünftige historische
    Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten
    ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem
    heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je
    eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR
    geschrieben und anschließend aus der DB gelöscht, so lange bis
    AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein
    archivierbarer (nicht-heutiger) Tag mehr übrig ist.

    Bewusst unabhängig von der wöchentlichen logrotate-Rotation von
    changes.log (siehe _write_logrotate_config): die Dateien hier sind
    tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag
    auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt
    sich nicht so eindeutig einem Tag zuordnen.

    Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'):
    die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit
    für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach
    einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben
    Dateiinhalt erneut, statt Zeilen zu duplizieren."""
    conn = get_db_connection()
    archived_days = 0
    archived_rows = 0
    try:
        total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"]
        if total <= AUDIT_LOG_ARCHIVE_THRESHOLD:
            return

        today_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
        os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True)

        while total > AUDIT_LOG_ARCHIVE_TARGET:
            oldest = conn.execute(
                "SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,)
            ).fetchone()["ts"]
            if not oldest:
                break  # nur noch der heutige Tag übrig -- nicht anfassen
            day_str = oldest[:10]
            day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59"
            rows = conn.execute(
                "SELECT ts, username, action, target, details FROM audit_log "
                "WHERE ts >= ? AND ts <= ? ORDER BY ts, id",
                (day_start, day_end),
            ).fetchall()
            if not rows:
                break

            lines = [
                _format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"])
                for r in rows
            ]
            with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f:
                f.write("\n".join(lines) + "\n")

            conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end))
            conn.commit()

            archived_days += 1
            archived_rows += len(rows)
            total -= len(rows)
    finally:
        conn.close()

    if archived_rows:
        log_action_system(
            "auditlog.archive",
            "Auditlog",
            f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert "
            f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET}).",
        )


def touch_record(conn, table, key_col, key_val):
    """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
    who = current_user.username if current_user.is_authenticated else "system"
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    conn.execute(
        f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?",
        (who, now, key_val),
    )


def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet:
    """Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) —
    für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export
    (im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist)."""
    kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
    key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
    return Fernet(key)


def encrypt_password(password: str) -> str:
    return fernet.encrypt(password.encode()).decode()


def decrypt_password(token: str) -> str:
    return fernet.decrypt(token.encode()).decode()


def get_setting(key, default=None):
    conn = get_db_connection()
    row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
    conn.close()
    return row["value"] if row else default


def set_setting(key, value):
    conn = get_db_connection()
    conn.execute(
        "INSERT INTO settings (key, value) VALUES (?, ?) "
        "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
        (key, value),
    )
    conn.commit()
    conn.close()


LOG_ROTATION_INTERVALS = {"daily", "weekly", "monthly"}
LOG_ROTATION_DEFAULT_INTERVAL = "weekly"
LOG_ROTATION_DEFAULT_KEEP = 4


def _write_logrotate_config():
    """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
    App-Logs (Live, Auditlog, App unter TESM_LOG_DIR; Kea-DHCP separat
    unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen
    einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen
    Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start
    (Rotation ist dadurch auch ohne einen expliziten Besuch der
    Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort.
    "copytruncate" statt Signal-/Neustart-basierter Rotation, da keiner der
    drei schreibenden Prozesse (poe.sh, Flask, kea-dhcp4-server) ein
    Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate
    funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea
    eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas
    eigenem AppArmor-Profil). "su root root" ist eine explizite Absicherung
    dagegen, dass logrotate die Rotation wegen "insecure permissions"
    verweigert, sobald TESM_LOG_DIR aus irgendeinem Grund wieder
    gruppen-/world-writable wird (live reproduziert: bei 0o777 rotierte
    logrotate live.log gar nicht mehr, siehe auch os.chmod(TESM_LOG_DIR)
    weiter oben, das den eigentlichen Regelfall -- 0o755 -- durchsetzt)."""
    interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL)
    if interval not in LOG_ROTATION_INTERVALS:
        interval = LOG_ROTATION_DEFAULT_INTERVAL
    try:
        keep = int(get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP))
        if keep < 1:
            keep = LOG_ROTATION_DEFAULT_KEEP
    except (TypeError, ValueError):
        keep = LOG_ROTATION_DEFAULT_KEEP

    content = (
        f"{TESM_LOG_DIR}/*.log {TESM_KEA_LOG_PATH} {{\n"
        f"    {interval}\n"
        f"    rotate {keep}\n"
        f"    missingok\n"
        f"    notifempty\n"
        f"    copytruncate\n"
        f"    su root root\n"
        f"}}\n"
    )
    try:
        with open(LOGROTATE_CONFIG_PATH, "w", encoding="utf-8") as f:
            f.write(content)
        return True
    except OSError:
        return False


_write_logrotate_config()


@login_manager.user_loader
def load_user(user_id):
    conn = get_db_connection()
    user = conn.execute("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone()
    conn.close()
    if user:
        return _build_user(user)
    return None



LDAP_DEFAULT_PORT = 389
LDAP_DEFAULT_FILTER_ATTR = "sAMAccountName"


def _ldap_groups_for_dropdown():
    conn = get_db_connection()
    rows = conn.execute(
        "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC"
    ).fetchall()
    conn.close()
    return rows


LDAP_BIND_SERVICE_ACCOUNT_PURPOSE = "ldap_bind"


def _ldap_bind_account():
    """Liest das LDAP-Bind-Konto aus der service_accounts-Tabelle (nicht
    aus settings — siehe Kommentar bei der Tabellen-Definition)."""
    conn = get_db_connection()
    row = conn.execute(
        "SELECT username, password FROM service_accounts WHERE purpose=?",
        (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,),
    ).fetchone()
    conn.close()
    return (row["username"], row["password"]) if row else ("", "")


def _ldap_settings():
    bind_dn, bind_password_enc = _ldap_bind_account()
    return {
        "enabled": get_setting("ldap_enabled", "0") == "1",
        "server": get_setting("ldap_server", "") or "",
        "port": get_setting("ldap_port", str(LDAP_DEFAULT_PORT)) or str(LDAP_DEFAULT_PORT),
        "use_ssl": get_setting("ldap_use_ssl", "0") == "1",
        "tls_skip_verify": get_setting("ldap_tls_skip_verify", "0") == "1",
        "bind_dn": bind_dn,
        "bind_password_enc": bind_password_enc,
        "base_dn": get_setting("ldap_base_dn", "") or "",
        "filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
        "default_group": get_setting("ldap_default_group", "") or "",
        "required_login_group": get_setting("ldap_required_login_group", "") or "",
    }


_LDAP_TRANSIENT_EXCEPTIONS = (
    LDAPSocketOpenError,
    LDAPSocketReceiveError,
    LDAPSocketSendError,
    LDAPResponseTimeoutError,
)


def _ldap_connect_with_retry(factory):
    """Baut eine LDAP-Verbindung auf, mit genau einem automatischen
    Wiederholversuch bei einem rein netzwerk-/timeout-bedingten Fehler
    (z.B. ein Domain-Controller in einem anderen, langsameren Netzsegment
    -- die allererste Verbindung dorthin ist oft spürbar langsamer als
    jede folgende, live reproduziert: erster Login schlug mit "Benutzer/
    Passwort ungültig" fehl, ein sofortiger zweiter Versuch klappte). Der
    Retry greift NICHT bei einer tatsächlich abgelehnten Anmeldung (z.B.
    falsches Passwort, eigene Exception-Klasse in ldap3) -- sonst würde
    ein einzelner Tippfehler beim Passwort einen AD-Kontosperren-Zähler
    unnötig doppelt statt einmal hochzählen. factory ist ein parameterloser
    Callable, das die Verbindung aufbaut (auto_bind=True) und zurückgibt."""
    try:
        return factory()
    except _LDAP_TRANSIENT_EXCEPTIONS:
        return factory()


def _ldap_connect_service(cfg):
    """Baut Server-Objekt + Service-Konto-Verbindung auf. Wirft nie —
    liefert (server, connection) oder (None, None) samt Fehlermeldung im
    zweiten Rückgabewert bei Bedarf über die aufrufende Funktion."""
    try:
        port = int(cfg["port"])
    except (TypeError, ValueError):
        port = LDAP_DEFAULT_PORT
    try:
        bind_password = decrypt_password(cfg["bind_password_enc"]) if cfg["bind_password_enc"] else ""
    except Exception:
        return None, "Bind-Passwort konnte nicht entschlüsselt werden."
    try:
        tls = None
        if cfg["use_ssl"] and cfg.get("tls_skip_verify"):
            tls = Tls(validate=ssl.CERT_NONE)
        server = Server(cfg["server"], port=port, use_ssl=cfg["use_ssl"], tls=tls, get_info=ALL, connect_timeout=10)
        conn = _ldap_connect_with_retry(lambda: Connection(
            server, user=cfg["bind_dn"], password=bind_password,
            authentication=SIMPLE, auto_bind=True, receive_timeout=10,
        ))
        return (server, conn), None
    except LDAPException as e:
        return None, str(e)


def _ldap_base_dn(server, cfg):
    if cfg["base_dn"].strip():
        return cfg["base_dn"].strip()
    try:
        return str(server.info.other["defaultNamingContext"][0])
    except (KeyError, IndexError, TypeError, AttributeError):
        return None


def _ldap_test_connection(cfg=None):
    """Für den 'Verbindung testen'-Button in den Systemeinstellungen —
    bindet mit dem Service-Konto und ermittelt die (automatische oder
    konfigurierte) Base-DN, ohne einen echten Benutzer zu prüfen. Testet
    standardmäßig die zuletzt gespeicherten Einstellungen, akzeptiert aber
    ein cfg-Override, damit "Verbindung testen" wie beim SSH-Terminal von
    Switchen/Geräten die aktuell im Formular eingetragenen (noch nicht
    gespeicherten) Werte prüfen kann. Gibt (True, meldung) oder
    (False, fehler) zurück."""
    cfg = cfg or _ldap_settings()
    if not cfg["server"] or not cfg["bind_dn"]:
        return False, "Server und Bind-Konto müssen ausgefüllt sein."
    result, error = _ldap_connect_service(cfg)
    if error:
        return False, f"Verbindung/Bind fehlgeschlagen: {error}"
    server, conn = result
    base_dn = _ldap_base_dn(server, cfg)
    conn.unbind()
    if not base_dn:
        return False, "Verbunden, aber Base-DN konnte nicht automatisch ermittelt werden — bitte manuell angeben."
    return True, f"Verbindung erfolgreich. Base-DN: {base_dn}"


_LDAP_ENTRY_ATTRS = ["distinguishedName", "givenName", "sn", "displayName", "userPrincipalName", "userAccountControl"]


def _ldap_find_entry(service_conn, base_dn, cfg, value):
    """Sucht GENAU einen Verzeichniseintrag, dessen konfiguriertes
    Login-Attribut (Standard sAMAccountName) ODER UPN (userPrincipalName,
    dient hier zugleich als "E-Mail" -- siehe _ldap_entry_info) mit dem
    übergebenen Wert übereinstimmt — deckt damit Login per bloßem
    Benutzernamen UND per user@domain in einer einzigen Suche ab. Fällt bei
    einem LDAP-Fehler (z.B. ein Server ohne userPrincipalName-Attribut im
    Schema) auf eine einfache Suche nur nach dem Login-Attribut zurück.
    Gibt das ldap3-Entry-Objekt oder None zurück (None auch bei mehr als
    einem Treffer — mehrdeutig soll wie "nicht gefunden" behandelt werden)."""
    safe_value = escape_filter_chars(value)
    combined_filter = f"(|({cfg['filter_attr']}={safe_value})(userPrincipalName={safe_value}))"
    try:
        service_conn.search(search_base=base_dn, search_filter=combined_filter, attributes=_LDAP_ENTRY_ATTRS)
    except LDAPException:
        try:
            service_conn.search(
                search_base=base_dn, search_filter=f"({cfg['filter_attr']}={safe_value})",
                attributes=_LDAP_ENTRY_ATTRS,
            )
        except LDAPException:
            return None
    if len(service_conn.entries) != 1:
        return None
    return service_conn.entries[0]


def _ldap_entry_info(entry, cfg):
    """Extrahiert Benutzername/Name/E-Mail/Sperrstatus aus einem
    ldap3-Entry. Nutzt givenName/sn (echte AD-Attribute) statt eine
    Vermutung anhand von displayName — fällt auf ein Aufsplitten von
    displayName nur zurück, wenn givenName/sn nicht gesetzt sind (z.B.
    minimalistisches generisches LDAP ohne diese Attribute)."""
    def _attr(name):
        val = getattr(entry, name, None)
        return str(val) if val is not None and val.value else None

    canonical_username = _attr(cfg["filter_attr"])
    first_name = _attr("givenName")
    last_name = _attr("sn")
    if not first_name and not last_name:
        display_name = _attr("displayName") or ""
        first_name, _, last_name = display_name.partition(" ")
        first_name = first_name or None
        last_name = last_name or None

    disabled = False
    uac = _attr("userAccountControl")
    if uac is not None:
        try:
            disabled = bool(int(uac) & 2)
        except ValueError:
            pass

    return {
        "username": canonical_username,
        "first_name": first_name,
        "last_name": last_name,
        "email": _attr("userPrincipalName"),
        "disabled": disabled,
    }


def _ldap_lookup(username):
    """Wie _ldap_authenticate(), aber OHNE Passwort-Prüfung — für die
    Vorab-Zuweisung eines AD-Benutzers zu einer Gruppe, bevor er sich das
    erste Mal angemeldet hat (siehe /users/ldap_add). Gibt (True, info)
    oder (False, None) zurück."""
    cfg = _ldap_settings()
    if not cfg["enabled"] or not username:
        return False, None
    if not cfg["server"] or not cfg["bind_dn"]:
        return False, None
    result, error = _ldap_connect_service(cfg)
    if error:
        return False, None
    server, service_conn = result
    try:
        base_dn = _ldap_base_dn(server, cfg)
        if not base_dn:
            return False, None
        entry = _ldap_find_entry(service_conn, base_dn, cfg, username)
        if not entry:
            return False, None
        return True, _ldap_entry_info(entry, cfg)
    except LDAPException:
        return False, None
    finally:
        try:
            service_conn.unbind()
        except Exception:
            pass


def _ldap_search_users(query, limit=25):
    """Durchsucht das Verzeichnis per Substring-Suche über Login-Attribut,
    Vor-/Nachname und E-Mail — für "Aus Active Directory hinzufügen" in
    der Benutzerverwaltung (Vorab-Zuweisung, siehe _ldap_lookup). Gibt eine
    Liste von Dicts zurück (siehe _ldap_entry_info), leer bei Fehler/
    deaktiviertem LDAP."""
    cfg = _ldap_settings()
    if not cfg["enabled"] or not query.strip():
        return []
    if not cfg["server"] or not cfg["bind_dn"]:
        return []
    result, error = _ldap_connect_service(cfg)
    if error:
        return []
    server, service_conn = result
    try:
        base_dn = _ldap_base_dn(server, cfg)
        if not base_dn:
            return []
        safe_q = escape_filter_chars(query.strip())
        filt = (
            f"(&(objectClass=person)"
            f"(|({cfg['filter_attr']}=*{safe_q}*)(displayName=*{safe_q}*)(givenName=*{safe_q}*)(sn=*{safe_q}*)(userPrincipalName=*{safe_q}*)))"
        )
        try:
            service_conn.search(search_base=base_dn, search_filter=filt, attributes=_LDAP_ENTRY_ATTRS, size_limit=limit)
        except LDAPException:
            return []
        results = []
        for entry in service_conn.entries:
            info = _ldap_entry_info(entry, cfg)
            if info["username"]:
                results.append(info)
        return results
    except LDAPException:
        return []
    finally:
        try:
            service_conn.unbind()
        except Exception:
            pass


def _ldap_is_member_of(service_conn, user_dn, group_dn):
    """Prüft AD-Gruppenmitgliedschaft REKURSIV (auch über verschachtelte
    Gruppen) über LDAP_MATCHING_RULE_IN_CHAIN, statt nur das flache,
    nicht-rekursive memberOf-Attribut des Benutzers direkt zu lesen —
    jemand in einer Gruppe, die wiederum Mitglied der erforderlichen
    Gruppe ist, soll sich genauso anmelden dürfen wie ein direktes
    Mitglied."""
    try:
        service_conn.search(
            search_base=user_dn, search_scope=BASE,
            search_filter=f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={escape_filter_chars(group_dn)})",
            attributes=[],
        )
        return len(service_conn.entries) == 1
    except LDAPException:
        return False


def _ldap_list_groups(limit=300):
    """Alle AD-Gruppen fürs Auswahl-Dropdown der erforderlichen Gruppe in
    den Systemeinstellungen — per Service-Konto, keine Benutzer-Zugangsdaten
    nötig. Gibt eine Liste von {"dn":..., "name":...} zurück, sortiert nach
    Name, leer bei Fehler."""
    cfg = _ldap_settings()
    if not cfg["server"] or not cfg["bind_dn"]:
        return []
    result, error = _ldap_connect_service(cfg)
    if error:
        return []
    server, service_conn = result
    try:
        base_dn = _ldap_base_dn(server, cfg)
        if not base_dn:
            return []
        try:
            service_conn.search(
                search_base=base_dn, search_filter="(objectClass=group)",
                attributes=["distinguishedName", "cn", "sAMAccountName"], size_limit=limit,
            )
        except LDAPException:
            return []
        groups = []
        for entry in service_conn.entries:
            dn = str(entry.distinguishedName) if "distinguishedName" in entry and entry.distinguishedName.value else None
            if not dn:
                continue
            name = None
            if "cn" in entry and entry.cn.value:
                name = str(entry.cn)
            elif "sAMAccountName" in entry and entry.sAMAccountName.value:
                name = str(entry.sAMAccountName)
            groups.append({"dn": dn, "name": name or dn})
        groups.sort(key=lambda g: g["name"].lower())
        return groups
    except LDAPException:
        return []
    finally:
        try:
            service_conn.unbind()
        except Exception:
            pass


def _ldap_group_mappings():
    conn = get_db_connection()
    rows = conn.execute("""
        SELECT m.id, m.ad_group_dn, m.ad_group_name, m.app_group_id,
               CASE
                   WHEN m.app_group_id = 'admin' THEN 'Admin'
                   WHEN g.name IS NULL THEN '(gelöscht)'
                   ELSE g.name
               END AS app_group_name
        FROM ldap_group_mappings m
        LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL
        ORDER BY m.ad_group_name ASC
    """).fetchall()
    conn.close()
    return rows


def _ldap_resolve_app_groups(service_conn, user_dn):
    """Prüft für jede konfigurierte AD-Gruppen-Zuordnung, ob der Benutzer
    Mitglied ist (auch über verschachtelte Gruppen, siehe
    _ldap_is_member_of), und gibt die Menge der zugeordneten App-Gruppen
    zurück -- normale Gruppen-IDs (int) sowie ggf. das Sentinel "admin".
    Additiv gedacht: mehrere passende AD-Gruppen ergeben die Vereinigung
    ihrer App-Gruppen, passend zum bestehenden Rechtesystem (mehrere
    Gruppen je Benutzer, Rechte addieren sich)."""
    matched = set()
    for mapping in _ldap_group_mappings():
        if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
            matched.add(mapping["app_group_id"])
    return matched


def _normalize_share_unc(raw):
    """Normalisiert einen vom Formular übergebenen Freigabe-Pfad auf das
    von mount.cifs benötigte "//server/freigabe"-Format -- ein
    Windows-Admin gibt naturgemäß "\\\\server\\freigabe" ein."""
    unc = raw.strip().replace("\\", "/")
    if unc and not unc.startswith("//"):
        unc = "//" + unc.lstrip("/")
    return unc


def _ldap_fileshare_mappings():
    conn = get_db_connection()
    rows = conn.execute(
        "SELECT id, ad_group_dn, ad_group_name, share_label, share_unc "
        "FROM ldap_fileshare_mappings ORDER BY ad_group_name ASC"
    ).fetchall()
    conn.close()
    return rows


def _ldap_resolve_fileshares(service_conn, user_dn):
    """Analog zu _ldap_resolve_app_groups, nur für Dateifreigaben: prüft für
    jede konfigurierte AD-Gruppe -> Freigabe-Zuordnung die (rekursive)
    Mitgliedschaft und gibt die Liste der passenden Freigaben zurück.
    Bewusst getrennt vom TESM-eigenen Rechtesystem -- OB überhaupt gemountet
    werden darf, entscheidet current_user.has_permission('fileshare.view')
    (siehe _mount_fileshares_for_login), WELCHE Freigaben es im Erfolgsfall
    sind, ausschließlich die AD-Gruppenmitgliedschaft hier."""
    matched = []
    seen_labels = set()
    for mapping in _ldap_fileshare_mappings():
        if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
            label = mapping["share_label"]
            if label in seen_labels:
                continue
            seen_labels.add(label)
            matched.append({"label": label, "unc": mapping["share_unc"]})
    return matched


def _ldap_authenticate(username, password):
    """Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
    für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
    wie bei _ldap_entry_info), sonst (False, None) — nie eine Exception
    nach außen, ein LDAP-Fehler soll wie eine falsche Anmeldung behandelt
    werden, nicht die Login-Seite mit einem 500er abbrechen."""
    cfg = _ldap_settings()
    if not cfg["enabled"] or not username or not password:
        return False, None
    if not cfg["server"] or not cfg["bind_dn"]:
        return False, None

    result, error = _ldap_connect_service(cfg)
    if error:
        return False, None
    server, service_conn = result

    try:
        base_dn = _ldap_base_dn(server, cfg)
        if not base_dn:
            return False, None
        entry = _ldap_find_entry(service_conn, base_dn, cfg, username)
        if not entry:
            return False, None
        user_dn = str(entry.distinguishedName)
        info = _ldap_entry_info(entry, cfg)
        if info["disabled"]:
            return False, None
        if cfg["required_login_group"] and not _ldap_is_member_of(service_conn, user_dn, cfg["required_login_group"]):
            # Kein Mitglied der erforderlichen AD-Gruppe -- Login wird
            # abgelehnt wie bei falschen Zugangsdaten, ohne den eigentlichen
            # Passwort-Bind unten überhaupt erst zu versuchen. Bewusst
            # dieselbe generische Fehlermeldung wie jeder andere
            # Login-Fehlschlag, um keine Gruppenmitgliedschaft zu verraten.
            return False, None
        info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
        info["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
    except LDAPException:
        return False, None
    finally:
        try:
            service_conn.unbind()
        except Exception:
            pass

    try:
        user_conn = _ldap_connect_with_retry(lambda: Connection(
            server, user=user_dn, password=password,
            authentication=SIMPLE, auto_bind=True, receive_timeout=10,
        ))
        user_conn.unbind()
    except LDAPException:
        return False, None

    return True, info



def _assign_ldap_groups(conn, user_id, app_groups):
    """Weist die per AD-Gruppenzuordnung ermittelten App-Gruppen zu — rein
    additiv (nimmt nie eine bereits vorhandene, z.B. manuell zugewiesene
    Gruppe wieder weg), damit ein Admin-Eingriff nie durch den nächsten
    Login überschrieben wird. Das Sentinel "admin" setzt is_admin direkt."""
    if "admin" in app_groups:
        conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,))
    for group_id in app_groups:
        if group_id == "admin":
            continue
        if not conn.execute("SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)).fetchone():
            continue
        conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, group_id))


def _ldap_default_group_id(conn):
    default_group = get_setting("ldap_default_group", "")
    if default_group:
        if conn.execute(
            "SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (default_group,)
        ).fetchone():
            return default_group
    row = conn.execute(
        "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (DEFAULT_GROUP_NAME,)
    ).fetchone()
    return row["id"] if row else None


# ============================================================================
# Dateifreigaben (Fileshare) -- sessionabhängiges Mounten von SMB/CIFS-
# Freigaben je nach AD-Gruppenmitgliedschaft, analog zu einer
# GPO-Laufwerkszuordnung, nur serverseitig im Browser statt als
# Laufwerksbuchstabe auf dem Client. Zwei UNABHÄNGIGE Voraussetzungen
# müssen zutreffen, bevor überhaupt gemountet wird:
#   1. Das TESM-eigene Recht fileshare.view (Rechtesystem-Gate).
#   2. Mindestens eine per LDAP-Gruppe zugeordnete Freigabe (siehe
#      _ldap_resolve_fileshares) -- WELCHE Freigaben es sind, entscheidet
#      ausschließlich die AD-Gruppenmitgliedschaft, nicht das Rechtesystem.
# Gemountet wird mit den eigenen AD-Zugangsdaten des Nutzers (aus dem
# Login-Vorgang, nie gespeichert), NICHT mit einem festen Service-Konto --
# Dateiserver-eigene ACLs bleiben dadurch individuell wirksam.
# ============================================================================

FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-shares")
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800

# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
# .xlsx-Dateien ordentlich, deshalb dort beide.
_FILESHARE_PREVIEW_KINDS = {
    ".pdf": "pdf",
    ".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
    ".webp": "image", ".bmp": "image", ".svg": "image",
    ".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
    ".docx": "docx",
    ".xlsx": "xlsx", ".xls": "xlsx",
}
_FILESHARE_PREVIEW_MIME = {
    ".pdf": "application/pdf",
    ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
    ".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
    ".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
    ".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
    ".json": "text/plain; charset=utf-8",
    ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    ".xls": "application/vnd.ms-excel",
}

_active_fileshare_mounts = {}


def _fileshare_cleanup_all_on_startup():
    """Räumt beim App-Start ALLE evtl. noch vorhandenen Mounts unter
    FILESHARE_MOUNT_ROOT aus einer vorherigen Prozess-Lebensdauer auf. Das
    In-Memory-Tracking (_active_fileshare_mounts) ist bei jedem Neustart
    (Update, Absturz, ...) naturgemäß leer -- ohne dieses Aufräumen blieben
    verwaiste Mounts für die App unsichtbar, aber real auf dem System
    bestehen."""
    if not os.path.isdir(FILESHARE_MOUNT_ROOT):
        return
    try:
        token_dirs = os.listdir(FILESHARE_MOUNT_ROOT)
    except OSError:
        return
    for token_dir in token_dirs:
        token_path = os.path.join(FILESHARE_MOUNT_ROOT, token_dir)
        if not os.path.isdir(token_path):
            continue
        try:
            share_dirs = os.listdir(token_path)
        except OSError:
            share_dirs = []
        for share_dir in share_dirs:
            share_path = os.path.join(token_path, share_dir)
            if os.path.ismount(share_path):
                subprocess.run(["umount", "-l", share_path], capture_output=True, timeout=15)
            try:
                os.rmdir(share_path)
            except OSError:
                pass
        try:
            os.rmdir(token_path)
        except OSError:
            pass


# app.py wird nicht nur von gunicorn (tesm.service) gestartet, sondern auch
# von generate_ips.py per "from app import ..." als reines Hilfsmodul
# importiert (u.a. alle paar Sekunden aus poe.sh heraus) -- ein Import
# führt sämtlichen Modul-Level-Code hier erneut aus. Ohne dieses Gate
# würde JEDER dieser Imports _fileshare_cleanup_all_on_startup() erneut
# auslösen und damit gerade aktive Fileshare-Mounts anderer, echter
# Sitzungen sofort wieder aushängen. TESM_WEB_PROCESS wird nur von
# tesm.service selbst gesetzt (siehe dessen Unit-Datei) -- nur dort soll
# das Aufräumen beim (Neu-)Start überhaupt stattfinden.
_IS_WEB_PROCESS = os.environ.get("TESM_WEB_PROCESS") == "1"

if _IS_WEB_PROCESS:
    _fileshare_cleanup_all_on_startup()


# ============================================================ Lizenzsystem ==
# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige
# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei (nach dem
# gleichen Prinzip wie _active_fileshare_mounts oben -- einmal geladen,
# NICHT pro Request neu von der Platte gelesen) plus die zwei zentralen
# Gate-Funktionen license_active()/module_licensed(), die von
# _nav_key_visible(), den Routen-Guards und den Templates aus aufgerufen
# werden. Kleine operative Zusatzangaben (letzter Heartbeat, letzter
# Fehler, Aktivierungsstatus) leben als gewöhnliche settings-Zeilen
# (get_setting/set_setting) -- die Lizenzdatei selbst bleibt eine flache
# Datei neben app.py (LICENSE_PATH), genau wie fernet.key/known_hosts.
LICENSE_HEARTBEAT_INTERVAL_SECONDS = 6 * 3600

_license_state = {"file": None, "valid": False, "status": None, "error": None}


def _load_license():
    """Liest LICENSE_PATH neu ein und prüft die Master-Signatur. Wird beim
    Modulimport einmal sowie nach jedem Upload/jeder Aktivierung/
    Deaktivierung/erfolgreichem Heartbeat erneut aufgerufen -- niemals
    implizit pro Request (siehe TESM_VERSION weiter oben für dasselbe
    Prinzip: Neuladen ist ein bewusster, seltener Vorgang, kein Read-Pfad)."""
    global _license_state
    try:
        with open(LICENSE_PATH, "r", encoding="utf-8") as f:
            license_file = json.load(f)
    except (OSError, ValueError):
        _license_state = {"file": None, "valid": False, "status": None, "error": None}
        return
    if not licensing.verify_license_file(license_file):
        _license_state = {
            "file": license_file, "valid": False, "status": None,
            "error": "Signatur ungültig -- Lizenzdatei beschädigt oder manipuliert.",
        }
        return
    _license_state = {
        "file": license_file, "valid": True,
        "status": licensing.license_status(license_file), "error": None,
    }


_load_license()


def _write_license_file(license_file):
    """Schreibt eine neue, signaturgeprüfte Lizenzdatei nach LICENSE_PATH
    und lädt den In-Memory-Zustand sofort neu (kein Dienst-Neustart
    nötig -- anders als z.B. bei der Versionsanzeige, siehe TESM_VERSION)."""
    with open(LICENSE_PATH, "w", encoding="utf-8") as f:
        json.dump(license_file, f, indent=2)
    try:
        os.chmod(LICENSE_PATH, 0o600)
    except OSError:
        pass
    _load_license()


def _delete_license_file():
    """Löscht die lokale Lizenzdatei unwiderruflich -- Schritt 5 der
    Deaktivierung (siehe licensing.py-Moduldocstring): erst NACH
    erfolgreich verifizierter Deaktivierungs-Bestätigung des Masters
    aufrufen, nie vorher."""
    try:
        os.remove(LICENSE_PATH)
    except OSError:
        pass
    set_setting("license_activation_status", "")
    _load_license()


def license_activated():
    """True erst NACH einer beim Master erfolgreich abgeschlossenen
    Aktivierung (online oder per Offline-Code) -- reines Hochladen einer
    gültig signierten Lizenzdatei allein reicht laut Protokoll bewusst
    NICHT aus, siehe licensing.py-Moduldocstring Schritt 5."""
    return get_setting("license_activation_status", "") == "active"


def license_active():
    """Zentrales Gate für alles, was 'sichtbar, aber ohne Lizenz inaktiv'
    ist (LDAP, NGINX, Papierkorb-Bearbeiten, Export, Prüfintervall,
    automatischer PoE-Neustart). True nur bei: signaturgültiger,
    aktivierter Lizenz, deren Ablaufdatum entweder noch nicht erreicht ist
    oder deren 30-tägige Gnadenfrist danach noch läuft
    (licensing.GRACE_PERIOD_DAYS)."""
    return bool(
        _license_state["valid"]
        and _license_state["status"]
        and _license_state["status"]["modules_active"]
        and license_activated()
    )


def module_licensed(module_key):
    """Gate für die komplett ausgeblendeten Vollmodule (dhcp/fileshare/
    maintenance) -- zusätzlich zu license_active() muss das Modul auch
    tatsächlich in der Lizenz enthalten sein."""
    return license_active() and module_key in _license_state["status"]["modules"]


def _require_module(module_key):
    """Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
    (dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
    muss ebenso abgewiesen werden wie das reine Fehlen des Nav-Eintrags
    (_nav_key_visible). Rückgabe: eine fertige Flash+Redirect-Response,
    falls das Modul nicht lizenziert ist, sonst None -- Aufrufer prüft
    `if resp := _require_module(...): return resp` nach dem eigentlichen
    Berechtigungs-Check, exakt im gleichen Stil wie die übrigen
    Permission-Guards in diesem File."""
    if module_licensed(module_key):
        return None
    flash("Für dieses Modul ist keine gültige Lizenz vorhanden.", "danger")
    return redirect(url_for("index"))


def license_export_allowed():
    """Export ist die EINE bewusste Ausnahme: bleibt auch im lizenzlosen
    Zustand nutzbar, wenn dieser Zustand durch eine vom Kunden selbst
    ausgelöste Deaktivierung (Systemwechsel-Fall) entstanden ist, damit
    die eigenen Daten vor dem Umzug noch exportierbar sind. Sobald wieder
    eine Lizenz aktiviert wird, greift ohnehin license_active() normal."""
    return license_active() or get_setting("license_export_grace", "") == "1"


def license_info():
    """Für Topbar/Settings-Seite: kompletter aktueller Rohzustand."""
    return _license_state


app.jinja_env.globals["license_active"] = license_active
app.jinja_env.globals["module_licensed"] = module_licensed
app.jinja_env.globals["license_export_allowed"] = license_export_allowed


def _license_api_post(endpoint, path, payload, timeout=10):
    """Einfacher JSON-POST gegen den Lizenzserver über die Python-
    Standardbibliothek (urllib) -- bewusst ohne zusätzliche Abhängigkeit
    wie 'requests', da genau drei schmale Aufrufe (activate/deactivate/
    heartbeat) das nicht rechtfertigen. Wirft bei jedem Fehler (Netzwerk,
    Timeout, HTTP-Fehlerstatus, kaputtes JSON) eine Exception -- der
    Aufrufer entscheidet, ob das den Online- oder den Offline-Code-Pfad
    bedeutet."""
    url = endpoint.rstrip("/") + path
    data = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _send_heartbeat():
    """Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
    master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
    und eine ggf. mitgeschickte aktualisierte Lizenz NUR nach eigener
    Signaturprüfung übernehmen. Bei Fehlschlag lediglich den Fehler
    vermerken -- ein ausbleibender Heartbeat schaltet für sich genommen
    nie ein Modul ab (siehe licensing.heartbeat_stale_warning, rein
    informativ in der Topbar)."""
    license_file = _license_state.get("file")
    if not license_file or not _license_state.get("valid") or not license_activated():
        return
    endpoint = license_file.get("master_endpoint", "")
    if not endpoint:
        return
    request_payload = licensing.build_client_request("heartbeat", license_file)
    try:
        response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
    except Exception as exc:
        set_setting("license_last_error", f"Heartbeat fehlgeschlagen: {exc}")
        return

    if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
        set_setting("license_last_error", "Heartbeat-Antwort mit ungültiger Signatur erhalten -- ignoriert.")
        return

    set_setting("license_last_heartbeat_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    set_setting("license_last_error", "")

    if response_payload.get("status") == "revoked":
        log_action_system("license.revoked_remote", license_file.get("license_id"),
                           "Lizenz vom Lizenzserver per Heartbeat widerrufen.")
        _delete_license_file()
        return

    license_update = response_payload.get("license_update")
    if license_update and licensing.verify_license_file(license_update):
        _write_license_file(license_update)
        log_action_system("license.updated_remote", license_update.get("license_id"),
                           "Aktualisierte Lizenz per Heartbeat übernommen.")


def _license_heartbeat_loop():
    """Läuft alle LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h, siehe Plan) und
    direkt einmal beim Start, analog zu _audit_archive_loop weiter oben."""
    while True:
        try:
            _send_heartbeat()
        except Exception:
            app.logger.error("Lizenz-Heartbeat fehlgeschlagen:\n%s", traceback.format_exc())
        time.sleep(LICENSE_HEARTBEAT_INTERVAL_SECONDS)


if _IS_WEB_PROCESS:
    threading.Thread(target=_license_heartbeat_loop, daemon=True).start()


def _resolve_host_preferring_ipv4(hostname):
    """Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
    IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
    zuverlässiger durchgeroutet ist als in DNS eingetragene IPv6-Adressen
    (siehe _resolve_unc_host_ip); eine funktionierende IPv6-Route wird
    aber genutzt, wenn es keine IPv4-Adresse gibt, statt komplett
    aufzugeben."""
    try:
        infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
    except socket.gaierror:
        return None
    if not infos:
        return None
    ipv4 = next((i for i in infos if i[0] == socket.AF_INET), None)
    return (ipv4 or infos[0])[4][0]


def _resolve_unc_host_ip(unc):
    """Löst den Host-Teil einer "//host/share"-UNC auf eine IP-Adresse
    auf, oder gibt None zurück (Host ist bereits eine IP-Literal, oder es
    lässt sich nichts ermitteln). Zwei Probleme werden hier abgefangen,
    die beide dazu führen, dass eine Freigabe per IP klappt, per Hostname
    aber nicht:
    1. Ein reiner Kurzname (z.B. "s2025" statt "s2025.ad.eertmoed.net")
       löst über die konfigurierte DNS des Servers oft GAR NICHT auf, weil
       hier (anders als bei einem domänenbeigetretenen Windows-Client)
       keine DNS-Suffixsuche eingerichtet ist. Als Fallback wird deshalb
       zusätzlich mit dem aus der LDAP-Servereinstellung abgeleiteten
       AD-Domänensuffix versucht (die dortige AD-DNS-Zone enthält
       erfahrungsgemäß auch die Datei-Server).
    2. Manche interne DNS-Zonen liefern für Server-Hostnamen NUR
       AAAA-Einträge (siehe ad.eertmoed.net) -- IPv4 wird bevorzugt
       verwendet, falls zusätzlich vorhanden, s.o.
    Der Hostname bleibt in jedem Fall unverändert in der UNC stehen --
    nur die tatsächliche Verbindung wird per "ip="-Mount-Option gezielt
    auf die ermittelte Adresse gelenkt."""
    host = unc[2:].split("/", 1)[0] if unc.startswith("//") else ""
    if not host:
        return None
    try:
        socket.inet_aton(host)
        return None  # Host ist bereits eine IPv4-Literal, nichts zu tun
    except OSError:
        pass
    ip = _resolve_host_preferring_ipv4(host)
    if ip:
        return ip
    if "." not in host:
        domain = get_setting("ldap_server", "")
        if domain and "." in domain:
            ip = _resolve_host_preferring_ipv4(f"{host}.{domain}")
    return ip


def _mount_one_fileshare(mount_root, label, unc, username, password):
    """Mountet EINE Freigabe unter mount_root/label per mount.cifs. Das
    Passwort wird bewusst über die PASSWD-Umgebungsvariable übergeben statt
    in der -o-Optionsliste (die kurzzeitig in der Prozessliste sichtbar
    wäre) -- von mount.cifs offiziell unterstützter Mechanismus genau für
    diesen Zweck. Gibt (ok, message) zurück, wirft nie -- ein nicht
    erreichbarer Server soll weder den Login blockieren noch andere
    Freigaben verhindern, nur diese eine fehlt dann."""
    target = os.path.join(mount_root, label)
    try:
        os.makedirs(target, exist_ok=True)
    except OSError as e:
        return False, str(e)
    options = f"username={username},vers=3.0,uid=0,gid=0,file_mode=0770,dir_mode=0770,iocharset=utf8"
    resolved_ip = _resolve_unc_host_ip(unc)
    if resolved_ip:
        options += f",ip={resolved_ip}"
    env = dict(os.environ)
    env["PASSWD"] = password
    try:
        result = subprocess.run(
            ["mount", "-t", "cifs", unc, target, "-o", options],
            capture_output=True, text=True, timeout=20, env=env,
        )
        ok = result.returncode == 0
        out = (result.stdout or "") + (result.stderr or "")
    except Exception as e:
        ok, out = False, str(e)
    if not ok:
        try:
            os.rmdir(target)
        except OSError:
            pass
    return ok, out


def _mount_fileshares_for_login(user_obj, username, password, fileshares):
    """Direkt nach erfolgreichem LDAP-Login aufzurufen, solange das
    Klartext-Passwort noch im Scope ist -- wird nie gespeichert, nur für
    diesen einen mount-Aufruf verwendet. Mountet NUR, wenn der Nutzer das
    TESM-Recht fileshare.view hat (siehe PERMISSIONS/fileshare_group) --
    fehlt es, wird bewusst gar nicht erst versucht zu mounten, unabhängig
    davon, ob AD-seitig passende Freigaben existieren würden."""
    if not fileshares or not user_obj.has_permission("fileshare.view"):
        return
    token = secrets.token_hex(16)
    mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
    mounted = []
    for share in fileshares:
        ok, err = _mount_one_fileshare(mount_root, share["label"], share["unc"], username, password)
        if ok:
            mounted.append({"label": share["label"], "mount_path": os.path.join(mount_root, share["label"])})
        else:
            app.logger.warning("Fileshare-Mount fehlgeschlagen (%s, Freigabe %s): %s", username, share["label"], err)
    if mounted:
        session["fileshare_token"] = token
        _active_fileshare_mounts[token] = {"mounted_at": time.time(), "shares": mounted}
    else:
        try:
            os.rmdir(mount_root)
        except OSError:
            pass


def _unmount_fileshare_token(token):
    """Hängt alle Freigaben eines Tokens aus und räumt dessen Mount-Root
    weg -- von /logout (mit dem Token der eigenen Session) UND vom
    Hintergrund-Sweep (mit einem beliebigen, zu alten Token) genutzt.
    "umount -l" (lazy) statt eines normalen umount, damit ein zufällig
    noch offener Dateihandle das Aushängen nicht mit "target busy"
    blockiert -- das Verzeichnis verschwindet dann, sobald der letzte
    Handle geschlossen wird, ohne dass TESM darauf warten muss."""
    entry = _active_fileshare_mounts.pop(token, None)
    if not entry:
        return
    for share in entry["shares"]:
        subprocess.run(["umount", "-l", share["mount_path"]], capture_output=True, timeout=15)
        try:
            os.rmdir(share["mount_path"])
        except OSError:
            pass
    mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
    try:
        os.rmdir(mount_root)
    except OSError:
        pass


def _unmount_fileshares_for_current_session():
    token = session.pop("fileshare_token", None)
    if token:
        _unmount_fileshare_token(token)


def _fileshare_sweep_loop():
    """Hintergrund-Thread (ein einziger Prozess dank --workers 1, siehe
    tesm.service): hängt Freigaben aus, deren Session seit
    FILESHARE_MAX_AGE_SECONDS besteht, unabhängig davon, ob sich der
    Nutzer je explizit abgemeldet hat -- Sicherheitsnetz gegen "Tab
    einfach geschlossen statt abgemeldet", da eine normale
    Flask-Session (signierter Cookie) dem Server sonst keinerlei Signal
    gibt, dass sie nicht mehr genutzt wird."""
    while True:
        time.sleep(FILESHARE_SWEEP_INTERVAL_SECONDS)
        try:
            now = time.time()
            for token, entry in list(_active_fileshare_mounts.items()):
                if now - entry["mounted_at"] > FILESHARE_MAX_AGE_SECONDS:
                    _unmount_fileshare_token(token)
        except Exception:
            app.logger.error("Fileshare-Sweep fehlgeschlagen:\n%s", traceback.format_exc())


if _IS_WEB_PROCESS:
    threading.Thread(target=_fileshare_sweep_loop, daemon=True).start()


def _audit_archive_loop():
    """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
    die audit_log-Tabelle den Schwellenwert überschritten hat, und
    archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft
    direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit
    ein frisch aktualisierter/neu gestarteter Dienst eine bereits
    übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt."""
    while True:
        try:
            _archive_old_audit_log_rows()
        except Exception:
            app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc())
        time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS)


if _IS_WEB_PROCESS:
    threading.Thread(target=_audit_archive_loop, daemon=True).start()


def _current_fileshare_mounts():
    """Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
    Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
    LDAP-Nutzer ohne fileshare.view, ohne passende AD-Gruppe, oder deren
    Session wurde inzwischen vom Sweep ausgehängt)."""
    token = session.get("fileshare_token")
    if not token:
        return []
    entry = _active_fileshare_mounts.get(token)
    return entry["shares"] if entry else []


def _fileshare_resolve_path(label, rel_path):
    """Löst einen vom Client übergebenen relativen Pfad GEGEN DIE MOUNT-
    ROOT DER EIGENEN SESSION auf und lehnt alles ab, was per Path-Traversal
    (z.B. "../../etc") außerhalb davon landen würde -- der zentrale
    Sicherheitspunkt des gesamten Features. Gibt den validierten absoluten
    Pfad zurück, oder None bei ungültigem Share/Pfad."""
    mounts = {m["label"]: m["mount_path"] for m in _current_fileshare_mounts()}
    root = mounts.get(label)
    if not root:
        return None
    root_real = os.path.realpath(root)
    candidate = os.path.realpath(os.path.join(root_real, (rel_path or "").lstrip("/\\")))
    if candidate != root_real and not candidate.startswith(root_real + os.sep):
        return None
    return candidate


def _fileshare_list_dir(abs_path):
    """Verzeichnisinhalt für die Fileshare-Seite -- Ordner zuerst, dann
    Dateien, jeweils alphabetisch. _format_log_size (bereits für
    NGINX-Zertifikate/Log-Dateigrößen genutzt) wird hier wiederverwendet."""
    entries = []
    try:
        with os.scandir(abs_path) as it:
            for entry in it:
                try:
                    st = entry.stat(follow_symlinks=False)
                except OSError:
                    continue
                is_dir = entry.is_dir(follow_symlinks=False)
                ext = os.path.splitext(entry.name)[1].lower()
                entries.append({
                    "name": entry.name,
                    "is_dir": is_dir,
                    "size_str": "" if is_dir else _format_log_size(st.st_size),
                    "mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
                    "preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
                })
    except OSError:
        pass
    entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
    return entries


def _fileshare_tree_ancestors(label, rel_path):
    """Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
    bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
    Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
    Position aufgeklappt gerendert werden kann. Alles darüber hinaus
    (Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
    bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
    Teilpfad ("" für die Freigabe-Wurzel selbst)."""
    segments = [p for p in rel_path.split("/") if p]
    expanded = {}
    acc = []
    for depth in range(len(segments) + 1):
        current_rel = "/".join(acc)
        abs_path = _fileshare_resolve_path(label, current_rel)
        if not abs_path or not os.path.isdir(abs_path):
            break
        folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
        expanded[current_rel] = folders
        if depth < len(segments):
            acc.append(segments[depth])
    return expanded


@app.route("/fileshare")
@login_required
def fileshare():
    if not current_user.has_permission("fileshare.view"):
        flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
        return redirect(url_for("index"))
    if resp := _require_module("fileshare"):
        return resp
    mounts = _current_fileshare_mounts()
    if not mounts:
        flash(
            "Keine Dateifreigabe verfügbar -- keine passende AD-Gruppenmitgliedschaft beim letzten Login, "
            "oder das Mounten ist fehlgeschlagen.",
            "danger",
        )
        return redirect(url_for("index"))

    labels = [m["label"] for m in mounts]
    selected_share = request.args.get("share") or labels[0]
    if selected_share not in labels:
        selected_share = labels[0]
    rel_path = request.args.get("path", "").strip("/\\")

    abs_path = _fileshare_resolve_path(selected_share, rel_path)
    if not abs_path or not os.path.isdir(abs_path):
        flash("Ungültiger Pfad -- zurück zum Freigabe-Root.", "danger")
        rel_path = ""
        abs_path = _fileshare_resolve_path(selected_share, "")

    breadcrumbs = []
    acc = []
    for part in [p for p in rel_path.split("/") if p]:
        acc.append(part)
        breadcrumbs.append({"name": part, "path": "/".join(acc)})

    return render_template(
        "fileshare.html",
        shares=labels, selected_share=selected_share, rel_path=rel_path, breadcrumbs=breadcrumbs,
        entries=_fileshare_list_dir(abs_path) if abs_path else [],
        can_create=current_user.has_permission("fileshare.create"),
        can_edit=current_user.has_permission("fileshare.edit"),
        tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
    )


@app.route("/fileshare/subfolders")
@login_required
def fileshare_subfolders():
    """Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
    Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
    die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
    andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
    gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
    if not current_user.has_permission("fileshare.view"):
        return jsonify({"error": "Keine Berechtigung."}), 403
    abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
    if not abs_path or not os.path.isdir(abs_path):
        return jsonify({"error": "Ungültiger Pfad."}), 404
    return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})


@app.route("/fileshare/view")
@login_required
def fileshare_view():
    """Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
    /fileshare/download) für eine feste Positivliste von Dateitypen (siehe
    _FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
    geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
    nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
    diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
    if not current_user.has_permission("fileshare.view"):
        return "Keine Berechtigung.", 403
    abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
    if not abs_path or not os.path.isfile(abs_path):
        return "Datei nicht gefunden.", 404
    ext = os.path.splitext(abs_path)[1].lower()
    mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
    if not mimetype:
        return "Vorschau für diesen Dateityp nicht verfügbar.", 415
    resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
    return resp


@app.route("/fileshare/download")
@login_required
def fileshare_download():
    if not current_user.has_permission("fileshare.view"):
        return "Keine Berechtigung.", 403
    abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
    if not abs_path or not os.path.isfile(abs_path):
        return "Datei nicht gefunden.", 404
    return send_file(abs_path, as_attachment=True, download_name=os.path.basename(abs_path))


@app.route("/fileshare/upload", methods=["POST"])
@login_required
def fileshare_upload():
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.create"):
        flash("Keine Berechtigung, Dateien hochzuladen.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    abs_dir = _fileshare_resolve_path(share, rel_path)
    # request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
    # Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
    # Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
    # eine Datei liefert genauso eine Liste mit einem Element.
    files = [f for f in request.files.getlist("file") if f and f.filename]
    if not abs_dir or not os.path.isdir(abs_dir) or not files:
        flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
    else:
        uploaded, rejected = [], []
        for file in files:
            filename = secure_filename(file.filename)
            dest = os.path.join(abs_dir, filename) if filename else None
            if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
                rejected.append(file.filename)
                continue
            file.save(dest)
            uploaded.append(filename)
        if uploaded:
            log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
            if len(uploaded) == 1:
                flash(f"„{uploaded[0]}“ hochgeladen.", "success")
            else:
                flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
        if rejected:
            flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
    return redirect(url_for("fileshare", share=share, path=rel_path))


@app.route("/fileshare/mkdir", methods=["POST"])
@login_required
def fileshare_mkdir():
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.create"):
        flash("Keine Berechtigung, Ordner anzulegen.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    name = secure_filename(request.form.get("name", "").strip())
    abs_dir = _fileshare_resolve_path(share, rel_path)
    if not abs_dir or not name:
        flash("Ungültiger Ordnername.", "danger")
    else:
        try:
            os.mkdir(os.path.join(abs_dir, name))
            log_action("fileshare.mkdir", share, f"{rel_path}/{name}".strip("/"))
            flash(f"Ordner „{name}“ angelegt.", "success")
        except OSError as e:
            flash(f"Ordner konnte nicht angelegt werden: {e}", "danger")
    return redirect(url_for("fileshare", share=share, path=rel_path))


def _fileshare_resolve_child(share, rel_path, name):
    """Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
    stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
    (per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
    _fileshare_resolve_path) -- von delete/delete-multi/download-multi
    gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
    if not name:
        return None
    parent_abs = _fileshare_resolve_path(share, rel_path)
    target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
    if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
        return None
    return target_abs


@app.route("/fileshare/delete", methods=["POST"])
@login_required
def fileshare_delete():
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.edit"):
        flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    name = request.form.get("name", "")
    target_abs = _fileshare_resolve_child(share, rel_path, name)
    if not target_abs:
        flash("Ungültiges Ziel.", "danger")
    else:
        try:
            if os.path.isdir(target_abs):
                shutil.rmtree(target_abs)
            else:
                os.remove(target_abs)
            log_action("fileshare.delete", share, f"{rel_path}/{name}".strip("/"))
            flash(f"„{name}“ gelöscht.", "success")
        except OSError as e:
            flash(f"Löschen fehlgeschlagen: {e}", "danger")
    return redirect(url_for("fileshare", share=share, path=rel_path))


@app.route("/fileshare/delete-multi", methods=["POST"])
@login_required
def fileshare_delete_multi():
    """Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
    an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.edit"):
        flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    names = [n for n in request.form.getlist("names") if n]
    if not names:
        flash("Keine Elemente ausgewählt.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    deleted, failed = [], []
    for name in names:
        target_abs = _fileshare_resolve_child(share, rel_path, name)
        if not target_abs:
            failed.append(name)
            continue
        try:
            if os.path.isdir(target_abs):
                shutil.rmtree(target_abs)
            else:
                os.remove(target_abs)
            deleted.append(name)
        except OSError:
            failed.append(name)

    if deleted:
        log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
        flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
    if failed:
        flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
    return redirect(url_for("fileshare", share=share, path=rel_path))


@app.route("/fileshare/download-multi", methods=["POST"])
@login_required
def fileshare_download_multi():
    """Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
    -- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
    einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
    etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
    aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.view"):
        return "Keine Berechtigung.", 403

    names = [n for n in request.form.getlist("names") if n]
    if not names:
        flash("Keine Elemente ausgewählt.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    targets = []
    skipped = []
    for name in names:
        target_abs = _fileshare_resolve_child(share, rel_path, name)
        if not target_abs or not os.path.exists(target_abs):
            skipped.append(name)
            continue
        targets.append((name, target_abs))
    if not targets:
        flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))
    if skipped:
        flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")

    buffer = io.BytesIO()
    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
        for name, target_abs in targets:
            if os.path.isdir(target_abs):
                for root, _dirs, files in os.walk(target_abs):
                    for fname in files:
                        full = os.path.join(root, fname)
                        arcname = os.path.join(name, os.path.relpath(full, target_abs))
                        zf.write(full, arcname)
            else:
                zf.write(target_abs, name)
    buffer.seek(0)

    if len(targets) == 1:
        base_name, _ext = os.path.splitext(targets[0][0])
        zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
    else:
        zip_name = secure_filename(f"{share}-Auswahl") or "Download"
    log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
    return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")


@app.route("/fileshare/rename", methods=["POST"])
@login_required
def fileshare_rename():
    share = request.form.get("share", "")
    rel_path = request.form.get("path", "")
    if not current_user.has_permission("fileshare.edit"):
        flash("Keine Berechtigung zum Umbenennen.", "danger")
        return redirect(url_for("fileshare", share=share, path=rel_path))

    old_name = request.form.get("old_name", "")
    new_name = secure_filename(request.form.get("new_name", "").strip())
    parent_abs = _fileshare_resolve_path(share, rel_path)
    old_abs = _fileshare_resolve_path(share, f"{rel_path}/{old_name}".strip("/")) if old_name else None
    if not parent_abs or not old_abs or not new_name:
        flash("Ungültige Angabe.", "danger")
    else:
        new_abs = os.path.join(parent_abs, new_name)
        if os.path.dirname(os.path.realpath(new_abs)) != os.path.realpath(parent_abs):
            flash("Ungültiger neuer Name.", "danger")
        else:
            try:
                os.rename(old_abs, new_abs)
                log_action("fileshare.rename", share, f"{old_name} -> {new_name}")
                flash(f"„{old_name}“ umbenannt zu „{new_name}“.", "success")
            except OSError as e:
                flash(f"Umbenennen fehlgeschlagen: {e}", "danger")
    return redirect(url_for("fileshare", share=share, path=rel_path))


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form["username"].strip()
        password = request.form["password"]
        conn = get_db_connection()

        user = conn.execute(
            "SELECT * FROM users WHERE username = ? AND deleted_at IS NULL", (username,)
        ).fetchone()

        if not user:
            user = conn.execute(
                "SELECT * FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '' "
                "AND deleted_at IS NULL",
                (username,),
            ).fetchone()

        if not user:
            user = conn.execute(
                "SELECT * FROM users WHERE auth_source='ldap' AND username = ? COLLATE NOCASE AND deleted_at IS NULL",
                (username,),
            ).fetchone()

        if user and user["is_locked"]:
            conn.close()
            flash("Dieses Konto ist gesperrt.", "danger")
            return render_template("login.html")

        if user and user["auth_source"] == "ldap":
            ok, info = _ldap_authenticate(user["username"], password)
            if ok:
                conn.execute(
                    "UPDATE users SET first_name=?, last_name=?, email=? WHERE id=?",
                    (info.get("first_name"), info.get("last_name"), info.get("email"), user["id"]),
                )
                _assign_ldap_groups(conn, user["id"], info.get("app_groups") or set())
                conn.commit()
                refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
                conn.close()
                logged_in_user = _build_user(refreshed)
                login_user(logged_in_user)
                _mount_fileshares_for_login(
                    logged_in_user, info.get("username") or username, password, info.get("fileshares") or [],
                )
                return redirect(url_for("index"))
            conn.close()
            flash("Ungültiger Benutzername oder Passwort", "danger")

        elif user:
            conn.close()
            if bcrypt.check_password_hash(user["password"], password):
                login_user(_build_user(user))
                return redirect(url_for("index"))
            flash("Ungültiger Benutzername oder Passwort", "danger")

        else:
            ok, info = _ldap_authenticate(username, password)
            if ok:
                canonical_username = info.get("username") or username
                placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
                app_groups = info.get("app_groups") or set()
                is_admin_new = 1 if "admin" in app_groups else 0
                try:
                    cur = conn.execute(
                        "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) "
                        "VALUES (?, ?, ?, ?, ?, ?, 'ldap')",
                        (canonical_username, placeholder_hash, is_admin_new,
                         info.get("first_name"), info.get("last_name"), info.get("email")),
                    )
                    if app_groups:
                        _assign_ldap_groups(conn, cur.lastrowid, app_groups)
                    else:
                        default_group = _ldap_default_group_id(conn)
                        if default_group:
                            conn.execute(
                                "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
                                (cur.lastrowid, default_group),
                            )
                    conn.commit()
                    new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
                    conn.close()
                    log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
                    logged_in_user = _build_user(new_user)
                    login_user(logged_in_user)
                    _mount_fileshares_for_login(
                        logged_in_user, canonical_username, password, info.get("fileshares") or [],
                    )
                    return redirect(url_for("index"))
                except sqlite3.IntegrityError:
                    conn.close()
                    flash("Ungültiger Benutzername oder Passwort", "danger")
            else:
                conn.close()
                flash("Ungültiger Benutzername oder Passwort", "danger")
    return render_template("login.html")


@app.route("/logout")
@login_required
def logout():
    _unmount_fileshares_for_current_session()
    logout_user()
    return redirect(url_for("index"))



@app.route("/account")
@login_required
def account():
    return render_template("account.html")


@app.route("/profile", methods=["POST"])
@login_required
def profile():
    conn = get_db_connection()

    if "update_profile" in request.form:
        first_name = request.form.get("first_name", "").strip() or None
        last_name = request.form.get("last_name", "").strip() or None
        conn.execute(
            "UPDATE users SET first_name=?, last_name=? WHERE id=?",
            (first_name, last_name, current_user.id),
        )
        conn.commit()
        log_action("profile.update", current_user.username)
        flash("Profil aktualisiert.", "success")

    elif "change_password" in request.form:
        if current_user.is_ldap_user:
            flash("Dieses Konto meldet sich über Active Directory an — das Passwort wird dort verwaltet.", "danger")
            conn.close()
            return redirect(url_for("account"))
        current_password = request.form.get("current_password", "")
        new_password = request.form.get("new_password", "")
        confirm_password = request.form.get("confirm_password", "")
        row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone()
        if not row or not bcrypt.check_password_hash(row["password"], current_password):
            flash("Aktuelles Passwort ist falsch.", "danger")
        elif not new_password or new_password != confirm_password:
            flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger")
        else:
            pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
            conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id))
            conn.commit()
            log_action("profile.password", current_user.username)
            flash("Passwort geändert.", "success")

    elif "upload_avatar" in request.form:
        file = request.files.get("avatar")
        if not file or not file.filename:
            flash("Bitte ein Bild auswählen.", "danger")
        else:
            ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
            if ext not in ALLOWED_AVATAR_EXT:
                flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger")
            else:
                old_row = conn.execute(
                    "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
                ).fetchone()
                filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}")
                file.save(os.path.join(AVATAR_DIR, filename))
                conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id))
                conn.commit()
                if old_row and old_row["avatar_filename"]:
                    old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"])
                    if os.path.isfile(old_path):
                        os.remove(old_path)
                flash("Profilbild aktualisiert.", "success")

    conn.close()
    return redirect(request.referrer or url_for("index"))



def _latest_log_file():
    """Aktuelles Live-Log — seit der Umstellung auf eine einzige, per
    logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein
    neues rpi-<timestamp>.log) gibt es nur noch GENAU eine "aktuelle"
    Datei, kein Ermitteln der "neuesten von vielen" mehr nötig."""
    return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None


LIVE_LOG_TAIL_LINES = 300


def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536):
    """Liest effizient nur die letzten n Zeilen einer Datei, ohne sie
    komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken,
    bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist.
    Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann
    live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein
    komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare
    Ladezeiten verursacht -- das war der eigentliche Auslöser dieser
    Funktion."""
    with open(path, "rb") as f:
        f.seek(0, os.SEEK_END)
        remaining = f.tell()
        data = b""
        while remaining > 0 and data.count(b"\n") <= n:
            read_size = min(chunk_size, remaining)
            remaining -= read_size
            f.seek(remaining)
            data = f.read(read_size) + data
    text = data.decode("utf-8", errors="replace")
    lines = text.split("\n")
    return "\n".join(lines[-n:]) if len(lines) > n else text


def _count_lines(path):
    """Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n,
    kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt
    X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder
    durch ein volles Einlesen zunichte zu machen."""
    try:
        count = 0
        with open(path, "rb") as f:
            for chunk in iter(lambda: f.read(1024 * 1024), b""):
                count += chunk.count(b"\n")
        return count
    except OSError:
        return None


def _format_log_size(num_bytes):
    size = float(num_bytes)
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} GB"


_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
_LOG_HISTORY_DATE_FMT = "%d.%m.%Y %H:%M"


def _live_log_history_files():
    """Alle über die Verlauf-Seite auswählbaren Stände des Live-Logs --
    generation 0 ist die aktuelle, noch laufende live.log selbst, 1 die
    zuletzt rotierte Kopie, 2 der Lauf davor usw. (copytruncate ohne
    compress, siehe _write_logrotate_config, daher reine Textdateien ohne
    .gz), sortiert von neu (0) nach alt.

    Jede Kopie deckt den Zeitraum zwischen ZWEI Rotationen ab: ihr eigenes
    mtime ist der Zeitpunkt, an dem ihr Inhalt eingefroren wurde (Ende des
    Zeitraums), das mtime der NÄCHSTälteren Generation der Beginn -- ein
    reines rename() bei jeder weiteren Rotation ändert mtime nicht, jede
    Generation behält also dauerhaft den Zeitpunkt ihrer eigenen
    Entstehung. Für die aktuelle live.log gibt es kein eigenes "Ende",
    dort wird stattdessen "laufend" angezeigt."""
    results = []
    try:
        if os.path.isfile(TESM_LIVE_LOG_PATH):
            mtime = os.path.getmtime(TESM_LIVE_LOG_PATH)
            size = os.path.getsize(TESM_LIVE_LOG_PATH)
            results.append({
                "filename": "live.log", "generation": 0,
                "mtime": mtime, "size_str": _format_log_size(size),
            })
    except OSError:
        pass

    try:
        entries = os.listdir(TESM_LOG_DIR)
    except OSError:
        entries = []
    for entry in entries:
        m = _LOG_HISTORY_FILE_RE.match(entry)
        if not m:
            continue
        full_path = os.path.join(TESM_LOG_DIR, entry)
        if not os.path.isfile(full_path):
            continue
        try:
            mtime = os.path.getmtime(full_path)
            size = os.path.getsize(full_path)
        except OSError:
            continue
        results.append({
            "filename": entry, "generation": int(m.group(1)),
            "mtime": mtime, "size_str": _format_log_size(size),
        })
    results.sort(key=lambda r: r["generation"])

    for i, entry in enumerate(results):
        older = results[i + 1] if i + 1 < len(results) else None
        start_str = datetime.fromtimestamp(older["mtime"]).strftime(_LOG_HISTORY_DATE_FMT) if older else None
        if entry["generation"] == 0:
            end_str = "laufend"
        else:
            end_str = datetime.fromtimestamp(entry["mtime"]).strftime(_LOG_HISTORY_DATE_FMT)
        entry["range_label"] = f"{start_str or '?'} – {end_str}"
    return results


def _resolve_log_history_file(filename):
    """Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
    die tatsächlich vorhandene aktuelle live.log bzw. rotierte Kopien davon
    in TESM_LOG_DIR -- verhindert Path-Traversal bzw. beliebiges
    Datei-Lesen über den Query-Parameter. Gibt den vollen Pfad oder None
    zurück."""
    if filename == "live.log":
        return TESM_LIVE_LOG_PATH if os.path.isfile(TESM_LIVE_LOG_PATH) else None
    if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
        return None
    full_path = os.path.join(TESM_LOG_DIR, filename)
    if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR):
        return None
    if not os.path.isfile(full_path):
        return None
    return full_path


def get_last_seen(dev_name: str):
    """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
    bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, …),
    nicht nur in der aktuellen Datei — sonst würde nach einer Rotation der
    "zuletzt online"-Zeitpunkt eines länger schon offline befindlichen
    Geräts verloren gehen, obwohl er in einer älteren Rotation noch
    vorhanden wäre."""
    log_files = glob.glob(TESM_LIVE_LOG_PATH + "*")
    if not log_files:
        return None

    latest_time = None
    for logfile in sorted(log_files):
        try:
            with open(logfile, "r") as f:
                for line in f:
                    line = line.strip()
                    if f"{dev_name} ist erreichbar!" in line:
                        try:
                            ts_str = line.split(" ")[0] + " " + line.split(" ")[1]
                            ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
                            if latest_time is None or ts > latest_time:
                                latest_time = ts
                        except Exception:
                            continue
        except OSError:
            continue

    if latest_time:
        return latest_time.strftime("Zuletzt Online am %d.%m.%Y um %H:%M Uhr")
    return None


_LOG_LINE_RE = re.compile(
    r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?) ist (erreichbar|nicht erreichbar)!$"
)


def _format_ts(ts_str: str) -> str:
    try:
        return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").strftime("%d.%m.%Y %H:%M")
    except ValueError:
        return ts_str


def get_last_run_at():
    """Zeitpunkt des letzten Prüf-Durchlaufs (jüngste Zeile im aktuellsten
    Logfile) — Basis für den globalen 'Nächste Prüfung'-Countdown in der
    Topbar, unabhängig von einer bestimmten Geräteliste."""
    latest_log = _latest_log_file()
    if not latest_log:
        return None
    latest_ts_str = None
    with open(latest_log, "r") as f:
        for line in f:
            m = _LOG_LINE_RE.match(line.strip())
            if m and (latest_ts_str is None or m.group(1) > latest_ts_str):
                latest_ts_str = m.group(1)
    if not latest_ts_str:
        return None
    try:
        return datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S")
    except ValueError:
        return None


@app.context_processor
def inject_check_timer():
    """Stellt den 'Nächste Prüfung'-Countdown global für die Topbar bereit
    (auf jeder Seite sichtbar, siehe base.html) — unabhängig davon, ob die
    jeweilige Route selbst etwas mit Geräten/Logs zu tun hat."""
    interval = int(get_setting("interval", 5))
    last_run_at = get_last_run_at()
    return {
        "global_check_interval": interval,
        "global_last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None,
    }


@app.context_processor
def inject_dhcp_topbar_status():
    """Ampel-Punkt für den Kea-Dienst neben dem Prüfintervall-Timer — nur
    für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner,
    kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von
    _detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt."""
    if (
        not current_user.is_authenticated
        or not current_user.has_permission("settings_dhcp.view")
        or not module_licensed("dhcp")
    ):
        return {"dhcp_topbar_active": None}
    try:
        result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2)
        active = result.stdout.strip() == "active"
    except Exception:
        active = None
    return {"dhcp_topbar_active": active}


@app.context_processor
def inject_license_topbar():
    """Lizenz-Ampel neben dem Prüfintervall-Countdown -- für jeden
    angemeldeten Benutzer sichtbar, unabhängig von Rechten (genau wie der
    Countdown selbst). Prioritätsreihenfolge exakt wie im Lizenz-Plan
    festgelegt: fehlt > abgelaufen > läuft in ≤30 Tagen ab > Heartbeat seit
    ≥14 Tagen ausgeblieben (rein informativ) > alles in Ordnung (keine
    Anzeige)."""
    if not current_user.is_authenticated:
        return {"license_topbar": None}

    state = _license_state
    if not state["valid"] or not license_activated():
        return {"license_topbar": {"level": "danger", "blink": True, "text": "Lizenz fehlt"}}

    status = state["status"]
    if status["expired"]:
        try:
            expiry_str = datetime.strptime(state["file"]["expires_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d.%m.")
        except ValueError:
            expiry_str = "?"
        return {"license_topbar": {"level": "danger", "blink": True, "text": f"Lizenz abgelaufen (seit {expiry_str})"}}

    if status["expiring_soon"]:
        days_left = max(1, math.ceil(status["days_left"]))
        plural = "en" if days_left != 1 else ""
        return {"license_topbar": {"level": "warning", "blink": True, "text": f"Lizenz läuft in {days_left} Tag{plural} ab"}}

    last_heartbeat = get_setting("license_last_heartbeat_at")
    if last_heartbeat:
        try:
            last_ts = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S").timestamp()
        except ValueError:
            last_ts = None
        if last_ts and licensing.heartbeat_stale_warning(last_ts):
            days = int((time.time() - last_ts) / 86400)
            return {"license_topbar": {
                "level": "warning", "blink": False,
                "text": f"Lizenzserver seit {days} Tag{'en' if days != 1 else ''} nicht erreichbar",
            }}

    return {"license_topbar": None}


def get_device_status(devices):
    """
    Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
    - status: online / offline / unbekannt
    - last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis)
    - last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte,
      über alle Logfiles hinweg — siehe get_last_seen)
    - last_run_at: Zeitpunkt (datetime) des letzten Prüf-Durchlaufs insgesamt
      (jüngste Zeile im Logfile) — Basis für den Countdown "Nächste Prüfung".
    """
    status_dict, last_seen_dict, last_checked_dict = {}, {}, {}
    latest_log = _latest_log_file()
    if not latest_log:
        return status_dict, last_seen_dict, last_checked_dict, None

    name_to_mac = {d["name"]: d["mac"] for d in devices}
    last_line_for_name = {}
    latest_ts_str = None

    with open(latest_log, "r") as f:
        for line in f:
            m = _LOG_LINE_RE.match(line.strip())
            if not m:
                continue
            ts_str, name, state = m.groups()
            if latest_ts_str is None or ts_str > latest_ts_str:
                latest_ts_str = ts_str
            if name in name_to_mac:
                last_line_for_name[name] = (ts_str, state == "erreichbar")

    for dev in devices:
        entry = last_line_for_name.get(dev["name"])
        if entry:
            ts_str, is_online = entry
            status_dict[dev["mac"]] = "online" if is_online else "offline"
            last_checked_dict[dev["mac"]] = _format_ts(ts_str)
            if not is_online:
                last_seen_dict[dev["mac"]] = get_last_seen(dev["name"])
        else:
            status_dict[dev["mac"]] = "unbekannt"

    last_run_at = None
    if latest_ts_str:
        try:
            last_run_at = datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S")
        except ValueError:
            last_run_at = None

    return status_dict, last_seen_dict, last_checked_dict, last_run_at



def _dashboard_context():
    """Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt,
    damit das Live-Update des Dashboards (ohne vollen Seiten-Reload) exakt
    dieselben Daten liefert wie der initiale Seitenaufruf."""
    conn = get_db_connection()
    all_devices = conn.execute(
        "SELECT mac, name, ip, switch_hostname, port, is_active FROM devices WHERE deleted_at IS NULL ORDER BY name ASC"
    ).fetchall()
    conn.close()

    interval = int(get_setting("interval", 5))
    status, last_seen, last_checked, last_run_at = get_device_status(all_devices)
    is_authenticated = current_user.is_authenticated

    def by_name(devs):
        return sorted(devs, key=lambda d: d["name"].lower())

    offline_devices = by_name(
        d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online"
    )
    online_devices = by_name(
        d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online"
    )
    disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else []

    visible_devices = offline_devices + online_devices + disabled_devices
    devices = offline_devices + online_devices

    stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)}
    if is_authenticated:
        stats["total"] = len(all_devices)
        stats["disabled"] = len(disabled_devices)

    last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None

    return dict(
        offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
        device_count=len(visible_devices),
        status=status, last_seen=last_seen,
        last_checked=last_checked, interval=interval, stats=stats,
        last_run_epoch_ms=last_run_epoch_ms,
    )


@app.route("/")
def index():
    """
    Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online,
    Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht
    leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt-
    Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle
    Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei
    deaktivierten Geräten eine Aktivieren-Option.
    """
    return render_template("index.html", **_dashboard_context())


@app.route("/dashboard/tiles")
def dashboard_tiles():
    """AJAX-Partial für das Live-Update der Dashboard-Kacheln (Status,
    Statistik) ohne vollen Seiten-Reload — liefert dasselbe Fragment, das
    index.html beim initialen Aufruf einbindet (siehe _dashboard_tiles.html)."""
    return render_template("_dashboard_tiles.html", **_dashboard_context())


def _restart_check_service():
    """Startet tesm-check.service (poe_wrapper.py -> poe.sh) neu, damit ein
    geändertes Prüfintervall sofort greift (poe.sh liest SLEEP nur einmal
    beim Start aus der DB) — läuft dabei automatisch auch ein sofortiger
    Prüf-Durchlauf mit frischem Logfile an. Schlägt in Umgebungen ohne
    systemd (z.B. Container) lautlos fehl, statt die Anfrage abzubrechen."""
    try:
        subprocess.run(["systemctl", "restart", "tesm-check.service"], check=False)
    except FileNotFoundError:
        pass


@app.route("/check/run-now", methods=["POST"])
@login_required
def run_check_now():
    """Manueller 'Jetzt prüfen'-Trigger (Zahnrad-Icon neben dem Prüf-Timer):
    startet denselben Service neu wie eine Intervalländerung — das erzeugt
    sofort ein frisches Logfile mit einem neuen Durchlauf. Liefert den neuen
    Zeitpunkt direkt in der Antwort mit, damit app.js den Topbar-Countdown
    auf JEDER Seite zurücksetzen kann (vorher aktualisierte nur die
    Dashboard-eigene Live-Aktualisierung die Anzeige — auf allen anderen
    Seiten lief der Countdown mit dem alten Zeitpunkt weiter, obwohl im
    Hintergrund längst neu geprüft wurde)."""
    if not current_user.is_admin:
        return {"success": False, "msg": "Keine Berechtigung."}, 403
    _restart_check_service()
    log_action("check.run_now", "Prüfintervall")
    time.sleep(1.5)
    last_run_at = get_last_run_at()
    return {
        "success": True,
        "msg": "Prüfung gestartet.",
        "last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None,
    }




NETWORK_REVERT_SECONDS = 45
_pending_network_revert = {}
NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml"


def _detect_network_backend():
    """Rein lesend: welcher der gängigen Netzwerk-Manager ist aktiv? Keine
    Annahme, falls keiner läuft (z.B. in dieser WSL-Testumgebung, die ihr
    Netz über den Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst
    rein lesend."""
    for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")):
        try:
            result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5)
            if result.stdout.strip() == "active":
                return name
        except Exception:
            pass
    try:
        result = subprocess.run(["systemctl", "is-active", "systemd-networkd"], capture_output=True, text=True, timeout=5)
        if result.stdout.strip() == "active" and shutil.which("netplan"):
            return "netplan"
    except Exception:
        pass
    return "unknown"


_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"}


def _read_resolv_conf_dns():
    try:
        with open("/etc/resolv.conf", encoding="utf-8") as f:
            return [line.split()[1] for line in f if line.strip().startswith("nameserver")]
    except OSError:
        return []


def _read_configured_dns(interface):
    """Die tatsächlich konfigurierten (Upstream-)DNS-Server statt des
    lokalen systemd-resolved-Stub-Resolvers: /etc/resolv.conf zeigt unter
    systemd-resolved (Standard auf aktuellem Ubuntu) immer nur "nameserver
    127.0.0.53" — die echten Server stehen stattdessen pro Link in
    "resolvectl dns <interface>". Fällt auf resolv.conf zurück, wenn
    resolvectl nicht verfügbar ist (z.B. anderes DNS-Setup ohne
    systemd-resolved); 127.0.0.53/127.0.0.1/::1 werden in beiden Fällen
    herausgefiltert, da sie nie ein sinnvoller "tatsächlicher" Server sind."""
    servers = []
    if shutil.which("resolvectl"):
        ok, out = _dhcp_run_privileged(["resolvectl", "dns", interface], timeout=5)
        if ok:
            for line in out.splitlines():
                stripped = line.strip()
                if not stripped:
                    continue
                m = re.match(r"^Link \d+ \([^)]*\):\s*(.*)$", stripped)
                servers.extend((m.group(1) if m else stripped).split())
    if not servers:
        servers = _read_resolv_conf_dns()
    servers = [s for s in servers if s not in _RESOLVED_STUB_ADDRESSES]
    ipv4_servers = []
    for s in servers:
        try:
            ipaddress.IPv4Address(s)
        except ValueError:
            continue
        ipv4_servers.append(s)
    return ipv4_servers


def _read_network_state(interface, backend):
    """Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network
    (bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf,
    Modus (dhcp/static) backend-spezifisch ermittelt. Jeder Erkennungsschritt
    ist defensiv (try/except) — im Zweifel lieber "unbekannt" anzeigen als
    einen falschen Zustand zu behaupten."""
    net_info = _detect_interface_network(interface)
    mode = "unknown"
    if backend == "networkmanager":
        try:
            dev_out = subprocess.run(
                ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
                capture_output=True, text=True, timeout=5,
            )
            conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
            if conn_name:
                method_out = subprocess.run(
                    ["nmcli", "-g", "ipv4.method", "connection", "show", conn_name],
                    capture_output=True, text=True, timeout=5,
                )
                mode = "static" if method_out.stdout.strip() == "manual" else "dhcp"
        except Exception:
            pass
    elif backend == "dhcpcd":
        try:
            with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
                content = f.read()
            block_match = re.search(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", content, re.S | re.M)
            block = block_match.group(1) if block_match else ""
            mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp"
        except OSError:
            mode = "dhcp"
    elif backend == "netplan":
        if net_info.get("ok"):
            mode = "dhcp" if net_info.get("dynamic") else "static"
    return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend}


def _backup_network_config(interface, backend):
    """Kompletten Ist-Zustand vor einer Änderung sichern, damit
    _revert_network_config() ihn exakt wiederherstellen kann."""
    state = _read_network_state(interface, backend)
    backup = {
        "interface": interface, "backend": backend, "mode": state["mode"],
        "ip": state.get("ip"), "prefix": state.get("prefix"), "gateway": state.get("gateway"),
        "dns": state.get("dns", []),
    }
    if backend == "dhcpcd":
        try:
            with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
                backup["dhcpcd_conf"] = f.read()
        except OSError:
            backup["dhcpcd_conf"] = None
    elif backend == "networkmanager":
        try:
            dev_out = subprocess.run(
                ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
                capture_output=True, text=True, timeout=5,
            )
            backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
        except Exception:
            backup["nm_connection"] = None
    elif backend == "netplan":
        try:
            with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
                backup["netplan_conf"] = f.read()
        except OSError:
            backup["netplan_conf"] = None
    return backup


def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list):
    """Wendet die neue Konfiguration über das erkannte Backend an. Gibt
    (ok, message) zurück statt zu werfen, damit der Aufrufer immer eine
    Flash-Meldung bekommt statt eines 500ers."""
    dns_csv = ",".join(dns_list)
    if backend == "networkmanager":
        try:
            dev_out = subprocess.run(
                ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
                capture_output=True, text=True, timeout=5,
            )
            conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
            if not conn_name:
                return False, f"Keine aktive NetworkManager-Verbindung für {interface} gefunden."
            args = ["nmcli", "connection", "modify", conn_name]
            if mode == "static":
                args += ["ipv4.method", "manual", "ipv4.addresses", f"{ip}/{prefix}", "ipv4.gateway", gateway]
            else:
                args += ["ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", ""]
            if dns_csv:
                args += ["ipv4.dns", dns_csv, "ipv4.ignore-auto-dns", "yes"]
            else:
                args += ["ipv4.dns", "", "ipv4.ignore-auto-dns", "no"]
            ok, out = _dhcp_run_privileged(args, timeout=15)
            if not ok:
                return False, out
            return _dhcp_run_privileged(["nmcli", "connection", "up", conn_name], timeout=20)
        except Exception as e:
            return False, str(e)

    if backend == "dhcpcd":
        try:
            with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
                content = f.read()
            content = re.sub(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", "", content, flags=re.S | re.M)
            block_lines = [f"interface {interface}"]
            if mode == "static":
                block_lines.append(f"static ip_address={ip}/{prefix}")
                if gateway:
                    block_lines.append(f"static routers={gateway}")
            if dns_list:
                block_lines.append(f"static domain_name_servers={' '.join(dns_list)}")
            if len(block_lines) > 1:
                content = content.rstrip() + "\n\n" + "\n".join(block_lines) + "\n"
            with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
                f.write(content)
            return _dhcp_run_privileged(["systemctl", "restart", "dhcpcd"], timeout=20)
        except Exception as e:
            return False, str(e)

    if backend == "netplan":
        try:
            eth_cfg = {}
            if mode == "static":
                eth_cfg["dhcp4"] = False
                eth_cfg["addresses"] = [f"{ip}/{prefix}"]
                if gateway:
                    eth_cfg["routes"] = [{"to": "default", "via": gateway}]
            else:
                eth_cfg["dhcp4"] = True
                if dns_list:
                    eth_cfg["dhcp4-overrides"] = {"use-dns": False}
            if dns_list:
                eth_cfg["nameservers"] = {"addresses": dns_list}
            try:
                with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
                    existing_doc = yaml.safe_load(f) or {}
            except OSError:
                existing_doc = {}
            ethernets = ((existing_doc.get("network") or {}).get("ethernets") or {}).copy()
            ethernets[interface] = eth_cfg
            doc = {"network": {"version": 2, "ethernets": ethernets}}
            with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
                yaml.safe_dump(doc, f, default_flow_style=False)
            os.chmod(NETPLAN_CONFIG_PATH, 0o600)
            return _dhcp_run_privileged(["netplan", "apply"], timeout=20)
        except Exception as e:
            return False, str(e)

    return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager, dhcpcd noch netplan aktiv)."


def _revert_network_config(token):
    """Timer-Callback: falls binnen NETWORK_REVERT_SECONDS keine Bestätigung
    einging, gesicherten Zustand wiederherstellen. Läuft in einem
    Hintergrund-Thread (threading.Timer) — Fehler werden geloggt statt die
    App zum Absturz zu bringen."""
    entry = _pending_network_revert.get(token)
    if not entry:
        return
    backup = entry["backup"]
    try:
        if backup["backend"] == "dhcpcd" and backup.get("dhcpcd_conf") is not None:
            with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
                f.write(backup["dhcpcd_conf"])
            subprocess.run(["systemctl", "restart", "dhcpcd"], timeout=20)
        elif backup["backend"] == "networkmanager":
            _apply_network_config(
                "networkmanager", backup["interface"],
                backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []),
            )
        elif backup["backend"] == "netplan":
            if backup.get("netplan_conf") is not None:
                with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
                    f.write(backup["netplan_conf"])
            else:
                try:
                    os.remove(NETPLAN_CONFIG_PATH)
                except OSError:
                    pass
            subprocess.run(["netplan", "apply"], timeout=20)
        if _stop_dhcp_service_if_active():
            log_action_system("settings.network_revert", backup["interface"], "DHCP-Dienst wegen Rollback gestoppt")
        log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt")
    except Exception as e:
        app.logger.error("Network auto-revert failed: %s", e)
    finally:
        _pending_network_revert.pop(token, None)


# ============================================================================
# NGINX (Systemeinstellungen -> NGINX)
# Verwaltet den kompletten Reverse-Proxy: Domain/Ports, SSL/HSTS,
# Zertifikat (Upload ODER Let's Encrypt).
#
# Let's Encrypt läuft bewusst über "certbot certonly --webroot", NICHT über
# das certbot-nginx-Plugin -- das Plugin würde NGINX_SITE_CONFIG_PATH
# direkt editieren (eigene server-Blöcke/Zeilen einfügen) und diese
# Änderungen bei der nächsten Änderung über diese Seite (die die Datei
# komplett neu schreibt, siehe _render_nginx_config) wieder verlieren.
# Mit der webroot-Methode bleibt _render_nginx_config die EINZIGE Quelle
# für den Datei-Inhalt; certbot legt nur Dateien unter TESM_ACME_WEBROOT
# ab und liest/schreibt sein Zertifikat unter /etc/letsencrypt/live/.
# ============================================================================

NGINX_REVERT_SECONDS = 45
_pending_nginx_revert = {}

_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$")


def _valid_server_name(name):
    """"_" (nginx-Catch-all, kein bestimmter Hostname -- Standard für rein
    interne Instanzen ohne eigene Domain) oder eine plausibel geformte
    FQDN."""
    return name == "_" or bool(_DOMAIN_RE.match(name))


def _valid_port(value):
    try:
        n = int(value)
    except (TypeError, ValueError):
        return False
    return 1 <= n <= 65535 and n != 5000


_NGINX_PROXY_LOCATIONS = """    location /ws/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    location / {
        # nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
        # großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
        # damit bei einer knapp 15MB großen Datei nginx nicht schon vor
        # Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
        client_max_body_size 16m;
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /static/ {
        alias /srv/tesm/static/;
        expires 7d;
    }
"""


def _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path):
    """Erzeugt den kompletten Inhalt von NGINX_SITE_CONFIG_PATH. Die
    Reverse-Proxy-Regeln (WebSocket-Terminal, Haupt-App, statische Dateien)
    sind bei aktivem SSL identisch, nur hinter einem HTTPS-Block mit
    Zertifikat/HSTS statt direkt hinter dem HTTP-Port -- der HTTP-Port
    leitet dann nur noch auf https weiter, damit ein alter Bookmark/Link
    nicht plötzlich ins Leere läuft. Die ACME-Challenge-Location wird
    IMMER mitgeschrieben (auch ohne aktives SSL) -- eine Let's-Encrypt-
    (Erst-)Anforderung braucht sie schon VOR dem ersten SSL-Aktivieren,
    eine Verlängerung später ebenso. ACME HTTP-01 verbindet sich immer auf
    Port 80 (protokollbedingt, nicht konfigurierbar) -- weicht der
    eingestellte HTTP-Port davon ab, wird zusätzlich ein minimaler, nur
    dafür zuständiger Port-80-Block ergänzt, damit Let's Encrypt trotzdem
    funktioniert."""
    header = 'map $http_upgrade $connection_upgrade {\n    default upgrade;\n    "" close;\n}\n\n'
    acme_location = (
        "    location /.well-known/acme-challenge/ {\n"
        f"        root {TESM_ACME_WEBROOT};\n"
        "        try_files $uri =404;\n"
        "    }\n\n"
    )

    extra_acme_block = ""
    if str(http_port) != "80":
        extra_acme_block = (
            "server {\n    listen 80;\n    server_name _;\n\n"
            + acme_location
            + "    location / { return 404; }\n}\n\n"
        )

    if not ssl_enabled:
        return (
            header + extra_acme_block
            + f"server {{\n    listen {http_port};\n    server_name {server_name};\n\n"
            + acme_location
            + _NGINX_PROXY_LOCATIONS
            + "}\n"
        )

    hsts_line = ""
    if hsts_enabled:
        hsts_line = '    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;\n\n'
    redirect_target = "https://$host$request_uri" if str(https_port) == "443" else f"https://$host:{https_port}$request_uri"
    return (
        header + extra_acme_block
        + f"server {{\n    listen {http_port};\n    server_name {server_name};\n\n"
        + acme_location
        + f"    location / {{\n        return 301 {redirect_target};\n    }}\n"
        + "}\n\n"
        + f"server {{\n    listen {https_port} ssl;\n    server_name {server_name};\n\n"
        + f"    ssl_certificate {cert_path};\n"
        + f"    ssl_certificate_key {key_path};\n\n"
        + hsts_line
        + _NGINX_PROXY_LOCATIONS
        + "}\n"
    )


def _current_cert_paths():
    """(cert_path, key_path) je nach aktiver Zertifikatsquelle (Setting
    ssl_source: "upload" oder "letsencrypt"), oder (None, None) wenn
    keins vorhanden ist."""
    if get_setting("ssl_source", "upload") == "letsencrypt":
        domain = get_setting("nginx_server_name", "_")
        cert, key = _le_cert_path(domain), _le_key_path(domain)
        if os.path.isfile(cert) and os.path.isfile(key):
            return cert, key
        return None, None
    if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH):
        return TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH
    return None, None


def _apply_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled):
    """Schreibt die neue nginx-Konfiguration, validiert sie (nginx -t) und
    lädt nginx erst danach neu -- schlägt Validierung ODER Reload fehl,
    wird der vorherige Inhalt sofort wiederhergestellt (Selbstschutz gegen
    einen Tippfehler/ein defektes Zertifikat, das sonst den kompletten
    Reverse-Proxy lahmlegen würde). Gibt (ok, fehlermeldung, alter_inhalt)
    zurück -- den alten Inhalt braucht der Aufrufer zusätzlich für das
    zeitgesteuerte Rückroll-Sicherheitsnetz, siehe NGINX_REVERT_SECONDS."""
    try:
        with open(NGINX_SITE_CONFIG_PATH, encoding="utf-8") as f:
            previous = f.read()
    except OSError:
        previous = None

    cert_path = key_path = None
    if ssl_enabled:
        cert_path, key_path = _current_cert_paths()
        if not cert_path:
            return False, "Kein Zertifikat verfügbar.", previous

    new_content = _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path)
    try:
        with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
            f.write(new_content)
    except OSError as e:
        return False, f"Konfiguration konnte nicht geschrieben werden: {e}", previous

    ok, out = _dhcp_run_privileged(["nginx", "-t"], timeout=10)
    if ok:
        ok, out = _dhcp_run_privileged(["systemctl", "reload", "nginx"], timeout=10)

    if not ok:
        if previous is not None:
            try:
                with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
                    f.write(previous)
                subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10)
            except OSError:
                pass
        return False, out, previous

    return True, "", previous


def _revert_nginx_config(token):
    """Timer-Callback analog zu _revert_network_config: keine Bestätigung
    binnen NGINX_REVERT_SECONDS -> vorherige nginx-Konfiguration UND alle
    zugehörigen Einstellungen (Domain, Ports, SSL, HSTS) wiederherstellen."""
    entry = _pending_nginx_revert.get(token)
    if not entry:
        return
    try:
        if entry["previous"] is not None:
            with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
                f.write(entry["previous"])
            subprocess.run(["nginx", "-t"], capture_output=True, timeout=10)
            subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10)
        for key, value in entry["prev_settings"].items():
            set_setting(key, value)
        log_action_system("settings.nginx_revert", "nginx", "automatisch nach Timeout zurückgerollt")
    except Exception as e:
        app.logger.error("nginx auto-revert failed: %s", e)
    finally:
        _pending_nginx_revert.pop(token, None)


def _parse_cert(cert_bytes):
    """Lädt ein PEM-Zertifikat und liefert sowohl das cryptography-Objekt
    (für den Schlüssel-Abgleich) als auch die für die UI relevanten Felder
    (Subject, Aussteller, Ablaufdatum)."""
    cert = x509.load_pem_x509_certificate(cert_bytes)
    try:
        not_after = cert.not_valid_after_utc.replace(tzinfo=None)
    except AttributeError:
        not_after = cert.not_valid_after
    return cert, {
        "subject": cert.subject.rfc4514_string(),
        "issuer": cert.issuer.rfc4514_string(),
        "not_after": not_after,
    }


def _validate_cert_key_pair(cert_bytes, key_bytes):
    """Prüft ein hochgeladenes Zertifikat+Key-Paar VOR dem Speichern --
    verhindert, dass ein kaputtes oder nicht zusammenpassendes Paar erst
    beim tatsächlichen SSL-Aktivieren auffällt (mit dem entsprechenden
    Ausfallrisiko für den Reverse-Proxy). Der Schlüssel-Abgleich
    funktioniert algorithmus-unabhängig (RSA/EC/...), da nur die
    öffentlichen Schlüssel als DER-Bytes verglichen werden, nicht
    algorithmus-spezifische Felder. Gibt (ok, fehlermeldung, info-dict)
    zurück."""
    try:
        cert, info = _parse_cert(cert_bytes)
    except ValueError as e:
        return False, f"Datei ist kein gültiges PEM-Zertifikat ({e}).", {}

    if info["not_after"] < datetime.utcnow():
        return False, f"Zertifikat ist bereits abgelaufen (gültig bis {info['not_after']:%d.%m.%Y}).", info

    try:
        private_key = serialization.load_pem_private_key(key_bytes, password=None)
    except (ValueError, TypeError) as e:
        return False, f"Datei ist kein gültiger, unverschlüsselter privater Schlüssel ({e}).", info

    cert_pub = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
    key_pub = private_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
    if cert_pub != key_pub:
        return False, "Zertifikat und privater Schlüssel passen nicht zusammen.", info

    return True, "", info


def _current_cert_info():
    """Für die NGINX-Seite: Status des aktuell aktiven Zertifikats (Upload
    oder Let's Encrypt, je nach ssl_source) inkl. verbleibender
    Gültigkeitsdauer."""
    cert_path, _key_path = _current_cert_paths()
    if not cert_path:
        return None
    try:
        with open(cert_path, "rb") as f:
            _, info = _parse_cert(f.read())
        info["days_left"] = (info["not_after"] - datetime.utcnow()).days
        info["source"] = get_setting("ssl_source", "upload")
        return info
    except (OSError, ValueError):
        return None


def _request_letsencrypt_cert(domain, email=None):
    """Fordert per certbot (webroot-Methode, siehe TESM_ACME_WEBROOT) ein
    Zertifikat für die angegebene Domain an. Setzt voraus, dass die Domain
    öffentlich auf diesen Host auflöst UND Port 80 aus dem Internet
    erreichbar ist (ACME HTTP-01) -- beides kann diese App nicht prüfen,
    ein Fehlschlag hier liegt i.d.R. an der Netzwerk-/DNS-Situation, nicht
    an TESM selbst. Automatische Verlängerung übernimmt certbots eigener
    systemd-Timer/Cron (Standard-Paketverhalten unter Debian/Ubuntu), der
    Deploy-Hook (_write_certbot_deploy_hook) lädt nginx danach neu."""
    args = [
        "certbot", "certonly", "--webroot", "-w", TESM_ACME_WEBROOT,
        "-d", domain, "--non-interactive", "--agree-tos", "--cert-name", domain,
    ]
    args += ["--email", email] if email else ["--register-unsafely-without-email"]
    return _dhcp_run_privileged(args, timeout=120)


def _certbot_timer_status():
    """Status von certbot.timer -- dem systemd-Timer, der die automatische
    Verlängerung tatsächlich antreibt (Standard-Paketverhalten unter
    Debian/Ubuntu, zweimal täglich). Rein informativ für die NGINX-Seite:
    "automatische Verlängerung" soll für den Admin sichtbar/nachprüfbar
    sein, nicht nur eine Behauptung im Quelltext bleiben. None, wenn
    certbot (noch) nicht installiert ist."""
    active_ok, active_out = _dhcp_run_privileged(["systemctl", "is-active", "certbot.timer"], timeout=5)
    if not active_ok and "could not be found" in active_out.lower():
        return None
    next_run = None
    ok, out = _dhcp_run_privileged(["systemctl", "list-timers", "certbot.timer", "--no-legend"], timeout=5)
    if ok and out.strip():
        parts = out.strip().split(None, 4)
        if len(parts) >= 4:
            next_run = " ".join(parts[0:4])
    return {"active": active_out.strip() == "active", "next_run": next_run}


@app.route("/settings/nginx/raw")
@login_required
def settings_nginx_raw():
    """Komplette, unbearbeitete nginx-Konfiguration für das RAW-Popup --
    gleiches Muster wie beim Live-Log (window.openRawLogModal)."""
    if not current_user.has_permission("settings_nginx.view"):
        return "Keine Berechtigung.", 403
    try:
        with open(NGINX_SITE_CONFIG_PATH, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError as e:
        content = f"Fehler beim Lesen der Konfiguration: {e}"
    response = app.response_class(content, mimetype="text/plain")
    response.headers["X-Log-Name"] = os.path.basename(NGINX_SITE_CONFIG_PATH)
    return response


@app.route("/settings/nginx", methods=["GET", "POST"])
@login_required
def settings_nginx():
    if request.method == "GET" and not current_user.has_permission("settings_nginx.view"):
        flash("Keine Berechtigung, die NGINX-Einstellungen anzusehen.", "danger")
        return redirect(url_for("index"))

    if request.method == "POST":
        if not current_user.has_permission("settings_nginx.edit"):
            flash("Keine Berechtigung, die NGINX-Einstellungen zu ändern.", "danger")
            return redirect(url_for("settings_nginx"))

        if "upload_cert" in request.form:
            cert_file = request.files.get("cert_file")
            key_file = request.files.get("key_file")
            if not cert_file or not cert_file.filename or not key_file or not key_file.filename:
                flash("Bitte sowohl Zertifikat (PEM) als auch privaten Schlüssel (PEM) auswählen.", "danger")
            else:
                cert_bytes = cert_file.read()
                key_bytes = key_file.read()
                ok, msg, info = _validate_cert_key_pair(cert_bytes, key_bytes)
                if not ok:
                    flash(f"Zertifikat/Schlüssel nicht gespeichert: {msg}", "danger")
                else:
                    try:
                        os.makedirs(TESM_SSL_DIR, exist_ok=True)
                        with open(TESM_SSL_CERT_PATH, "wb") as f:
                            f.write(cert_bytes)
                        with open(TESM_SSL_KEY_PATH, "wb") as f:
                            f.write(key_bytes)
                        os.chmod(TESM_SSL_KEY_PATH, 0o600)
                        os.chmod(TESM_SSL_CERT_PATH, 0o644)
                        set_setting("ssl_source", "upload")
                        log_action("settings.nginx_cert_upload", info.get("subject", ""), f"gültig bis {info['not_after']:%d.%m.%Y}")
                        flash(
                            f"Zertifikat gespeichert (Subject: {info.get('subject')}, gültig bis "
                            f"{info['not_after']:%d.%m.%Y}). SSL ist damit noch nicht aktiviert -- unten aktivieren.",
                            "success",
                        )
                    except OSError as e:
                        flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger")

        elif "request_letsencrypt" in request.form:
            domain = request.form.get("le_domain", "").strip()
            email = request.form.get("le_email", "").strip() or None
            if not domain or not _DOMAIN_RE.match(domain):
                flash("Bitte eine gültige, öffentlich auflösbare Domain angeben.", "danger")
            else:
                ok, out = _request_letsencrypt_cert(domain, email)
                if not ok:
                    flash(f"Let's-Encrypt-Anforderung fehlgeschlagen: {out.strip()[-800:]}", "danger")
                else:
                    set_setting("ssl_source", "letsencrypt")
                    set_setting("nginx_server_name", domain)
                    log_action("settings.nginx_letsencrypt_request", domain, "Zertifikat erfolgreich ausgestellt")
                    flash(
                        f"Let's-Encrypt-Zertifikat für {domain} ausgestellt (automatische Verlängerung läuft über "
                        f"certbots eigenen Timer). SSL ist damit noch nicht aktiviert -- unten aktivieren.",
                        "success",
                    )

        elif "test_renewal" in request.form:
            if get_setting("ssl_source", "upload") != "letsencrypt":
                flash("Verlängerungs-Test setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
            else:
                domain = get_setting("nginx_server_name", "_")
                ok, out = _dhcp_run_privileged(["certbot", "renew", "--dry-run", "--cert-name", domain], timeout=120)
                if ok:
                    log_action("settings.nginx_letsencrypt_test_renewal", domain)
                    flash("Verlängerungs-Test erfolgreich (Dry-Run — es wurde nichts tatsächlich verändert).", "success")
                else:
                    flash(f"Verlängerungs-Test fehlgeschlagen: {out.strip()[-800:]}", "danger")

        elif "force_renew" in request.form:
            if get_setting("ssl_source", "upload") != "letsencrypt":
                flash("Manuelle Verlängerung setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
            else:
                domain = get_setting("nginx_server_name", "_")
                ok, out = _dhcp_run_privileged(["certbot", "renew", "--force-renewal", "--cert-name", domain], timeout=120)
                if ok:
                    log_action("settings.nginx_letsencrypt_force_renew", domain)
                    flash("Zertifikat manuell erneuert — nginx wurde über den Deploy-Hook automatisch neu geladen.", "success")
                else:
                    flash(f"Manuelle Verlängerung fehlgeschlagen: {out.strip()[-800:]}", "danger")

        elif "apply_nginx" in request.form:
            new_server_name = request.form.get("server_name", "_").strip() or "_"
            new_http_port = request.form.get("http_port", "80").strip()
            new_https_port = request.form.get("https_port", "443").strip()
            new_ssl = "ssl_enabled" in request.form
            new_hsts = "hsts_enabled" in request.form

            if not _valid_server_name(new_server_name):
                flash("Ungültiger Domain-/Servername.", "danger")
            elif not _valid_port(new_http_port) or not _valid_port(new_https_port):
                flash("Port muss eine Zahl zwischen 1 und 65535 sein (5000 ist für die App selbst reserviert).", "danger")
            elif new_http_port == new_https_port:
                flash("HTTP- und HTTPS-Port müssen unterschiedlich sein.", "danger")
            elif new_ssl and not _current_cert_paths()[0]:
                flash("Kein Zertifikat verfügbar -- bitte zuerst hochladen oder per Let's Encrypt anfordern.", "danger")
            elif new_hsts and not new_ssl:
                flash("HSTS setzt aktives SSL voraus.", "danger")
            else:
                prev_settings = {
                    "nginx_server_name": get_setting("nginx_server_name", "_"),
                    "nginx_http_port": get_setting("nginx_http_port", "80"),
                    "nginx_https_port": get_setting("nginx_https_port", "443"),
                    "ssl_enabled": get_setting("ssl_enabled", "0"),
                    "hsts_enabled": get_setting("hsts_enabled", "0"),
                }
                ok, err, previous = _apply_nginx_config(new_server_name, new_http_port, new_https_port, new_ssl, new_hsts)
                if not ok:
                    flash(f"nginx-Konfiguration konnte nicht angewendet werden, unverändert gelassen: {err}", "danger")
                else:
                    set_setting("nginx_server_name", new_server_name)
                    set_setting("nginx_http_port", new_http_port)
                    set_setting("nginx_https_port", new_https_port)
                    set_setting("ssl_enabled", "1" if new_ssl else "0")
                    set_setting("hsts_enabled", "1" if new_hsts else "0")
                    log_action(
                        "settings.nginx_apply", new_server_name,
                        f"http={new_http_port} https={new_https_port} ssl={new_ssl} hsts={new_hsts}",
                    )
                    token = secrets.token_hex(8)
                    timer = threading.Timer(NGINX_REVERT_SECONDS, _revert_nginx_config, args=(token,))
                    timer.daemon = True
                    _pending_nginx_revert.clear()
                    _pending_nginx_revert[token] = {"timer": timer, "previous": previous, "prev_settings": prev_settings}
                    timer.start()
                    scheme = "https" if new_ssl else "http"
                    port = new_https_port if new_ssl else new_http_port
                    port_suffix = "" if port in ("80", "443") else f":{port}"
                    flash(
                        f"nginx-Konfiguration angewendet. Falls diese Seite jetzt noch über {scheme}{port_suffix} "
                        f"erreichbar ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s "
                        f"automatisch zurückgerollt.",
                        "success",
                    )

        elif "confirm_nginx" in request.form:
            token = request.form.get("confirm_nginx")
            entry = _pending_nginx_revert.pop(token, None)
            if entry:
                entry["timer"].cancel()
                log_action("settings.nginx_confirm", "nginx")
                flash("nginx-Konfiguration bestätigt — kein automatisches Rollback mehr.", "success")
            else:
                flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")

        elif "remove_cert" in request.form:
            if get_setting("ssl_enabled", "0") == "1":
                flash("Zertifikat kann nicht entfernt werden, solange SSL aktiv ist -- bitte zuerst deaktivieren.", "danger")
            elif get_setting("ssl_source", "upload") == "letsencrypt":
                domain = get_setting("nginx_server_name", "_")
                ok, out = _dhcp_run_privileged(["certbot", "delete", "--cert-name", domain, "--non-interactive"], timeout=30)
                if not ok:
                    flash(f"Let's-Encrypt-Zertifikat konnte nicht entfernt werden: {out}", "danger")
                else:
                    log_action("settings.nginx_cert_remove", domain, "Let's Encrypt")
                    flash("Let's-Encrypt-Zertifikat entfernt.", "success")
            else:
                for path in (TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH):
                    try:
                        os.remove(path)
                    except OSError:
                        pass
                log_action("settings.nginx_cert_remove", "upload")
                flash("Hochgeladenes Zertifikat entfernt.", "success")

        return redirect(url_for("settings_nginx"))

    pending_token = next(iter(_pending_nginx_revert), None)
    return render_template(
        "settings_nginx.html",
        cert_info=_current_cert_info(),
        ssl_enabled=get_setting("ssl_enabled", "0") == "1",
        hsts_enabled=get_setting("hsts_enabled", "0") == "1",
        server_name=get_setting("nginx_server_name", "_"),
        http_port=get_setting("nginx_http_port", "80"),
        https_port=get_setting("nginx_https_port", "443"),
        pending_nginx_token=pending_token,
        nginx_revert_seconds=NGINX_REVERT_SECONDS,
        certbot_timer=_certbot_timer_status(),
    )


@app.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
    if request.method == "GET" and not current_user.can_view_settings_system:
        flash("Keine Berechtigung, die Systemeinstellungen anzusehen.", "danger")
        return redirect(url_for("index"))

    interval = int(get_setting("interval", 5))

    if request.method == "POST":
        if not current_user.has_permission("settings_system.edit"):
            flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger")
            return redirect(url_for("settings"))

        if "interval" in request.form:
            if not license_active():
                flash("Das Prüfintervall kann nur mit gültiger Lizenz geändert werden.", "danger")
                return redirect(url_for("settings"))
            new_interval = int(request.form["interval"])
            set_setting("interval", new_interval)
            set_setting("check_interval", new_interval * 60)
            log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
            _restart_check_service()
            flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")

        elif "apply_network" in request.form:
            interface = request.form.get("net_interface", "").strip()
            mode = request.form.get("net_mode", "dhcp")
            ip = request.form.get("net_ip", "").strip()
            prefix = request.form.get("net_prefix", "").strip()
            gateway = request.form.get("net_gateway", "").strip()
            dns_list = [d.strip() for d in request.form.get("net_dns", "").split(",") if d.strip()]
            backend = _detect_network_backend()

            if interface not in _list_network_interfaces():
                flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
            elif backend == "unknown":
                flash("Kein unterstütztes Netzwerk-Backend erkannt — Änderungen über die App sind deaktiviert.", "danger")
            elif mode == "static" and not (ip and prefix and gateway):
                flash("Für eine statische Konfiguration werden IP-Adresse, Prefix und Gateway benötigt.", "danger")
            else:
                backup = _backup_network_config(interface, backend)
                ok, out = _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list)
                if ok:
                    token = secrets.token_hex(8)
                    timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,))
                    timer.daemon = True
                    _pending_network_revert.clear()
                    _pending_network_revert[token] = {"timer": timer, "backup": backup}
                    timer.start()
                    set_setting("net_interface", interface)
                    log_action("settings.network_apply", interface, f"Modus {mode}")
                    msg = (
                        f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte "
                        f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt."
                    )
                    if _stop_dhcp_service_if_active():
                        log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt")
                        msg += (
                            " Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum "
                            "neuen Netz nicht mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf "
                            "neu starten (bleibt sonst auch nach einem Host-Neustart aus)."
                        )
                    flash(msg, "success")
                else:
                    flash(f"Anwenden fehlgeschlagen: {out}", "danger")

        elif "hostname" in request.form:
            new_hostname = request.form.get("hostname", "").strip()
            if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", new_hostname):
                flash("Ungültiger Hostname (nur Buchstaben, Ziffern und Bindestrich, max. 63 Zeichen).", "danger")
            else:
                ok, out = _dhcp_run_privileged(["hostnamectl", "set-hostname", new_hostname], timeout=10)
                if ok:
                    log_action("settings.update", "Hostname", new_hostname)
                    flash(f"Hostname auf „{new_hostname}“ gesetzt.", "success")
                else:
                    flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger")

        elif "timezone" in request.form:
            new_tz = request.form.get("timezone", "").strip()
            if new_tz not in _list_timezones():
                flash("Ungültige Zeitzone.", "danger")
            else:
                ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10)
                if ok:
                    time.tzset()
                    log_action("settings.update", "Zeitzone", new_tz)
                    flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success")
                else:
                    flash(f"Zeitzone konnte nicht gesetzt werden: {out}", "danger")

        elif "confirm_network" in request.form:
            token = request.form.get("confirm_network")
            entry = _pending_network_revert.pop(token, None)
            if entry:
                entry["timer"].cancel()
                log_action("settings.network_confirm", entry["backup"]["interface"])
                flash("Netzwerkkonfiguration bestätigt — kein automatisches Rollback mehr.", "success")
            else:
                flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")

        elif "save_log_rotation" in request.form:
            new_log_interval = request.form.get("log_rotation_interval", "")
            new_log_keep_raw = request.form.get("log_rotation_keep", "")
            if new_log_interval not in LOG_ROTATION_INTERVALS:
                flash("Ungültiges Rotations-Intervall.", "danger")
            elif not new_log_keep_raw.isdigit() or int(new_log_keep_raw) < 1:
                flash("Aufbewahrung muss eine Zahl ≥ 1 sein.", "danger")
            else:
                set_setting("log_rotation_interval", new_log_interval)
                set_setting("log_rotation_keep", int(new_log_keep_raw))
                if _write_logrotate_config():
                    log_action("settings.update", "Log-Rotation", f"{new_log_interval}, {new_log_keep_raw} Rotationen")
                    flash(f"Log-Rotation gespeichert: {new_log_interval}, {new_log_keep_raw} Rotationen aufbewahrt.", "success")
                else:
                    flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger")

        elif "save_trash_retention" in request.form:
            new_days_raw = request.form.get("trash_retention_days", "").strip()
            if not new_days_raw.isdigit() or int(new_days_raw) < 1:
                flash("Aufbewahrungsdauer muss eine Zahl ≥ 1 sein.", "danger")
            else:
                set_setting("trash_retention_days", new_days_raw)
                log_action("settings.update", "Papierkorb-Aufbewahrung", f"{new_days_raw} Tage")
                flash(f"Papierkorb-Aufbewahrung auf {new_days_raw} Tage gesetzt.", "success")

        elif "purge_trash_now" in request.form:
            _purge_expired_trash()
            log_action("settings.update", "Papierkorb sofort bereinigt")
            flash("Abgelaufene Papierkorb-Einträge wurden endgültig gelöscht.", "success")


        return redirect(url_for("settings"))

    net_backend = _detect_network_backend()
    all_interfaces = _list_network_interfaces()
    net_interface = get_setting("net_interface") or (all_interfaces or [None])[0]
    net_state = _read_network_state(net_interface, net_backend) if net_interface else None
    active_net_states = [
        {"interface": iface, **_read_network_state(iface, net_backend)}
        for iface in all_interfaces
    ]
    active_net_states = [s for s in active_net_states if s.get("ok")]
    pending_token = next(iter(_pending_network_revert), None)
    try:
        current_hostname = socket.gethostname()
    except Exception:
        current_hostname = None
    return render_template(
        "settings.html",
        interval=interval,
        current_hostname=current_hostname,
        net_backend=net_backend,
        net_interfaces=all_interfaces,
        net_interface=net_interface,
        net_state=net_state,
        active_net_states=active_net_states,
        net_revert_seconds=NETWORK_REVERT_SECONDS,
        pending_network_token=pending_token,
        log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL),
        log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP),
        log_paths={
            "live": TESM_LIVE_LOG_PATH,
            "changes": TESM_CHANGES_LOG_PATH,
            "app": TESM_APP_LOG_PATH,
            "kea": TESM_KEA_LOG_PATH,
        },
        current_timezone=_current_timezone(),
        timezones=_list_timezones(),
        full_nav_items=_ordered_nav_items() if current_user.is_admin else [],
        trash_retention_days=_trash_retention_days(),
    )


@app.route("/settings/license")
@login_required
def settings_license():
    if not current_user.can_view_settings_system:
        flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
        return redirect(url_for("index"))
    pending_raw = get_setting("license_pending_request", "")
    pending = json.loads(pending_raw) if pending_raw else None
    return render_template(
        "settings_license.html",
        license_file=_license_state["file"],
        license_valid=_license_state["valid"],
        license_error=_license_state["error"],
        license_status=_license_state["status"],
        license_is_activated=license_activated(),
        license_last_heartbeat_at=get_setting("license_last_heartbeat_at", ""),
        license_last_error=get_setting("license_last_error", ""),
        license_fingerprint=licensing.system_fingerprint(),
        pending_action=pending,
        pending_code=licensing.encode_code(pending) if pending else None,
    )


@app.route("/settings/license/upload", methods=["POST"])
@login_required
def license_upload():
    if not current_user.has_permission("settings_system.edit"):
        flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
        return redirect(url_for("settings_license"))
    uploaded = request.files.get("license_file")
    if not uploaded or not uploaded.filename:
        flash("Bitte eine Lizenzdatei auswählen.", "danger")
        return redirect(url_for("settings_license"))
    try:
        license_file = json.loads(uploaded.read().decode("utf-8"))
    except (ValueError, UnicodeDecodeError):
        flash("Datei ist keine gültige Lizenzdatei (kein lesbares JSON).", "danger")
        return redirect(url_for("settings_license"))
    if not licensing.verify_license_file(license_file):
        flash("Lizenzdatei-Signatur ungültig -- Datei beschädigt, manipuliert oder von einem anderen Lizenzserver.", "danger")
        return redirect(url_for("settings_license"))
    _write_license_file(license_file)
    set_setting("license_activation_status", "")
    set_setting("license_pending_request", "")
    customer_name = license_file.get("customer", {}).get("name", "?")
    log_action("license.upload", license_file.get("license_id", ""), f"Lizenz für {customer_name} hochgeladen -- muss noch aktiviert werden.")
    flash("Lizenzdatei eingespielt. Bitte jetzt aktivieren.", "success")
    return redirect(url_for("settings_license"))


def _license_handshake(action, endpoint_path, success_setting_fn, success_log_action, success_flash):
    """Gemeinsame Logik für Aktivierung/Deaktivierung: EIN Protokoll, zwei
    Transportwege (siehe licensing.py-Moduldocstring). Versucht zuerst
    automatisch online; schlägt das fehl (keine Netzwerkverbindung zum
    Lizenzserver o.ä.), wird die Anfrage als Code hinterlegt, den der
    Admin manuell beim Lizenzserver eingeben kann -- die Bestätigung
    kommt dann über *_confirm() unten zurück."""
    license_file = _license_state.get("file")
    request_payload = licensing.build_client_request(action, license_file)
    endpoint = license_file.get("master_endpoint", "")
    try:
        if not endpoint:
            raise RuntimeError("Kein Lizenzserver in der Lizenzdatei hinterlegt.")
        response_payload = _license_api_post(endpoint, endpoint_path, request_payload)
        if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
            raise RuntimeError("Antwort mit ungültiger Signatur erhalten.")
        if response_payload.get("status") != "ok":
            raise RuntimeError(response_payload.get("status", "abgelehnt"))
        success_setting_fn()
        set_setting("license_pending_request", "")
        log_action(success_log_action, license_file.get("license_id", ""), "Online beim Lizenzserver bestätigt.")
        flash(success_flash, "success")
    except Exception as exc:
        set_setting("license_pending_request", json.dumps(request_payload))
        set_setting("license_last_error", f"Online-{action} fehlgeschlagen: {exc}")
        flash(
            "Online-Verbindung zum Lizenzserver nicht möglich. Bitte den unten angezeigten Code beim "
            "Lizenzserver-Administrator eingeben und den dort erhaltenen Bestätigungscode unten eintragen.",
            "warning",
        )
    return redirect(url_for("settings_license"))


def _license_handshake_confirm(action, success_setting_fn, success_log_action, success_flash):
    """Gegenstück zu _license_handshake() für den Offline-Zweig: der Admin
    hat den vom Master erzeugten Bestätigungscode erhalten und trägt ihn
    hier ein."""
    license_file = _license_state.get("file")
    pending_raw = get_setting("license_pending_request", "")
    if not license_file or not pending_raw:
        flash("Keine offene Anfrage vorhanden.", "danger")
        return redirect(url_for("settings_license"))
    code = request.form.get("confirmation_code", "").strip()
    try:
        response_payload = licensing.decode_code(code)
    except Exception:
        flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger")
        return redirect(url_for("settings_license"))
    if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
        flash("Code hat eine ungültige Signatur -- bitte erneut vom Lizenzserver kopieren.", "danger")
        return redirect(url_for("settings_license"))
    pending = json.loads(pending_raw)
    if (response_payload.get("license_id") != pending.get("license_id")
            or response_payload.get("fingerprint") != pending.get("fingerprint")):
        flash("Code passt nicht zur offenen Anfrage.", "danger")
        return redirect(url_for("settings_license"))
    if response_payload.get("status") != "ok":
        flash(f"Lizenzserver hat die Anfrage abgelehnt ({response_payload.get('status')}).", "danger")
        return redirect(url_for("settings_license"))
    success_setting_fn()
    set_setting("license_pending_request", "")
    log_action(success_log_action, license_file.get("license_id", ""), "Offline per Code bestätigt.")
    flash(success_flash, "success")
    return redirect(url_for("settings_license"))


@app.route("/settings/license/activate", methods=["POST"])
@login_required
def license_activate():
    if not current_user.has_permission("settings_system.edit"):
        flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
        return redirect(url_for("settings_license"))
    if not _license_state.get("file") or not _license_state.get("valid"):
        flash("Keine gültige Lizenzdatei vorhanden.", "danger")
        return redirect(url_for("settings_license"))

    def on_success():
        set_setting("license_activation_status", "active")
        set_setting("license_export_grace", "")

    return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")


@app.route("/settings/license/activate/confirm", methods=["POST"])
@login_required
def license_activate_confirm():
    if not current_user.has_permission("settings_system.edit"):
        flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
        return redirect(url_for("settings_license"))

    def on_success():
        set_setting("license_activation_status", "active")
        set_setting("license_export_grace", "")

    return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")


@app.route("/settings/license/deactivate", methods=["POST"])
@login_required
def license_deactivate():
    if not current_user.has_permission("settings_system.edit"):
        flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
        return redirect(url_for("settings_license"))
    if not _license_state.get("file") or not license_activated():
        flash("Keine aktive Lizenz zum Deaktivieren vorhanden.", "danger")
        return redirect(url_for("settings_license"))

    def on_success():
        set_setting("license_export_grace", "1")
        _delete_license_file()

    return _license_handshake(
        "deactivate", "/api/deactivate", on_success, "license.deactivated",
        "Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
    )


@app.route("/settings/license/deactivate/confirm", methods=["POST"])
@login_required
def license_deactivate_confirm():
    if not current_user.has_permission("settings_system.edit"):
        flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
        return redirect(url_for("settings_license"))

    def on_success():
        set_setting("license_export_grace", "1")
        _delete_license_file()

    return _license_handshake_confirm(
        "deactivate", on_success, "license.deactivated",
        "Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
    )


@app.route("/settings/network_state")
@login_required
def settings_network_state():
    """JSON-Endpunkt für die Interface-Auswahl auf der Netzwerkeinstellungen-
    Karte -- liefert den tatsächlichen Ist-Zustand DES ausgewählten
    Interfaces, damit die Eingabemaske beim Umschalten live aktualisiert
    wird statt weiter die Werte des zuvor angezeigten Interfaces zu zeigen
    (bei mehreren Interfaces sonst leicht mit dessen Konfiguration zu
    verwechseln -- live reproduziert)."""
    if not current_user.can_view_settings_system:
        return jsonify({"error": "Keine Berechtigung."}), 403
    interface = request.args.get("interface", "").strip()
    if interface not in _list_network_interfaces():
        return jsonify({"error": "Unbekanntes Interface."}), 404
    backend = _detect_network_backend()
    state = _read_network_state(interface, backend)
    state.pop("network", None)
    return jsonify(state)


@app.route("/settings/import-export")
@login_required
def settings_import_export():
    if not current_user.can_view_settings_importexport:
        flash("Keine Berechtigung für Im-/Export.", "danger")
        return redirect(url_for("index"))
    return render_template(
        "settings_import_export.html",
        export_sections=EXPORT_SECTIONS,
        admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
        export_section_hints=EXPORT_SECTION_HINTS,
    )



DHCP_PACKAGE = "kea-dhcp4-server"
DHCP_SERVICE = "kea-dhcp4-server"
KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv"

DHCP_DEFAULTS = {
    "dhcp_domain": "local",
    "dhcp_lease_default": "600",
    "dhcp_lease_max": "7200",
    "dhcp_output_path": "/etc/kea/kea-dhcp4.conf",
}


def _dhcp_subnets(conn):
    return conn.execute("SELECT * FROM dhcp_subnets ORDER BY sort_order ASC, id ASC").fetchall()


def _dhcp_subnet_matches(subnet_row):
    """Validiert eine einzelne Subnetz-Zeile gegen das tatsächliche Netz
    ihres Interfaces (siehe _dhcp_matching_network) und gibt bei Erfolg
    (net_info, None) zurück, sonst (None, Fehlertext). Eine Subnetz-Zeile
    ohne passendes reales Netz kann Kea nicht zugeordnet werden — es muss
    eine physische IP im gewünschten Bereich vorhanden sein, sonst fehlt
    die CIDR/Netzmaske, die Kea für den subnet4-Block braucht."""
    return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"])

DHCP_OPTION_TYPES = [
    ("string", "Text (string)"),
    ("ipv4-address", "IP-Adresse (ipv4-address)"),
    ("uint8", "Zahl, 1 Byte (uint8)"),
    ("uint16", "Zahl, 2 Byte (uint16)"),
    ("uint32", "Zahl, 4 Byte (uint32)"),
    ("boolean", "Wahrheitswert (boolean)"),
    ("binary", "Hexadezimal/Rohdaten (binary)"),
]
DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES}


def _load_dhcp_settings():
    return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}


def _detect_dhcp_status():
    """Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und
    läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich
    über die separaten, bestätigten Aktionen weiter unten."""
    kea_path = shutil.which("kea-dhcp4")
    installed = kea_path is not None
    active = None
    enabled = None
    version = None
    if installed:
        try:
            version_out = subprocess.run(
                ["kea-dhcp4", "-v"], capture_output=True, text=True, timeout=5
            )
            out = (version_out.stdout or version_out.stderr or "").strip()
            version = out.splitlines()[0] if out else None
        except Exception:
            version = None
        try:
            status = subprocess.run(
                ["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
            )
            active = status.stdout.strip() == "active"
        except Exception:
            active = None
        try:
            enabled_out = subprocess.run(
                ["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
            )
            enabled = enabled_out.stdout.strip() == "enabled"
        except Exception:
            enabled = None
    return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version}


def _stop_dhcp_service_if_active():
    """Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft —
    aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert
    (Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende
    Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich
    zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu
    starten — der Admin muss die Range aktiv auf der DHCP-Seite
    prüfen/bestätigen, statt dass die App mit einer möglicherweise falschen
    Konfiguration weiterläuft oder blind rät. Zusätzlich deaktivieren (nicht
    nur stoppen), damit ein Host-Neustart vor dieser Prüfung den Dienst
    nicht trotzdem automatisch mit der ggf. falschen Range wieder hochfährt."""
    try:
        status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
        if status.stdout.strip() != "active":
            return False
        ok = subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0
        subprocess.run(["systemctl", "disable", DHCP_SERVICE], timeout=15)
        return ok
    except Exception:
        return False


def _list_network_interfaces():
    """Echte Netzwerkschnittstellen dieses Hosts (ohne loopback) — Quelle
    für die Interface-Auswahl, damit nur tatsächlich vorhandene Interfaces
    wählbar sind statt eines frei eintippbaren Textfelds."""
    try:
        return sorted(name for name in os.listdir("/sys/class/net") if name != "lo")
    except OSError:
        return []


def _detect_interface_networks(interface):
    """Wie _detect_interface_network() (siehe unten), aber für ALLE IPv4-
    Adressen des Interfaces statt nur der ersten — ein Interface kann
    mehrere IPs/Subnetze gleichzeitig tragen (z.B. Alias-IPs oder mehrere
    Ranges auf derselben Karte). Gibt eine Liste von net_info-Dicts
    zurück (leer, falls keine IPv4-Adresse gefunden wurde/das Interface
    nicht existiert)."""
    if not interface:
        return []
    try:
        addr_out = subprocess.run(
            ["ip", "-4", "-o", "addr", "show", "dev", interface],
            capture_output=True, text=True, timeout=5,
        )
        route_out = subprocess.run(
            ["ip", "-4", "route", "show", "default", "dev", interface],
            capture_output=True, text=True, timeout=5,
        )
        gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
        if not gw_match:
            route_out = subprocess.run(
                ["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5,
            )
            gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
        gateway = gw_match.group(1) if gw_match else None

        results = []
        for line in addr_out.stdout.splitlines():
            match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", line)
            if not match:
                continue
            ip_str, prefix = match.group(1), int(match.group(2))
            network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False)
            dynamic = "dynamic" in line
            results.append({
                "ok": True, "ip": ip_str, "prefix": prefix, "network": network,
                "netmask": str(network.netmask), "gateway": gateway, "dynamic": dynamic, "error": None,
            })
        return results
    except Exception:
        return []


def _detect_interface_network(interface):
    """Liest Subnet, Netzmaske und Gateway der PRIMÄREN (ersten) IPv4-
    Adresse eines Interfaces direkt aus der laufenden System-
    Netzwerkkonfiguration aus (statt sie manuell pflegen zu lassen) —
    dadurch koppelt sich die generierte DHCP-Konfiguration immer an das
    Netz, in dem der Host tatsächlich hängt, auch wenn sich dessen
    IP/Subnet mal ändert. Trägt das Interface mehrere IPv4-Adressen,
    siehe _detect_interface_networks() für alle."""
    if not interface:
        return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
                "dynamic": None, "error": "Kein Interface ausgewählt."}
    networks = _detect_interface_networks(interface)
    if not networks:
        return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
                "dynamic": None, "error": f"Keine IPv4-Adresse auf „{interface}“ gefunden."}
    return networks[0]


def _dhcp_matching_network(interface, range_start, range_end):
    """Prüft eine DHCP-Range gegen ALLE auf dem Interface aktuell
    konfigurierten IPv4-Netze (ein Interface kann mehrere IPs/Subnetze
    tragen, z.B. Alias-IPs) und gibt bei Erfolg (net_info, None) zurück,
    sonst (None, Fehlertext). Verhindert, dass eine Range gespeichert,
    geschrieben oder ein Dienststart erlaubt wird, die zu keinem
    tatsächlich vorhandenen Subnetz passt — z.B. wenn sich das Netz des
    Hosts seit dem letzten Speichern geändert hat."""
    try:
        start_ip = ipaddress.IPv4Address(range_start)
        end_ip = ipaddress.IPv4Address(range_end)
    except ValueError:
        return None, "Range Start/Ende müssen gültige IPv4-Adressen sein."
    if int(start_ip) > int(end_ip):
        return None, "Range Start muss kleiner oder gleich Range Ende sein."
    networks = _detect_interface_networks(interface)
    if not networks:
        return None, f"Keine IPv4-Adresse auf „{interface}“ gefunden."
    for net_info in networks:
        if start_ip in net_info["network"] and end_ip in net_info["network"]:
            return net_info, None
    nets_str = ", ".join(str(n["network"]) for n in networks)
    return None, f"Range {range_start}–{range_end} liegt in keinem der auf „{interface}“ erkannten Netze ({nets_str})."


def _dhcp_safe_hostname(name, mac):
    """Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
    gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
    doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4
    Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner
    entsteht."""
    slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-")
    slug = re.sub(r"-{2,}", "-", slug)
    if slug:
        return slug
    return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:]


def _dhcp_valid_subnets(subnets):
    """Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen
    ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine
    physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea
    damit nichts anfangen). Deaktivierte Subnetze (enabled=0) werden dabei
    komplett übersprungen — bewusst nicht als "invalid" gewertet, da das
    Fehlen eines echten Netzes hier kein Fehler ist, sondern Absicht.
    Gibt (valid, invalid) zurück: valid ist um "net_info" angereichert (für
    Reservierungs-Zuordnung und Kea-Config), invalid enthält (Zeile,
    Fehlertext) für die Anzeige/Warnungen."""
    valid, invalid = [], []
    for s in subnets:
        if not s["enabled"]:
            continue
        net_info, error = _dhcp_subnet_matches(s)
        if error:
            invalid.append((s, error))
        else:
            valid.append({
                "id": s["id"], "interface": s["interface"],
                "range_start": s["range_start"], "range_end": s["range_end"],
                "gateway": s["gateway"], "dns": s["dns"], "net_info": net_info,
            })
    return valid, invalid


def _dhcp_reservation_candidates(conn, valid_subnets):
    """Baut die Liste der Kea-Reservierungen aus ZWEI Quellen: automatisch
    aus aktiven Geräten mit gültiger MAC + IP (Clients dieser App) sowie
    zusätzlich aus manuell angelegten Reservierungen (dhcp_manual_reservations
    — für Geräte, die nicht als Client gepflegt werden, z.B. ein externes
    Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in
    beiden Quellen gewinnt die automatische (echte Client-Daten vor einer
    ggf. veralteten manuellen Zeile). Geräte ohne ip/mac werden
    übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
    übersprungen: Einträge, deren IP zu KEINEM der übergebenen (bereits
    validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb
    ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test
    gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id,
    out_of_subnet_count) zurück — jeder Eintrag landet bei genau einem
    Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere
    Subnetze gleichzeitig anbieten)."""
    auto_rows = conn.execute(
        "SELECT mac, ip, name FROM devices "
        "WHERE is_active=1 AND deleted_at IS NULL AND mac IS NOT NULL AND ip IS NOT NULL ORDER BY name ASC"
    ).fetchall()
    manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall()

    candidates = {}
    for row in manual_rows:
        candidates[row["mac"].upper()] = {
            "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"],
        }
    for row in auto_rows:
        candidates[row["mac"].upper()] = {
            "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "auto", "manual_id": None,
        }

    seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets}
    reservations_by_subnet = {s["id"]: [] for s in valid_subnets}
    out_of_subnet = 0
    for c in sorted(candidates.values(), key=lambda c: c["name"]):
        try:
            ip = ipaddress.IPv4Address(c["ip"])
        except ValueError:
            out_of_subnet += 1
            continue
        matched = next((s for s in valid_subnets if ip in s["net_info"]["network"]), None)
        if matched is None:
            out_of_subnet += 1
            continue
        hostname = _dhcp_safe_hostname(c["name"], c["mac"])
        seen = seen_hostnames_by_subnet[matched["id"]]
        base_hostname, suffix = hostname, 2
        while hostname in seen:
            hostname = f"{base_hostname}-{suffix}"
            suffix += 1
        seen.add(hostname)
        reservations_by_subnet[matched["id"]].append({
            "mac": c["mac"], "ip": c["ip"], "hostname": hostname, "name": c["name"], "source": c["source"],
            "manual_id": c["manual_id"], "subnet_id": matched["id"],
            "subnet_label": f"{matched['interface']} ({matched['net_info']['network']})",
        })
    return reservations_by_subnet, out_of_subnet


def _dhcp_option_defs(conn):
    return conn.execute(
        "SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs ORDER BY code ASC"
    ).fetchall()


def _dhcp_option_values(conn):
    """{option_def_id: {"": <globaler Wert>, "<mac>": <Override>, ...}}
    — device_mac='' ist der globale Wert (siehe create_db.py)."""
    values = {}
    for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"):
        values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"]
    return values


def _dhcp_read_leases():
    """Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV
    (siehe lease-file in _render_kea_config) — kein Kea-Control-Agent
    nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene
    Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem
    Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt).
    Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil
    der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben
    hat — statt fälschlich "keine Leases" zu behaupten."""
    try:
        with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
            rows = list(csv.DictReader(f))
    except OSError:
        return None
    now = int(time.time())
    by_ip = {}
    for row in rows:
        if (row.get("state") or "0") != "0":
            continue
        try:
            expire = int(row.get("expire") or 0)
        except ValueError:
            expire = 0
        if expire and expire < now:
            continue
        ip = row.get("address") or ""
        existing = by_ip.get(ip)
        if existing is not None and (existing.get("expire") or 0) >= expire:
            continue
        by_ip[ip] = {"expire": expire, "row": row}
    leases = []
    for ip, entry in by_ip.items():
        row, expire = entry["row"], entry["expire"]
        leases.append({
            "ip": ip,
            "mac": (row.get("hwaddr") or "").upper(),
            "hostname": row.get("hostname") or "",
            "expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None,
            "expire_epoch_ms": expire * 1000 if expire else None,
        })
    try:
        leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"]))
    except ValueError:
        pass
    return leases


def _dhcp_delete_lease(ip):
    """Entfernt ALLE Zeilen einer Adresse direkt aus der Kea-Lease-CSV
    (memfile-Backend, siehe _dhcp_read_leases für dieselbe Datei) und
    startet den laufenden Dienst neu, damit Kea die Datei beim Hochfahren
    frisch neu einliest -- ein bloßes Editieren bei laufendem Dienst würde
    sonst von Keas eigenem, periodischem Lease-File-Cleanup irgendwann
    wieder mit dem alten Stand aus dem Speicher überschrieben. Startet den
    Dienst nur neu, wenn er gerade aktiv ist (kein ungefragtes Hochfahren
    eines bewusst gestoppten Dienstes). Gibt (ok, message) zurück."""
    try:
        original_stat = os.stat(KEA_LEASE_FILE)
        with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            fieldnames = reader.fieldnames
            rows = list(reader)
    except OSError:
        return False, "Keine Lease-Datei gefunden."
    remaining = [r for r in rows if (r.get("address") or "") != ip]
    if len(remaining) == len(rows):
        return False, f"Keine Lease für {ip} in der Datei gefunden."
    try:
        with open(KEA_LEASE_FILE, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames, lineterminator="\n")
            writer.writeheader()
            writer.writerows(remaining)
        os.chown(KEA_LEASE_FILE, original_stat.st_uid, original_stat.st_gid)
        os.chmod(KEA_LEASE_FILE, stat.S_IMODE(original_stat.st_mode))
    except OSError as e:
        return False, f"Lease-Datei konnte nicht geschrieben werden: {e}"
    try:
        status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
        active = status.stdout.strip() == "active"
    except Exception:
        active = False
    if active:
        ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
        if not ok_restart:
            return False, f"Lease für {ip} aus der Datei entfernt, Neustart des Dienstes fehlgeschlagen: {restart_out}"
    return True, f"Lease für {ip} gelöscht."


def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values):
    """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-
    serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein
    Host mehrere IPs/Interfaces mit jeweils eigenem Netz gleichzeitig
    bedienen kann. Subnet/Netzmaske kommen bewusst NICHT aus manueller
    Eingabe, sondern aus dem net_info jedes Subnetzes (siehe
    _dhcp_valid_subnets) — nur Subnetze mit einer zu einem echten,
    tatsächlich vorhandenen Netz passenden Range fließen hier ein
    (`valid_subnets`, bereits gefiltert). Gateway/DNS sind je Subnetz
    eigenes option-data (unterschiedliche Netze haben i.d.R.
    unterschiedliche Router); Domain und selbstdefinierte Options bleiben
    global. Client-Overrides landen im option-data der jeweiligen
    Reservierung — Kea überschreibt damit automatisch gleichnamige
    globale/Subnet-Options für genau diesen Client, ohne dass wir das
    selbst zusammenrechnen müssen."""
    option_def_list = [
        {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
        for d in option_defs if not d["is_standard"]
    ]

    global_option_data = []
    if cfg["dhcp_domain"]:
        global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]})
    for d in option_defs:
        global_value = option_values.get(d["id"], {}).get("")
        if global_value:
            global_option_data.append({"name": d["name"], "data": global_value})

    subnet4 = []
    for idx, s in enumerate(valid_subnets, start=1):
        router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or ""
        dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip())
        sub_option_data = []
        if router:
            sub_option_data.append({"name": "routers", "data": router})
        if dns_servers:
            sub_option_data.append({"name": "domain-name-servers", "data": dns_servers})

        kea_reservations = []
        for r in reservations_by_subnet.get(s["id"], []):
            res_option_data = []
            for d in option_defs:
                override = option_values.get(d["id"], {}).get(r["mac"])
                if override:
                    res_option_data.append({"name": d["name"], "data": override})
            entry = {
                "hw-address": r["mac"].lower(),
                "ip-address": r["ip"],
                "hostname": r["hostname"],
            }
            if res_option_data:
                entry["option-data"] = res_option_data
            kea_reservations.append(entry)

        subnet4.append({
            "id": idx,
            "subnet": f"{s['net_info']['network'].network_address}/{s['net_info']['network'].prefixlen}",
            "pools": [{"pool": f"{s['range_start']} - {s['range_end']}"}],
            **({"option-data": sub_option_data} if sub_option_data else {}),
            "reservations": kea_reservations,
        })

    return {
        "Dhcp4": {
            "interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})},
            "lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE},
            "valid-lifetime": int(cfg["dhcp_lease_default"] or 600),
            "max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200),
            "option-def": option_def_list,
            "option-data": global_option_data,
            "subnet4": subnet4,
            "loggers": [
                {
                    "name": "kea-dhcp4",
                    "output_options": [{"output": TESM_KEA_LOG_PATH}],
                    "severity": "INFO",
                }
            ],
        }
    }


def _dhcp_run_privileged(args, timeout):
    """Führt einen Installations-/Dienst-Befehl aus und fasst das Ergebnis
    einheitlich zusammen — wirft nie, damit der aufrufende Code immer eine
    Flash-Meldung statt eines 500ers bekommt."""
    try:
        result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
        return result.returncode == 0, (result.stdout or "") + (result.stderr or "")
    except Exception as e:
        return False, str(e)


def _list_timezones():
    """Alle von diesem System unterstützten IANA-Zeitzonen fürs
    Auswahl-Dropdown (Systemeinstellungen → Zeitzone) — timedatectl selbst
    validiert beim tatsächlichen Setzen ohnehin noch einmal serverseitig."""
    ok, out = _dhcp_run_privileged(["timedatectl", "list-timezones"], timeout=10)
    if not ok:
        return []
    return [line.strip() for line in out.splitlines() if line.strip()]


def _current_timezone():
    ok, out = _dhcp_run_privileged(["timedatectl", "show", "--property=Timezone", "--value"], timeout=5)
    return out.strip() if ok else None


def _dhcp_write_and_restart(conn):
    """Schreibt die Kea-Konfiguration aus dem aktuellen DB-Stand neu und
    startet den Dienst neu, FALLS er gerade aktiv ist -- aufgerufen direkt
    nach jeder Änderung, die sich auf die geschriebene Konfiguration
    auswirkt (Reservierung, Option, Subnetz, globale Einstellung), damit
    eine Änderung sofort wirksam wird und ein manuelles "Datei schreiben" +
    "Neu starten" nicht mehr vergessen werden kann. Schreibt nur, wenn
    mindestens ein gültiges Subnetz konfiguriert ist (sonst gäbe es nichts
    Sinnvolles zu schreiben); startet nur neu, wenn der Dienst bereits
    läuft -- ein bewusst gestoppter Dienst wird dadurch nicht ungefragt
    wieder hochgefahren. Flasht nur bei einem tatsächlichen Fehler etwas
    Zusätzliches, ein stiller Erfolg reiht sich in die ohnehin schon
    gezeigte Erfolgsmeldung der jeweiligen Aktion ein."""
    cfg = _load_dhcp_settings()
    valid_subnets, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
    if not valid_subnets:
        return
    reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
    option_defs = _dhcp_option_defs(conn)
    option_values = _dhcp_option_values(conn)
    content = json.dumps(
        _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
        indent=2, ensure_ascii=False,
    )
    output_path = cfg["dhcp_output_path"]
    try:
        os.makedirs(os.path.dirname(output_path), exist_ok=True)
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(content + "\n")
    except OSError as e:
        flash(f"Konfiguration konnte nicht automatisch geschrieben werden: {e}", "danger")
        return
    try:
        status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
        active = status.stdout.strip() == "active"
    except Exception:
        active = False
    if active:
        ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
        if not ok_restart:
            flash(f"Konfiguration geschrieben, Neustart des DHCP-Dienstes fehlgeschlagen: {restart_out}", "danger")


@app.route("/settings/dhcp", methods=["GET", "POST"])
@login_required
def settings_dhcp():
    if request.method == "GET" and not current_user.can_view_settings_dhcp:
        flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger")
        return redirect(url_for("index"))
    if resp := _require_module("dhcp"):
        return resp

    conn = get_db_connection()

    if request.method == "POST":
        if not current_user.has_permission("settings_dhcp.edit"):
            flash("Keine Berechtigung, die DHCP-Konfiguration zu ändern.", "danger")
            conn.close()
            return redirect(url_for("settings_dhcp"))

        if "save_dhcp_config" in request.form:
            for key in DHCP_DEFAULTS:
                value = request.form.get(key, "").strip()
                if value:
                    set_setting(key, value)
            log_action("settings.update", "DHCP-Konfiguration")
            flash("DHCP-Konfiguration gespeichert.", "success")
            _dhcp_write_and_restart(conn)

        elif "add_dhcp_subnet" in request.form:
            interface = request.form.get("interface", "").strip()
            range_start = request.form.get("range_start", "").strip()
            range_end = request.form.get("range_end", "").strip()
            gateway = request.form.get("gateway", "").strip()
            dns = request.form.get("dns", "").strip()
            if interface not in _list_network_interfaces():
                flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
            else:
                _, range_error = _dhcp_matching_network(interface, range_start, range_end)
                if range_error:
                    flash(range_error, "danger")
                else:
                    max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
                    conn.execute(
                        "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
                        "VALUES (?, ?, ?, ?, ?, ?)",
                        (interface, range_start, range_end, gateway or None, dns or None, max_order + 1),
                    )
                    conn.commit()
                    log_action("settings.dhcp_subnet_add", interface, f"{range_start}–{range_end}")
                    flash(f"Subnetz {range_start}–{range_end} auf „{interface}“ hinzugefügt.", "success")
                    _dhcp_write_and_restart(conn)

        elif "edit_dhcp_subnet" in request.form:
            subnet_id = request.form.get("edit_dhcp_subnet")
            interface = request.form.get("interface", "").strip()
            range_start = request.form.get("range_start", "").strip()
            range_end = request.form.get("range_end", "").strip()
            gateway = request.form.get("gateway", "").strip()
            dns = request.form.get("dns", "").strip()
            if interface not in _list_network_interfaces():
                flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
            else:
                _, range_error = _dhcp_matching_network(interface, range_start, range_end)
                if range_error:
                    flash(range_error, "danger")
                else:
                    conn.execute(
                        "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=? WHERE id=?",
                        (interface, range_start, range_end, gateway or None, dns or None, subnet_id),
                    )
                    conn.commit()
                    log_action("settings.dhcp_subnet_edit", interface, f"{range_start}–{range_end}")
                    flash("Subnetz aktualisiert.", "success")
                    _dhcp_write_and_restart(conn)

        elif "delete_dhcp_subnet" in request.form:
            subnet_id = request.form.get("delete_dhcp_subnet")
            conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
            conn.commit()
            log_action("settings.dhcp_subnet_delete", subnet_id)
            flash("Subnetz gelöscht.", "success")
            _dhcp_write_and_restart(conn)

        elif "toggle_dhcp_subnet" in request.form:
            subnet_id = request.form.get("toggle_dhcp_subnet")
            row = conn.execute("SELECT enabled, interface FROM dhcp_subnets WHERE id=?", (subnet_id,)).fetchone()
            if row:
                new_state = 0 if row["enabled"] else 1
                conn.execute("UPDATE dhcp_subnets SET enabled=? WHERE id=?", (new_state, subnet_id))
                conn.commit()
                log_action("settings.dhcp_subnet_toggle", row["interface"], "aktiviert" if new_state else "deaktiviert")
                flash(
                    "Subnetz aktiviert." if new_state else
                    "Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.",
                    "success",
                )
                _dhcp_write_and_restart(conn)

        elif "add_dhcp_option" in request.form:
            code_raw = request.form.get("code", "").strip()
            name = request.form.get("name", "").strip()
            opt_type = request.form.get("type", "").strip()
            description = request.form.get("description", "").strip()
            initial_value = request.form.get("initial_value", "").strip()
            if not code_raw.isdigit() or not (1 <= int(code_raw) <= 254):
                flash("Code muss eine Zahl zwischen 1 und 254 sein.", "danger")
            elif not re.match(r"^[a-z][a-z0-9-]*$", name):
                flash("Name muss mit einem Kleinbuchstaben beginnen und darf nur a-z, 0-9 und - enthalten.", "danger")
            elif opt_type not in DHCP_OPTION_TYPE_KEYS:
                flash("Ungültiger Datentyp.", "danger")
            else:
                try:
                    cur = conn.execute(
                        "INSERT INTO dhcp_option_defs (code, name, type, description) VALUES (?, ?, ?, ?)",
                        (int(code_raw), name, opt_type, description or None),
                    )
                    if initial_value:
                        conn.execute(
                            "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?)",
                            (cur.lastrowid, initial_value),
                        )
                    conn.commit()
                    log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}")
                    flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success")
                    _dhcp_write_and_restart(conn)
                except sqlite3.IntegrityError:
                    flash("Code oder Name wird bereits verwendet.", "danger")

        elif "delete_dhcp_option" in request.form:
            option_def_id = request.form.get("delete_dhcp_option")
            option_def = conn.execute("SELECT name, is_standard FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone()
            if option_def and option_def["is_standard"]:
                flash("Standard-Optionen können nicht gelöscht werden — leeren Wert speichern, um sie auszublenden.", "danger")
            else:
                conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,))
                conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,))
                conn.commit()
                log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id)
                flash("Option gelöscht.", "success")
                _dhcp_write_and_restart(conn)

        elif "save_dhcp_option_global" in request.form:
            option_def_id = request.form.get("option_def_id")
            value = request.form.get("value", "").strip()
            if value:
                conn.execute(
                    "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?) "
                    "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
                    (option_def_id, value),
                )
            else:
                conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=''", (option_def_id,))
            conn.commit()
            log_action("settings.dhcp_option_value", option_def_id, "global")
            flash("Globaler Wert gespeichert.", "success")
            _dhcp_write_and_restart(conn)

        elif "save_dhcp_device_options" in request.form:
            mac = request.form.get("mac", "").strip()
            for d in _dhcp_option_defs(conn):
                value = request.form.get(f"opt_{d['id']}", "").strip()
                if value:
                    conn.execute(
                        "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) "
                        "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
                        (d["id"], mac, value),
                    )
                else:
                    conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=?", (d["id"], mac))
            conn.commit()
            log_action("settings.dhcp_option_value", mac, "Client-Override")
            flash(f"DHCP-Optionen für {mac} gespeichert.", "success")
            _dhcp_write_and_restart(conn)

        elif "add_dhcp_reservation" in request.form:
            mac = request.form.get("mac", "").strip().upper()
            ip = request.form.get("ip", "").strip()
            name = request.form.get("name", "").strip()
            if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac):
                flash("Ungültige MAC-Adresse (Format xx:xx:xx:xx:xx:xx).", "danger")
            elif not name:
                flash("Name darf nicht leer sein.", "danger")
            else:
                try:
                    ipaddress.IPv4Address(ip)
                except ValueError:
                    flash("Ungültige IPv4-Adresse.", "danger")
                else:
                    existing_device = conn.execute(
                        "SELECT 1 FROM devices WHERE is_active=1 AND deleted_at IS NULL AND UPPER(mac)=?", (mac,)
                    ).fetchone()
                    if existing_device:
                        flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger")
                    else:
                        try:
                            conn.execute(
                                "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?)", (mac, ip, name)
                            )
                            conn.commit()
                            log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})")
                            flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success")
                            _dhcp_write_and_restart(conn)
                        except sqlite3.IntegrityError:
                            flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger")

        elif "delete_dhcp_reservation" in request.form:
            reservation_id = request.form.get("delete_dhcp_reservation")
            row = conn.execute("SELECT mac FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)).fetchone()
            conn.execute("DELETE FROM dhcp_manual_reservations WHERE id=?", (reservation_id,))
            conn.commit()
            log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id)
            flash("Manuelle Reservierung gelöscht.", "success")
            _dhcp_write_and_restart(conn)

        elif "write_dhcp_file" in request.form:
            cfg = _load_dhcp_settings()
            valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn))
            if not valid_subnets:
                flash(
                    "Kein gültiges Subnetz konfiguriert — mindestens ein Subnetz mit einer zu einem echten Netz "
                    "passenden Range wird benötigt, bevor eine Konfiguration geschrieben wird.",
                    "danger",
                )
                conn.close()
                return redirect(url_for("settings_dhcp"))
            reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
            option_defs = _dhcp_option_defs(conn)
            option_values = _dhcp_option_values(conn)
            content = json.dumps(
                _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
                indent=2, ensure_ascii=False,
            )
            output_path = cfg["dhcp_output_path"]
            total_reservations = sum(len(v) for v in reservations_by_subnet.values())
            try:
                os.makedirs(os.path.dirname(output_path), exist_ok=True)
                with open(output_path, "w", encoding="utf-8") as f:
                    f.write(content + "\n")
                log_action(
                    "settings.dhcp_write", output_path,
                    f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen",
                )
                try:
                    status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
                    active = status.stdout.strip() == "active"
                except Exception:
                    active = False
                restart_ok, restart_out = (True, "")
                if active:
                    restart_ok, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
                msg = (
                    f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach "
                    f"{output_path} geschrieben"
                    + (" und Dienst neu gestartet." if active and restart_ok else "." if not active else "")
                )
                if active and not restart_ok:
                    msg += f" Neustart des Dienstes fehlgeschlagen: {restart_out}"
                if invalid_subnets:
                    skipped = ", ".join(f"{s['interface']} ({s['range_start']}–{s['range_end']})" for s, _ in invalid_subnets)
                    msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}."
                flash(msg, "success" if (not active or restart_ok) else "danger")
            except OSError as e:
                flash(f"Konnte Datei nicht schreiben: {e}", "danger")

        elif "install_dhcp_package" in request.form:
            ok_update, _ = _dhcp_run_privileged(["apt-get", "update"], timeout=60)
            env_prefix = {"DEBIAN_FRONTEND": "noninteractive"}
            try:
                result = subprocess.run(
                    ["apt-get", "install", "-y", DHCP_PACKAGE],
                    capture_output=True, text=True, timeout=180,
                    env={**os.environ, **env_prefix},
                )
                ok = result.returncode == 0
                output_tail = "\n".join(((result.stdout or "") + (result.stderr or "")).strip().splitlines()[-10:])
            except Exception as e:
                ok, output_tail = False, str(e)
            log_action("settings.dhcp_install", DHCP_PACKAGE, "erfolgreich" if ok else "fehlgeschlagen")
            if ok:
                flash(f"{DHCP_PACKAGE} installiert.", "success")
            else:
                flash(f"Installation fehlgeschlagen: {output_tail or 'unbekannter Fehler'}", "danger")

        elif "dhcp_service_action" in request.form:
            action = request.form.get("dhcp_service_action")
            valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
            if action == "enable_restart" and not valid_subnets_now:
                flash(
                    "Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und "
                    "die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.",
                    "danger",
                )
            elif action == "enable_restart":
                ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
                ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
                log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen"))
                if ok_enable and ok_restart:
                    flash(f"{DHCP_SERVICE} aktiviert und neu gestartet.", "success")
                else:
                    flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger")
            elif action == "stop":
                ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15)
                _dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15)
                log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen"))
                flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger")

        conn.close()
        return redirect(url_for("settings_dhcp"))

    cfg = _load_dhcp_settings()
    subnets = _dhcp_subnets(conn)
    valid_subnets, invalid_subnets = _dhcp_valid_subnets(subnets)
    reservations_by_subnet, out_of_subnet_count = _dhcp_reservation_candidates(conn, valid_subnets)
    reservations = [r for rs in reservations_by_subnet.values() for r in rs]
    option_defs = _dhcp_option_defs(conn)
    option_values = _dhcp_option_values(conn)
    skipped_count = conn.execute(
        "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND deleted_at IS NULL AND (mac IS NULL OR ip IS NULL)"
    ).fetchone()["n"]
    conn.close()
    status = _detect_dhcp_status()
    preview = json.dumps(
        _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
        indent=2, ensure_ascii=False,
    )
    reserved_macs = {r["mac"].upper() for r in reservations}
    leases = _dhcp_read_leases()
    if leases is not None:
        for lease in leases:
            lease["reserved"] = lease["mac"] in reserved_macs
    subnet_status = {}
    for vs in valid_subnets:
        subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])}
    for s, error in invalid_subnets:
        subnet_status[s["id"]] = {"ok": False, "error": error}
    return render_template(
        "settings_dhcp.html",
        cfg=cfg,
        status=status,
        subnets=subnets,
        subnet_status=subnet_status,
        interfaces=_list_network_interfaces(),
        reservations=reservations,
        skipped_count=skipped_count,
        out_of_subnet_count=out_of_subnet_count,
        preview=preview,
        option_defs=option_defs,
        option_values=option_values,
        option_types=DHCP_OPTION_TYPES,
        dhcp_package=DHCP_PACKAGE,
        dhcp_service=DHCP_SERVICE,
        leases=leases,
    )


@app.route("/settings/dhcp/lease/delete", methods=["POST"])
@login_required
def delete_dhcp_lease():
    if not current_user.has_permission("settings_dhcp.edit"):
        flash("Keine Berechtigung, Leases zu löschen.", "danger")
        return redirect(url_for("settings_dhcp"))
    ip = request.form.get("ip", "").strip()
    ok, message = _dhcp_delete_lease(ip)
    log_action("settings.dhcp_lease_delete", ip, message)
    flash(message, "success" if ok else "danger")
    return redirect(url_for("settings_dhcp"))


@app.route("/settings/dhcp/autostart/toggle", methods=["POST"])
@login_required
def toggle_dhcp_autostart():
    """Eigenständiger Autostart-Schalter, entkoppelt vom aktuellen
    Start/Stopp-Zustand -- steuert ausschließlich, ob systemd den Dienst
    bei einem Host-Neustart automatisch mitstartet (systemctl enable/
    disable), OHNE ihn hier auch tatsächlich zu (de)starten."""
    if not current_user.has_permission("settings_dhcp.edit"):
        return {"success": False, "msg": "Keine Berechtigung."}, 403
    try:
        result = subprocess.run(["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
        currently_enabled = result.stdout.strip() == "enabled"
    except Exception:
        currently_enabled = False
    action = "disable" if currently_enabled else "enable"
    ok, out = _dhcp_run_privileged(["systemctl", action, DHCP_SERVICE], timeout=15)
    if not ok:
        verb = "deaktivieren" if currently_enabled else "aktivieren"
        return {"success": False, "msg": f"Autostart konnte nicht {verb} werden: {out}"}
    new_enabled = not currently_enabled
    log_action("settings.dhcp_autostart", DHCP_SERVICE, "aktiviert" if new_enabled else "deaktiviert")
    return {
        "success": True,
        "msg": f"Autostart {'aktiviert' if new_enabled else 'deaktiviert'}.",
        "new_status": 1 if new_enabled else 0,
    }


@app.route("/settings/ldap", methods=["GET", "POST"])
@login_required
def settings_ldap():
    if request.method == "GET" and not current_user.can_view_settings_ldap:
        flash("Keine Berechtigung, die LDAP/AD-Einstellungen anzusehen.", "danger")
        return redirect(url_for("index"))

    if request.method == "POST":
        if not current_user.has_permission("settings_ldap.edit"):
            flash("Keine Berechtigung, die LDAP/AD-Konfiguration zu ändern.", "danger")
            return redirect(url_for("settings_ldap"))

        if "save_ldap" in request.form:
            new_port_raw = request.form.get("ldap_port", "").strip()
            new_bind_dn = request.form.get("ldap_bind_dn", "").strip()
            if not new_port_raw.isdigit() or not (1 <= int(new_port_raw) <= 65535):
                flash("LDAP-Port muss eine Zahl zwischen 1 und 65535 sein.", "danger")
            else:
                set_setting("ldap_enabled", "1" if request.form.get("ldap_enabled") else "0")
                set_setting("ldap_server", request.form.get("ldap_server", "").strip())
                set_setting("ldap_port", new_port_raw)
                set_setting("ldap_use_ssl", "1" if request.form.get("ldap_use_ssl") else "0")
                set_setting("ldap_tls_skip_verify", "1" if request.form.get("ldap_tls_skip_verify") else "0")
                set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip())
                set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
                set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
                set_setting("ldap_required_login_group", request.form.get("ldap_required_login_group", "").strip())
                new_bind_password = request.form.get("ldap_bind_password", "")
                if new_bind_dn:
                    conn = get_db_connection()
                    if new_bind_password:
                        conn.execute(
                            "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
                            "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password",
                            (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, encrypt_password(new_bind_password)),
                        )
                    else:
                        existing = conn.execute(
                            "SELECT password FROM service_accounts WHERE purpose=?",
                            (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,),
                        ).fetchone()
                        conn.execute(
                            "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
                            "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username",
                            (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, existing["password"] if existing else ""),
                        )
                    conn.commit()
                    conn.close()
                log_action("settings.update", "LDAP/Active Directory")
                flash("LDAP-Einstellungen gespeichert.", "success")

        elif "test_ldap" in request.form:
            new_bind_password = request.form.get("ldap_bind_password", "")
            _, saved_bind_password_enc = _ldap_bind_account()
            test_cfg = {
                "server": request.form.get("ldap_server", "").strip(),
                "port": request.form.get("ldap_port", "").strip() or str(LDAP_DEFAULT_PORT),
                "use_ssl": bool(request.form.get("ldap_use_ssl")),
                "tls_skip_verify": bool(request.form.get("ldap_tls_skip_verify")),
                "bind_dn": request.form.get("ldap_bind_dn", "").strip(),
                "base_dn": request.form.get("ldap_base_dn", "").strip(),
                "bind_password_enc": encrypt_password(new_bind_password) if new_bind_password else saved_bind_password_enc,
            }
            ok, message = _ldap_test_connection(test_cfg)
            flash(message, "success" if ok else "danger")

        elif "clear_ldap_bind" in request.form:
            conn = get_db_connection()
            conn.execute("DELETE FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,))
            conn.commit()
            conn.close()
            set_setting("ldap_enabled", "0")
            log_action("settings.update", "LDAP-Bind-Konto gelöscht")
            flash("LDAP-Bind-Konto gelöscht. LDAP-Anmeldung wurde deaktiviert.", "success")

        elif "add_ldap_group_mapping" in request.form:
            ad_group_dn = request.form.get("ad_group_dn", "").strip()
            ad_group_name = request.form.get("ad_group_name", "").strip()
            app_group_id = request.form.get("app_group_id", "").strip()
            if not ad_group_dn or not app_group_id:
                flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
            else:
                conn = get_db_connection()
                conn.execute(
                    "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
                    "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
                    (ad_group_dn, ad_group_name or ad_group_dn, app_group_id),
                )
                conn.commit()
                conn.close()
                log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {app_group_id}")
                flash("Gruppenzuordnung gespeichert.", "success")

        elif "edit_ldap_group_mapping" in request.form:
            mapping_id = request.form.get("edit_ldap_group_mapping")
            ad_group_dn = request.form.get("ad_group_dn", "").strip()
            ad_group_name = request.form.get("ad_group_name", "").strip()
            app_group_id = request.form.get("app_group_id", "").strip()
            if not ad_group_dn or not app_group_id:
                flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
            else:
                conn = get_db_connection()
                try:
                    conn.execute(
                        "UPDATE ldap_group_mappings SET ad_group_dn=?, ad_group_name=?, app_group_id=? WHERE id=?",
                        (ad_group_dn, ad_group_name or ad_group_dn, app_group_id, mapping_id),
                    )
                    conn.commit()
                    log_action("settings.update", "LDAP-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {app_group_id}")
                    flash("Gruppenzuordnung aktualisiert.", "success")
                except sqlite3.IntegrityError:
                    flash("Diese AD-Gruppe ist bereits einer anderen Rechtegruppe zugeordnet.", "danger")
                conn.close()

        elif "delete_ldap_group_mapping" in request.form:
            mapping_id = request.form.get("delete_ldap_group_mapping")
            conn = get_db_connection()
            row = conn.execute("SELECT ad_group_name FROM ldap_group_mappings WHERE id=?", (mapping_id,)).fetchone()
            conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
            conn.commit()
            conn.close()
            log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
            flash("Gruppenzuordnung gelöscht.", "success")

        elif "add_fileshare_mapping" in request.form:
            ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
            ad_group_name = request.form.get("fs_ad_group_name", "").strip()
            share_label = request.form.get("fs_share_label", "").strip()
            share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
            if not ad_group_dn or not share_label or not share_unc:
                flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
            else:
                conn = get_db_connection()
                conn.execute(
                    "INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) "
                    "VALUES (?, ?, ?, ?) "
                    "ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
                    (ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc),
                )
                conn.commit()
                conn.close()
                log_action("settings.update", "Fileshare-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
                flash("Fileshare-Zuordnung gespeichert.", "success")

        elif "edit_fileshare_mapping" in request.form:
            mapping_id = request.form.get("edit_fileshare_mapping")
            ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
            ad_group_name = request.form.get("fs_ad_group_name", "").strip()
            share_label = request.form.get("fs_share_label", "").strip()
            share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
            if not ad_group_dn or not share_label or not share_unc:
                flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
            else:
                conn = get_db_connection()
                try:
                    conn.execute(
                        "UPDATE ldap_fileshare_mappings SET ad_group_dn=?, ad_group_name=?, share_label=?, share_unc=? WHERE id=?",
                        (ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc, mapping_id),
                    )
                    conn.commit()
                    log_action("settings.update", "Fileshare-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
                    flash("Fileshare-Zuordnung aktualisiert.", "success")
                except sqlite3.IntegrityError:
                    flash("Diese Kombination aus AD-Gruppe und Freigabe-Pfad existiert bereits.", "danger")
                conn.close()

        elif "delete_fileshare_mapping" in request.form:
            mapping_id = request.form.get("delete_fileshare_mapping")
            conn = get_db_connection()
            row = conn.execute("SELECT share_label FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,)).fetchone()
            conn.execute("DELETE FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,))
            conn.commit()
            conn.close()
            log_action("settings.update", "Fileshare-Zuordnung gelöscht", row["share_label"] if row else mapping_id)
            flash("Fileshare-Zuordnung gelöscht.", "success")

        return redirect(url_for("settings_ldap"))

    return render_template(
        "settings_ldap.html",
        ldap=_ldap_settings(),
        ldap_groups=_ldap_groups_for_dropdown(),
        mappings=_ldap_group_mappings(),
        fileshare_mappings=_ldap_fileshare_mappings(),
    )


@app.route("/settings/ldap/ad-groups")
@login_required
def settings_ldap_ad_groups():
    """JSON-Endpunkt für den 'AD-Gruppen laden'-Button in settings_ldap.html
    -- fragt live per Bind-Konto alle Gruppen aus AD ab, damit die
    Zuordnungstabelle nicht von Hand mit DNs gefüttert werden muss."""
    if not current_user.has_permission("settings_ldap.edit"):
        return jsonify({"error": "Keine Berechtigung."}), 403
    return jsonify(_ldap_list_groups())


@app.route("/settings/nav-order", methods=["POST"])
@login_required
def save_nav_order():
    if not current_user.is_admin:
        flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
        return redirect(url_for("index"))

    order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
    order += [k for k in DEFAULT_NAV_ORDER if k not in order]
    set_setting("nav_order", json.dumps(order))

    child_order = {}
    for group_key, item in NAV_ITEMS_BY_KEY.items():
        if not item.get("children"):
            continue
        valid_child_keys = {c["key"] for c in item["children"]}
        submitted = [k for k in request.form.getlist(f"nav_child_order_{group_key}") if k in valid_child_keys]
        submitted += [c["key"] for c in item["children"] if c["key"] not in submitted]
        child_order[group_key] = submitted
    set_setting("nav_child_order", json.dumps(child_order))

    log_action("settings.update", "Navbar-Reihenfolge")
    flash("Navbar-Reihenfolge gespeichert.", "success")
    return redirect(url_for("account"))



def _read_known_hosts_content():
    """Für den Export: der Inhalt von known_hosts gehört inhaltlich zu den
    Zugangsdaten (jeder per SSH bereits bestätigte Host-Key eines Geräts/
    Switches, das über gespeicherte Zugangsdaten erreicht wurde) -- landet
    daher NICHT als eigene EXPORT_BUILDERS-Kategorie, sondern als
    zusätzlicher Payload-Schlüssel "known_hosts", sobald "credentials"
    mitexportiert wird (siehe export_data())."""
    try:
        with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
            content = f.read()
        return content if content.strip() else None
    except OSError:
        return None


def _merge_known_hosts_content(new_content):
    """Für den Import: ergänzt die eigene known_hosts-Datei um die im
    Export enthaltenen Zeilen -- bewusst ergänzend statt überschreibend
    (die Zielumgebung kann bereits eigene, unabhängig bestätigte Host-Keys
    haben) und mit Deduplizierung nach vollständiger Zeile, damit ein
    wiederholter Import derselben Exportdatei nicht bei jedem Mal weitere
    Duplikate anhäuft. Gibt die Anzahl tatsächlich neu hinzugefügter
    Zeilen zurück."""
    try:
        try:
            with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
                existing_lines = set(f.read().splitlines())
        except OSError:
            existing_lines = set()
        new_lines = [ln for ln in new_content.splitlines() if ln.strip() and ln not in existing_lines]
        if not new_lines:
            return 0
        os.makedirs(os.path.dirname(SSH_KNOWN_HOSTS_PATH) or ".", exist_ok=True)
        with open(SSH_KNOWN_HOSTS_PATH, "a", encoding="utf-8") as f:
            f.write("\n".join(new_lines) + "\n")
        return len(new_lines)
    except OSError:
        return 0


EXPORT_SECTIONS = [
    ("devices", "Clients"),
    ("switches", "Switche"),
    ("credentials", "Zugangsdaten"),
    ("users", "Benutzer"),
    ("groups", "Gruppen"),
    ("ldap", "LDAP/AD"),
    ("dhcp", "DHCP"),
    ("nginx", "NGINX"),
    ("logs", "Auditlog"),
]
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)

# Zusätzlicher Hinweistext je Kategorie fürs "i"-Icon neben dem Label
# (siehe _hint_icon.html) -- nur für Kategorien mit erklärungsbedürftigem
# Zusatzverhalten, das über den reinen Namen hinausgeht.
EXPORT_SECTION_HINTS = {
    "users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.",
    "ldap": "Gesichert werden Server-/Bind-Einstellungen, die automatischen AD-Gruppen- und Fileshare-Zuordnungen sowie für bereits bekannte AD-Benutzer deren Sperrstatus und individuelle Gruppenzuweisungen — damit ein gesperrtes AD-Konto auf dem Zielsystem gesperrt bleibt und manuell vergebene Rechte erhalten bleiben, statt beim nächsten Login verloren zu gehen.",
    "credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.",
    "nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.",
    "logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).",
}

IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}

LDAP_SETTING_KEYS = [
    "ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify",
    "ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group", "ldap_required_login_group",
]


def _export_devices(conn):
    return [dict(r) for r in conn.execute("""
        SELECT devices.mac, devices.ip, devices.port, devices.name, devices.switch_hostname,
               devices.is_active, devices.ssh_port, credentials.name AS credential_name
        FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
        WHERE devices.deleted_at IS NULL
    """).fetchall()]


def _export_switches(conn):
    return [dict(r) for r in conn.execute("""
        SELECT switches.hostname, switches.ip, switches.ssh_port, credentials.name AS credential_name
        FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
        WHERE switches.deleted_at IS NULL
    """).fetchall()]


def _export_credentials(conn):
    rows = conn.execute("SELECT name, username, password, category FROM credentials WHERE deleted_at IS NULL").fetchall()
    return [
        {"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"]), "category": r["category"]}
        for r in rows
    ]


def _export_users(conn):
    """avatar_filename wird bewusst NICHT exportiert -- das ist nur ein
    Dateiname, die eigentliche Bilddatei liegt unter static/uploads/avatars
    auf DIESEM Host und würde auf dem Zielsystem nicht mitkommen; ein
    exportierter Verweis auf eine dort nicht existierende Datei wäre
    schlimmer als gar keiner (kaputtes <img>, siehe auch die analoge
    Begründung bei _export_nginx() für TLS-Zertifikat/Schlüssel)."""
    rows = conn.execute(
        "SELECT id, username, password, first_name, last_name, email, is_admin, is_locked "
        "FROM users WHERE auth_source='local' AND deleted_at IS NULL"
    ).fetchall()
    result = []
    for r in rows:
        groups = [g["name"] for g in conn.execute(
            "SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id "
            "WHERE ug.user_id=? AND g.deleted_at IS NULL",
            (r["id"],),
        ).fetchall()]
        result.append({
            "username": r["username"], "password_hash": r["password"], "first_name": r["first_name"],
            "last_name": r["last_name"], "email": r["email"], "is_admin": r["is_admin"],
            "is_locked": r["is_locked"], "groups": groups,
        })
    return result


def _export_groups(conn):
    rows = conn.execute("SELECT id, name, is_default FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
    result = []
    for g in rows:
        perms = [p["permission"] for p in conn.execute(
            "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],)
        ).fetchall()]
        members = [m["username"] for m in conn.execute(
            "SELECT u.username FROM user_groups ug JOIN users u ON u.id=ug.user_id "
            "WHERE ug.group_id=? AND u.deleted_at IS NULL",
            (g["id"],),
        ).fetchall()]
        result.append({"name": g["name"], "is_default": g["is_default"], "permissions": perms, "members": members})
    return result


def _export_ldap(conn):
    """app_group_id ist eine rohe, lokale groups.id (oder der Sonderwert
    "admin") -- über eine Umgebungsgrenze hinweg garantiert NICHT stabil
    (frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
    der Gruppenname (analog _export_switches()' credential_name), _import_ldap
    löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
    "admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben.

    ad_user_states: Sperrstatus + aktuelle Gruppenzugehörigkeit (inkl.
    Admin-Sentinel) je bereits bekanntem AD-Benutzer -- bewusst hier bei
    LDAP statt bei "users" (das AD-Konten komplett ausschließt, siehe
    _export_users()), da es explizit um AD-Konten geht: ein gesperrtes
    AD-Konto soll auf dem Zielsystem gesperrt bleiben (nicht durch die
    Migration wieder hereinkommen), und individuell/manuell vergebene
    Rechte sollen erhalten bleiben statt beim nächsten Login verloren zu
    gehen."""
    settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
    data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": [], "ad_user_states": []}
    bind = conn.execute(
        "SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
    ).fetchone()
    if bind:
        data["bind_username"] = bind["username"]
        data["bind_password"] = decrypt_password(bind["password"])
    data["group_mappings"] = [dict(m) for m in conn.execute("""
        SELECT m.ad_group_dn, m.ad_group_name,
               CASE WHEN m.app_group_id = 'admin' THEN 'Admin' ELSE g.name END AS app_group_name
        FROM ldap_group_mappings m LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL
    """).fetchall()]
    data["fileshare_mappings"] = [dict(m) for m in conn.execute(
        "SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
    ).fetchall()]
    ad_users = conn.execute(
        "SELECT id, username, is_admin, is_locked FROM users WHERE auth_source='ldap' AND deleted_at IS NULL"
    ).fetchall()
    for u in ad_users:
        groups = [g["name"] for g in conn.execute(
            "SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id "
            "WHERE ug.user_id=? AND g.deleted_at IS NULL", (u["id"],)
        ).fetchall()]
        if u["is_admin"]:
            groups.append("Admin")
        data["ad_user_states"].append({"username": u["username"], "is_locked": u["is_locked"], "groups": groups})
    if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]
            or data["ad_user_states"]):
        return None
    return data


def _export_dhcp(conn):
    settings_data = {k: get_setting(k) for k in DHCP_DEFAULTS if get_setting(k) not in (None, "")}
    subnets = [dict(r) for r in conn.execute(
        "SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled FROM dhcp_subnets"
    ).fetchall()]
    reservations = [dict(r) for r in conn.execute(
        "SELECT mac, ip, name FROM dhcp_manual_reservations"
    ).fetchall()]
    option_defs = conn.execute(
        "SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
    ).fetchall()
    options = []
    for d in option_defs:
        values = conn.execute(
            "SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?", (d["id"],)
        ).fetchall()
        if not values and d["is_standard"]:
            continue
        options.append({
            "code": d["code"], "name": d["name"], "type": d["type"], "description": d["description"],
            "is_standard": d["is_standard"],
            "values": [{"device_mac": v["device_mac"], "value": v["value"]} for v in values],
        })
    if not (settings_data or subnets or reservations or options):
        return None
    return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options}


NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"]


def _export_nginx(conn):
    """Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden,
    das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da
    JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das
    hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf
    diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen,
    genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind-
    Passwort), die ebenfalls im Klartext in den -- als Ganzes
    passphrasenverschlüsselten -- Export wandern. Bewusst AUSGESCHLOSSEN
    bleibt trotzdem jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source,
    ACME-Konto, Renewal-Timer): das ist an genau diesen Host und dessen
    Domain-Registrierung gebunden und kann nicht mitziehen -- ein Import
    setzt ssl_source deshalb immer auf "upload", siehe _import_nginx()."""
    settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
    data = {"settings": settings_data}
    try:
        if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH):
            with open(TESM_SSL_CERT_PATH, "rb") as f:
                data["cert_pem"] = base64.b64encode(f.read()).decode("ascii")
            with open(TESM_SSL_KEY_PATH, "rb") as f:
                data["key_pem"] = base64.b64encode(f.read()).decode("ascii")
    except OSError:
        pass
    if not (settings_data or "cert_pem" in data):
        return None
    return data


def _export_logs(conn):
    """Exportiert das komplette, aktuell in der DB stehende Auditlog --
    bereits archivierte Tage (siehe _archive_old_audit_log_rows) liegen
    als eigene Dateien unter AUDIT_ARCHIVE_DIR und sind bewusst NICHT Teil
    dieses Exports, das Auditlog-Export-Formular exportiert nur die
    Datenbank-Kategorien."""
    rows = conn.execute(
        "SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC"
    ).fetchall()
    return [dict(r) for r in rows] if rows else None


EXPORT_BUILDERS = {
    "devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials,
    "users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp,
    "nginx": _export_nginx, "logs": _export_logs,
}


def _import_devices(conn, items):
    n = 0
    for d in items:
        cred_id = None
        if d.get("credential_name"):
            row = conn.execute(
                "SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (d["credential_name"],)
            ).fetchone()
            cred_id = row["id"] if row else None
        conn.execute(
            """INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, ssh_port, credential_id)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)
               ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name,
                   switch_hostname=excluded.switch_hostname, is_active=excluded.is_active,
                   ssh_port=excluded.ssh_port, credential_id=excluded.credential_id
               WHERE devices.deleted_at IS NULL""",
            (d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1),
             d.get("ssh_port"), cred_id),
        )
        n += 1
    return n


def _import_switches(conn, items):
    n = 0
    for s in items:
        cred_id = None
        if s.get("credential_name"):
            row = conn.execute(
                "SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (s["credential_name"],)
            ).fetchone()
            cred_id = row["id"] if row else None
        conn.execute(
            """INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)
               ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, ssh_port=excluded.ssh_port,
                   credential_id=excluded.credential_id
               WHERE switches.deleted_at IS NULL""",
            (s["hostname"], s["ip"], s.get("ssh_port"), cred_id),
        )
        n += 1
    return n


def _import_credentials(conn, items):
    n = 0
    for c in items:
        conn.execute(
            """INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)
               ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password,
                   category=excluded.category
               WHERE credentials.deleted_at IS NULL""",
            (c["name"], c["username"], encrypt_password(c["password"]), c.get("category", "switch")),
        )
        n += 1
    return n


def _import_users(conn, items):
    n = 0
    for u in items:
        existing = conn.execute(
            "SELECT id, auth_source, deleted_at FROM users WHERE username=?", (u["username"],)
        ).fetchone()
        if existing and existing["auth_source"] != "local":
            continue
        if existing and existing["deleted_at"] is not None:
            continue
        if existing:
            conn.execute(
                "UPDATE users SET password=?, is_admin=?, first_name=?, last_name=?, email=?, is_locked=? WHERE id=?",
                (u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"),
                 u.get("email"), u.get("is_locked", 0), existing["id"]),
            )
            user_id = existing["id"]
        else:
            cur = conn.execute(
                "INSERT INTO users (username, password, is_admin, first_name, last_name, email, is_locked, auth_source) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, 'local')",
                (u["username"], u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"),
                 u.get("email"), u.get("is_locked", 0)),
            )
            user_id = cur.lastrowid
        conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
        for gname in u.get("groups", []):
            grow = conn.execute(
                "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,)
            ).fetchone()
            if grow:
                conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"]))
        n += 1
    return n


def _import_groups(conn, items):
    n = 0
    for g in items:
        existing = conn.execute(
            "SELECT id, is_system, deleted_at FROM groups WHERE name=?", (g["name"],)
        ).fetchone()
        if existing and (existing["is_system"] or existing["deleted_at"] is not None):
            continue
        if existing:
            group_id = existing["id"]
        else:
            cur = conn.execute(
                "INSERT INTO groups (name, is_default, is_system) VALUES (?, ?, 0)",
                (g["name"], g.get("is_default", 0)),
            )
            group_id = cur.lastrowid
        conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
        valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS]
        conn.executemany(
            "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", [(group_id, p) for p in valid_perms]
        )
        for username in g.get("members", []):
            urow = conn.execute(
                "SELECT id FROM users WHERE username=? AND deleted_at IS NULL", (username,)
            ).fetchone()
            if urow:
                conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (urow["id"], group_id))
        n += 1
    return n


def _import_ldap(conn, data):
    for key, value in data.get("settings", {}).items():
        if key in LDAP_SETTING_KEYS:
            conn.execute(
                "INSERT INTO settings (key, value) VALUES (?, ?) "
                "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
                (key, value),
            )
    if data.get("bind_username"):
        conn.execute(
            "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
            "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password",
            (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, data["bind_username"], encrypt_password(data.get("bind_password", ""))),
        )
    n = 0
    for m in data.get("group_mappings", []):
        app_group_name = m.get("app_group_name")
        if app_group_name == "Admin":
            app_group_id = "admin"
        else:
            grow = conn.execute(
                "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (app_group_name,)
            ).fetchone()
            if not grow:
                continue  # Rechtegruppe existiert auf dem Zielsystem (noch) nicht -- Zuordnung überspringen statt kaputt anzulegen
            app_group_id = grow["id"]
        conn.execute(
            "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
            "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
            (m["ad_group_dn"], m["ad_group_name"], app_group_id),
        )
        n += 1
    for m in data.get("fileshare_mappings", []):
        conn.execute(
            "INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) VALUES (?, ?, ?, ?) "
            "ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
            (m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
        )
        n += 1
    for u in data.get("ad_user_states", []):
        groups = u.get("groups", [])
        is_admin = 1 if "Admin" in groups else 0
        existing = conn.execute(
            "SELECT id FROM users WHERE username=? AND auth_source='ldap'", (u["username"],)
        ).fetchone()
        if existing:
            user_id = existing["id"]
            conn.execute(
                "UPDATE users SET is_locked=?, is_admin=? WHERE id=?",
                (u.get("is_locked", 0), is_admin, user_id),
            )
        else:
            # Konto hat sich auf dem Zielsystem noch nie eingeloggt -- ein
            # Platzhalter-Passwort-Hash wird nie geprüft (der Login-Zweig
            # für auth_source='ldap' authentifiziert immer gegen AD, siehe
            # login()), er füllt nur das NOT-NULL-Feld. Wichtig ist einzig,
            # dass is_locked schon VOR dem ersten Login greift.
            placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
            cur = conn.execute(
                "INSERT INTO users (username, password, is_admin, auth_source, is_locked) VALUES (?, ?, ?, 'ldap', ?)",
                (u["username"], placeholder_hash, is_admin, u.get("is_locked", 0)),
            )
            user_id = cur.lastrowid
        for gname in groups:
            if gname == "Admin":
                continue
            grow = conn.execute(
                "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,)
            ).fetchone()
            if grow:
                conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"]))
        n += 1
    return n


def _import_dhcp(conn, data):
    for key, value in data.get("settings", {}).items():
        if key in DHCP_DEFAULTS:
            conn.execute(
                "INSERT INTO settings (key, value) VALUES (?, ?) "
                "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
                (key, value),
            )
    n = 0
    for s in data.get("subnets", []):
        exists = conn.execute(
            "SELECT 1 FROM dhcp_subnets WHERE interface=? AND range_start=? AND range_end=?",
            (s["interface"], s["range_start"], s["range_end"]),
        ).fetchone()
        if not exists:
            max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
            conn.execute(
                "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order, enabled) "
                "VALUES (?, ?, ?, ?, ?, ?, ?)",
                (s["interface"], s["range_start"], s["range_end"], s.get("gateway"), s.get("dns"),
                 max_order + 1, s.get("enabled", 1)),
            )
            n += 1
    for r in data.get("reservations", []):
        conn.execute(
            "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?) "
            "ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, name=excluded.name",
            (r["mac"], r["ip"], r["name"]),
        )
        n += 1
    for opt in data.get("options", []):
        conn.execute(
            "INSERT INTO dhcp_option_defs (code, name, type, description, is_standard) VALUES (?, ?, ?, ?, ?) "
            "ON CONFLICT(name) DO UPDATE SET code=excluded.code, type=excluded.type, description=excluded.description",
            (opt["code"], opt["name"], opt["type"], opt.get("description"), opt.get("is_standard", 0)),
        )
        def_id = conn.execute("SELECT id FROM dhcp_option_defs WHERE name=?", (opt["name"],)).fetchone()["id"]
        for v in opt.get("values", []):
            conn.execute(
                "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) "
                "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
                (def_id, v.get("device_mac", ""), v["value"]),
            )
        n += 1
    return n


def _import_nginx(conn, data):
    n = 0
    for key, value in data.get("settings", {}).items():
        if key in NGINX_SETTING_KEYS:
            conn.execute(
                "INSERT INTO settings (key, value) VALUES (?, ?) "
                "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
                (key, value),
            )
            n += 1
    if data.get("cert_pem") and data.get("key_pem"):
        try:
            cert_bytes = base64.b64decode(data["cert_pem"])
            key_bytes = base64.b64decode(data["key_pem"])
            ok, _msg, _info = _validate_cert_key_pair(cert_bytes, key_bytes)
            if ok:
                os.makedirs(TESM_SSL_DIR, exist_ok=True)
                with open(TESM_SSL_CERT_PATH, "wb") as f:
                    f.write(cert_bytes)
                with open(TESM_SSL_KEY_PATH, "wb") as f:
                    f.write(key_bytes)
                os.chmod(TESM_SSL_KEY_PATH, 0o600)
                os.chmod(TESM_SSL_CERT_PATH, 0o644)
                # ACME-/certbot-Zustand kommt bewusst nie mit (siehe
                # _export_nginx) -- nach dem Import ist es technisch nur
                # noch ein hochgeladenes Zertifikat, keine automatische
                # Verlängerung mehr, auch wenn es ursprünglich per Let's
                # Encrypt ausgestellt wurde.
                conn.execute(
                    "INSERT INTO settings (key, value) VALUES ('ssl_source', 'upload') "
                    "ON CONFLICT(key) DO UPDATE SET value=excluded.value"
                )
                n += 1
        except (OSError, ValueError, TypeError):
            pass
    return n


def _import_logs(conn, items):
    conn.executemany(
        "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
        [(l["ts"], l["username"], l["action"], l.get("target"), l.get("details")) for l in items],
    )
    return len(items)


IMPORT_APPLIERS = {
    "devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials,
    "users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp,
    "nginx": _import_nginx, "logs": _import_logs,
}

_pending_imports = {}
PENDING_IMPORT_TTL_SECONDS = 900


def _purge_expired_imports():
    now = datetime.now()
    expired = [t for t, e in _pending_imports.items() if (now - e["created_at"]).total_seconds() > PENDING_IMPORT_TTL_SECONDS]
    for t in expired:
        _pending_imports.pop(t, None)


@app.route("/settings/export", methods=["POST"])
@login_required
def export_data():
    if not current_user.has_permission("settings_importexport.export"):
        flash("Keine Berechtigung, Daten zu exportieren.", "danger")
        return redirect(url_for("index"))
    if not license_export_allowed():
        flash("Für den Export ist keine gültige Lizenz vorhanden.", "danger")
        return redirect(url_for("settings_import_export"))

    passphrase = request.form.get("export_passphrase", "")
    sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS]
    if not passphrase:
        flash("Bitte eine Passphrase für den Export angeben.", "danger")
        return redirect(url_for("settings_import_export"))
    if not sections:
        flash("Bitte mindestens eine Kategorie zum Exportieren auswählen.", "danger")
        return redirect(url_for("settings_import_export"))
    if not current_user.is_admin and (set(sections) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
        flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins exportieren.", "danger")
        return redirect(url_for("settings_import_export"))

    conn = get_db_connection()
    payload, counts = {}, {}
    for key in sections:
        value = EXPORT_BUILDERS[key](conn)
        if value:
            payload[key] = value
            counts[key] = len(value) if isinstance(value, list) else 1
    conn.close()

    if not payload:
        flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
        return redirect(url_for("settings_import_export"))

    if "credentials" in payload:
        known_hosts_content = _read_known_hosts_content()
        if known_hosts_content:
            payload["known_hosts"] = known_hosts_content

    salt = secrets.token_bytes(16)
    export_fernet = _derive_export_fernet(passphrase, salt)
    encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))

    envelope = {
        "app": "tesm",
        "version": 2,
        "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
        "payload": encrypted_payload.decode("utf-8"),
    }

    summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload if k in EXPORT_SECTION_LABELS)
    if "known_hosts" in payload:
        summary += " (inkl. known_hosts)"
    log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload if k in EXPORT_SECTION_LABELS), summary)

    filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
    response = jsonify(envelope)
    response.headers["Content-Disposition"] = f"attachment; filename={filename}"
    return response


@app.route("/settings/import", methods=["POST"])
@login_required
def import_data():
    if not current_user.has_permission("settings_importexport.edit"):
        flash("Keine Berechtigung, Daten zu importieren.", "danger")
        return redirect(url_for("index"))

    passphrase = request.form.get("import_passphrase", "")
    upload = request.files.get("import_file")
    if not passphrase or not upload or not upload.filename:
        flash("Bitte Passphrase und Export-Datei angeben.", "danger")
        return redirect(url_for("settings_import_export"))

    try:
        envelope = json.loads(upload.read().decode("utf-8"))
        salt = base64.urlsafe_b64decode(envelope["salt"])
        export_fernet = _derive_export_fernet(passphrase, salt)
        payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
    except Exception:
        flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
        return redirect(url_for("settings_import_export"))

    present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)]
    if not present_sections:
        flash("Die Datei enthält keine bekannten/unterstützten Daten.", "danger")
        return redirect(url_for("settings_import_export"))

    _purge_expired_imports()
    token = secrets.token_urlsafe(24)
    _pending_imports[token] = {"payload": payload, "created_at": datetime.now(), "by": current_user.username}
    return redirect(url_for("settings_import_preview", token=token))


@app.route("/settings/import/preview/<token>")
@login_required
def settings_import_preview(token):
    if not current_user.has_permission("settings_importexport.edit"):
        flash("Keine Berechtigung, Daten zu importieren.", "danger")
        return redirect(url_for("index"))

    entry = _pending_imports.get(token)
    if not entry:
        flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger")
        return redirect(url_for("settings_import_export"))

    payload = entry["payload"]
    present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)]
    preview_sections = [
        {
            "key": key, "label": EXPORT_SECTION_LABELS.get(key, key),
            "count": len(payload[key]) if isinstance(payload[key], list) else 1,
            "admin_only": key in IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
        }
        for key in present_sections
    ]
    return render_template(
        "settings_import_export.html",
        import_preview={"token": token, "sections": preview_sections},
        export_sections=EXPORT_SECTIONS,
        admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
        export_section_hints=EXPORT_SECTION_HINTS,
    )


@app.route("/settings/import/apply", methods=["POST"])
@login_required
def import_apply():
    if not current_user.has_permission("settings_importexport.edit"):
        flash("Keine Berechtigung, Daten zu importieren.", "danger")
        return redirect(url_for("index"))

    token = request.form.get("import_token", "")
    entry = _pending_imports.pop(token, None)
    if not entry:
        flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger")
        return redirect(url_for("settings_import_export"))

    payload = entry["payload"]
    selected = [s for s in request.form.getlist("import_sections") if s in IMPORT_APPLIERS and payload.get(s)]
    if not selected:
        flash("Bitte mindestens eine Kategorie zum Importieren auswählen.", "danger")
        return redirect(url_for("settings_import_export"))
    if not current_user.is_admin and (set(selected) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
        flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins importieren.", "danger")
        return redirect(url_for("settings_import_export"))

    conn = get_db_connection()
    results = {key: IMPORT_APPLIERS[key](conn, payload[key]) for key in selected}
    conn.commit()
    conn.close()

    summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
    if "credentials" in selected and payload.get("known_hosts"):
        added = _merge_known_hosts_content(payload["known_hosts"])
        summary += f", known_hosts: {added} neue Zeile(n)"
    log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
    flash(f"Import abgeschlossen: {summary}.", "success")
    return redirect(url_for("settings_import_export"))



@app.route("/devices", methods=["GET", "POST"])
@login_required
def devices():
    if request.method == "GET" and not current_user.can_view_devices:
        flash("Keine Berechtigung, die Devices-Seite anzusehen.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()
    switches = conn.execute(
        "SELECT hostname FROM switches WHERE deleted_at IS NULL ORDER BY hostname ASC"
    ).fetchall()

    if request.method == "POST":
        if "add_device" in request.form:
            if not current_user.has_permission("devices.create"):
                flash("Keine Berechtigung, Geräte anzulegen.", "danger")
                return redirect(url_for("devices"))

            mac = request.form.get("mac")
            ip = request.form.get("ip")
            port = request.form.get("port")
            name = request.form.get("name")
            switch_hostname = request.form.get("switch_hostname") or None
            is_active = 1 if "is_active" in request.form else 0

            if not all([mac, ip, name]):
                flash("Alle Felder müssen ausgefüllt sein!", "danger")
                return redirect(url_for("devices"))

            if conn.execute("SELECT name FROM devices WHERE ip=? AND deleted_at IS NULL", (ip,)).fetchone():
                flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger")
                return redirect(url_for("devices"))

            if conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)).fetchone():
                flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
                return redirect(url_for("devices"))

            ssh_port, port_error = _parse_ssh_port(request.form)
            credential_id, cred_error = (None, None)
            if request.form.get("credential_choice"):
                credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
            if port_error:
                flash(port_error, "danger")
                return redirect(url_for("devices"))
            if cred_error:
                flash(cred_error, "danger")
                return redirect(url_for("devices"))

            try:
                conn.execute(
                    "INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                    (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port),
                )
                touch_record(conn, "devices", "mac", mac)
                conn.commit()
                log_action("device.create", name, f"MAC {mac}, IP {ip}")
                flash(f"Gerät {name} hinzugefügt.", "success")
            except sqlite3.IntegrityError:
                flash("Fehler beim Hinzufügen des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung, dort zuerst wiederherstellen oder endgültig löschen).", "danger")

        elif "edit_device" in request.form:
            if not current_user.has_permission("devices.edit"):
                flash("Keine Berechtigung, Geräte zu bearbeiten.", "danger")
                return redirect(url_for("devices"))

            old_mac = request.form.get("old_mac")
            mac = request.form.get("mac")
            ip = request.form.get("ip")
            port = request.form.get("port") or None
            name = request.form.get("name")
            switch_hostname_field = "switch_hostname" in request.form

            if switch_hostname_field and not any([mac, ip, name]):
                device = conn.execute(
                    "SELECT name, switch_hostname FROM devices WHERE mac=? AND deleted_at IS NULL", (old_mac,)
                ).fetchone()
                if not device:
                    flash("Gerät nicht gefunden!", "danger")
                    return redirect(url_for("devices"))

                old_switch = device["switch_hostname"] or "kein Switch"
                new_switch = request.form.get("switch_hostname") or None
                conn.execute(
                    "UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac)
                )
                touch_record(conn, "devices", "mac", old_mac)
                conn.commit()
                log_action("device.edit", device["name"], f"Switch: {old_switch} → {new_switch or 'Kein Switch'}")
                flash(
                    f"Switch von {device['name']} geändert: {old_switch} → {new_switch or 'Kein Switch'}",
                    "success",
                )
                return redirect(url_for("devices"))

            if not all([old_mac, mac, ip, name]):
                flash("Felder 'MAC', 'IP' und 'Name' müssen ausgefüllt sein!", "danger")
                return redirect(url_for("devices"))

            if conn.execute(
                "SELECT name FROM devices WHERE ip=? AND mac<>? AND deleted_at IS NULL", (ip, old_mac)
            ).fetchone():
                flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger")
                return redirect(url_for("devices"))

            if conn.execute(
                "SELECT name FROM devices WHERE mac=? AND mac<>? AND deleted_at IS NULL", (mac, old_mac)
            ).fetchone():
                flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
                return redirect(url_for("devices"))

            ssh_port, port_error = _parse_ssh_port(request.form)
            credential_id, cred_error = (None, None)
            if request.form.get("credential_choice"):
                credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
            if port_error:
                flash(port_error, "danger")
                return redirect(url_for("devices"))
            if cred_error:
                flash(cred_error, "danger")
                return redirect(url_for("devices"))

            try:
                conn.execute(
                    "UPDATE devices SET mac=?, ip=?, port=?, name=?, credential_id=?, ssh_port=? "
                    "WHERE mac=? AND deleted_at IS NULL",
                    (mac, ip, port, name, credential_id, ssh_port, old_mac),
                )
                touch_record(conn, "devices", "mac", mac)
                conn.commit()
                log_action("device.edit", name, f"MAC {old_mac} → {mac}, IP {ip}")
                flash(f"Gerät {name} aktualisiert.", "success")
            except sqlite3.IntegrityError:
                flash("Fehler beim Aktualisieren des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung).", "danger")

        elif "delete_device" in request.form:
            if not current_user.has_permission("devices.edit"):
                flash("Keine Berechtigung, Geräte zu löschen.", "danger")
                return redirect(url_for("devices"))

            del_mac = request.form.get("delete_device")
            if del_mac:
                device = conn.execute(
                    "SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (del_mac,)
                ).fetchone()
                name = device["name"] if device else del_mac
                conn.execute(
                    "UPDATE devices SET deleted_at=? WHERE mac=? AND deleted_at IS NULL",
                    (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), del_mac),
                )
                conn.commit()
                log_action("device.delete", name, f"MAC {del_mac} — in den Papierkorb verschoben")
                flash(f"Gerät {name} in den Papierkorb verschoben.", "success")
            else:
                flash("Keine MAC-Adresse übermittelt!", "danger")

        conn.close()
        return redirect(url_for("devices"))

    device_rows = conn.execute("""
        SELECT devices.mac, devices.ip, devices.port, devices.name, devices.is_active,
               devices.credential_id, devices.ssh_port,
               switches.hostname AS switch_hostname,
               credentials.name AS credential_name, credentials.username AS credential_username
        FROM devices
        LEFT JOIN switches ON devices.switch_hostname = switches.hostname
        LEFT JOIN credentials ON credentials.id = devices.credential_id
        WHERE devices.deleted_at IS NULL
        ORDER BY switches.hostname ASC, devices.name ASC
    """).fetchall()
    all_credentials = conn.execute(
        "SELECT id, name, username, category FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC"
    ).fetchall()
    conn.close()
    return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials)


@app.route("/devices/toggle/<mac>", methods=["POST"])
@login_required
def toggle_device(mac):
    if not current_user.has_permission("devices.edit"):
        return {"success": False, "msg": "Keine Berechtigung."}, 403

    conn = get_db_connection()
    device = conn.execute(
        "SELECT is_active, name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)
    ).fetchone()
    if not device:
        conn.close()
        return {"success": False, "msg": "Gerät nicht gefunden!"}, 404

    new_status = 0 if device["is_active"] else 1
    conn.execute("UPDATE devices SET is_active=? WHERE mac=? AND deleted_at IS NULL", (new_status, mac))
    touch_record(conn, "devices", "mac", mac)
    conn.commit()
    conn.close()

    status_text = "deaktiviert" if new_status == 0 else "aktiviert"
    log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"])
    return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status}



def _resolve_credential_choice(conn, default_category="switch"):
    """
    Liest die Zugangsdaten-Auswahl aus einem Switch-/Client-Formular:
    entweder eine bestehende credential_id, oder (choice == "new") legt
    direkt aus dem Formular heraus neue Zugangsdaten an — damit man beim
    Anlegen nicht zuerst zu "Zugangsdaten" wechseln muss. default_category
    wird nur für neu angelegte Zugangsdaten verwendet (z.B. "linux", wenn
    aus dem Client-Formular heraus angelegt), bestehende behalten ihre
    eigene Kategorie. Gibt (credential_id, error_message) zurück;
    error_message ist None bei Erfolg.
    """
    choice = request.form.get("credential_choice", "")
    if choice == "new":
        if not current_user.has_permission("credentials.create"):
            return None, "Keine Berechtigung, neue Zugangsdaten anzulegen."
        name = request.form.get("new_credential_name", "").strip()
        username = request.form.get("new_credential_username", "").strip()
        password = request.form.get("new_credential_password", "")
        if not (name and username and password):
            return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!"
        try:
            cur = conn.execute(
                "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
                (name, username, encrypt_password(password), default_category),
            )
            return cur.lastrowid, None
        except sqlite3.IntegrityError:
            return None, "Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!"
    elif choice.isdigit():
        cred_id = int(choice)
        if not conn.execute(
            "SELECT 1 FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,)
        ).fetchone():
            return None, "Ausgewählte Zugangsdaten wurden zwischenzeitlich gelöscht."
        return cred_id, None
    return None, "Bitte Zugangsdaten auswählen oder neue anlegen."


def _parse_ssh_port(form):
    """Liest das optionale SSH-Port-Feld aus dem Formular. Leer -> None (=
    Fallback auf SWITCH_DEFAULT_SSH_PORT überall, wo der Port gebraucht
    wird). Gibt (port_or_none, error_message) zurück."""
    raw = (form.get("ssh_port") or "").strip()
    if not raw:
        return None, None
    if not raw.isdigit() or not (1 <= int(raw) <= 65535):
        return None, "SSH-Port muss eine Zahl zwischen 1 und 65535 sein (oder leer für Standard 22)!"
    return int(raw), None


@app.route("/switches", methods=["GET", "POST"])
@login_required
def switches():
    if not current_user.can_manage_switches:
        flash("Keine Berechtigung für die Switch-Verwaltung.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()

    if request.method == "POST" and "add_switch" in request.form:
        if not current_user.has_permission("switches.create"):
            flash("Keine Berechtigung, Switche anzulegen.", "danger")
            return redirect(url_for("switches"))
        hostname = request.form["hostname"]
        ip = request.form["ip"]
        ssh_port, port_error = _parse_ssh_port(request.form)
        credential_id, cred_error = _resolve_credential_choice(conn)
        if port_error:
            flash(port_error, "danger")
        elif cred_error:
            flash(cred_error, "danger")
        else:
            try:
                conn.execute(
                    "INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)",
                    (hostname, ip, ssh_port, credential_id),
                )
                touch_record(conn, "switches", "hostname", hostname)
                conn.commit()
                log_action("switch.create", hostname, f"IP {ip}")
                flash(f"Switch {hostname} hinzugefügt.", "success")
            except sqlite3.IntegrityError:
                flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger")

    if request.method == "POST" and "edit_switch" in request.form:
        if not current_user.has_permission("switches.edit"):
            flash("Keine Berechtigung, Switche zu bearbeiten.", "danger")
            return redirect(url_for("switches"))
        old_hostname = request.form["old_hostname"]
        hostname = request.form["hostname"]
        ip = request.form["ip"]
        ssh_port, port_error = _parse_ssh_port(request.form)
        credential_id, cred_error = _resolve_credential_choice(conn)
        if port_error:
            flash(port_error, "danger")
        elif cred_error:
            flash(cred_error, "danger")
        else:
            try:
                conn.execute(
                    "UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=? "
                    "WHERE hostname=? AND deleted_at IS NULL",
                    (hostname, ip, ssh_port, credential_id, old_hostname),
                )
                if hostname != old_hostname:
                    conn.execute(
                        "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
                        (hostname, old_hostname),
                    )
                touch_record(conn, "switches", "hostname", hostname)
                conn.commit()
                log_action("switch.edit", hostname, f"IP {ip}")
                flash(f"Switch {hostname} aktualisiert.", "success")
            except sqlite3.IntegrityError:
                flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger")

    switch_rows = conn.execute("""
        SELECT switches.hostname, switches.ip, switches.ssh_port, switches.credential_id,
               credentials.name AS credential_name, credentials.username AS credential_username
        FROM switches
        LEFT JOIN credentials ON credentials.id = switches.credential_id
        WHERE switches.deleted_at IS NULL
        ORDER BY switches.hostname ASC
    """).fetchall()
    all_credentials = conn.execute(
        "SELECT id, name, username FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC"
    ).fetchall()
    conn.close()
    return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials)


@app.route("/switches/delete/<hostname>", methods=["POST"])
@login_required
def delete_switch(hostname):
    if not current_user.has_permission("switches.edit"):
        flash("Keine Berechtigung, Switche zu löschen.", "danger")
        return redirect(url_for("switches"))

    conn = get_db_connection()
    assigned = conn.execute(
        "SELECT name FROM devices WHERE switch_hostname=? AND deleted_at IS NULL", (hostname,)
    ).fetchall()
    if assigned:
        names = ", ".join(d["name"] for d in assigned)
        flash(f"Folgende Geräte sind noch dem Switch {hostname} zugewiesen: {names}", "danger")
        conn.close()
        return redirect(url_for("switches"))

    conn.execute(
        "UPDATE switches SET deleted_at=? WHERE hostname=? AND deleted_at IS NULL",
        (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), hostname),
    )
    conn.commit()
    conn.close()
    log_action("switch.delete", hostname, "in den Papierkorb verschoben")
    flash(f"Switch '{hostname}' in den Papierkorb verschoben.", "success")
    return redirect(url_for("switches"))



@app.route("/credentials", methods=["GET", "POST"])
@login_required
def credentials():
    if not current_user.can_manage_credentials:
        flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()

    if request.method == "POST" and "add_credential" in request.form:
        if not current_user.has_permission("credentials.create"):
            flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger")
            return redirect(url_for("credentials"))
        name = request.form.get("name", "").strip()
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "")
        category = request.form.get("category", "").strip()
        if category not in CREDENTIAL_CATEGORY_KEYS:
            category = "switch"
        if name and username and password:
            try:
                conn.execute(
                    "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
                    (name, username, encrypt_password(password), category),
                )
                conn.commit()
                log_action("credential.create", name)
                flash(f"Zugangsdaten '{name}' angelegt.", "success")
            except sqlite3.IntegrityError:
                flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger")
        else:
            flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger")

    elif request.method == "POST" and "edit_credential" in request.form:
        if not current_user.has_permission("credentials.edit"):
            flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger")
            return redirect(url_for("credentials"))
        cred_id = request.form.get("credential_id")
        name = request.form.get("name", "").strip()
        username = request.form.get("username", "").strip()
        new_password = request.form.get("password", "")
        category = request.form.get("category", "").strip()
        if category not in CREDENTIAL_CATEGORY_KEYS:
            category = "switch"
        if name and username:
            try:
                if new_password:
                    conn.execute(
                        "UPDATE credentials SET name=?, username=?, password=?, category=? "
                        "WHERE id=? AND deleted_at IS NULL",
                        (name, username, encrypt_password(new_password), category, cred_id),
                    )
                else:
                    conn.execute(
                        "UPDATE credentials SET name=?, username=?, category=? WHERE id=? AND deleted_at IS NULL",
                        (name, username, category, cred_id),
                    )
                conn.commit()
                log_action("credential.edit", name)
                flash(f"Zugangsdaten '{name}' aktualisiert.", "success")
            except sqlite3.IntegrityError:
                flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger")
        else:
            flash("Name und Username müssen ausgefüllt sein!", "danger")

    elif request.method == "POST" and "delete_credential" in request.form:
        if not current_user.has_permission("credentials.edit"):
            flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
            return redirect(url_for("credentials"))
        cred_id = request.form.get("delete_credential")
        used_by_switches = conn.execute(
            "SELECT hostname FROM switches WHERE credential_id=? AND deleted_at IS NULL", (cred_id,)
        ).fetchall()
        used_by_devices = conn.execute(
            "SELECT name FROM devices WHERE credential_id=? AND deleted_at IS NULL", (cred_id,)
        ).fetchall()
        if used_by_switches or used_by_devices:
            names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices])
            flash(f"Diese Zugangsdaten werden noch verwendet von: {names}", "danger")
        else:
            cred_row = conn.execute(
                "SELECT name FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,)
            ).fetchone()
            conn.execute(
                "UPDATE credentials SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
                (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), cred_id),
            )
            conn.commit()
            log_action("credential.delete", cred_row["name"] if cred_row else cred_id, "in den Papierkorb verschoben")
            flash("Zugangsdaten in den Papierkorb verschoben.", "success")

    credential_rows = conn.execute("""
        SELECT credentials.id, credentials.name, credentials.username, credentials.category,
               COUNT(DISTINCT switches.hostname) AS switch_count,
               COUNT(DISTINCT devices.mac) AS device_count
        FROM credentials
        LEFT JOIN switches ON switches.credential_id = credentials.id AND switches.deleted_at IS NULL
        LEFT JOIN devices ON devices.credential_id = credentials.id AND devices.deleted_at IS NULL
        WHERE credentials.deleted_at IS NULL
        GROUP BY credentials.id
        ORDER BY credentials.name ASC
    """).fetchall()
    conn.close()
    return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)



_maintenance_jobs_lock = threading.Lock()
_maintenance_jobs = {}


def _maintenance_job_set(mac, **fields):
    with _maintenance_jobs_lock:
        job = _maintenance_jobs.setdefault(mac, {})
        job.update(fields)


def _maintenance_job_immediate_error(mac, action, message):
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    _maintenance_job_set(mac, action=action, status="error", message=message, output="", started=now, finished=now)


def _maintenance_devices(conn):
    """Alle Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie
    DEVICE_MAINTENANCE_CATEGORY (aktuell nur 'linux') -- nur für diese
    bietet 'Wartung' überhaupt eine Aktion an (Windows/PowerShell folgt
    als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar)."""
    return conn.execute(
        """
        SELECT devices.mac, devices.name, devices.ip, devices.ssh_port,
               devices.credential_id, devices.is_active,
               credentials.username AS cred_username
        FROM devices
        JOIN credentials ON credentials.id = devices.credential_id
        WHERE credentials.category = ? AND devices.deleted_at IS NULL AND credentials.deleted_at IS NULL
        ORDER BY devices.name ASC
        """,
        (DEVICE_MAINTENANCE_CATEGORY,),
    ).fetchall()



TRASH_RETENTION_DEFAULT_DAYS = 30


def _trash_retention_days():
    try:
        return max(1, int(get_setting("trash_retention_days", str(TRASH_RETENTION_DEFAULT_DAYS))))
    except (TypeError, ValueError):
        return TRASH_RETENTION_DEFAULT_DAYS


def _trashed_devices(conn):
    return conn.execute(
        "SELECT mac, name, ip, deleted_at FROM devices WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
    ).fetchall()


def _trashed_switches(conn):
    return conn.execute(
        "SELECT hostname, ip, deleted_at FROM switches WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
    ).fetchall()


def _trashed_credentials(conn):
    return conn.execute(
        "SELECT id, name, username, category, deleted_at FROM credentials "
        "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
    ).fetchall()


def _trashed_users(conn):
    return conn.execute(
        "SELECT id, username, first_name, last_name, deleted_at FROM users "
        "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
    ).fetchall()


def _trashed_groups(conn):
    return conn.execute(
        "SELECT id, name, deleted_at FROM groups WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
    ).fetchall()


def _purge_device(conn, mac):
    conn.execute("DELETE FROM dhcp_option_values WHERE device_mac=?", (mac,))
    conn.execute("DELETE FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,))


def _purge_switch(conn, hostname):
    conn.execute("DELETE FROM switches WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,))


def _purge_credential(conn, cred_id):
    conn.execute("DELETE FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,))


def _purge_user(conn, user_id):
    conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
    conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,))


def _purge_group(conn, group_id):
    conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
    conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
    conn.execute("DELETE FROM ldap_group_mappings WHERE app_group_id=?", (group_id,))
    conn.execute("DELETE FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,))


def _purge_expired_trash():
    """Wird bei jedem Aufruf der Wartung-Seite ausgeführt (kein eigener
    Scheduler nötig, analog _purge_expired_imports()) -- löscht alle
    Papierkorb-Einträge endgültig, deren Aufbewahrungsfrist abgelaufen ist."""
    cutoff = (datetime.now() - timedelta(days=_trash_retention_days())).strftime("%Y-%m-%d %H:%M:%S")
    conn = get_db_connection()
    for row in conn.execute(
        "SELECT mac FROM devices WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    ).fetchall():
        _purge_device(conn, row["mac"])
    for row in conn.execute(
        "SELECT hostname FROM switches WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    ).fetchall():
        _purge_switch(conn, row["hostname"])
    for row in conn.execute(
        "SELECT id FROM credentials WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    ).fetchall():
        _purge_credential(conn, row["id"])
    for row in conn.execute(
        "SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    ).fetchall():
        _purge_user(conn, row["id"])
    for row in conn.execute(
        "SELECT id FROM groups WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    ).fetchall():
        _purge_group(conn, row["id"])
    conn.commit()
    conn.close()


@app.route("/devices/restore/<mac>", methods=["POST"])
@login_required
def restore_device(mac):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone()
    conn.execute("UPDATE devices SET deleted_at=NULL WHERE mac=? AND deleted_at IS NOT NULL", (mac,))
    conn.commit()
    conn.close()
    log_action("device.restore", device["name"] if device else mac)
    flash(f"Gerät {device['name'] if device else mac} wiederhergestellt.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/devices/purge/<mac>", methods=["POST"])
@login_required
def purge_device(mac):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone()
    _purge_device(conn, mac)
    conn.commit()
    conn.close()
    log_action("device.purge", device["name"] if device else mac)
    flash(f"Gerät {device['name'] if device else mac} endgültig gelöscht.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/switches/restore/<hostname>", methods=["POST"])
@login_required
def restore_switch(hostname):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    conn.execute("UPDATE switches SET deleted_at=NULL WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,))
    conn.commit()
    conn.close()
    log_action("switch.restore", hostname)
    flash(f"Switch {hostname} wiederhergestellt.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/switches/purge/<hostname>", methods=["POST"])
@login_required
def purge_switch(hostname):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    _purge_switch(conn, hostname)
    conn.commit()
    conn.close()
    log_action("switch.purge", hostname)
    flash(f"Switch {hostname} endgültig gelöscht.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
@login_required
def restore_credential(cred_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone()
    conn.execute("UPDATE credentials SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (cred_id,))
    conn.commit()
    conn.close()
    log_action("credential.restore", cred["name"] if cred else cred_id)
    flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' wiederhergestellt.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
@login_required
def purge_credential(cred_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone()
    _purge_credential(conn, cred_id)
    conn.commit()
    conn.close()
    log_action("credential.purge", cred["name"] if cred else cred_id)
    flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' endgültig gelöscht.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/users/restore/<int:user_id>", methods=["POST"])
@login_required
def restore_user(user_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    target = conn.execute(
        "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,)
    ).fetchone()
    if target and target["is_admin"] and not current_user.is_admin:
        flash("Nur Admins dürfen Admin-Konten wiederherstellen.", "danger")
        conn.close()
        return redirect(url_for("papierkorb"))
    conn.execute("UPDATE users SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (user_id,))
    conn.commit()
    conn.close()
    log_action("user.restore", target["username"] if target else user_id)
    flash(f"Benutzer „{target['username'] if target else user_id}“ wiederhergestellt.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/users/purge/<int:user_id>", methods=["POST"])
@login_required
def purge_user(user_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    target = conn.execute(
        "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,)
    ).fetchone()
    if target and target["is_admin"] and not current_user.is_admin:
        flash("Nur Admins dürfen Admin-Konten endgültig löschen.", "danger")
        conn.close()
        return redirect(url_for("papierkorb"))
    _purge_user(conn, user_id)
    conn.commit()
    conn.close()
    log_action("user.purge", target["username"] if target else user_id)
    flash(f"Benutzer „{target['username'] if target else user_id}“ endgültig gelöscht.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/groups/restore/<int:group_id>", methods=["POST"])
@login_required
def restore_group(group_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone()
    conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (group_id,))
    conn.commit()
    conn.close()
    log_action("group.restore", group["name"] if group else group_id)
    flash(f"Gruppe '{group['name'] if group else group_id}' wiederhergestellt.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/groups/purge/<int:group_id>", methods=["POST"])
@login_required
def purge_group(group_id):
    if not current_user.has_permission("papierkorb.edit") or not license_active():
        flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
        return redirect(url_for("papierkorb"))
    conn = get_db_connection()
    group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone()
    _purge_group(conn, group_id)
    conn.commit()
    conn.close()
    log_action("group.purge", group["name"] if group else group_id)
    flash(f"Gruppe '{group['name'] if group else group_id}' endgültig gelöscht.", "success")
    return redirect(url_for("papierkorb"))


@app.route("/maintenance")
@login_required
def maintenance():
    if not current_user.can_view_maintenance:
        flash("Keine Berechtigung für die Wartungsseite.", "danger")
        return redirect(url_for("index"))
    if resp := _require_module("maintenance"):
        return resp
    conn = get_db_connection()
    devices_rows = _maintenance_devices(conn)
    conn.close()
    with _maintenance_jobs_lock:
        for mac in [m for m, job in _maintenance_jobs.items() if job.get("status") == "success"]:
            del _maintenance_jobs[mac]
        jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()}
    return render_template(
        "maintenance.html",
        devices=devices_rows,
        jobs=jobs_snapshot,
        can_run=current_user.can_run_maintenance,
    )


@app.route("/papierkorb")
@login_required
def papierkorb():
    if not current_user.can_view_papierkorb:
        flash("Keine Berechtigung für den Papierkorb.", "danger")
        return redirect(url_for("index"))
    _purge_expired_trash()
    conn = get_db_connection()
    # Global: papierkorb.view zeigt alle fünf Kategorien, unabhängig vom
    # jeweiligen Ressourcen-Leserecht (siehe can_view_papierkorb).
    trash = {
        "devices": _trashed_devices(conn),
        "switches": _trashed_switches(conn),
        "credentials": _trashed_credentials(conn),
        "users": _trashed_users(conn),
        "groups": _trashed_groups(conn),
    }
    conn.close()
    return render_template(
        "papierkorb.html",
        trash=trash,
        trash_retention_days=_trash_retention_days(),
    )


@app.route("/maintenance/status")
@login_required
def maintenance_status():
    if not current_user.can_view_maintenance:
        return jsonify({"error": "Keine Berechtigung."}), 403
    with _maintenance_jobs_lock:
        return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()})


_MAINTENANCE_OUTPUT_MAX_LINES = 400
TESM_CLIENT_UPDATE_LOG_DIR = os.path.join(TESM_LOG_DIR, "clientupdate")


def _write_client_update_error_log(name, exit_code, lines):
    """Schreibt bei einem mit Exit-Code fehlgeschlagenen Wartungs-Update
    eine eigene Fehler-Logdatei pro Client (überschreibt eine vorherige aus
    einem früheren Fehlschlag desselben Clients) -- zusätzlich zur ohnehin
    im Job-Status/Live-Fenster sichtbaren Ausgabe, damit ein Fehlschlag
    auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
    try:
        os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
        os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o755)
        safe_name = secure_filename(name) or "unbenannt"
        path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
        with open(path, "w", encoding="utf-8") as f:
            f.write(f"Update fehlgeschlagen: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"Exit-Code: {exit_code}\n\n")
            f.write("\n".join(lines))
            f.write("\n")
    except OSError:
        app.logger.error("_write_client_update_error_log failed:\n%s", traceback.format_exc())


def _maintenance_run_update(mac, name, host, port, username, password):
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    _maintenance_job_set(
        mac, action="update", status="running", message="Update läuft …",
        output="", started=now, finished=None,
    )

    lines = []

    def on_line(line, is_err):
        lines.append(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {line}")
        if len(lines) > _MAINTENANCE_OUTPUT_MAX_LINES:
            del lines[: len(lines) - _MAINTENANCE_OUTPUT_MAX_LINES]
        _maintenance_job_set(mac, output="\n".join(lines))

    result = _run_ssh_command_streaming(
        host, port, username, password, _apt_upgrade_command(), on_line,
        sudo_password=password, overall_timeout=600,
    )
    finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    if result["error"]:
        lines.append(f"{finished} ✖ Update abgebrochen: {result['error']}")
        _maintenance_job_set(mac, status="error", message=result["error"], output="\n".join(lines), finished=finished)
        log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
    elif result["success"]:
        lines.append(f"{finished} ✔ All Updates Done")
        _maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output="\n".join(lines), finished=finished)
        log_action_system("maintenance.update", name, "erfolgreich")
    else:
        lines.append(f"{finished} ✖ Update fehlgeschlagen (Exit-Code {result['exit_code']})")
        _maintenance_job_set(
            mac, status="error",
            message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
            output="\n".join(lines), finished=finished,
        )
        _write_client_update_error_log(name, result["exit_code"], lines)
        log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")


_MAINTENANCE_REBOOT_GRACE_SECONDS = 15
_MAINTENANCE_REBOOT_TIMEOUT_SECONDS = 300
_MAINTENANCE_REBOOT_POLL_INTERVAL = 5


def _ping_once(host, timeout=2):
    try:
        result = subprocess.run(
            ["ping", "-c", "1", "-W", str(timeout), host],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout + 2,
        )
        return result.returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def _maintenance_wait_online_after_reboot(mac, name, host):
    """Wartet nach einem erfolgreich ausgelösten Neustart eine kurze
    Schonfrist (die Maschine braucht einen Moment zum Herunterfahren, ein
    zu früher Ping könnte noch die absterbende alte Instanz erreichen),
    pingt danach regelmäßig, bis sie wieder antwortet oder ein Timeout
    erreicht ist. Läuft im selben Hintergrund-Thread wie
    _maintenance_run_reboot weiter, blockiert also niemanden."""
    _maintenance_job_set(mac, status="running", message="Neustart ausgelöst, warte auf Online …")
    time.sleep(_MAINTENANCE_REBOOT_GRACE_SECONDS)

    deadline = time.time() + _MAINTENANCE_REBOOT_TIMEOUT_SECONDS
    while time.time() < deadline:
        if _ping_once(host):
            _maintenance_job_set(
                mac, status="success", message="Neustart erfolgreich — Gerät wieder online.",
                finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            )
            log_action_system("maintenance.reboot", name, "wieder online")
            return
        time.sleep(_MAINTENANCE_REBOOT_POLL_INTERVAL)

    _maintenance_job_set(
        mac, status="error",
        message=f"Nach dem Neustart nicht innerhalb von {_MAINTENANCE_REBOOT_TIMEOUT_SECONDS // 60} Minuten wieder online (Ping ohne Antwort).",
        finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    )
    log_action_system("maintenance.reboot", name, "nach Neustart nicht wieder online")


def _maintenance_run_reboot(mac, name, host, port, username, password):
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    _maintenance_job_set(mac, action="reboot", status="running", message="Neustart wird ausgelöst …", output="", started=now, finished=None)
    result = _run_ssh_reboot(host, port, username, password)
    if result["error"]:
        _maintenance_job_set(
            mac, status="error", message=result["error"],
            finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        )
        log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}")
        return

    log_action_system("maintenance.reboot", name, "ausgelöst")
    _maintenance_wait_online_after_reboot(mac, name, host)


def _maintenance_dispatch(action_name, thread_target):
    """Gemeinsame Logik für /maintenance/update und /maintenance/reboot:
    ausgewählte, berechtigte MACs auflösen, bereits laufende überspringen,
    je Gerät einen Hintergrund-Thread starten."""
    macs = set(request.form.getlist("macs"))
    if not macs:
        flash("Bitte mindestens ein Gerät auswählen.", "danger")
        return redirect(url_for("maintenance"))

    conn = get_db_connection()
    eligible = {d["mac"]: d for d in _maintenance_devices(conn) if d["mac"] in macs}

    started = []
    for mac, device in eligible.items():
        with _maintenance_jobs_lock:
            existing = _maintenance_jobs.get(mac)
            if existing and existing.get("status") == "running":
                continue
        host, port, username, password, error = _resolve_device_ssh_target(conn, device)
        if error:
            _maintenance_job_immediate_error(mac, action_name, error)
            continue
        thread = threading.Thread(
            target=thread_target,
            args=(mac, device["name"], host, port, username, password),
            daemon=True,
        )
        thread.start()
        started.append(device["name"])
    conn.close()

    if started:
        flash(f"{action_name.capitalize()} gestartet für: {', '.join(started)}", "success")
    else:
        flash("Keine Aktion gestartet (bereits laufend oder Zugangsdaten fehlerhaft).", "danger")
    return redirect(url_for("maintenance"))


@app.route("/maintenance/update", methods=["POST"])
@login_required
def maintenance_update():
    if not current_user.can_run_maintenance:
        flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
        return redirect(url_for("maintenance"))
    return _maintenance_dispatch("update", _maintenance_run_update)


@app.route("/maintenance/reboot", methods=["POST"])
@login_required
def maintenance_reboot():
    if not current_user.can_run_maintenance:
        flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
        return redirect(url_for("maintenance"))
    return _maintenance_dispatch("reboot", _maintenance_run_reboot)



_SSH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\.\-:]{0,253}$")
_SSH_USER_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$")


def _send_and_close(ws, message):
    """
    Nachricht senden und Verbindung schließen. Die kurze Pause dazwischen
    zwingt beide WebSocket-Frames auf getrennte TCP-Schreibvorgänge — ohne
    sie kommt es bei manchen Browsern vor, dass eine Nachricht direkt gefolgt
    von der Schließung der Verbindung als Frame-Fehler gewertet und die
    Nachricht verworfen wird, bevor sie angezeigt werden kann.
    """
    try:
        ws.send(message)
        time.sleep(0.15)
    except ConnectionClosed:
        pass
    try:
        ws.close()
    except Exception:
        pass


def _terminal_read_line(ws, echo=True):
    """Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis
    Enter kommt — für Prompts, bevor eine Remote-Shell existiert, die das
    selbst übernehmen könnte (Host-Key-Bestätigung, Passwortabfrage).
    echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie
    ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt."""
    line = ""
    while True:
        try:
            raw = ws.receive(timeout=120)
        except ConnectionClosed:
            return None
        if raw is None:
            return None
        try:
            payload = json.loads(raw)
        except (TypeError, ValueError):
            continue
        if payload.get("type") != "input":
            continue
        for ch in str(payload.get("data", "")):
            if ch in ("\r", "\n"):
                ws.send("\r\n")
                return line
            elif ch in ("\x7f", "\b"):
                if line:
                    line = line[:-1]
                    if echo:
                        ws.send("\b \b")
            elif ch == "\x03":
                ws.send("^C\r\n")
                return None
            else:
                line += ch
                if echo:
                    ws.send(ch)


def _verify_host_key_interactive(ws, host, port, server_key):
    """Emuliert das bekannte SSH-CLI-Verhalten bei Host-Keys — unbekannt:
    interaktiv über das Browser-Terminal nachfragen (wie "Are you sure you
    want to continue connecting?"); GEÄNDERT (Eintrag vorhanden, aber
    anderer Key): hart ablehnen ohne Rückfrage, wie ein normales ssh-CLI
    bei einer möglichen MITM-Situation. Gibt True zurück, wenn die
    Verbindung fortgesetzt werden darf."""
    lookup_host = host if port == 22 else f"[{host}]:{port}"
    hostkeys = paramiko.HostKeys()
    try:
        hostkeys.load(SSH_KNOWN_HOSTS_PATH)
    except (OSError, paramiko.SSHException):
        pass

    existing = hostkeys.lookup(lookup_host)
    if existing and existing.get(server_key.get_name()) and existing[server_key.get_name()] != server_key:
        ws.send(
            f"\r\n\x1b[31mWARNUNG: Host-Key für '{lookup_host}' hat sich geändert "
            f"— Verbindung abgelehnt (möglicher Man-in-the-Middle).\x1b[0m\r\n"
        )
        return False
    if hostkeys.check(lookup_host, server_key):
        return True

    fingerprint = ":".join(f"{b:02x}" for b in server_key.get_fingerprint())
    ws.send(
        f"\r\nDie Authentizität von Host '{lookup_host}' kann nicht festgestellt werden.\r\n"
        f"{server_key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n"
        f"Trotzdem verbinden (yes/no)? "
    )
    answer = _terminal_read_line(ws, echo=True)
    if not answer or answer.strip().lower() not in ("yes", "y"):
        return False

    hostkeys.add(lookup_host, server_key.get_name(), server_key)
    try:
        if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
            open(SSH_KNOWN_HOSTS_PATH, "a").close()
        hostkeys.save(SSH_KNOWN_HOSTS_PATH)
    except OSError:
        app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
    return True


def _authenticate_interactive(ws, transport, username):
    """Fragt Zugangsdaten genauso ab, wie es ein echtes ssh-CLI täte —
    direkt über die WebSocket-Verbindung, da an dieser Stelle noch keine
    Remote-Shell existiert, in die man tippen könnte. Vorher wurde hier nie
    tatsächlich ein Passwort abgefragt (das Init-Payload vom Browser enthält
    gar keins) — SSHClient.connect() ohne password/pkey/agent scheitert
    sofort mit "No authentication methods available", bevor überhaupt eine
    Passwortabfrage möglich wäre. Fragt daher aktiv nach, welche
    Auth-Methoden der Server anbietet, und fordert bei Bedarf interaktiv
    zur Eingabe auf (Passwort oder keyboard-interactive)."""
    allowed = []
    try:
        transport.auth_none(username)
    except paramiko.BadAuthenticationType as e:
        allowed = e.allowed_types
    except paramiko.SSHException:
        allowed = []

    if transport.is_authenticated():
        return True

    if "password" in allowed:
        ws.send("\r\nPasswort: ")
        pw = _terminal_read_line(ws, echo=False)
        try:
            transport.auth_password(username, pw or "")
        except paramiko.AuthenticationException:
            pass
    elif "keyboard-interactive" in allowed:
        def handler(title, instructions, prompt_list):
            for text in (title, instructions):
                if text:
                    ws.send(f"\r\n{text}")
            responses = []
            for prompt_text, echo in prompt_list:
                ws.send(f"\r\n{prompt_text or 'Passwort: '}")
                responses.append(_terminal_read_line(ws, echo=bool(echo)) or "")
            return responses
        try:
            transport.auth_interactive(username, handler)
        except paramiko.AuthenticationException:
            pass
    else:
        ws.send(f"\r\n\x1b[31mServer bietet keine unterstützte Auth-Methode an ({', '.join(allowed) or 'keine'}).\x1b[0m\r\n")

    return transport.is_authenticated()


@sock.route("/ws/ssh_terminal")
def ssh_terminal(ws):
    try:
        return _ssh_terminal_inner(ws)
    except Exception:
        app.logger.error("ssh_terminal crashed:\n%s", traceback.format_exc())
        raise


def _ssh_terminal_inner(ws):
    if not current_user.is_authenticated or not current_user.is_admin:
        _send_and_close(ws, "\r\n\x1b[31mZugriff verweigert.\x1b[0m\r\n")
        return

    try:
        init = json.loads(ws.receive(timeout=10) or "{}")
    except Exception:
        ws.close()
        return

    host = str(init.get("host") or "").strip()
    username = str(init.get("username") or "").strip()
    port_str = str(init.get("port") or "22").strip()

    if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port_str.isdigit()):
        _send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n")
        return
    port = int(port_str)

    ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port} …\x1b[0m\r\n")

    try:
        sock_conn = socket.create_connection((host, port), timeout=10)
    except OSError as e:
        _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
        return

    transport = paramiko.Transport(sock_conn)
    try:
        transport.start_client(timeout=10)
    except (paramiko.SSHException, EOFError, OSError) as e:
        _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
        return

    if not _verify_host_key_interactive(ws, host, port, transport.get_remote_server_key()):
        transport.close()
        _send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt oder Verbindung abgebrochen.\x1b[0m\r\n")
        return

    try:
        authenticated = _authenticate_interactive(ws, transport, username)
    except (paramiko.SSHException, OSError, EOFError) as e:
        transport.close()
        _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
        return

    if not authenticated:
        transport.close()
        _send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n")
        return

    ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n")

    try:
        channel = transport.open_session(timeout=10)
        channel.get_pty(term="xterm-256color", width=80, height=24)
        channel.invoke_shell()
    except paramiko.SSHException as e:
        _send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n")
        transport.close()
        return

    channel.settimeout(0.0)
    closed = threading.Event()

    def pump_output():
        while not closed.is_set():
            try:
                if channel.recv_ready():
                    data = channel.recv(4096)
                    if not data:
                        break
                    ws.send(data.decode("utf-8", errors="replace"))
                else:
                    time.sleep(0.03)
                if channel.closed or channel.exit_status_ready():
                    if not channel.recv_ready():
                        break
            except (OSError, EOFError):
                break
            except Exception:
                break
        closed.set()

    reader = threading.Thread(target=pump_output, daemon=True)
    reader.start()

    try:
        while not closed.is_set():
            message = ws.receive()
            if message is None:
                break
            try:
                payload = json.loads(message)
            except (TypeError, ValueError):
                continue
            if payload.get("type") == "input":
                try:
                    channel.send(str(payload.get("data", "")))
                except OSError:
                    break
            elif payload.get("type") == "resize":
                try:
                    channel.resize_pty(width=int(payload.get("cols", 80)), height=int(payload.get("rows", 24)))
                except paramiko.SSHException:
                    pass
    except Exception:
        pass
    finally:
        closed.set()
        try:
            channel.close()
        except Exception:
            pass
        try:
            transport.close()
        except Exception:
            pass



_LINUX_UPDATE_STEPS = [
    ("apt update", "apt-get update"),
    ("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"),
    (
        "apt upgrade",
        'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
    ),
    (
        "apt full-upgrade",
        'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade',
    ),
    ("apt clean", "apt-get clean"),
    ("apt autoremove", "apt-get -y autoremove"),
]


def _apt_upgrade_command():
    """Baut die Wartungs-Update-Kette aus _LINUX_UPDATE_STEPS zusammen --
    jeder Schritt einzeln mit eigenem "=== Schritt N/M: ... ===" und
    danach "Schritt N (...) -> erfolgreich"/"-> fehlgeschlagen (Exit-Code
    ...)" im Live-Log, statt nur einer einzigen Erfolgs-/Fehlermeldung für
    die ganze Kette. Bricht beim ersten fehlgeschlagenen Schritt sofort ab
    (dessen Exit-Code wird zum Exit-Code des Gesamtkommandos), statt
    blind mit dem nächsten Schritt weiterzumachen.

    DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
    verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
    Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
    unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt).

    MODULES=most wird -- anders als zuvor -- dauerhaft in
    /etc/initramfs-tools/initramfs.conf gesetzt (nicht nur für diesen einen
    Aufruf per Umgebungsvariable): falls apt upgrade/full-upgrade einen
    neuen Kernel installiert und dabei ein initramfs neu gebaut wird,
    landen so ALLE verfügbaren Treiber darin (statt nur die per "dep"
    erkannten), damit das Gerät nach einem Kernel-Wechsel zuverlässig
    bootet -- und das bleibt auch für jeden künftigen, unabhängig von
    dieser App ausgelösten Kernel-Update/initramfs-Rebuild so bestehen."""
    script_lines = ["export DEBIAN_FRONTEND=noninteractive"]
    total = len(_LINUX_UPDATE_STEPS)
    for i, (label, cmd) in enumerate(_LINUX_UPDATE_STEPS, start=1):
        script_lines.append(f"echo '=== Schritt {i}/{total}: {label} ==='")
        script_lines.append(cmd)
        script_lines.append("_rc=$?")
        script_lines.append(
            f'if [ "$_rc" -eq 0 ]; then echo "Schritt {i} ({label}) -> erfolgreich"; '
            f'else echo "Schritt {i} ({label}) -> fehlgeschlagen (Exit-Code $_rc)"; exit "$_rc"; fi'
        )
    script = "\n".join(script_lines)
    return f"sudo -S -p '' bash -c {shlex.quote(script)}"


def _resolve_device_ssh_target(conn, device):
    """Löst Host/Port/Zugangsdaten für eine SSH-Wartungsaktion an einem
    Gerät auf. Gibt (host, port, username, password, error) zurück -- bei
    einem Fehler ist error gesetzt und die übrigen Werte None."""
    if not device["credential_id"]:
        return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt."
    cred = conn.execute(
        "SELECT username, password FROM credentials WHERE id=? AND deleted_at IS NULL", (device["credential_id"],)
    ).fetchone()
    if not cred:
        return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr."
    port = device["ssh_port"] or SWITCH_DEFAULT_SSH_PORT
    try:
        password = decrypt_password(cred["password"])
    except Exception:
        return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden."
    return device["ip"], port, cred["username"], password, None


def _run_ssh_command_streaming(host, port, username, password, command, on_line,
                                sudo_password=None, connect_timeout=25, overall_timeout=600):
    """Wie _run_ssh_command, liest die Ausgabe aber zeilenweise WÄHREND das
    Kommando noch läuft, statt sie erst nach Abschluss komplett auf einmal
    zu liefern -- Grundlage für die live mitwachsende, automatisch
    mitscrollende Ausgabe beim Wartungs-Update (siehe
    pollMaintenanceStatus() in maintenance.html). on_line(line, is_stderr)
    wird für jede vollständige Zeile aufgerufen, sobald sie eintrifft."""
    result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}

    if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
        result["error"] = (
            "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
            "einmal interaktiv verbinden und den Host-Key bestätigen."
        )
        return result

    client = paramiko.SSHClient()
    client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
    client.set_missing_host_key_policy(paramiko.RejectPolicy())

    try:
        client.connect(
            host, port=port, username=username, password=password,
            timeout=connect_timeout, banner_timeout=connect_timeout, auth_timeout=connect_timeout,
            look_for_keys=False, allow_agent=False,
        )
    except paramiko.BadHostKeyException:
        result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
        return result
    except paramiko.AuthenticationException:
        result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
        return result
    except paramiko.SSHException as e:
        result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
        return result
    except (OSError, socket.error) as e:
        result["error"] = f"Verbindung fehlgeschlagen: {e}"
        return result

    out_parts, err_parts = [], []
    out_buf, err_buf = bytearray(), bytearray()

    def drain(ready, recv, buf, parts, is_err):
        got_data = False
        while ready():
            chunk = recv(4096)
            if not chunk:
                break
            buf.extend(chunk)
            got_data = True
        while b"\n" in buf:
            idx = buf.index(b"\n")
            line = bytes(buf[:idx]).decode("utf-8", errors="replace").rstrip("\r")
            del buf[:idx + 1]
            parts.append(line)
            on_line(line, is_err)
        return got_data

    try:
        stdin, stdout, stderr = client.exec_command(command, timeout=connect_timeout)
        channel = stdout.channel
        if sudo_password is not None:
            try:
                stdin.write(sudo_password + "\n")
                stdin.flush()
            except OSError:
                pass
        deadline = time.time() + overall_timeout
        while True:
            got = drain(channel.recv_ready, channel.recv, out_buf, out_parts, False)
            got = drain(channel.recv_stderr_ready, channel.recv_stderr, err_buf, err_parts, True) or got
            if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
                break
            if time.time() > deadline:
                result["error"] = f"Kommando nach {overall_timeout}s abgebrochen (Timeout)."
                return result
            if not got:
                time.sleep(0.2)
        if out_buf:
            line = bytes(out_buf).decode("utf-8", errors="replace")
            out_parts.append(line)
            on_line(line, False)
        if err_buf:
            line = bytes(err_buf).decode("utf-8", errors="replace")
            err_parts.append(line)
            on_line(line, True)
        result["exit_code"] = channel.recv_exit_status()
        result["stdout"] = "\n".join(out_parts)
        result["stderr"] = "\n".join(err_parts)
        result["success"] = result["exit_code"] == 0
    except (paramiko.SSHException, OSError) as e:
        result["error"] = f"Kommando fehlgeschlagen: {e}"
    finally:
        try:
            client.close()
        except Exception:
            pass

    return result


def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25):
    """Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
    keine Rückfragemöglichkeit). Gibt ein dict zurück:
    {"success": bool, "exit_code": int|None, "stdout": str, "stderr": str,
     "error": str|None} -- "error" ist nur bei Verbindungs-/Auth-Fehlern
    gesetzt, ein regulär durchgelaufenes aber fehlgeschlagenes Kommando
    (exit_code != 0) zählt als success=False mit leerem error."""
    result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}

    if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
        result["error"] = (
            "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
            "einmal interaktiv verbinden und den Host-Key bestätigen."
        )
        return result

    client = paramiko.SSHClient()
    client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
    client.set_missing_host_key_policy(paramiko.RejectPolicy())

    try:
        client.connect(
            host, port=port, username=username, password=password,
            timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
            look_for_keys=False, allow_agent=False,
        )
    except paramiko.BadHostKeyException:
        result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
        return result
    except paramiko.AuthenticationException:
        result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
        return result
    except paramiko.SSHException as e:
        result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
        return result
    except (OSError, socket.error) as e:
        result["error"] = f"Verbindung fehlgeschlagen: {e}"
        return result

    try:
        stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
        if sudo_password is not None:
            try:
                stdin.write(sudo_password + "\n")
                stdin.flush()
            except OSError:
                pass
        result["exit_code"] = stdout.channel.recv_exit_status()
        result["stdout"] = stdout.read().decode("utf-8", errors="replace")
        result["stderr"] = stderr.read().decode("utf-8", errors="replace")
        result["success"] = result["exit_code"] == 0
    except (paramiko.SSHException, OSError) as e:
        result["error"] = f"Kommando fehlgeschlagen: {e}"
    finally:
        try:
            client.close()
        except Exception:
            pass

    return result


def _run_ssh_reboot(host, port, username, password, timeout=15):
    """Startet 'sudo reboot' per SSH. Verbindungs-/Auth-Fehler VOR dem
    eigentlichen Neustart-Kommando gelten als echter Fehler; ein abrupter
    Verbindungsabbruch WÄHREND/NACH dem Kommando ist dagegen erwartet (die
    Maschine fährt herunter, bevor sie überhaupt noch antworten kann) und
    zählt als Erfolg."""
    result = {"success": False, "error": None}

    if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
        result["error"] = (
            "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
            "einmal interaktiv verbinden und den Host-Key bestätigen."
        )
        return result

    client = paramiko.SSHClient()
    client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
    client.set_missing_host_key_policy(paramiko.RejectPolicy())

    try:
        client.connect(
            host, port=port, username=username, password=password,
            timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
            look_for_keys=False, allow_agent=False,
        )
    except paramiko.BadHostKeyException:
        result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
        return result
    except paramiko.AuthenticationException:
        result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
        return result
    except paramiko.SSHException as e:
        result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
        return result
    except (OSError, socket.error) as e:
        result["error"] = f"Verbindung fehlgeschlagen: {e}"
        return result

    try:
        stdin, stdout, stderr = client.exec_command("sudo -S -p '' reboot", timeout=timeout)
        stdin.write(password + "\n")
        stdin.flush()
        stdout.channel.recv_exit_status()
    except (paramiko.SSHException, OSError, EOFError):
        pass
    finally:
        try:
            client.close()
        except Exception:
            pass

    result["success"] = True
    return result



@app.route("/get_log")
@login_required
def get_log():
    """Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren-
    Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES
    Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser
    wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten
    mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar."""
    if not current_user.can_view_live_log:
        return "Keine Berechtigung.", 403
    latest_log = _latest_log_file()
    if not latest_log:
        return "Keine Logfiles gefunden."
    try:
        content = _tail_lines(latest_log)
        total_lines = _count_lines(latest_log)
    except OSError as e:
        content = f"Fehler beim Lesen des Logs: {e}"
        total_lines = None
    response = app.response_class(content, mimetype="text/plain")
    response.headers["X-Log-Name"] = os.path.basename(latest_log)
    if total_lines is not None:
        response.headers["X-Total-Lines"] = str(total_lines)
    return response


@app.route("/logs/live/raw")
@login_required
def get_log_raw():
    """Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein
    eigener Endpoint statt eines Query-Parameters an /get_log, damit der
    normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem
    Prüfintervall) unter keinen Umständen versehentlich die komplette Datei
    lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick)
    nachgeladen."""
    if not current_user.can_view_live_log:
        return "Keine Berechtigung.", 403
    latest_log = _latest_log_file()
    if not latest_log:
        return "Keine Logfiles gefunden."
    try:
        with open(latest_log, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError as e:
        content = f"Fehler beim Lesen des Logs: {e}"
    response = app.response_class(content, mimetype="text/plain")
    response.headers["X-Log-Name"] = os.path.basename(latest_log)
    return response


@app.route("/logs/history")
@login_required
def logs_history():
    """Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als
    eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view,
    siehe User.can_view_log_history) -- bewusst getrennt vom laufenden
    Live-Poll, damit das Durchblättern alter Logstände nicht denselben
    Performance-Pfad wie die Live-Ansicht belastet."""
    if not current_user.can_view_log_history:
        flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger")
        return redirect(url_for("index"))

    files = _live_log_history_files()
    requested = request.args.get("file", "")
    selected_path = _resolve_log_history_file(requested) if requested else None
    selected_name = requested if selected_path else None
    if not selected_path and files:
        selected_name = files[0]["filename"]
        selected_path = _resolve_log_history_file(selected_name)
    selected_file = next((f for f in files if f["filename"] == selected_name), None)

    log_content = None
    total_lines = None
    if selected_path:
        try:
            log_content = _tail_lines(selected_path)
            total_lines = _count_lines(selected_path)
        except OSError as e:
            log_content = f"Fehler beim Lesen des Logs: {e}"

    return render_template(
        "logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
        log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
        audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(),
        audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET,
    )


@app.route("/logs/history/raw")
@login_required
def logs_history_raw():
    if not current_user.can_view_log_history:
        return "Keine Berechtigung.", 403
    path = _resolve_log_history_file(request.args.get("file", ""))
    if not path:
        return "Unbekannte oder ungültige Log-Datei.", 404
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError as e:
        content = f"Fehler beim Lesen des Logs: {e}"
    response = app.response_class(content, mimetype="text/plain")
    response.headers["X-Log-Name"] = os.path.basename(path)
    return response


@app.route("/logs/history/audit-raw")
@login_required
def logs_history_audit_raw():
    """Wie logs_history_raw(), aber für einen einzelnen bereits
    archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine
    Live-Log-Rotation."""
    if not current_user.can_view_log_history:
        return "Keine Berechtigung.", 403
    path = _resolve_audit_archive_file(request.args.get("file", ""))
    if not path:
        return "Unbekannte oder ungültige Archivdatei.", 404
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError as e:
        content = f"Fehler beim Lesen der Datei: {e}"
    response = app.response_class(content, mimetype="text/plain")
    response.headers["X-Log-Name"] = os.path.basename(path)
    return response


@app.route("/logs/history/audit-export", methods=["POST"])
@login_required
def logs_history_audit_export():
    """Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und
    löscht sie danach vom Server -- bewusst eine einzige, manuell
    ausgelöste Aktion (Recht logs_history.edit, siehe
    User.can_manage_log_history) statt einer automatischen Löschung nach
    Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein
    Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning()
    anschlägt)."""
    if not current_user.can_manage_log_history:
        return "Keine Berechtigung.", 403

    files = _audit_archive_files()
    if not files:
        flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info")
        return redirect(url_for("logs_history"))

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for f in files:
            zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"])
    buf.seek(0)

    deleted = []
    for f in files:
        try:
            os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]))
            deleted.append(f["filename"])
        except OSError:
            pass

    log_action(
        "auditlog.export_delete",
        "Auditlog",
        f"{len(deleted)} Datei(en) exportiert und gelöscht: {', '.join(deleted)}",
    )

    today_str = datetime.now().strftime("%Y-%m-%d")
    return send_file(
        buf, mimetype="application/zip", as_attachment=True,
        download_name=f"auditlog-archiv-{today_str}.zip",
    )


@app.route("/logs")
@login_required
def logs():
    if not current_user.can_view_live_log:
        flash("Keine Berechtigung, das Live-Log einzusehen.", "danger")
        return redirect(url_for("index"))
    interval = int(get_setting("interval", 5))
    latest_log = _latest_log_file()

    if not latest_log:
        return render_template(
            "logs.html", log_content="Keine Logfiles gefunden.", interval=interval,
            total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES,
        )

    try:
        log_content = _tail_lines(latest_log)
        total_lines = _count_lines(latest_log)
    except OSError as e:
        log_content = f"Fehler beim Lesen des Logs: {e}"
        total_lines = None

    return render_template(
        "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval,
        total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
    )


@app.route("/get_kea_log")
@login_required
def get_kea_log():
    if not current_user.can_view_kea_log:
        return "Keine Berechtigung.", 403
    try:
        with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except FileNotFoundError:
        content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
                   "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
    except OSError as e:
        content = f"Fehler beim Lesen des Logs: {e}"
    return app.response_class(content, mimetype="text/plain")


@app.route("/logs/kea")
@login_required
def kea_log():
    """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/
    Auditlog — die Konfiguration (siehe _render_kea_config) leitet Keas
    eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den
    distributionsabhängigen Default (meist Syslog), damit sie sich hier
    genauso wie die anderen Logs einsehen und per logrotate rotieren
    lässt."""
    if not current_user.can_view_kea_log:
        flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
        return redirect(url_for("index"))
    if resp := _require_module("dhcp"):
        return resp
    try:
        with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
            log_content = f.read()
    except FileNotFoundError:
        log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
                      "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
    except OSError as e:
        log_content = f"Fehler beim Lesen des Logs: {e}"
    return render_template("logs_kea.html", log_content=log_content)


AUDIT_LOG_PAGE_SIZE = 300


@app.route("/logs/aenderungen")
@login_required
def activity_log():
    """Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
    Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.
    Lädt anfangs nur die neuesten AUDIT_LOG_PAGE_SIZE Zeilen (Performance bei
    einer ggf. noch nicht archivierten, großen Tabelle) -- ältere Zeilen
    werden bei Bedarf über "Weitere 300 laden" bzw. "Alle laden" (siehe
    activity_log_more()) nachgeladen, ohne die Seite neu zu laden."""
    if not current_user.can_view_activity_log:
        flash("Keine Berechtigung, das Auditlog einzusehen.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()
    entries = conn.execute(
        "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?",
        (AUDIT_LOG_PAGE_SIZE,),
    ).fetchall()
    # Gesamtzahlen je Kategorie (nicht nur der geladenen Zeilen) für die
    # "X / Y geladen"-Anzeige in den Statistik-Kacheln -- eine einzige
    # aggregierte Abfrage statt die komplette Tabelle einzulesen. Die
    # LIKE-Muster bilden exakt dieselbe Kategorisierung wie category_of()
    # in _audit_log_macros.html ab (kind = action.split(".")[-1]).
    totals_row = conn.execute(
        "SELECT COUNT(*) AS total, "
        "SUM(CASE WHEN action LIKE '%.create' OR action LIKE '%.upload' OR action LIKE '%.mkdir' THEN 1 ELSE 0 END) AS create_n, "
        "SUM(CASE WHEN action LIKE '%.delete' THEN 1 ELSE 0 END) AS delete_n "
        "FROM audit_log"
    ).fetchone()
    conn.close()

    total_count = totals_row["total"] or 0
    total_create = totals_row["create_n"] or 0
    total_delete = totals_row["delete_n"] or 0
    total_edit = total_count - total_create - total_delete

    oldest_loaded_id = entries[-1]["id"] if entries else None
    has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE and total_count > len(entries)
    return render_template(
        "activity_log.html", entries=entries, total_count=total_count,
        total_create=total_create, total_edit=total_edit, total_delete=total_delete,
        oldest_loaded_id=oldest_loaded_id, has_more=has_more,
    )


@app.route("/logs/aenderungen/more")
@login_required
def activity_log_more():
    """AJAX-Nachladen älterer Auditlog-Zeilen in AUDIT_LOG_PAGE_SIZE-Schritten
    (Cursor-Pagination über die id-Spalte, absteigend) -- liefert reines
    HTML-Zeilenfragment (dieselbe Zeilen-Darstellung wie die Erstladung, über
    das gemeinsame Makro in _audit_log_macros.html) statt JSON, damit das
    Anhängen im Frontend ein einfaches insertAdjacentHTML bleibt."""
    if not current_user.can_view_activity_log:
        return "Keine Berechtigung.", 403
    try:
        before_id = int(request.args.get("before_id", ""))
    except (TypeError, ValueError):
        return "Ungültiger Parameter.", 400

    conn = get_db_connection()
    entries = conn.execute(
        "SELECT id, ts, username, action, target, details FROM audit_log WHERE id < ? ORDER BY id DESC LIMIT ?",
        (before_id, AUDIT_LOG_PAGE_SIZE),
    ).fetchall()
    conn.close()

    oldest_loaded_id = entries[-1]["id"] if entries else before_id
    has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE
    html = render_template("_audit_log_rows.html", entries=entries)
    resp = app.response_class(html, mimetype="text/html")
    resp.headers["X-Row-Count"] = str(len(entries))
    resp.headers["X-Has-More"] = "1" if has_more else "0"
    resp.headers["X-Oldest-Id"] = str(oldest_loaded_id)
    return resp



@app.route("/restart/<mac>", methods=["POST"])
@login_required
def restart_device(mac):
    if not current_user.has_permission("devices.restart"):
        return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403

    conn = get_db_connection()
    device = conn.execute(
        "SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)
    ).fetchone()
    conn.close()

    if not device:
        return jsonify({"success": False, "message": "Gerät wurde nicht gefunden."}), 404

    if device["is_active"] == 0:
        return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400

    if not device["switch_hostname"] or not device["port"]:
        return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400

    try:
        subprocess.Popen(
            ["/bin/bash", POE_SCRIPT, "restart", mac],
            env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")},
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        return jsonify({"success": True, "device": device["name"]})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)}), 500



@app.route("/users", methods=["GET", "POST"])
@login_required
def users():
    if request.method == "GET" and not current_user.can_manage_users:
        flash("Keine Berechtigung, die Benutzerverwaltung anzusehen.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()

    if request.method == "POST":
        if "add_user" in request.form:
            if not current_user.has_permission("users.create"):
                flash("Keine Berechtigung, Benutzer anzulegen.", "danger")
                return redirect(url_for("users"))

            username = request.form["username"].strip()
            password = request.form["password"].strip()
            first_name = request.form.get("first_name", "").strip() or None
            last_name = request.form.get("last_name", "").strip() or None
            email = request.form.get("email", "").strip() or None
            group_choice = request.form.get("group_id") or ""
            if group_choice == "admin" and not current_user.is_admin:
                flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger")
                return redirect(url_for("users"))
            is_admin = 1 if group_choice == "admin" else 0

            if username and password:
                pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
                try:
                    cur = conn.execute(
                        "INSERT INTO users (username, password, is_admin, first_name, last_name, email) "
                        "VALUES (?, ?, ?, ?, ?, ?)",
                        (username, pw_hash, is_admin, first_name, last_name, email),
                    )
                    if not is_admin and group_choice:
                        conn.execute(
                            "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
                            (cur.lastrowid, group_choice),
                        )
                    conn.commit()
                    log_action("user.create", username)
                    flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
                except sqlite3.IntegrityError:
                    flash("Benutzername existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")
            else:
                flash("Username und Passwort dürfen nicht leer sein!", "danger")

        elif "edit_user" in request.form:
            if not current_user.has_permission("users.edit"):
                flash("Keine Berechtigung, Benutzer zu bearbeiten.", "danger")
                return redirect(url_for("users"))

            user_id = request.form["user_id"]
            target_user = conn.execute(
                "SELECT is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
            ).fetchone()
            if target_user and target_user["is_admin"] and not current_user.is_admin:
                flash("Nur Admins dürfen Admin-Konten bearbeiten.", "danger")
                return redirect(url_for("users"))

            if target_user and target_user["auth_source"] == "ldap":
                flash("AD/LDAP-Konten werden über Active Directory verwaltet und können hier nicht bearbeitet werden — nur sperren oder eine Gruppe zuweisen.", "danger")
                return redirect(url_for("users"))

            username = request.form.get("username", "").strip()
            first_name = request.form.get("first_name", "").strip() or None
            last_name = request.form.get("last_name", "").strip() or None
            email = request.form.get("email", "").strip() or None
            new_password = request.form.get("new_password", "").strip()
            if username:
                if new_password:
                    pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
                    conn.execute(
                        "UPDATE users SET username=?, first_name=?, last_name=?, email=?, password=? "
                        "WHERE id=? AND deleted_at IS NULL",
                        (username, first_name, last_name, email, pw_hash, user_id),
                    )
                else:
                    conn.execute(
                        "UPDATE users SET username=?, first_name=?, last_name=?, email=? "
                        "WHERE id=? AND deleted_at IS NULL",
                        (username, first_name, last_name, email, user_id),
                    )
                conn.commit()
                log_action("user.edit", username)
                flash("Benutzer aktualisiert!", "success")
            else:
                flash("Username darf nicht leer sein!", "danger")

        elif "assign_group" in request.form:
            if not current_user.has_permission("users.edit"):
                flash("Keine Berechtigung, Gruppen zuzuweisen.", "danger")
                return redirect(url_for("users"))

            user_id = request.form["user_id"]
            choice = request.form.get("group_id") or ""

            target = conn.execute(
                "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
            ).fetchone()
            if (choice == "admin" or (target and target["is_admin"])) and not current_user.is_admin:
                flash("Nur Admins dürfen Admin-Zuweisungen ändern.", "danger")
                return redirect(url_for("users"))

            if target and target["is_admin"] and choice != "admin":
                admin_count = conn.execute(
                    "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND deleted_at IS NULL"
                ).fetchone()["n"]
                if admin_count <= 1:
                    flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
                    conn.close()
                    return redirect(url_for("users"))

            conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
            if choice == "admin":
                conn.execute("UPDATE users SET is_admin=1 WHERE id=? AND deleted_at IS NULL", (user_id,))
            else:
                conn.execute("UPDATE users SET is_admin=0 WHERE id=? AND deleted_at IS NULL", (user_id,))
                if choice:
                    conn.execute(
                        "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
                        (user_id, choice),
                    )
            conn.commit()
            log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe")
            flash("Gruppe zugewiesen!", "success")

        elif "delete_user" in request.form:
            if not current_user.has_permission("users.edit"):
                flash("Keine Berechtigung, Benutzer zu löschen.", "danger")
                return redirect(url_for("users"))

            user_id = request.form["delete_user"]
            target = conn.execute(
                "SELECT username, is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
            ).fetchone()
            if target and target["is_admin"] and not current_user.is_admin:
                flash("Nur Admins dürfen Admin-Konten löschen.", "danger")
                return redirect(url_for("users"))

            if target and target["auth_source"] == "ldap":
                conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
                conn.execute("DELETE FROM users WHERE id=?", (user_id,))
                conn.commit()
                log_action("user.delete", target["username"], "AD/LDAP-Konto, kein Papierkorb")
                flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success")
            else:
                conn.execute(
                    "UPDATE users SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
                    (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_id),
                )
                conn.commit()
                log_action("user.delete", target["username"] if target else user_id, "in den Papierkorb verschoben")
                flash("Benutzer in den Papierkorb verschoben.", "success")

        elif "toggle_lock" in request.form:
            if not current_user.has_permission("users.edit"):
                flash("Keine Berechtigung, Benutzer zu sperren.", "danger")
                return redirect(url_for("users"))

            user_id = request.form["toggle_lock"]
            target = conn.execute(
                "SELECT username, is_admin, is_locked FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
            ).fetchone()
            if not target:
                flash("Benutzer nicht gefunden.", "danger")
                return redirect(url_for("users"))
            if target["is_admin"] and not current_user.is_admin:
                flash("Nur Admins dürfen Admin-Konten sperren.", "danger")
                return redirect(url_for("users"))
            if str(user_id) == str(current_user.id):
                flash("Das eigene Konto kann nicht gesperrt werden.", "danger")
                return redirect(url_for("users"))
            new_state = 0 if target["is_locked"] else 1
            if new_state and target["is_admin"]:
                unlocked_admins = conn.execute(
                    "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
                ).fetchone()["n"]
                if unlocked_admins <= 1:
                    flash("Es muss mindestens ein entsperrtes Admin-Konto bestehen bleiben.", "danger")
                    conn.close()
                    return redirect(url_for("users"))
            conn.execute("UPDATE users SET is_locked=? WHERE id=? AND deleted_at IS NULL", (new_state, user_id))
            conn.commit()
            log_action("user.lock" if new_state else "user.unlock", target["username"])
            flash(f"Benutzer „{target['username']}“ {'gesperrt' if new_state else 'entsperrt'}.", "success")

        elif "ldap_add_user" in request.form:
            if not current_user.has_permission("users.create"):
                flash("Keine Berechtigung, Benutzer anzulegen.", "danger")
                return redirect(url_for("users"))

            ldap_username = request.form.get("ldap_username", "").strip()
            group_choice = request.form.get("group_id") or ""
            if group_choice == "admin" and not current_user.is_admin:
                flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger")
                return redirect(url_for("users"))

            found, info = _ldap_lookup(ldap_username) if ldap_username else (False, None)
            if not found:
                flash("Benutzer wurde im Verzeichnis nicht (mehr) gefunden.", "danger")
            else:
                canonical_username = info["username"]
                placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
                is_admin_new = 1 if group_choice == "admin" else 0
                try:
                    cur = conn.execute(
                        "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) "
                        "VALUES (?, ?, ?, ?, ?, ?, 'ldap')",
                        (canonical_username, placeholder_hash, is_admin_new,
                         info.get("first_name"), info.get("last_name"), info.get("email")),
                    )
                    if not is_admin_new and group_choice:
                        conn.execute(
                            "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
                            (cur.lastrowid, group_choice),
                        )
                    conn.commit()
                    log_action("user.ldap_add", canonical_username, "Aus Active Directory hinzugefügt")
                    flash(f"„{canonical_username}“ aus Active Directory hinzugefügt.", "success")
                except sqlite3.IntegrityError:
                    flash("Dieser Benutzer existiert bereits (ggf. im Papierkorb unter Wartung).", "danger")

    users_list = conn.execute("""
        SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
               users.auth_source, users.email, users.is_locked,
               GROUP_CONCAT(groups.name, ', ') AS group_names,
               (SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id
        FROM users
        LEFT JOIN user_groups ON user_groups.user_id = users.id
        LEFT JOIN groups ON groups.id = user_groups.group_id AND groups.deleted_at IS NULL
        WHERE users.deleted_at IS NULL
        GROUP BY users.id
        ORDER BY users.username ASC
    """).fetchall()
    all_groups = conn.execute(
        "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC"
    ).fetchall()
    conn.close()
    return render_template(
        "users.html", users=users_list, all_groups=all_groups,
        ldap_enabled=get_setting("ldap_enabled", "0") == "1",
    )


@app.route("/users/ldap_search")
@login_required
def users_ldap_search():
    """JSON-Endpunkt für 'Aus Active Directory hinzufügen' -- Substring-Suche
    im Verzeichnis, gefiltert um bereits lokal bekannte Benutzernamen
    (case-insensitiv, unabhängig von auth_source)."""
    if not current_user.has_permission("users.create"):
        return jsonify({"error": "Keine Berechtigung."}), 403
    query = request.args.get("q", "").strip()
    if len(query) < 2:
        return jsonify([])
    results = _ldap_search_users(query)
    conn = get_db_connection()
    existing = {
        row["username"].lower()
        for row in conn.execute("SELECT username FROM users WHERE deleted_at IS NULL").fetchall()
    }
    conn.close()
    return jsonify([r for r in results if r["username"] and r["username"].lower() not in existing])



@app.route("/groups", methods=["GET", "POST"])
@login_required
def groups():
    if request.method == "GET" and not current_user.can_manage_groups:
        flash("Keine Berechtigung, die Gruppenverwaltung anzusehen.", "danger")
        return redirect(url_for("index"))

    conn = get_db_connection()

    if request.method == "POST":
        if "add_group" in request.form:
            if not current_user.has_permission("groups.create"):
                flash("Keine Berechtigung, Gruppen anzulegen.", "danger")
                return redirect(url_for("groups"))
            name = request.form.get("name", "").strip()
            if name:
                try:
                    cur = conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
                    selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
                    if selected_permissions:
                        conn.executemany(
                            "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
                            [(cur.lastrowid, p) for p in selected_permissions],
                        )
                    conn.commit()
                    log_action("group.create", name, f"{len(selected_permissions)} Rechte" if selected_permissions else None)
                    flash(f"Gruppe '{name}' angelegt.", "success")
                except sqlite3.IntegrityError:
                    flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")
            else:
                flash("Gruppenname darf nicht leer sein!", "danger")

        elif "save_group" in request.form:
            if not current_user.has_permission("groups.edit"):
                flash("Keine Berechtigung, Gruppen zu bearbeiten.", "danger")
                return redirect(url_for("groups"))

            group_id = request.form.get("group_id")
            name = request.form.get("name", "").strip()

            if not name:
                flash("Gruppenname darf nicht leer sein!", "danger")
                conn.close()
                return redirect(url_for("groups"))

            target_group = conn.execute(
                "SELECT name, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)
            ).fetchone()
            is_system = bool(target_group["is_system"]) if target_group else False
            system_unlocked = is_system and current_user.is_admin and request.form.get("unlock_system_group") == "1"
            if is_system and "permissions_submitted" in request.form and not system_unlocked:
                flash("Die Rechte der Systemgruppe können nicht geändert werden — über „Freischalten“ kann ein Admin dies gezielt erlauben.", "danger")
                conn.close()
                return redirect(url_for("groups"))
            if is_system and target_group and name != target_group["name"]:
                flash("Systemgruppen können nicht umbenannt werden.", "danger")
                conn.close()
                return redirect(url_for("groups"))

            try:
                conn.execute("UPDATE groups SET name=? WHERE id=? AND deleted_at IS NULL", (name, group_id))

                if "permissions_submitted" in request.form:
                    selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
                    conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
                    conn.executemany(
                        "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
                        [(group_id, p) for p in selected_permissions],
                    )

                if "members_submitted" in request.form:
                    selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()}
                    conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
                    conn.executemany(
                        "INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
                        [(uid, group_id) for uid in selected_members],
                    )

                conn.commit()
                detail_bits = []
                if "permissions_submitted" in request.form:
                    detail_bits.append("Rechte geändert")
                if "members_submitted" in request.form:
                    detail_bits.append("Mitglieder geändert")
                log_action("group.edit", name, ", ".join(detail_bits) or None)
                flash(f"Gruppe '{name}' aktualisiert.", "success")
            except sqlite3.IntegrityError:
                flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")

        elif "assign_admins" in request.form:
            if not current_user.is_admin:
                flash("Nur Admins dürfen Admin-Mitgliedschaften ändern.", "danger")
                return redirect(url_for("groups"))
            selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()}
            if not selected_admins:
                flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
            else:
                conn.execute("UPDATE users SET is_admin=0 WHERE deleted_at IS NULL")
                conn.executemany(
                    "UPDATE users SET is_admin=1 WHERE id=?",
                    [(uid,) for uid in selected_admins],
                )
                conn.executemany(
                    "DELETE FROM user_groups WHERE user_id=?",
                    [(uid,) for uid in selected_admins],
                )
                conn.commit()
                log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder")
                flash("Admin-Zuweisung aktualisiert.", "success")

        elif "delete_group" in request.form:
            if not current_user.has_permission("groups.edit"):
                flash("Keine Berechtigung, Gruppen zu löschen.", "danger")
                return redirect(url_for("groups"))
            group_id = request.form.get("delete_group")
            target = conn.execute(
                "SELECT name, is_default, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)
            ).fetchone()
            if target and (target["is_default"] or target["is_system"]):
                flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger")
            else:
                conn.execute(
                    "UPDATE groups SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
                    (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), group_id),
                )
                conn.commit()
                log_action("group.delete", target["name"] if target else group_id, "in den Papierkorb verschoben")
                flash("Gruppe in den Papierkorb verschoben.", "success")

        conn.close()
        return redirect(url_for("groups"))

    group_rows = conn.execute(
        "SELECT id, name, is_default, is_system FROM groups WHERE deleted_at IS NULL "
        "ORDER BY is_default DESC, name ASC"
    ).fetchall()
    all_users = conn.execute(
        "SELECT id, username FROM users WHERE is_admin=0 AND deleted_at IS NULL ORDER BY username ASC"
    ).fetchall()

    groups_data = []
    for g in group_rows:
        perm_rows = conn.execute(
            "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],)
        ).fetchall()
        member_rows = conn.execute("""
            SELECT users.id, users.username FROM users
            JOIN user_groups ON user_groups.user_id = users.id
            WHERE user_groups.group_id = ? AND users.deleted_at IS NULL
            ORDER BY users.username ASC
        """, (g["id"],)).fetchall()
        groups_data.append({
            "id": g["id"],
            "name": g["name"],
            "is_default": bool(g["is_default"]),
            "is_system": bool(g["is_system"]),
            "permissions": {p["permission"] for p in perm_rows},
            "members": {m["id"] for m in member_rows},
            "member_names": [m["username"] for m in member_rows],
        })

    admin_rows = conn.execute(
        "SELECT username FROM users WHERE is_admin=1 AND deleted_at IS NULL ORDER BY username ASC"
    ).fetchall()
    admin_virtual_group = {
        "name": "Admin",
        "permissions": set(ALL_PERMISSION_KEYS),
        "member_names": [u["username"] for u in admin_rows],
    }
    all_users_all = conn.execute(
        "SELECT id, username, is_admin FROM users WHERE deleted_at IS NULL ORDER BY username ASC"
    ).fetchall()

    conn.close()
    return render_template(
        "groups.html",
        groups=groups_data,
        admin_virtual_group=admin_virtual_group,
        all_users=all_users,
        all_users_all=all_users_all,
        permission_catalog=PERMISSIONS,
        permission_row_types=PERMISSION_ROW_TYPES,
        group_row_types=GROUP_ROW_TYPES,
        permission_labels=PERMISSION_LABELS,
    )


if __name__ == "__main__":
    debug = os.environ.get("FLASK_DEBUG", "0") == "1"
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True)
