"""Konsole im Browser -- fuer Switche und fuer Clients, zeilenorientiert.

Bewusste Entscheidung gegen ein vollstaendiges Terminal-Emulat: der Vorgaenger
lieferte dafuer xterm.js als Fremdcode im Repo mit (mehrere hundert Kilobyte,
eigene Aktualisierungspflicht) und brauchte einen dauerhaft blockierten
Worker-Thread pro Sitzung. Eine Zeile hinein, eine Antwort heraus deckt beide
Faelle ab -- die ArubaOS-Kommandozeile ist ohnehin zeilenorientiert, und auf
einem Linux-Client ist eine eigene ``exec``-Sitzung je Kommando sogar
robuster als eine geteilte Shell.

Zwei Ziele, ein Ablauf:

* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
  :class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
  ``maintenance``. Ein Kommando je ``exec``-Kanal ueber
  :class:`~tesm.services.shellcli.ShellSession`.

Die Rechte sind absichtlich verschieden: ``devices.execute`` erlaubt heute
einen PoE-Neustart. Wer ein Geraet neu starten darf, soll damit nicht
automatisch eine Shell darauf bekommen -- das waere genau die stille
Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.

Gemeinsame Sicherheitsmerkmale:

* Frische Passwortbestaetigung, zusaetzlich zum Recht.
* Abschaltbar ueber die Einstellung ``terminal_enabled``.
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
  keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
  interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
* Jede Sitzung wird vollstaendig mitgeschrieben (Datei unter
  ``<log>/terminal/``) und im Aenderungsprotokoll vermerkt.
* Leerlauf-Zeitlimit trennt die Verbindung.
"""

from __future__ import annotations

import json
import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from flask import Blueprint, abort, render_template
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip, reauth_is_fresh
from tesm_core.extension import core, db

from ..services import inventory, shellcli, ssh, switchcli

logger = logging.getLogger(__name__)

bp = Blueprint("terminal", __name__)

MAX_LINE_LENGTH = 400

KIND_SWITCH = "switch"
KIND_DEVICE = "device"

#: Vorschlaege fuer die Auswahlliste auf einem Linux-Client. Reine
#: Lesekommandos -- die Liste ist eine Bequemlichkeit, keine Schranke: das
#: Eingabefeld nimmt jedes Kommando an. Eine Positivliste waere hier
#: Sicherheitstheater, denn wer eine Shell hat, hat eine Shell.
CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
    ("uptime", "Laufzeit und Last"),
    ("df -h", "Belegung der Dateisysteme"),
    ("free -h", "Arbeitsspeicher"),
    ("ip -brief address", "Netzwerkadressen"),
    ("systemctl --failed", "Fehlgeschlagene Dienste"),
    ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
    ("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"),
    ("uname -a", "Kernel und Architektur"),
)


@dataclass(frozen=True, slots=True)
class Target:
    """Alles, was die Konsole ueber ihr Gegenueber wissen muss."""

    kind: str
    id: int
    label: str
    host: str
    port: int
    credential_id: int
    back_endpoint: str
    back_label: str

    @property
    def is_switch(self) -> bool:
        return self.kind == KIND_SWITCH

    @property
    def audit_prefix(self) -> str:
        return "switch" if self.is_switch else "device"


def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
    if kind == KIND_SWITCH:
        row = inventory.get_switch(conn, target_id)
        if row is None:
            return None
        return Target(
            kind=KIND_SWITCH,
            id=int(row["id"]),
            label=str(row["hostname"]),
            host=str(row["ip"]),
            port=int(row["ssh_port"] or 22),
            credential_id=int(row["credential_id"] or 0),
            back_endpoint="switches.detail",
            back_label="Switche",
        )
    row = inventory.get_device(conn, target_id)
    if row is None or row["deleted_at"]:
        return None
    return Target(
        kind=KIND_DEVICE,
        id=int(row["id"]),
        label=str(row["name"]),
        host=str(row["ip"]),
        port=int(row["ssh_port"] or 22),
        credential_id=int(row["credential_id"] or 0),
        back_endpoint="devices.detail",
        back_label="Clients",
    )


