"""Serverseitige Sitzungen in SQLite.

Der Vorgaenger nutzte Flasks signierte Cookie-Sitzungen. Das ist bequem, hat
aber drei Nachteile, die fuer eine Verwaltungsanwendung mit Adminrechten
relevant sind:

1. **Nicht widerrufbar.** Ein gestohlenes Cookie bleibt bis zum Ablauf gueltig;
   "alle Sitzungen beenden" ist unmoeglich.
2. **Kein Leerlauf-Timeout.** Cookie-Sitzungen kennen nur ein absolutes Ablauf-
   datum, keine Inaktivitaet.
3. **Sitzungsinhalt liegt beim Client.** Rotieren des Flask-Secret-Keys wirft
   alle Nutzer heraus; ausserdem ist jede Sitzungsvariable im Browser sichtbar
   (signiert, aber nicht verschluesselt).

Hier: eine Zeile pro Sitzung in ``user_sessions``, der Cookie traegt nur eine
256-Bit-Zufalls-ID. Damit sind Widerruf, Leerlauf-Timeout, ein absolutes
Maximum, eine Uebersicht "meine aktiven Sitzungen" und Sitzungsrotation beim
Rechtewechsel moeglich.
"""

from __future__ import annotations

import json
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any

from flask import Flask, Request, Response
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict

from ..db import Database

SESSION_ID_BYTES = 32


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


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


