"""nginx-Konfiguration und TLS-Zertifikate.

Der Vorgaenger schrieb die Site-Datei nur, wenn sie **noch nicht existierte**.
Das hat produktiv realen Schaden verursacht: eine sehr alte Datei mit einem
falschen ``alias``-Pfad blieb ueber beliebig viele Updates bestehen, obwohl das
Repo-Template den Fehler laengst behoben hatte -- Ergebnis: alle statischen
Dateien 404, Oberflaeche komplett ohne Design.

Loesung hier: die Datei wird **immer** aus der Vorlage erzeugt. Alles, was ein
Administrator sonst per Hand hineinschrieb (Domain, Ports, Zertifikate, HSTS),
ist Einstellung in der Anwendung und fliesst in die Vorlage ein. Damit gibt es
keinen Grund mehr, eine erzeugte Datei per Hand zu bearbeiten -- und keinen
Grund, sie beim Update in Ruhe zu lassen.

Zielzustand im Betrieb: Port 80 leitet auf 443 um (ausser dem ACME-Pfad),
Port 443 terminiert TLS, HSTS wird gesetzt.
"""

from __future__ import annotations

import logging
import re
import socket
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from ..sysops import SysOps

logger = logging.getLogger(__name__)

REVERT_SECONDS = 45
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$")


@dataclass(frozen=True, slots=True)
class WebConfig:
    app_key: str
    upstream: str
    static_root: str
    domain: str = ""
    https_enabled: bool = False
    hsts_enabled: bool = False
    redirect_http: bool = True
    cert_path: str = ""
    key_path: str = ""
    acme_webroot: str = "/var/www/tesm-acme"
    websocket_path: str = ""
    max_body_mb: int = 512
    #: Abweichende Ports braucht nur eine Testinstanz neben einer laufenden
    #: Produktion. Der Regelfall -- und das Ziel jeder echten Installation --
    #: ist 80 und 443.
    http_port: int = 80
    https_port: int = 443
    #: ``http2 on;`` gibt es erst ab nginx 1.25.1. Aeltere Versionen kennen nur
    #: ``listen ... ssl http2;`` und weisen die neue Direktive mit
    #: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
    #: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
    #: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
    #: nur eine Warnung.
    http2_directive: bool = False

    @property
    def server_name(self) -> str:
        return self.domain or "_"

    @property
    def default_marker(self) -> str:
        """``default_server`` fuer die Site ohne eigenen Namen.

        ``server_name _;`` ist bei nginx kein Sonderfall -- es ist nur ein Name,
        auf den kein echter Host-Header passt. Ohne ``default_server`` wird die
        *zuerst geladene* Site zur Vorgabe, und das ist die alphabetisch erste
        Datei in ``sites-enabled``. Auf einem Host mit zwei Anwendungen
        entscheidet dann der Zufall des Dateinamens, wer Anfragen ohne
        passenden Namen bekommt.

        Mit Domain gibt es keinen Grund, Vorgabe zu sein -- dann greift der
        Name. Zwei namenlose Sites auf demselben Port weist nginx ab; das ist
        richtig so und wird beim Uebernehmen sichtbar.
        """
        return "" if self.domain else " default_server"

    @property
    def https_suffix(self) -> str:
        """``:8443`` bei abweichendem Port, sonst leer -- fuer die Umleitung."""
        return "" if self.https_port == 443 else f":{self.https_port}"


def validate_domain(value: str) -> str:
    text = (value or "").strip().lower().rstrip(".")
    if not text:
        return ""
    if not DOMAIN_RE.match(text):
        raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
    return text


