"""Clients: Bestand pflegen, Neustart ausloesen, SSH-Host-Schluessel freigeben.

Die Host-Schluessel-Freigabe gab es zuerst nur fuer Switche. Fuer Clients
fehlte sie -- mit der Folge, dass die Wartung sich weigerte ("Schluessel
unbekannt") und es keine Stelle gab, an der man ihn haette ansehen und
freigeben koennen. Eine Sackgasse, aus der man nur mit einem Shell-Zugang
auf den Server herauskam.
"""

from __future__ import annotations

from typing import Any

from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
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
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction

from ..services import inventory, monitor, restart, ssh

bp = Blueprint("devices", __name__, url_prefix="/clients")


def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
    audit.log(
        conn,
        actor=current_user.username,
        actor_id=current_user.id,
        action=action,
        target=target,
        detail=detail,
        ip=client_ip(),
        severity=severity,
    )


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


def _form_values() -> dict[str, Any]:
    return {
        "name": request.form.get("name", ""),
        "mac": request.form.get("mac", ""),
        "ip": request.form.get("ip", ""),
        "switch_id": request.form.get("switch_id") or None,
        "port": request.form.get("port", ""),
        "credential_id": request.form.get("credential_id") or None,
        "ssh_port": request.form.get("ssh_port") or 22,
        "category": request.form.get("category", ""),
        "location": request.form.get("location", ""),
        "notes": request.form.get("notes", ""),
        "is_active": request.form.get("is_active") == "1",
        "auto_restart": request.form.get("auto_restart") == "1",
        "actor": current_user.username,
    }


@bp.get("/")
@permission_required("devices.view")
def overview():
    conn = db()
    devices = inventory.list_devices(conn)
    maintenance_active = _maintenance_active()
    return render_template(
        "tesm/devices.html",
        devices=devices,
        restart_methods={
            int(device["id"]): restart.available(
                device, maintenance_active=maintenance_active
            )
            for device in devices
        },
        switches=inventory.list_switches(conn),
        category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
        credential_categories=inventory.CREDENTIAL_CATEGORIES,
        category_effects=inventory.CATEGORY_EFFECTS,
        credentials=inventory.list_credentials(conn),
        categories=inventory.DEVICE_CATEGORIES,
        counts=monitor.dashboard_counts(conn),
    )


def _credential_from_form(conn: Any) -> int | None:
    """Legt bei Bedarf neue Zugangsdaten an und gibt deren Kennung zurueck.

    ``None`` heisst: nichts anzulegen, das Formular hat eine vorhandene Auswahl
    (oder gar keine). Fehler werden als ``ValidationError`` durchgereicht,
    damit das Geraet dann ebenfalls nicht entsteht -- ein Geraet ohne die
    beabsichtigten Zugangsdaten waere eine stille Halbausfuehrung.
    """
    if request.form.get("credential_id") != "__neu__":
        return None
    if not current_user.has("credentials.create"):
        raise inventory.ValidationError(
            "Zum Anlegen neuer Zugangsdaten fehlt die Berechtigung."
        )
    name = (request.form.get("new_credential_name") or "").strip()
    if not name:
        raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.")
    return inventory.create_credential(
        conn,
        core().keystore,
        name=name,
        username=(request.form.get("new_credential_username") or "").strip(),
        secret=request.form.get("new_credential_secret") or "",
        category=request.form.get("new_credential_category", "linux"),
        description="Beim Anlegen eines Geraets erfasst.",
        actor=current_user.username,
    )


@bp.post("/neu")
@permission_required("devices.create")
def create():
    try:
        with transaction() as conn:
            values = _form_values()
            new_credential = _credential_from_form(conn)
            if new_credential is not None:
                values["credential_id"] = new_credential
            device_id = inventory.create_device(conn, **values)
            _audit(
                conn,
                "device.created",
                request.form.get("name", ""),
                f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
                severity="notice",
            )
        flash("Geraet angelegt.", "success")
        return redirect(url_for("devices.overview", highlight=device_id))
    except inventory.ValidationError as exc:
        flash(str(exc), "danger")
        return redirect(url_for("devices.overview"))


