"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).

"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.

Sicherheitsaenderungen gegenueber dem Vorgaenger:

* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
  Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
  Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
  ``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
  protokolliert.
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
  LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
  einen Man-in-the-Middle.
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
  ``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
  ohne Kontext.
"""

from __future__ import annotations

import logging
import re
import socket
import sqlite3
import ssl
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal

from .. import audit
from ..db import Database
from ..extension import core
from ..settings_store import SettingSpec

logger = logging.getLogger(__name__)

SERVICE_PURPOSE = "ldap_bind"
AAD_LDAP_BIND = "service_accounts.ldap_bind"

#: userAccountControl-Bit fuer "Konto deaktiviert".
ACCOUNTDISABLE = 0x0002
#: OID fuer rekursive Gruppenauflösung in AD.
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"

Status = Literal["ok", "invalid", "unavailable", "disabled"]

SETTINGS: tuple[SettingSpec, ...] = (
    SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
    SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
    SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
    SettingSpec(
        "ldap_security",
        "ldaps",
        "str",
        "Transport",
        section="ldap",
        choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
    ),
    SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
    SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
    SettingSpec(
        "ldap_allow_insecure",
        False,
        "bool",
        "Unverschluesselte Verbindung ausdruecklich erlauben",
        hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
        section="ldap",
    ),
    SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
    SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
    SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
    SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
    SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
)

_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")


@dataclass(frozen=True, slots=True)
class DirectoryProfile:
    username: str
    dn: str
    first_name: str = ""
    last_name: str = ""
    email: str = ""
    group_dns: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class DirectoryResult:
    status: Status
    profile: DirectoryProfile | None = None
    message: str = ""
    warnings: tuple[str, ...] = field(default=())


def _now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def is_enabled(conn: sqlite3.Connection) -> bool:
    settings = core().settings
    if not settings.get(conn, "ldap_enabled"):
        return False
    return bool(settings.get(conn, "ldap_host"))


def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
    """Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
    row = Database.one(
        conn,
        "SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
        (SERVICE_PURPOSE,),
    )
    if row is None:
        return "", ""
    encrypted = str(row.get("password_encrypted") or "")
    if not encrypted:
        return str(row.get("username") or ""), ""
    try:
        secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
    except Exception:  # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
        logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
        return str(row.get("username") or ""), ""
    return str(row.get("username") or ""), secret


def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
    """Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
    existing = Database.one(
        conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
    )
    encrypted = str(existing.get("password_encrypted") or "") if existing else ""
    if password:
        encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
    conn.execute(
        "INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
        "VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
        "password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
        (SERVICE_PURPOSE, username.strip(), encrypted, _now()),
    )


# --------------------------------------------------------------------------- #
# Verbindung
# --------------------------------------------------------------------------- #


def _tls_object(config: dict[str, Any]):
    from ldap3 import Tls

    verify = bool(config["ldap_verify_cert"])
    ca_file = str(config["ldap_ca_file"] or "") or None
    return Tls(
        validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
        version=ssl.PROTOCOL_TLS_CLIENT,
        ca_certs_file=ca_file,
    )


def _server(config: dict[str, Any]):
    from ldap3 import Server

    security = str(config["ldap_security"])
    return Server(
        str(config["ldap_host"]),
        port=int(config["ldap_port"]),
        use_ssl=security == "ldaps",
        tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
        get_info="ALL",
        connect_timeout=int(config["ldap_timeout"]),
    )


def _connect(config: dict[str, Any], user: str, password: str):
    """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
    from ldap3 import Connection

    security = str(config["ldap_security"])
    if security == "none" and not config["ldap_allow_insecure"]:
        raise PermissionError(
            "Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
            "Nutzen Sie LDAPS oder StartTLS."
        )
    connection = Connection(
        _server(config),
        user=user or None,
        password=password or None,
        auto_bind=False,
        raise_exceptions=False,
        receive_timeout=int(config["ldap_timeout"]),
    )
    if security == "starttls":
        if not connection.open():
            raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
        if not connection.start_tls():
            raise ConnectionError(
                f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
            )
    if not connection.bind():
        return connection, False
    return connection, True


def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
    settings = core().settings
    return settings.get_many(conn, [spec.key for spec in SETTINGS])


