"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.

Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):

1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
   Hash berechnet wird.
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
   Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
   Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.

Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
"""

from __future__ import annotations

import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal

from flask import current_app, g, request, session

from .. import audit
from ..db import Database
from ..extension import core, db
from ..rbac import service as rbac
from ..security import passwords, ratelimit
from ..security import sessions as session_store
from .models import ANONYMOUS, Group, User, build_user

logger = logging.getLogger(__name__)

SESSION_USER_KEY = "user_id"
SESSION_PENDING_KEY = "_pending_user_id"
SESSION_REAUTH_KEY = "_reauth_at"

BUCKET_LOGIN_USER = "login:user"
BUCKET_LOGIN_IP = "login:ip"
BUCKET_TOTP = "totp:user"

GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."

Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]


@dataclass(frozen=True, slots=True)
class AuthOutcome:
    status: Status
    message: str = ""
    user_row: dict[str, Any] | None = None
    retry_after: int = 0
    #: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
    #: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
    #: Mal koennen es andere sein.
    group_dns: tuple[str, ...] = ()

    @property
    def ok(self) -> bool:
        return self.status == "ok"


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _iso(value: datetime) -> str:
    return value.isoformat(timespec="seconds")


def client_ip() -> str:
    """Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
    hops = core().config.security.trusted_proxy_hops
    if hops > 0:
        forwarded = request.headers.get("X-Forwarded-For", "")
        if forwarded:
            parts = [part.strip() for part in forwarded.split(",") if part.strip()]
            if parts:
                # Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
                index = max(0, len(parts) - hops)
                return parts[index]
    return request.remote_addr or ""


# --------------------------------------------------------------------------- #
# Benutzer laden
# --------------------------------------------------------------------------- #


def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
    row = Database.one(
        conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
    )
    if row is None or row.get("is_locked"):
        return None
    groups = [
        Group(
            id=int(entry["id"]),
            name=str(entry["name"]),
            is_system=bool(entry["is_system"]),
            is_default=bool(entry["is_default"]),
        )
        for entry in rbac.user_groups(conn, user_id)
    ]
    return build_user(
        row,
        groups=groups,
        granted=rbac.granted_permissions(conn, user_id),
        tree=core().permissions,
    )


def load_request_user() -> None:
    """``before_request``-Hook: setzt ``g.user``."""
    g.user = ANONYMOUS
    user_id = session.get(SESSION_USER_KEY)
    if not user_id:
        return
    user = load_user(db(), int(user_id))
    if user is None:
        # Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
        session.clear()
        return
    g.user = user


# --------------------------------------------------------------------------- #
# Anmeldung
# --------------------------------------------------------------------------- #


def authenticate(
    conn: sqlite3.Connection, username: str, password: str, *, ip: str
) -> AuthOutcome:
    security = core().config.security
    name = (username or "").strip()
    if not name or not password:
        return AuthOutcome("invalid", GENERIC_FAILURE)

    ip_verdict = ratelimit.check(
        conn,
        BUCKET_LOGIN_IP,
        ip or "unbekannt",
        limit=security.login_max_attempts_per_ip,
        window_minutes=security.login_lockout_minutes,
    )
    if ip_verdict.blocked:
        return AuthOutcome(
            "rate_limited",
            "Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
            retry_after=ip_verdict.retry_after_seconds,
        )

    user_verdict = ratelimit.check(
        conn,
        BUCKET_LOGIN_USER,
        name.lower(),
        limit=security.login_max_attempts,
        window_minutes=security.login_lockout_minutes,
    )
    if user_verdict.blocked:
        return AuthOutcome(
            "rate_limited",
            "Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
            retry_after=user_verdict.retry_after_seconds,
        )

    row = rbac.find_user_by_username(conn, name)
    outcome = _verify_credentials(conn, row, name, password)

    if not outcome.ok:
        with core().database.transaction(conn):
            ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
            ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
            audit.log(
                conn,
                actor=name,
                action="auth.login_failed",
                target=name,
                detail=outcome.status,
                ip=ip,
                severity="warning",
                request_id=getattr(g, "request_id", ""),
            )
        return outcome

    user_row = outcome.user_row or {}
    if bool(user_row.get("is_locked")):
        with core().database.transaction(conn):
            audit.log(
                conn,
                actor=str(user_row.get("username") or name),
                action="auth.login_locked",
                target=str(user_row.get("username") or name),
                ip=ip,
                severity="warning",
            )
        return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")

    with core().database.transaction(conn):
        ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())

    if str(user_row.get("totp_secret") or ""):
        return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)

    if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
        # Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
        # einrichten (die Kontoseite fuehrt ihn dorthin).
        logger.warning(
            "Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
        )

    # ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
    # laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
    # erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
    # haengt.
    return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)