@bp.post("/<int:device_id>")
@permission_required("devices.edit")
def update(device_id: int):
    try:
        with transaction() as conn:
            values = _form_values()
            new_credential = _credential_from_form(conn)
            if new_credential is not None:
                values["credential_id"] = new_credential
            changes = inventory.update_device(conn, device_id, **values)
            if changes:
                _audit(
                    conn,
                    "device.updated",
                    request.form.get("name", str(device_id)),
                    "; ".join(changes),
                    severity="notice",
                )
        flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
    except inventory.ValidationError as exc:
        flash(str(exc), "danger")
    return redirect(url_for("devices.overview"))


@bp.post("/<int:device_id>/aktiv")
@permission_required("devices.edit")
def toggle_active(device_id: int):
    active = request.form.get("active") == "1"
    with transaction() as conn:
        device = inventory.get_device(conn, device_id)
        if device is None:
            abort(404)
        inventory.set_device_active(conn, device_id, active)
        _audit(
            conn,
            "device.activated" if active else "device.deactivated",
            str(device["name"]),
            severity="notice",
        )
    flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
    return redirect(request.referrer or url_for("devices.overview"))


@bp.post("/<int:device_id>/loeschen")
@permission_required("devices.delete")
def delete(device_id: int):
    with transaction() as conn:
        device = inventory.get_device(conn, device_id)
        if device is None:
            abort(404)
        inventory.delete_device(conn, device_id)
        _audit(conn, "device.deleted", str(device["name"]), severity="warning")
    flash("Geraet in den Papierkorb verschoben.", "success")
    return redirect(url_for("devices.overview"))


@bp.post("/<int:device_id>/neustart")
@permission_required("devices.execute")
def restart_device(device_id: int):
    """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.

    Der PoE-Neustart ist bewusst **nicht** lizenzpflichtig: ein Kunde ohne
    gueltige Lizenz soll ein haengendes Geraet weiter von Hand neu starten
    koennen. Nur der *automatische* Neustart bei Ausfall und die Wege ueber
    SSH bzw. RPC gehoeren zum Modul Wartung.
    """
    extension = core()
    conn = db()
    device = inventory.get_device(conn, device_id)
    if device is None or device["deleted_at"]:
        abort(404)

    maintenance_active = _maintenance_active()
    method = restart.resolve(
        dict(device),
        (request.form.get("method") or "").strip(),
        maintenance_active=maintenance_active,
    )
    if method is None:
        options = restart.available(dict(device), maintenance_active=maintenance_active)
        if options:
            flash(
                "Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
                "Moeglich waere: " + ", ".join(entry.label for entry in options),
                "warning",
            )
        else:
            flash(
                f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
                "fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.",
                "warning",
            )
        return redirect(request.referrer or url_for("devices.overview"))

    if method.is_poe:
        return _restart_over_poe(extension, conn, device)
    return _restart_as_job(extension, conn, device, method)


def _restart_over_poe(extension: Any, conn: Any, device: Any):  # type: ignore[no-untyped-def]
    """Sofort und synchron -- der Portzyklus dauert nur Sekunden."""
    live = extension.registry.get("live_log")
    try:
        outcome = monitor.restart_device_poe(
            conn,
            dict(device),
            keystore=extension.keystore,
            trigger="manuell",
            actor=current_user.username,
            live=live,
            database=extension.database,
        )
        with transaction():
            _audit(
                conn,
                "device.poe_restart",
                str(device["name"]),
                outcome["message"],
                severity="notice" if outcome["ok"] else "warning",
            )
    except inventory.ValidationError as exc:
        flash(str(exc), "danger")
        return redirect(request.referrer or url_for("devices.overview"))

    flash(
        f"PoE-Neustart fuer {device['name']} durchgefuehrt."
        if outcome["ok"]
        else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
        "success" if outcome["ok"] else "danger",
    )
    return redirect(request.referrer or url_for("devices.overview"))