def _base_dn(connection, config: dict[str, Any]) -> str:
    configured = str(config["ldap_base_dn"] or "").strip()
    if configured:
        return configured
    info = getattr(connection.server, "info", None)
    naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
    if isinstance(naming, list) and naming:
        return str(naming[0])
    if isinstance(naming, str):
        return naming
    contexts = getattr(info, "naming_contexts", None) if info else None
    if contexts:
        return str(contexts[0])
    return ""


def _escape(value: str) -> str:
    """RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
    replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
    return "".join(replacements.get(char, char) for char in value)


def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
    """Suchen-und-Binden gegen das Verzeichnis."""
    try:
        import ldap3  # noqa: F401
    except ImportError:
        return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")

    if not _USERNAME_RE.match(username or ""):
        return DirectoryResult("invalid")

    config = _read_config(conn)
    if not config.get("ldap_enabled") or not config.get("ldap_host"):
        return DirectoryResult("disabled")

    bind_user, bind_password = bind_credentials(conn)
    warnings: list[str] = []
    if str(config["ldap_security"]) == "none":
        warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
    if not config["ldap_verify_cert"]:
        warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")

    try:
        service_conn, bound = _connect(config, bind_user, bind_password)
    except (PermissionError, ConnectionError) as exc:
        logger.error("Verzeichnisdienst: %s", exc)
        return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
    except (socket.error, ssl.SSLError, OSError) as exc:
        logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
        return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))

    if not bound:
        detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
        logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
        return DirectoryResult(
            "unavailable",
            message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
            warnings=tuple(warnings),
        )

    try:
        base = _base_dn(service_conn, config)
        if not base:
            return DirectoryResult(
                "unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
            )

        attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
        email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
        wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]

        service_conn.search(
            search_base=base,
            search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
            attributes=list(dict.fromkeys(wanted)),
            size_limit=2,
        )
        entries = list(service_conn.entries)
        if len(entries) != 1:
            return DirectoryResult("invalid", warnings=tuple(warnings))

        entry = entries[0]
        dn = str(entry.entry_dn)
        control = _attr_int(entry, "userAccountControl")
        if control is not None and control & ACCOUNTDISABLE:
            return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))

        # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
        # Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
        user_conn, user_bound = _connect(config, dn, password)
        try:
            if not user_bound:
                return DirectoryResult("invalid", warnings=tuple(warnings))
        finally:
            try:
                user_conn.unbind()
            except Exception:  # noqa: BLE001
                pass

        group_dns: tuple[str, ...] = ()
        if config["ldap_group_sync"]:
            group_dns = _resolve_groups(service_conn, base, dn)

        profile = DirectoryProfile(
            username=_attr(entry, attribute) or username,
            dn=dn,
            first_name=_attr(entry, "givenName"),
            last_name=_attr(entry, "sn"),
            email=_attr(entry, email_attribute),
            group_dns=group_dns,
        )
        return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
    finally:
        try:
            service_conn.unbind()
        except Exception:  # noqa: BLE001
            pass


def _attr(entry: Any, name: str) -> str:
    try:
        value = entry[name].value
    except Exception:  # noqa: BLE001
        return ""
    if isinstance(value, (list, tuple)):
        return str(value[0]) if value else ""
    return "" if value is None else str(value)


def _attr_int(entry: Any, name: str) -> int | None:
    raw = _attr(entry, name)
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
    """Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
    try:
        connection.search(
            search_base=base,
            search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
            attributes=["distinguishedName", "cn"],
            size_limit=500,
        )
        return tuple(str(entry.entry_dn) for entry in connection.entries)
    except Exception as exc:  # noqa: BLE001
        logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
        return ()


# --------------------------------------------------------------------------- #
# Schattenkonto
# --------------------------------------------------------------------------- #