def _verify_credentials(
    conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
) -> AuthOutcome:
    from . import directory  # lokaler Import: ldap3 ist optional

    directory_enabled = directory.is_enabled(conn)

    if row is not None and str(row.get("auth_source")) == "local":
        if not passwords.verify_password(str(row.get("password_hash") or ""), password):
            return AuthOutcome("invalid", GENERIC_FAILURE)
        if passwords.needs_rehash(str(row.get("password_hash") or "")):
            with core().database.transaction(conn):
                conn.execute(
                    "UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
                    (passwords.hash_password(password), _iso(_now()), row["id"]),
                )
        return AuthOutcome("ok", user_row=row)

    if not directory_enabled:
        passwords.dummy_verify()
        return AuthOutcome("invalid", GENERIC_FAILURE)

    result = directory.authenticate(conn, name, password)
    if result.status == "unavailable":
        return AuthOutcome(
            "directory_unavailable",
            "Der Verzeichnisdienst ist derzeit nicht erreichbar. "
            "Bitte versuchen Sie es spaeter erneut.",
        )
    if result.status != "ok" or result.profile is None:
        return AuthOutcome("invalid", GENERIC_FAILURE)

    user_row = directory.sync_account(conn, result.profile)
    return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)


def complete_login(
    conn: sqlite3.Connection,
    user_row: dict[str, Any],
    *,
    ip: str,
    secret: str = "",
    group_dns: tuple[str, ...] = (),
) -> User:
    """Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).

    ``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
    und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
    angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
    genau von einem: der Einbindung von Dateifreigaben, die sich mit der
    Anmeldung des Benutzers am Fileserver anmeldet.
    """
    from ..security import csrf

    with core().database.transaction(conn):
        session_store.rotate(conn, session, reason="login")  # type: ignore[arg-type]
        conn.execute(
            "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
        )
        audit.log(
            conn,
            actor=str(user_row["username"]),
            actor_id=int(user_row["id"]),
            action="auth.login",
            target=str(user_row["username"]),
            detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
            ip=ip,
            request_id=getattr(g, "request_id", ""),
        )

    session.clear()
    session[SESSION_USER_KEY] = int(user_row["id"])
    session[SESSION_REAUTH_KEY] = _iso(_now())
    session["_ip"] = ip
    session["_ua"] = (request.user_agent.string or "")[:200]
    csrf.rotate_token()

    user = load_user(conn, int(user_row["id"]))
    if user is None:  # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
        raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
    g.user = user
    _run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
    return user


def _run_login_hooks(
    conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
) -> None:
    """Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.

    Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
    Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
    wird jeder Fehler protokolliert und geschluckt.
    """
    for hook in core().login_hooks:
        try:
            hook(conn, user, secret, group_dns)
        except Exception:  # pragma: no cover - haengt am Fileserver
            current_app.logger.exception(
                "Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
            )


def logout(conn: sqlite3.Connection) -> None:
    user = getattr(g, "user", ANONYMOUS)
    if user.is_authenticated:
        # Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
        # raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
        for hook in core().logout_hooks:
            try:
                hook(conn, user)
            except Exception:  # pragma: no cover - haengt am Fileserver
                current_app.logger.exception(
                    "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
                )
        with core().database.transaction(conn):
            audit.log(
                conn,
                actor=user.username,
                actor_id=user.id,
                action="auth.logout",
                target=user.username,
                ip=client_ip(),
            )
    session.destroy() if hasattr(session, "destroy") else session.clear()  # type: ignore[attr-defined]
    g.user = ANONYMOUS