def _client_module_active() -> bool:
    manager = core().license
    return manager is None or manager.module_active("maintenance")


def _render_console(target: Target):  # type: ignore[no-untyped-def]
    extension = core()
    conn = db()
    if not extension.settings.get(conn, "terminal_enabled"):
        abort(404)
    if not target.credential_id:
        abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")

    return render_template(
        "tesm/console.html",
        target=target,
        host_key_ready=ssh.is_trusted(conn, target.host, target.port),
        presets=switchcli.SAFE_COMMANDS if target.is_switch else CLIENT_COMMANDS,
        idle_timeout=int(extension.settings.get(conn, "terminal_idle_timeout") or 300),
        websocket_path=f"/ws/{target.kind}/{target.id}",
    )


@bp.get("/switche/<int:switch_id>/konsole")
@permission_required("switches.execute")
@reauth_required
def switch_console(switch_id: int):
    target = _load_target(db(), KIND_SWITCH, switch_id)
    if target is None:
        abort(404)
    return _render_console(target)


@bp.get("/clients/<int:device_id>/konsole")
@permission_required("maintenance.execute")
@reauth_required
def device_console(device_id: int):
    if not _client_module_active():
        abort(404)
    target = _load_target(db(), KIND_DEVICE, device_id)
    if target is None:
        abort(404)
    return _render_console(target)


def init_app(app) -> None:  # type: ignore[no-untyped-def]
    """Meldet die HTTP-Routen und -- falls verfuegbar -- die WebSockets an."""
    app.register_blueprint(bp)

    try:
        from flask_sock import Sock
    except ImportError:  # pragma: no cover - optionale Abhaengigkeit
        logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.")
        return

    sock = Sock(app)
    app.config.setdefault("SOCK_SERVER_OPTIONS", {"ping_interval": 25})

    @sock.route("/ws/switch/<int:target_id>")
    def switch_socket(ws, target_id: int):  # type: ignore[no-untyped-def]
        _handle_socket(ws, KIND_SWITCH, target_id)

    @sock.route("/ws/device/<int:target_id>")
    def device_socket(ws, target_id: int):  # type: ignore[no-untyped-def]
        _handle_socket(ws, KIND_DEVICE, target_id)


def _send(ws, kind: str, text: str) -> None:  # type: ignore[no-untyped-def]
    ws.send(json.dumps({"type": kind, "data": text}))


def _transcript_name(label: str) -> str:
    """Dateiname aus dem Ziel -- ohne alles, was einen Pfad ergeben koennte."""
    return re.sub(r"[^A-Za-z0-9._-]", "_", label)[:60] or "unbenannt"


def _handle_socket(ws, kind: str, target_id: int) -> None:  # type: ignore[no-untyped-def]
    extension = core()
    conn = db()

    # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
    #    WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
    required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
    if not current_user.is_authenticated or not current_user.has(required):
        _send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
        return
    if not reauth_is_fresh():
        _send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
        return
    if not extension.settings.get(conn, "terminal_enabled"):
        _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
        return
    if kind == KIND_DEVICE and not _client_module_active():
        _send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.")
        return

    target = _load_target(conn, kind, target_id)
    if target is None or not target.credential_id:
        _send(ws, "error", "Ziel oder Zugangsdaten nicht gefunden.")
        return

    idle_timeout = int(extension.settings.get(conn, "terminal_idle_timeout") or 300)
    transcript_dir = extension.config.log_dir / "terminal"
    transcript_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    transcript_path = (
        transcript_dir
        / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
    )

    try:
        username, password = inventory.credential_secret(
            conn, extension.keystore, target.credential_id
        )
    except inventory.ValidationError as exc:
        _send(ws, "error", str(exc))
        return

    with extension.database.transaction(conn):
        audit.log(
            conn,
            actor=current_user.username,
            actor_id=current_user.id,
            action=f"{target.audit_prefix}.console_opened",
            target=target.label,
            detail=f"Mitschrift: {transcript_path.name}",
            ip=client_ip(),
            severity="warning",
        )

    commands = 0
    try:
        with transcript_path.open("a", encoding="utf-8", newline="\n") as transcript:
            transcript.write(
                f"# Sitzung {stamp} von {current_user.username} auf {target.label} "
                f"({target.host}:{target.port})\n"
            )
            if target.is_switch:
                commands = _run_switch_session(
                    ws, conn, target, username, password, transcript, idle_timeout
                )
            else:
                commands = _run_client_session(
                    ws, conn, target, username, password, transcript, idle_timeout
                )
    except ssh.UnknownHostKey as exc:
        page = "Switch-Seite" if target.is_switch else "Client-Seite"
        _send(
            ws,
            "error",
            f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der "
            f"{page} ausdruecklich freigeben.",
        )
    except ssh.SshError as exc:
        _send(ws, "error", str(exc))
    except Exception as exc:  # noqa: BLE001
        logger.exception("Konsolensitzung abgebrochen")
        _send(ws, "error", f"Unerwarteter Fehler: {exc}")
    finally:
        with extension.database.session() as fresh, extension.database.transaction(fresh):
            audit.log(
                fresh,
                actor=current_user.username,
                actor_id=current_user.id,
                action=f"{target.audit_prefix}.console_closed",
                target=target.label,
                detail=f"{commands} Kommandos, Mitschrift {transcript_path.name}",
                ip=client_ip(),
                severity="notice",
            )