def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
    """Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
    from ..rbac import service as rbac

    row = rbac.find_user_by_username(conn, profile.username)
    with core().database.transaction(conn):
        if row is None:
            cursor = conn.execute(
                "INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
                "last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
                (
                    profile.username,
                    "",  # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
                    profile.first_name,
                    profile.last_name,
                    profile.email,
                    _now(),
                    _now(),
                ),
            )
            user_id = int(cursor.lastrowid or 0)
            audit.log(
                conn,
                actor=profile.username,
                actor_id=user_id,
                action="auth.directory_account_created",
                target=profile.username,
                detail=profile.dn,
                severity="notice",
            )
        else:
            user_id = int(row["id"])
            conn.execute(
                "UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
                "updated_at=? WHERE id=?",
                (profile.first_name, profile.last_name, profile.email, _now(), user_id),
            )

        _apply_group_mappings(conn, user_id, profile.group_dns)

    result = rbac.get_user(conn, user_id)
    assert result is not None
    return result


def _apply_group_mappings(
    conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
) -> None:
    """Setzt die Gruppen additiv aus den Verzeichnisgruppen.

    Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
    verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
    zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
    haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
    """
    if not group_dns:
        return
    mappings = Database.all(
        conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
    )
    if not mappings:
        return
    mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
    lowered = {dn.lower() for dn in group_dns}
    wanted = {
        int(row["app_group_id"])
        for row in mappings
        if str(row["ad_group_dn"]).lower() in lowered
    }

    current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
    # Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
    keep = {gid for gid in current if gid not in mapped_group_ids}
    final = keep | wanted

    if final == current:
        return
    conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
    conn.executemany(
        "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
        [(user_id, gid) for gid in sorted(final)],
    )


def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
    return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))


def search_directory_users(
    conn: sqlite3.Connection, query: str, *, limit: int = 20
) -> list[dict[str, str]]:
    """Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
    if len(query.strip()) < 2:
        return []
    try:
        import ldap3  # noqa: F401
    except ImportError:
        return []

    config = _read_config(conn)
    if not config.get("ldap_enabled") or not config.get("ldap_host"):
        return []
    bind_user, bind_password = bind_credentials(conn)
    try:
        connection, bound = _connect(config, bind_user, bind_password)
    except Exception:  # noqa: BLE001
        return []
    if not bound:
        return []
    try:
        base = _base_dn(connection, config)
        attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
        email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
        escaped = _escape(query.strip())
        connection.search(
            search_base=base,
            search_filter=(
                f"(&(objectClass=user)(|({attribute}={escaped}*)"
                f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
            ),
            attributes=[attribute, "givenName", "sn", email_attribute],
            size_limit=limit,
        )
        return [
            {
                "username": _attr(entry, attribute),
                "first_name": _attr(entry, "givenName"),
                "last_name": _attr(entry, "sn"),
                "email": _attr(entry, email_attribute),
                "dn": str(entry.entry_dn),
            }
            for entry in connection.entries
            if _attr(entry, attribute)
        ]
    except Exception:  # noqa: BLE001
        return []
    finally:
        try:
            connection.unbind()
        except Exception:  # noqa: BLE001
            pass


def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
    """Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
    if len(query.strip()) < 2:
        return []
    try:
        import ldap3  # noqa: F401
    except ImportError:
        return []
    config = _read_config(conn)
    if not config.get("ldap_enabled") or not config.get("ldap_host"):
        return []
    bind_user, bind_password = bind_credentials(conn)
    try:
        connection, bound = _connect(config, bind_user, bind_password)
    except Exception:  # noqa: BLE001
        return []
    if not bound:
        return []
    try:
        base = _base_dn(connection, config)
        escaped = _escape(query.strip())
        connection.search(
            search_base=base,
            search_filter=f"(&(objectClass=group)(cn={escaped}*))",
            attributes=["cn", "distinguishedName"],
            size_limit=limit,
        )
        return [
            {"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
        ]
    except Exception:  # noqa: BLE001
        return []
    finally:
        try:
            connection.unbind()
        except Exception:  # noqa: BLE001
            pass


def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
    """Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
    try:
        import ldap3  # noqa: F401
    except ImportError:
        return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
    config = _read_config(conn)
    if not config.get("ldap_host"):
        return {"ok": False, "message": "Es ist kein Server konfiguriert."}
    bind_user, bind_password = bind_credentials(conn)
    try:
        connection, bound = _connect(config, bind_user, bind_password)
    except Exception as exc:  # noqa: BLE001
        return {"ok": False, "message": str(exc)}
    if not bound:
        detail = connection.result.get("description", "unbekannt") if connection.result else ""
        return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
    try:
        base = _base_dn(connection, config)
        return {
            "ok": True,
            "message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
            "base_dn": base,
        }
    finally:
        try:
            connection.unbind()
        except Exception:  # noqa: BLE001
            pass
