"""SSH-Grundlagen: Host-Schluessel-Pruefung und Verbindungsaufbau.

Der Vorgaenger hatte hier zwei Verfahren nebeneinander: das Browser-Terminal
akzeptierte unbekannte Host-Schluessel interaktiv (``AutoAddPolicy``-artig), die
Wartung wies sie hart ab (``RejectPolicy``). Die Begruendung war richtig -- beim
Terminal sieht ein Mensch zu, bei einem Hintergrundjob nicht -- aber die
Umsetzung lag in einer Datei ``known_hosts``, die niemand einsehen konnte.

Hier liegt die Vertrauensentscheidung in der Datenbank (``switch_host_keys`` --
der Name stammt aus der Zeit, als es nur Switche gab; die Tabelle ist nach
Host und Port geschluesselt und gilt fuer Switche wie Clients gleichermassen):

* **Unbekannter Schluessel**: Aufbau wird abgebrochen. Die Oberflaeche zeigt den
  Fingerprint und erlaubt eine ausdrueckliche, protokollierte Freigabe. Kein
  Hintergrundprozess kann diese Freigabe erteilen.
* **Geaenderter Schluessel**: immer ein Fehler, nie eine stille Uebernahme --
  das ist der Fall, der bei einem Man-in-the-Middle auftritt.
* Jede Freigabe steht mit Zeitpunkt und Benutzer im Aenderungsprotokoll.
"""

from __future__ import annotations

import base64
import hashlib
import logging
import re
import socket
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from tesm_core.db import Database

logger = logging.getLogger(__name__)

CONNECT_TIMEOUT = 12
BANNER_TIMEOUT = 20
AUTH_TIMEOUT = 20


class SshError(Exception):
    """Verbindungs- oder Anmeldefehler mit Klartextmeldung fuer die Oberflaeche."""


class UnknownHostKey(SshError):
    """Der Host-Schluessel ist unbekannt und muss freigegeben werden."""

    def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
        super().__init__(
            f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt "
            f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben."
        )
        self.host = host
        self.port = port
        self.key_type = key_type
        self.fingerprint = fingerprint


class HostKeyChanged(SshError):
    """Der Host-Schluessel weicht vom gespeicherten ab."""

    def __init__(self, host: str, port: int, expected: str, received: str) -> None:
        super().__init__(
            f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert "
            f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
            "Das kann ein Geraetetausch sein -- oder ein Angriff."
        )
        self.host = host
        self.port = port
        self.expected = expected
        self.received = received


#: Ein SHA256-Fingerprint in OpenSSH-Schreibweise: "SHA256:" plus 43 Zeichen
#: Base64 ohne Fuellzeichen (32 Byte Hash).
FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")


def valid_fingerprint(value: str) -> bool:
    """Prueft die Form eines Fingerprints, bevor er gespeichert wird.

    Vorher wurde nur auf das Praefix ``SHA256:`` geprueft -- ``SHA256:unsinn``
    kam damit durch. Ein abgeschnittener oder vertippter Wert laesst sich
    danach von keinem echten Schluessel mehr treffen: die Anwendung meldet bei
    jeder Verbindung "Schluessel geaendert" und der Verdacht faellt auf das
    Geraet statt auf den Tippfehler.
    """
    return bool(FINGERPRINT_RE.match((value or "").strip()))


def fingerprint_of(key: Any) -> str:
    """OpenSSH-kompatibler SHA256-Fingerprint (``SHA256:...``)."""
    digest = hashlib.sha256(key.asbytes()).digest()
    return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")


@dataclass(frozen=True, slots=True)
class HostKeyRecord:
    host: str
    port: int
    key_type: str
    fingerprint: str


def known_key(
    conn: sqlite3.Connection, host: str, port: int, key_type: str
) -> HostKeyRecord | None:
    row = Database.one(
        conn,
        "SELECT host, port, key_type, fingerprint FROM switch_host_keys "
        "WHERE host=? AND port=? AND key_type=?",
        (host, int(port), key_type),
    )
    if row is None:
        return None
    return HostKeyRecord(
        host=str(row["host"]),
        port=int(row["port"]),
        key_type=str(row["key_type"]),
        fingerprint=str(row["fingerprint"]),
    )


def trust_key(  # noqa: PLR0913 - benannte Argumente, bewusst explizit
    conn: sqlite3.Connection,
    *,
    host: str,
    port: int,
    key_type: str,
    fingerprint: str,
    actor: str,
) -> None:
    conn.execute(
        "INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) "
        "VALUES (?,?,?,?,?,?) ON CONFLICT(host, port, key_type) DO UPDATE SET "
        "fingerprint=excluded.fingerprint, added_at=excluded.added_at, added_by=excluded.added_by",
        (
            host,
            int(port),
            key_type,
            fingerprint,
            datetime.now(timezone.utc).isoformat(timespec="seconds"),
            actor,
        ),
    )


def forget_key(conn: sqlite3.Connection, host: str, port: int) -> None:
    conn.execute("DELETE FROM switch_host_keys WHERE host=? AND port=?", (host, int(port)))


def all_known_keys(conn: sqlite3.Connection, host: str, port: int) -> list[HostKeyRecord]:
    """Alle gespeicherten Schluessel eines Ziels, unabhaengig vom Typ."""
    return [
        HostKeyRecord(
            host=str(row["host"]),
            port=int(row["port"]),
            key_type=str(row["key_type"]),
            fingerprint=str(row["fingerprint"]),
        )
        for row in Database.all(
            conn,
            "SELECT host, port, key_type, fingerprint FROM switch_host_keys "
            "WHERE host=? AND port=? ORDER BY key_type",
            (host, int(port)),
        )
    ]