def render(config: WebConfig) -> str:
    """Erzeugt die vollstaendige Site-Konfiguration."""
    body = f"{max(1, config.max_body_mb)}m"
    # Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu,
    # denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST
    # real passiert: der Websocket-Block erbte 120s und setzte noch einmal
    # 3600s, "proxy_read_timeout directive is duplicate", nginx startete nicht).
    common_proxy = [
        # $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
        # abweichendem Port schlaegt die Origin-Pruefung der Anwendung sonst fehl.
        "        proxy_set_header Host $http_host;",
        "        proxy_set_header X-Real-IP $remote_addr;",
        "        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
        "        proxy_set_header X-Forwarded-Proto $scheme;",
        "        proxy_set_header X-Forwarded-Host $http_host;",
        "        proxy_set_header X-Forwarded-Port $server_port;",
        f"        proxy_pass http://{config.upstream};",
        "        proxy_http_version 1.1;",
    ]

    lines: list[str] = [
        f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
        "# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
        "",
    ]

    # ---- Port 80 --------------------------------------------------------- #
    lines += [
        "server {",
        f"    listen {config.http_port}{config.default_marker};",
        f"    listen [::]:{config.http_port}{config.default_marker};",
        f"    server_name {config.server_name};",
        "",
        "    # ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar --",
        "    # sonst kann Let's Encrypt nie erneuern.",
        "    location /.well-known/acme-challenge/ {",
        f"        alias {config.acme_webroot.rstrip('/')}/.well-known/acme-challenge/;",
        "        default_type \"text/plain\";",
        "    }",
        "",
    ]
    if config.https_enabled and config.redirect_http:
        lines += [
            "    location / {",
            f"        return 308 https://$host{config.https_suffix}$request_uri;",
            "    }",
            "}",
            "",
        ]
    else:
        lines += _site_body(config, common_proxy, body, tls=False)
        lines += ["}", ""]

    # ---- Port 443 -------------------------------------------------------- #
    if config.https_enabled:
        lines += [
            "server {",
            f"    listen {config.https_port} ssl"
            f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
            f"    listen [::]:{config.https_port} ssl"
            f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
            *(["    http2 on;"] if config.http2_directive else []),
            f"    server_name {config.server_name};",
            "",
            f"    ssl_certificate     {config.cert_path};",
            f"    ssl_certificate_key {config.key_path};",
            "    ssl_protocols TLSv1.2 TLSv1.3;",
            "    ssl_prefer_server_ciphers off;",
            "    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
            "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
            "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;",
            "    ssl_session_cache shared:TESM:10m;",
            "    ssl_session_timeout 1d;",
            "    ssl_session_tickets off;",
        ]
        # OCSP-Stapling nur mit einem Zertifikat einer echten CA. Ein
        # selbstsigniertes hat keinen OCSP-Responder; nginx warnt dann bei
        # jedem Start ("ssl_stapling ignored") -- eine Warnung, die niemand
        # abstellen kann und die echte Warnungen untergehen laesst.
        if config.domain:
            lines += ["    ssl_stapling on;", "    ssl_stapling_verify on;"]
        lines += [""]
        if config.hsts_enabled:
            lines.append(
                '    add_header Strict-Transport-Security "max-age=15552000; '
                'includeSubDomains" always;'
            )
            lines.append("")
        lines += _site_body(config, common_proxy, body, tls=True)
        lines += ["}", ""]

    return "\n".join(lines)


def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]:
    lines = [
        f"    client_max_body_size {body};",
        "    # Groesser als das Limit der Anwendung, damit deren eigene, erklaerende",
        "    # Fehlerseite gewinnt statt der generischen 413 von nginx.",
        "",
        "    location /assets/ {",
        f"        alias {config.static_root.rstrip('/')}/;",
        "        expires 7d;",
        "        access_log off;",
        "        add_header Cache-Control \"public, max-age=604800, immutable\";",
        "    }",
        "",
        "    location /core-assets/ {",
        f"        alias {config.static_root.rstrip('/')}-core/;",
        "        expires 7d;",
        "        access_log off;",
        "    }",
        "",
    ]
    if config.websocket_path:
        # Eine SSH-Sitzung im Browser steht minutenlang still, ohne tot zu sein.
        lines += [
            f"    location {config.websocket_path} {{",
            "        proxy_set_header Upgrade $http_upgrade;",
            '        proxy_set_header Connection "upgrade";',
            *common_proxy,
            "        proxy_read_timeout 3600s;",
            "        proxy_send_timeout 3600s;",
            "        proxy_buffering off;",
            "    }",
            "",
        ]
    lines += [
        "    location / {",
        *common_proxy,
        "        proxy_read_timeout 120s;",
        "        proxy_send_timeout 120s;",
        "    }",
    ]
    return lines


def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
    """Schreibt die Konfiguration, prueft sie und laedt nginx neu.

    Ein einziger Helferaufruf, nicht drei. Der Helfer nimmt die Aenderung bei
    einem fehlgeschlagenen ``nginx -t`` selbst zurueck; danach ist der Zustand
    auf der Platte garantiert derselbe wie vorher.

    Das war ein echter Fehler in der ersten Fassung: ``nginx-write`` verlinkt
    die Datei sofort nach ``sites-enabled``. Wurde sie anschliessend abgelehnt,
    blieb sie aktiviert liegen -- der laufende nginx merkte nichts, aber der
    naechste Reload (auch fuer eine ganz andere Site) scheiterte. Aus einer
    abgelehnten Eingabe wurde ein Ausfall des Webservers.
    """
    result = sysops.apply_nginx(content)
    if not result.ok:
        return {
            "ok": False,
            "message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
            "detail": result.output,
        }
    return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}