def _read_command(ws, idle_timeout: int) -> str | None:  # type: ignore[no-untyped-def]
    """Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
    raw = ws.receive(timeout=idle_timeout)
    if raw is None:
        _send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.")
        return None
    try:
        message = json.loads(raw)
    except ValueError:
        return ""
    command = str(message.get("command") or "").strip()
    if not command:
        return ""
    if len(command) > MAX_LINE_LENGTH:
        _send(ws, "error", "Die Zeile ist zu lang.")
        return ""
    if command.lower() in {"exit", "quit", "logout"}:
        _send(ws, "info", "Sitzung beendet.")
        return None
    return command


def _run_switch_session(  # type: ignore[no-untyped-def]
    ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
    commands = 0
    with switchcli.SwitchCli(
        conn, host=target.host, port=target.port, username=username, password=password
    ) as cli:
        _send(ws, "ready", f"Verbunden mit {target.label} ({target.host}).")
        _send(ws, "output", cli.full_transcript)
        while True:
            command = _read_command(ws, idle_timeout)
            if command is None:
                break
            if not command:
                continue
            transcript.write(f"$ {command}\n")
            started = time.monotonic()
            try:
                output = cli.send(command, tolerate_error=True)
            except ssh.SshError as exc:
                _send(ws, "error", str(exc))
                transcript.write(f"! {exc}\n")
                break
            duration = int((time.monotonic() - started) * 1000)
            transcript.write(output + "\n")
            transcript.flush()
            commands += 1
            _send(ws, "output", output)
            _send(ws, "done", f"{duration} ms")
    return commands


def _run_client_session(  # type: ignore[no-untyped-def]
    ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
    commands = 0
    with shellcli.ShellSession(
        conn, host=target.host, port=target.port, username=username, password=password
    ) as session:
        _send(ws, "ready", f"Verbunden mit {target.label} ({target.host}) als {username}.")
        banner = session.banner()
        if banner:
            _send(ws, "output", banner)
            transcript.write(banner + "\n")
        while True:
            command = _read_command(ws, idle_timeout)
            if command is None:
                break
            if not command:
                continue
            transcript.write(f"$ {command}\n")
            try:
                result = session.run(command)
            except ssh.SshError as exc:
                _send(ws, "error", str(exc))
                transcript.write(f"! {exc}\n")
                break
            transcript.write(result["output"] + "\n")
            transcript.flush()
            commands += 1
            if result["output"]:
                _send(ws, "output", result["output"])
            # Der Rueckgabewert ist auf einem Client die eigentliche Antwort:
            # ein Kommando ohne Ausgabe kann trotzdem fehlgeschlagen sein.
            if result["status"] == 0:
                _send(ws, "done", f"{result['duration_ms']} ms")
            else:
                _send(
                    ws,
                    "error",
                    f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
                )
    return commands