def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any):  # type: ignore[no-untyped-def]
    """Ueber SSH bzw. RPC -- als Auftrag, denn das Warten dauert Minuten."""
    from ..services import maintenance as maintenance_service

    try:
        username, password = inventory.credential_secret(
            conn, extension.keystore, int(device["credential_id"])
        )
    except inventory.ValidationError as exc:
        flash(f"{device['name']}: {exc}", "danger")
        return redirect(request.referrer or url_for("devices.overview"))

    with transaction():
        handle = maintenance_service.create_job(
            conn,
            kind=maintenance_service.JOB_KIND_REBOOT,
            subject=str(device["name"]),
            actor=current_user.username,
            message="Wartet auf Start",
        )
        _audit(
            conn,
            "device.restart",
            str(device["name"]),
            f"{method.label}, Auftrag {handle.id}",
            severity="notice",
        )

    maintenance_service.start_background(
        maintenance_service.run_reboot,
        database=extension.database,
        job_id=handle.id,
        device=dict(device),
        username=username,
        password=password,
        wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
        actor=current_user.username,
        # Der Weg ist oben schon entschieden (restart.resolve). Ihn hier
        # mitzugeben ist der Unterschied zwischen "Windows-Geraet ueber RPC" und
        # "Windows-Geraet ueber SSH auf Port 22, bis das Zeitlimit greift".
        method=method.key,
    )
    flash(
        f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
        "success",
    )
    return redirect(request.referrer or url_for("devices.overview"))


@bp.get("/<int:device_id>")
@permission_required("devices.view")
def detail(device_id: int):
    conn = db()
    device = inventory.get_device(conn, device_id)
    if device is None:
        abort(404)
    events = Database.all(
        conn,
        "SELECT * FROM restart_events WHERE device_id=? ORDER BY ts DESC LIMIT 50",
        (device_id,),
    )
    host_keys = ssh.all_known_keys(conn, str(device["ip"]), int(device["ssh_port"] or 22))
    extension = core()
    return render_template(
        "tesm/device_detail.html",
        device=device,
        restart_methods=restart.available(
            device, maintenance_active=_maintenance_active()
        ),
        events=events,
        switches=inventory.list_switches(conn),
        credentials=inventory.list_credentials(conn),
        categories=inventory.DEVICE_CATEGORIES,
        host_keys=host_keys,
        method_labels=monitor.METHOD_LABELS,
        terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
    )


# --------------------------------------------------------------------------- #
# SSH-Host-Schluessel
# --------------------------------------------------------------------------- #


@bp.post("/<int:device_id>/hostkey/pruefen")
@permission_required("devices.edit")
def probe_host_key(device_id: int):
    """Liest den Host-Schluessel des Clients, ohne sich anzumelden.

    Ausdruecklich getrennt vom Freigeben: erst ansehen und vergleichen, dann
    -- in einem zweiten, bestaetigten Schritt -- freigeben.
    """
    conn = db()
    device = inventory.get_device(conn, device_id)
    if device is None:
        abort(404)
    try:
        record = ssh.probe_host_key(str(device["ip"]), int(device["ssh_port"] or 22))
    except ssh.SshError as exc:
        flash(str(exc), "danger")
        return redirect(url_for("devices.detail", device_id=device_id))

    level, message = ssh.describe_probe(conn, record)
    flash(message, level)
    return redirect(
        url_for(
            "devices.detail",
            device_id=device_id,
            probed_type=record.key_type,
            probed_fingerprint=record.fingerprint,
        )
    )


@bp.post("/<int:device_id>/hostkey/freigeben")
@permission_required("devices.edit")
@reauth_required
def trust_host_key(device_id: int):
    conn = db()
    device = inventory.get_device(conn, device_id)
    if device is None:
        abort(404)
    key_type = (request.form.get("key_type") or "").strip()
    fingerprint = (request.form.get("fingerprint") or "").strip()
    if not key_type or not ssh.valid_fingerprint(fingerprint):
        flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
        return redirect(url_for("devices.detail", device_id=device_id))

    with transaction():
        ssh.trust_key(
            conn,
            host=str(device["ip"]),
            port=int(device["ssh_port"] or 22),
            key_type=key_type,
            fingerprint=fingerprint,
            actor=current_user.username,
        )
        _audit(
            conn,
            "device.host_key_trusted",
            str(device["name"]),
            f"{key_type} {fingerprint}",
            severity="notice",
        )
    flash("Host-Schluessel freigegeben.", "success")
    return redirect(url_for("devices.detail", device_id=device_id))


@bp.post("/<int:device_id>/hostkey/verwerfen")
@permission_required("devices.edit")
def forget_host_key(device_id: int):
    conn = db()
    device = inventory.get_device(conn, device_id)
    if device is None:
        abort(404)
    with transaction():
        ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
        _audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
    flash("Gespeicherte Host-Schluessel entfernt.", "info")
    return redirect(url_for("devices.detail", device_id=device_id))