def request_certificate(
    sysops: SysOps, *, domain: str, email: str, webroot: str
) -> dict[str, Any]:
    """Fordert ein Let's-Encrypt-Zertifikat per Webroot an.

    Bewusst ``certbot certonly --webroot`` und nicht das nginx-Plugin: das
    Plugin bearbeitet die Site-Datei selbst und wuerde die von der Anwendung
    erzeugte Konfiguration verbiegen.
    """
    if not domain:
        return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
    if not email:
        return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
    result = sysops.certbot_issue(domain, email, webroot)
    if not result.ok:
        return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
    return {
        "ok": True,
        "message": f"Zertifikat fuer {domain} ausgestellt.",
        "detail": result.output,
        "cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
        "key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
    }


def create_self_signed(
    sysops: SysOps, *, common_name: str, alt_names: Sequence[str] = ()
) -> dict[str, Any]:
    """Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.

    Der Regelfall im Zielumfeld ist ein Geraet in einem geschlossenen Netz:
    keine oeffentliche Domain, kein Let's Encrypt, oft nicht einmal ein
    aufloesbarer Name -- nur eine IP. Ohne diesen Weg gaebe es dort ueberhaupt
    kein HTTPS, und Port 443 bliebe leer.

    Der Browser zeigt eine Warnung; das ist der ehrliche Preis. Verschluesselt
    ist die Verbindung trotzdem, und das Sitzungscookie darf endlich
    ``Secure`` tragen.
    """
    name = (common_name or "").strip().lower().rstrip(".")
    if not name:
        return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
    if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
        return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}

    cleaned: list[str] = []
    for entry in alt_names:
        value = (entry or "").strip().lower().rstrip(".")
        if not value or value == name or value in cleaned:
            continue
        if re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", value):
            cleaned.append(value)

    result = sysops.issue_self_signed(name, *cleaned)
    if not result.ok:
        return {
            "ok": False,
            "message": "Das Zertifikat konnte nicht erzeugt werden.",
            "detail": result.output,
        }
    paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
    if len(paths) != 2:
        return {"ok": False, "message": "Unerwartete Antwort des Helfers.", "detail": result.output}
    return {
        "ok": True,
        "message": (
            f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
            "Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
        ),
        "cert_path": paths[0],
        "key_path": paths[1],
    }


def local_names() -> list[str]:
    """Hostname und eigene IP-Adressen -- Vorschlag fuer die Namen im Zertifikat."""
    names: list[str] = []
    try:
        hostname = socket.gethostname().strip().lower()
    except OSError:  # pragma: no cover - defensiv
        hostname = ""
    for candidate in (hostname, f"{hostname}.local" if hostname else ""):
        if candidate and candidate not in names:
            names.append(candidate)
    try:
        for info in socket.getaddrinfo(hostname or "localhost", None, socket.AF_INET):
            address = info[4][0]
            if address and not address.startswith("127.") and address not in names:
                names.append(address)
    except OSError:  # pragma: no cover - defensiv
        pass
    return names


def certificate_info(cert_path: str) -> dict[str, Any]:
    """Liest Aussteller und Ablauf eines Zertifikats -- ohne externes Werkzeug."""
    path = Path(cert_path or "")
    try:
        if not path.is_file():
            return {"present": False}
        from cryptography import x509

        certificate = x509.load_pem_x509_certificate(path.read_bytes())
    except PermissionError:
        # Kein Grund, die ganze Seite abzuwerfen: das Zertifikat kann
        # ausserhalb der Reichweite des Dienstbenutzers liegen. nginx liest es
        # trotzdem -- nur die Anzeige von Aussteller und Ablauf entfaellt.
        return {"present": True, "error": "Die Anwendung darf diese Datei nicht lesen."}
    except OSError as exc:
        return {"present": True, "error": str(exc)}
    except Exception as exc:  # noqa: BLE001
        return {"present": True, "error": str(exc)}
    return {
        "present": True,
        "subject": certificate.subject.rfc4514_string(),
        "issuer": certificate.issuer.rfc4514_string(),
        "not_valid_after": certificate.not_valid_after_utc.isoformat(timespec="seconds"),
        "not_valid_before": certificate.not_valid_before_utc.isoformat(timespec="seconds"),
        "serial": format(certificate.serial_number, "x"),
    }