# --------------------------------------------------------------------------- #
# Zweiter Faktor
# --------------------------------------------------------------------------- #


def start_totp_challenge(user_row: dict[str, Any]) -> None:
    session[SESSION_PENDING_KEY] = int(user_row["id"])
    session["_pending_since"] = _iso(_now())


def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
    user_id = session.get(SESSION_PENDING_KEY)
    if not user_id:
        return None
    started = session.get("_pending_since")
    if started:
        try:
            if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
                session.pop(SESSION_PENDING_KEY, None)
                return None
        except ValueError:
            pass
    return Database.one(
        conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
    )


def verify_second_factor(
    conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
) -> AuthOutcome:
    from ..security import totp

    security = core().config.security
    subject = str(user_row["id"])
    verdict = ratelimit.check(
        conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
    )
    if verdict.blocked:
        return AuthOutcome(
            "rate_limited",
            "Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
            retry_after=verdict.retry_after_seconds,
        )

    cleaned = (code or "").strip()
    if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
        with core().database.transaction(conn):
            ratelimit.clear(conn, BUCKET_TOTP, subject)
        return AuthOutcome("ok", user_row=user_row)

    if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
        return AuthOutcome("ok", user_row=user_row)

    with core().database.transaction(conn):
        ratelimit.record(conn, BUCKET_TOTP, subject)
        audit.log(
            conn,
            actor=str(user_row["username"]),
            actor_id=int(user_row["id"]),
            action="auth.totp_failed",
            target=str(user_row["username"]),
            ip=ip,
            severity="warning",
        )
    return AuthOutcome("invalid", "Der Code ist nicht korrekt.")


def _consume_recovery_code(
    conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
) -> bool:
    candidates = Database.all(
        conn,
        "SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
        (user_id,),
    )
    normalized = code.strip().lower()
    for entry in candidates:
        if passwords.verify_password(str(entry["code_hash"]), normalized):
            with core().database.transaction(conn):
                conn.execute(
                    "UPDATE user_recovery_codes SET used_at=? WHERE id=?",
                    (_iso(_now()), entry["id"]),
                )
                remaining = int(
                    Database.value(
                        conn,
                        "SELECT COUNT(*) FROM user_recovery_codes "
                        "WHERE user_id=? AND used_at IS NULL",
                        (user_id,),
                        default=0,
                    )
                    or 0
                )
                audit.log(
                    conn,
                    actor=str(
                        Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
                        or ""
                    ),
                    actor_id=user_id,
                    action="auth.recovery_code_used",
                    detail=f"Verbleibende Codes: {remaining}",
                    ip=ip,
                    severity="notice",
                )
            return True
    return False


# --------------------------------------------------------------------------- #
# Erneute Bestaetigung ("sudo-Modus")
# --------------------------------------------------------------------------- #


def reauth_is_fresh() -> bool:
    stamp = session.get(SESSION_REAUTH_KEY)
    if not stamp:
        return False
    try:
        moment = datetime.fromisoformat(str(stamp))
    except ValueError:
        return False
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=timezone.utc)
    window = timedelta(minutes=core().config.security.reauth_valid_minutes)
    return _now() - moment <= window


def mark_reauth() -> None:
    session[SESSION_REAUTH_KEY] = _iso(_now())


def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
    """Prueft das Passwort des angemeldeten Benutzers erneut.

    Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
    koennten LDAP-Nutzer den sudo-Modus nie erreichen.
    """
    from . import directory

    row = rbac.get_user(conn, user.id)
    if row is None:
        return False
    if str(row.get("auth_source")) == "local":
        return passwords.verify_password(str(row.get("password_hash") or ""), password)
    if not directory.is_enabled(conn):
        return False
    return directory.authenticate(conn, user.username, password).status == "ok"