def _parse(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


class ServerSession(CallbackDict, SessionMixin):
    """Sitzungsobjekt, das seine Aenderungen selbst als "dirty" markiert."""

    def __init__(self, initial: dict[str, Any] | None = None, sid: str = "", *, new: bool = False):
        def on_update(_self: Any) -> None:
            self.modified = True

        super().__init__(initial or {}, on_update)
        self.sid = sid
        self.new = new
        self.modified = False
        self.destroyed = False
        #: Von der Datenbank geladene Metadaten (nur lesend fuer den Aufrufer).
        self.meta: dict[str, Any] = {}

    def destroy(self) -> None:
        """Sitzung serverseitig verwerfen (Logout)."""
        self.clear()
        self.destroyed = True
        self.modified = True


class SqliteSessionInterface(SessionInterface):
    """Flask-Sitzungsschnittstelle gegen die Tabelle ``user_sessions``."""

    session_class = ServerSession
    pickle_based = False

    def __init__(
        self,
        database: Database,
        *,
        cookie_name: str,
        idle_minutes: int,
        absolute_hours: int,
    ) -> None:
        self.database = database
        self.cookie_name = cookie_name
        self.idle_minutes = idle_minutes
        self.absolute_hours = absolute_hours

    # -- Flask-Schnittstelle ------------------------------------------- #

    def open_session(self, app: Flask, request: Request) -> ServerSession:
        sid = request.cookies.get(self.cookie_name, "")
        if not sid or len(sid) < 40:
            return self.session_class(new=True)

        with self.database.session() as conn:
            row = Database.one(
                conn,
                "SELECT * FROM user_sessions WHERE id=? AND revoked_at IS NULL",
                (sid,),
            )
            if row is None:
                return self.session_class(new=True)

            now = _now()
            idle_deadline = _parse(row.get("idle_expires_at"))
            absolute_deadline = _parse(row.get("absolute_expires_at"))
            if (idle_deadline and now > idle_deadline) or (
                absolute_deadline and now > absolute_deadline
            ):
                with self.database.transaction(conn):
                    conn.execute(
                        "UPDATE user_sessions SET revoked_at=?, revoked_reason='expired' "
                        "WHERE id=?",
                        (_iso(now), sid),
                    )
                return self.session_class(new=True)

            try:
                data = json.loads(row.get("data") or "{}")
            except ValueError:
                data = {}

            session = self.session_class(data, sid)
            session.meta = {
                "user_id": row.get("user_id"),
                "created_at": row.get("created_at"),
                "last_seen_at": row.get("last_seen_at"),
                "reauth_at": row.get("reauth_at"),
                "absolute_expires_at": row.get("absolute_expires_at"),
                "ip": row.get("ip"),
                "user_agent": row.get("user_agent"),
            }
            # Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
            # schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
            last_seen = _parse(row.get("last_seen_at"))
            if last_seen is None or (now - last_seen) > timedelta(seconds=60):
                with self.database.transaction(conn):
                    conn.execute(
                        "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
                        (_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
                    )
            return session

    def save_session(self, app: Flask, session: ServerSession, response: Response) -> None:  # type: ignore[override]
        name = self.cookie_name
        domain = self.get_cookie_domain(app)
        path = self.get_cookie_path(app)

        if session.destroyed:
            if session.sid:
                with self.database.session() as conn, self.database.transaction(conn):
                    conn.execute(
                        "UPDATE user_sessions SET revoked_at=?, revoked_reason='logout' WHERE id=?",
                        (_iso(_now()), session.sid),
                    )
            response.delete_cookie(name, domain=domain, path=path)
            return

        if not session:
            # Leere Sitzung: nichts speichern, aber ein evtl. vorhandenes Cookie
            # nicht anfassen (sonst wuerde jeder anonyme Request es loeschen).
            if session.modified and session.sid:
                response.delete_cookie(name, domain=domain, path=path)
            return

        if not session.modified and not session.new:
            return

        now = _now()
        payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
        with self.database.session() as conn, self.database.transaction(conn):
            if session.new or not session.sid:
                session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
                conn.execute(
                    "INSERT INTO user_sessions "
                    "(id, user_id, created_at, last_seen_at, idle_expires_at, "
                    " absolute_expires_at, ip, user_agent, data) "
                    "VALUES (?,?,?,?,?,?,?,?,?)",
                    (
                        session.sid,
                        session.get("user_id"),
                        _iso(now),
                        _iso(now),
                        _iso(now + timedelta(minutes=self.idle_minutes)),
                        _iso(now + timedelta(hours=self.absolute_hours)),
                        session.get("_ip", ""),
                        session.get("_ua", ""),
                        payload,
                    ),
                )
                session.new = False
            else:
                conn.execute(
                    "UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
                    "idle_expires_at=?, reauth_at=? WHERE id=?",
                    (
                        session.get("user_id"),
                        payload,
                        _iso(now),
                        _iso(now + timedelta(minutes=self.idle_minutes)),
                        session.get("_reauth_at"),
                        session.sid,
                    ),
                )

        response.set_cookie(
            name,
            session.sid,
            httponly=True,
            secure=self.get_cookie_secure(app),
            samesite=self.get_cookie_samesite(app),
            domain=domain,
            path=path,
            max_age=None,  # Sitzungscookie: verschwindet beim Schliessen des Browsers.
        )
        response.vary.add("Cookie")


# --------------------------------------------------------------------------- #
# Verwaltung
# --------------------------------------------------------------------------- #


def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "privilege-change") -> str:
    """Erzeugt eine neue Sitzungs-ID und entwertet die alte (Session-Fixation).

    Wird bei jeder Anmeldung und bei jeder Rechteaenderung aufgerufen.
    """
    old_sid = session.sid
    if old_sid:
        conn.execute(
            "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
            (_iso(_now()), reason, old_sid),
        )
    session.sid = ""
    session.new = True
    session.modified = True
    return old_sid


def revoke(conn: sqlite3.Connection, sid: str, *, reason: str = "manual") -> None:
    conn.execute(
        "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL",
        (_iso(_now()), reason, sid),
    )


def revoke_all_for_user(
    conn: sqlite3.Connection, user_id: int, *, except_sid: str = "", reason: str = "manual"
) -> int:
    cursor = conn.execute(
        "UPDATE user_sessions SET revoked_at=?, revoked_reason=? "
        "WHERE user_id=? AND revoked_at IS NULL AND id <> ?",
        (_iso(_now()), reason, user_id, except_sid),
    )
    return cursor.rowcount or 0


def active_for_user(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
    return Database.all(
        conn,
        "SELECT id, created_at, last_seen_at, absolute_expires_at, ip, user_agent "
        "FROM user_sessions WHERE user_id=? AND revoked_at IS NULL "
        "AND idle_expires_at > ? ORDER BY last_seen_at DESC",
        (user_id, _iso(_now())),
    )


def prune(conn: sqlite3.Connection, *, keep_days: int = 30) -> int:
    """Entfernt abgelaufene und widerrufene Sitzungen."""
    horizon = _iso(_now() - timedelta(days=keep_days))
    cursor = conn.execute(
        "DELETE FROM user_sessions WHERE (revoked_at IS NOT NULL AND revoked_at < ?) "
        "OR idle_expires_at < ?",
        (horizon, horizon),
    )
    return cursor.rowcount or 0
