"""Eigenes Konto: Stammdaten, Passwort, zweiter Faktor, eigene Sitzungen.

Bewusst getrennt von der Benutzerverwaltung: hier aendert jeder nur sich
selbst, ohne irgendein Recht aus der Rechtematrix.
"""

from __future__ import annotations

import secrets
from datetime import datetime, timezone
from pathlib import Path

from flask import (
    Blueprint,
    abort,
    flash,
    redirect,
    render_template,
    request,
    send_from_directory,
    session,
    url_for,
)
from werkzeug.utils import secure_filename

from .. import audit
from ..auth.models import current_user, login_required, reauth_required
from ..auth.service import client_ip, confirm_password
from ..db import Database
from ..extension import core, db, transaction
from ..rbac import service as rbac
from ..security import passwords, sessions as session_store, totp

bp = Blueprint("account", __name__, url_prefix="/konto")

ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MAX_AVATAR_BYTES = 2 * 1024 * 1024

#: Magische Bytes je erlaubtem Bildtyp. Der Vorgaenger prueft nur die Endung --
#: damit laesst sich beliebiger Inhalt als ".png" hochladen.
_MAGIC = {
    b"\x89PNG\r\n\x1a\n": "png",
    b"\xff\xd8\xff": "jpeg",
    b"GIF87a": "gif",
    b"GIF89a": "gif",
    b"RIFF": "webp",
}


def _avatar_dir() -> Path:
    path = core().config.data_dir / "avatars"
    path.mkdir(parents=True, exist_ok=True)
    return path


def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
    audit.log(
        conn,
        actor=current_user.username,
        actor_id=current_user.id,
        action=action,
        target=current_user.username,
        detail=detail,
        ip=client_ip(),
        severity=severity,
    )


@bp.get("/")
@login_required
def overview():
    conn = db()
    row = rbac.get_user(conn, current_user.id) or {}
    return render_template(
        "tesm_core/account.html",
        profile=row,
        groups=rbac.user_groups(conn, current_user.id),
        permissions=sorted(current_user.effective_permissions),
        tree=core().permissions,
    )


@bp.post("/stammdaten")
@login_required
def update_profile():
    if current_user.is_directory_account:
        flash(
            "Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
            "Anmeldung uebernommen.",
            "info",
        )
        return redirect(url_for("account.overview"))
    with transaction() as conn:
        conn.execute(
            "UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?",
            (
                (request.form.get("first_name") or "").strip()[:64],
                (request.form.get("last_name") or "").strip()[:64],
                (request.form.get("email") or "").strip()[:190],
                datetime.now(timezone.utc).isoformat(timespec="seconds"),
                current_user.id,
            ),
        )
        _audit(conn, "account.profile_updated")
    flash("Stammdaten gespeichert.", "success")
    return redirect(url_for("account.overview"))