def is_trusted(conn: sqlite3.Connection, host: str, port: int) -> bool:
    """Gibt es ueberhaupt einen freigegebenen Schluessel fuer dieses Ziel?"""
    return bool(all_known_keys(conn, host, int(port)))


def describe_probe(
    conn: sqlite3.Connection, record: HostKeyRecord
) -> tuple[str, str]:
    """Formuliert das Ergebnis einer Schluesselpruefung als (Stufe, Text).

    Steht hier und nicht in den Blueprints, weil Switche und Clients denselben
    Ablauf haben -- und weil die drei Faelle (unbekannt, abweichend, gleich)
    ueberall gleich beurteilt werden muessen. Zwei Kopien dieser Logik waeren
    zwei Gelegenheiten, den mittleren Fall zu verharmlosen.
    """
    known = known_key(conn, record.host, record.port, record.key_type)
    if known is None:
        return (
            "info",
            f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
            "Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
        )
    if known.fingerprint != record.fingerprint:
        return (
            "danger",
            f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
            f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
            "Geraetetausch sein -- oder ein Angriff.",
        )
    return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")


def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
    """Holt den Host-Schluessel, ohne sich anzumelden -- fuer den Freigabedialog."""
    import paramiko

    try:
        sock = socket.create_connection((host, int(port)), timeout=timeout)
    except OSError as exc:
        raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc
    transport = paramiko.Transport(sock)
    try:
        transport.start_client(timeout=timeout)
        key = transport.get_remote_server_key()
        return HostKeyRecord(
            host=host, port=int(port), key_type=key.get_name(), fingerprint=fingerprint_of(key)
        )
    except paramiko.SSHException as exc:
        raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
    finally:
        transport.close()
        try:
            sock.close()
        except OSError:
            pass


def open_transport(
    conn: sqlite3.Connection,
    *,
    host: str,
    port: int,
    username: str,
    password: str,
    allow_unknown: bool = False,
) -> Any:
    """Baut eine geprueft-vertrauenswuerdige SSH-Verbindung auf.

    Bewusst die Low-Level-``Transport``-API statt ``SSHClient.connect()``: nur so
    laesst sich der Host-Schluessel *vor* der Passwortuebergabe pruefen -- und nur
    so kann das Browser-Terminal eine Freigabe zwischen Handshake und Anmeldung
    einschieben.

    Authentisierungsreihenfolge ``password`` vor ``keyboard-interactive``:
    mehrere ArubaOS-Varianten bieten letzteres unzuverlaessig an; wer damit
    beginnt, sieht nie einen Prompt und bekommt sofort "Zugangsdaten falsch".
    """
    import paramiko

    try:
        sock = socket.create_connection((host, int(port)), timeout=CONNECT_TIMEOUT)
    except OSError as exc:
        raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc

    transport = paramiko.Transport(sock)
    transport.banner_timeout = BANNER_TIMEOUT
    transport.auth_timeout = AUTH_TIMEOUT

    # Wenn fuer dieses Ziel schon Schluessel freigegeben sind, wird die
    # Aushandlung auf genau diese Typen begrenzt -- so macht es OpenSSH mit
    # known_hosts auch.
    #
    # Ohne diese Begrenzung entsteht ein Zustand, der niemandem zu erklaeren
    # ist: die Oberflaeche zeigt "freigegeben", der Verbindungsversuch meldet
    # trotzdem "Schluessel unbekannt". Grund waere schlicht, dass der Server
    # einen anderen Typ angeboten hat (etwa ssh-rsa statt ssh-ed25519) als den
    # freigegebenen. Der Fehler steckt dann nicht im Vertrauen, sondern in der
    # Auswahl -- und die laesst sich hier treffen.
    trusted_types = [record.key_type for record in all_known_keys(conn, host, int(port))]
    if trusted_types:
        options = transport.get_security_options()
        supported = [name for name in options.key_types if name in trusted_types]
        if supported:
            options.key_types = tuple(supported)

    try:
        transport.start_client(timeout=CONNECT_TIMEOUT)
    except paramiko.SSHException as exc:
        transport.close()
        if trusted_types:
            raise SshError(
                f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
                f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
                "bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der "
                "Schluessel neu ausgelesen und freigegeben werden."
            ) from exc
        raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc

    server_key = transport.get_remote_server_key()
    key_type = server_key.get_name()
    received = fingerprint_of(server_key)
    record = known_key(conn, host, int(port), key_type)

    if record is None:
        if not allow_unknown:
            transport.close()
            raise UnknownHostKey(host, int(port), key_type, received)
    elif record.fingerprint != received:
        transport.close()
        raise HostKeyChanged(host, int(port), record.fingerprint, received)

    try:
        transport.auth_password(username, password)
    except paramiko.AuthenticationException:
        try:
            transport.auth_interactive_dumb(username, lambda *_: [password])
        except paramiko.SSHException as exc:
            transport.close()
            raise SshError("Die Zugangsdaten wurden abgewiesen.") from exc
    except paramiko.SSHException as exc:
        transport.close()
        raise SshError(f"Anmeldung fehlgeschlagen ({exc}).") from exc

    if not transport.is_authenticated():
        transport.close()
        raise SshError("Die Zugangsdaten wurden abgewiesen.")
    return transport
