"""CSRF-Schutz und Header-Haertung.

Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein
gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im
Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen.

Hier drei Schichten:

1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser).
2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter
   Zeit verglichen wird.
3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite.

Alle drei sind unabhaengig voneinander wirksam.
"""

from __future__ import annotations

import hmac
import secrets
from urllib.parse import urlparse

from flask import Flask, Response, abort, current_app, request, session

SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
FIELD_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
_SESSION_KEY = "_csrf"


class CsrfError(Exception):
    """Wird als HTTP 403 mit erklaerender Seite ausgeliefert."""


def token() -> str:
    """Aktuelles Token der Sitzung, bei Bedarf neu erzeugt."""
    value = session.get(_SESSION_KEY)
    if not value:
        value = secrets.token_urlsafe(32)
        session[_SESSION_KEY] = value
    return str(value)


def rotate_token() -> str:
    """Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel."""
    session.pop(_SESSION_KEY, None)
    return token()


def _submitted_token() -> str:
    for source in (request.form, request.args):
        value = source.get(FIELD_NAME)
        if value:
            return value
    header = request.headers.get(HEADER_NAME)
    if header:
        return header
    if request.is_json:
        try:
            payload = request.get_json(silent=True) or {}
        except Exception:  # pragma: no cover - defensiv
            payload = {}
        if isinstance(payload, dict) and payload.get(FIELD_NAME):
            return str(payload[FIELD_NAME])
    return ""


def _origin_ok() -> bool:
    """Prueft Origin bzw. Referer gegen den Host der Anfrage."""
    origin = request.headers.get("Origin") or ""
    referer = request.headers.get("Referer") or ""
    candidate = origin or referer
    if not candidate:
        # Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen.
        # Das Token allein muss dann reichen.
        return True
    parsed = urlparse(candidate)
    if not parsed.netloc:
        return False

    # Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder
    # ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein
    # *anderer* Host tut das nie.
    expected: set[str] = {request.host}
    forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip()
    if forwarded_host:
        expected.add(forwarded_host)
    forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip()
    if forwarded_port:
        expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected))
    # Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen
    # Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung.
    expected.update(host.split(":")[0] for host in set(expected))
    # Und die Standardports, falls der Proxy sie weglaesst.
    scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80"
    expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected))

    return parsed.netloc in expected


def protect() -> None:
    """``before_request``-Hook."""
    if request.method in SAFE_METHODS:
        return
    view = current_app.view_functions.get(request.endpoint or "")
    if getattr(view, "_csrf_exempt", False):
        return
    if not _origin_ok():
        abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).")
    expected = session.get(_SESSION_KEY) or ""
    provided = _submitted_token()
    if not expected or not provided:
        abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
    if not hmac.compare_digest(str(expected), str(provided)):
        abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")


def exempt(view):
    """Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs).

    Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies
    autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine
    Luecke.
    """
    view._csrf_exempt = True  # type: ignore[attr-defined]
    return view


def init_app(app: Flask) -> None:
    app.before_request(protect)
    app.jinja_env.globals["csrf_token"] = token
    app.jinja_env.globals["csrf_field_name"] = FIELD_NAME


# --------------------------------------------------------------------------- #
# Sicherheits-Header
# --------------------------------------------------------------------------- #


def content_security_policy(nonce: str) -> str:
    """Strikte CSP.

    Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce
    dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen
    Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut
    setzt; Style-Attribute sind kein Skriptausfuehrungsvektor.
    """
    return "; ".join(
        [
            "default-src 'self'",
            f"script-src 'self' 'nonce-{nonce}'",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data:",
            "font-src 'self'",
            "connect-src 'self'",
            "form-action 'self'",
            "frame-ancestors 'none'",
            "base-uri 'none'",
            "object-src 'none'",
            "worker-src 'self'",
            "manifest-src 'self'",
        ]
    )


def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response:
    response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce))
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("X-Frame-Options", "DENY")
    response.headers.setdefault("Referrer-Policy", "same-origin")
    response.headers.setdefault(
        "Permissions-Policy",
        "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
    )
    response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
    response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
    response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
    if hsts:
        response.headers.setdefault("Strict-Transport-Security", hsts)
    return response