@bp.post("/profilbild")
@login_required
def upload_avatar():
    upload = request.files.get("avatar")
    if upload is None or not upload.filename:
        flash("Keine Datei ausgewaehlt.", "danger")
        return redirect(url_for("account.overview"))

    suffix = Path(upload.filename).suffix.lower()
    if suffix not in ALLOWED_AVATAR_SUFFIXES:
        flash("Erlaubt sind PNG, JPG, WEBP und GIF.", "danger")
        return redirect(url_for("account.overview"))

    head = upload.stream.read(16)
    upload.stream.seek(0)
    if not any(head.startswith(magic) for magic in _MAGIC):
        flash("Die Datei ist kein Bild.", "danger")
        return redirect(url_for("account.overview"))

    data = upload.stream.read(MAX_AVATAR_BYTES + 1)
    if len(data) > MAX_AVATAR_BYTES:
        flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
        return redirect(url_for("account.overview"))

    filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
    target = _avatar_dir() / secure_filename(filename)
    target.write_bytes(data)

    with transaction() as conn:
        previous = Database.value(
            conn, "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
        )
        conn.execute(
            "UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
            (target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
        )
        _audit(conn, "account.avatar_updated")
    if previous:
        (_avatar_dir() / str(previous)).unlink(missing_ok=True)
    flash("Profilbild aktualisiert.", "success")
    return redirect(url_for("account.overview"))


@bp.get("/profilbild/<path:filename>")
@login_required
def avatar(filename: str):
    safe = secure_filename(filename)
    if safe != filename or Path(safe).suffix.lower() not in ALLOWED_AVATAR_SUFFIXES:
        abort(404)
    return send_from_directory(_avatar_dir(), safe, max_age=3600)


# --------------------------------------------------------------------------- #
# Sicherheit
# --------------------------------------------------------------------------- #


@bp.get("/sicherheit")
@login_required
def security():
    conn = db()
    row = rbac.get_user(conn, current_user.id) or {}
    pending_secret = session.get("_totp_setup", "")
    return render_template(
        "tesm_core/account_security.html",
        profile=row,
        policy=core().password_policy,
        sessions=session_store.active_for_user(conn, current_user.id),
        current_sid=getattr(session, "sid", ""),
        totp_enabled=bool(row.get("totp_secret")),
        pending_secret=pending_secret,
        pending_uri=(
            totp.provisioning_uri(
                pending_secret, account=current_user.username, issuer=core().config.app_name
            )
            if pending_secret
            else ""
        ),
        pending_secret_grouped=totp.format_secret(pending_secret) if pending_secret else "",
        recovery_remaining=int(
            Database.value(
                conn,
                "SELECT COUNT(*) FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
                (current_user.id,),
                default=0,
            )
            or 0
        ),
        recovery_codes=session.pop("_recovery_codes", []),
        require_totp_for_admins=core().config.security.require_totp_for_admins,
    )


@bp.post("/passwort")
@login_required
def change_password():
    conn = db()
    if current_user.is_directory_account:
        flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
        return redirect(url_for("account.security"))

    current = request.form.get("current_password") or ""
    new = request.form.get("new_password") or ""
    repeat = request.form.get("repeat_password") or ""

    if not confirm_password(conn, current_user, current):
        flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
        return redirect(url_for("account.security"))
    if new != repeat:
        flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
        return redirect(url_for("account.security"))
    if passwords.constant_time_equals(current, new):
        flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
        return redirect(url_for("account.security"))

    try:
        with transaction():
            rbac.set_password(conn, current_user.id, new, policy=core().password_policy)
            # Alle anderen Sitzungen beenden: nach einem Passwortwechsel soll
            # eine ggf. uebernommene Sitzung nicht weiterlaufen.
            revoked = session_store.revoke_all_for_user(
                conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
            )
            _audit(
                conn,
                "account.password_changed",
                f"{revoked} weitere Sitzungen beendet",
                severity="notice",
            )
    except rbac.ValidationError as exc:
        flash(str(exc), "danger")
        return redirect(url_for("account.security"))

    flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
    return redirect(url_for("account.security"))


@bp.post("/zwei-faktor/starten")
@login_required
@reauth_required
def totp_start():
    session["_totp_setup"] = totp.generate_secret()
    flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
    return redirect(url_for("account.security"))


@bp.post("/zwei-faktor/bestaetigen")
@login_required
@reauth_required
def totp_confirm():
    secret = session.get("_totp_setup", "")
    if not secret:
        flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
        return redirect(url_for("account.security"))
    if not totp.verify_code(secret, request.form.get("code") or ""):
        flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
        return redirect(url_for("account.security"))

    codes = totp.generate_recovery_codes()
    with transaction() as conn:
        conn.execute(
            "UPDATE users SET totp_secret=?, totp_confirmed_at=?, updated_at=? WHERE id=?",
            (
                secret,
                datetime.now(timezone.utc).isoformat(timespec="seconds"),
                datetime.now(timezone.utc).isoformat(timespec="seconds"),
                current_user.id,
            ),
        )
        conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
        conn.executemany(
            "INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
            [
                (
                    current_user.id,
                    passwords.hash_password(code),
                    datetime.now(timezone.utc).isoformat(timespec="seconds"),
                )
                for code in codes
            ],
        )
        _audit(conn, "account.totp_enabled", severity="notice")

    session.pop("_totp_setup", None)
    session["_recovery_codes"] = codes
    flash("Zwei-Faktor-Authentisierung aktiviert. Bitte die Wiederherstellungscodes sichern.", "success")
    return redirect(url_for("account.security"))


@bp.post("/zwei-faktor/deaktivieren")
@login_required
@reauth_required
def totp_disable():
    if core().config.security.require_totp_for_admins and current_user.is_admin:
        flash(
            "Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
            "Installation verpflichtend.",
            "danger",
        )
        return redirect(url_for("account.security"))
    with transaction() as conn:
        conn.execute(
            "UPDATE users SET totp_secret='', totp_confirmed_at=NULL, updated_at=? WHERE id=?",
            (datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
        )
        conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
        _audit(conn, "account.totp_disabled", severity="warning")
    flash("Zwei-Faktor-Authentisierung deaktiviert.", "info")
    return redirect(url_for("account.security"))


@bp.post("/zwei-faktor/codes-erneuern")
@login_required
@reauth_required
def recovery_codes():
    codes = totp.generate_recovery_codes()
    with transaction() as conn:
        conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
        conn.executemany(
            "INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
            [
                (
                    current_user.id,
                    passwords.hash_password(code),
                    datetime.now(timezone.utc).isoformat(timespec="seconds"),
                )
                for code in codes
            ],
        )
        _audit(conn, "account.recovery_codes_renewed", severity="notice")
    session["_recovery_codes"] = codes
    flash("Neue Wiederherstellungscodes erzeugt. Die alten gelten nicht mehr.", "success")
    return redirect(url_for("account.security"))


@bp.post("/sitzungen/<sid>/beenden")
@login_required
def revoke_session(sid: str):
    conn = db()
    owner = Database.value(conn, "SELECT user_id FROM user_sessions WHERE id=?", (sid,))
    if owner is None or int(owner) != current_user.id:
        abort(404)
    with transaction():
        session_store.revoke(conn, sid, reason="self")
        _audit(conn, "account.session_revoked", sid[:12])
    flash("Sitzung beendet.", "success")
    return redirect(url_for("account.security"))


@bp.post("/sitzungen/alle-beenden")
@login_required
def revoke_other_sessions():
    with transaction() as conn:
        count = session_store.revoke_all_for_user(
            conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
        )
        _audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
    flash(f"{count} weitere Sitzung(en) beendet.", "success")
    return redirect(url_for("account.security"))
