"""Wartung: Paketaktualisierung und Neustart ueber SSH.

Lizenzpflichtiges Vollmodul. Es erscheinen Geraete, deren Zugangsdaten die
Kategorie ``linux`` oder ``windows`` tragen:

* **Linux** -- Paketaktualisierung (apt) und Neustart.
* **Windows** -- nur Neustart, ueber ``shutdown.exe``. Das Geraet braucht dafuer
  den OpenSSH-Server. Paketaktualisierung bleibt bewusst aus: dafuer braeuchte
  es PSWindowsUpdate oder WSUS, und beides darf man nicht voraussetzen.

Die Kategorie setzt der Administrator ausdruecklich, sie wird bewusst nicht per
Ping/TTL erraten (das war im Vorgaenger schon so und ist richtig: eine falsch
geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
"""

from __future__ import annotations

from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_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, maintenance as service, ssh, windowsops

bp = Blueprint("maintenance", __name__, url_prefix="/wartung")


@bp.before_request
def _require_module() -> None:
    manager = core().license
    if manager is not None and not manager.module_active("maintenance"):
        abort(404)


def _eligible_devices(conn) -> list[dict]:  # type: ignore[no-untyped-def]
    """Geraete, auf denen die Wartung ueberhaupt arbeiten kann.

    Zusaetzlich zur Auswahl steht am Ergebnis, ob der SSH-Host-Schluessel schon
    freigegeben ist. Ohne diese Angabe lief man in eine Sackgasse: die Wartung
    brach mit "Schluessel unbekannt" ab, und auf der Seite war nicht zu sehen,
    woran es lag oder wo man es beheben kann.
    """
    rows = _query_devices(conn)
    for row in rows:
        row["ssh_managed"] = inventory.is_ssh_managed(row["category"])
        # Windows braucht keinen Host-Schluessel -- dort waere "offen" eine
        # Warnung, die sich nie abstellen laesst.
        row["host_key_ready"] = (
            ssh.is_trusted(conn, str(row["ip"]), int(row["ssh_port"] or 22))
            if row["ssh_managed"]
            else True
        )
    return rows


def _query_devices(conn) -> list[dict]:  # type: ignore[no-untyped-def]
    return Database.all(
        conn,
        """
        SELECT d.id, d.name, d.ip, d.ssh_port, c.name AS credential_name, c.id AS credential_id,
               c.category AS category,
               COALESCE(st.state,'unknown') AS state
          FROM devices d
          JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
                            AND c.category IN ({placeholders})
          LEFT JOIN device_status st ON st.device_id = d.id
         WHERE d.deleted_at IS NULL AND d.is_active = 1
         ORDER BY d.name COLLATE NOCASE
        """.format(placeholders=",".join("?" * len(inventory.MAINTENANCE_CATEGORIES))),
        inventory.MAINTENANCE_CATEGORIES,
    )


@bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def overview():
    conn = db()
    return render_template(
        "tesm/maintenance.html",
        devices=_eligible_devices(conn),
        jobs=service.list_jobs(conn),
        category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
        category_effects=inventory.CATEGORY_EFFECTS,
        update_category=inventory.MAINTENANCE_CATEGORY,
        terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
        windows_tool_missing=_windows_tool_missing(conn),
        windows_package=windowsops.REQUIRED_PACKAGE,
    )


def _windows_tool_missing(conn) -> bool:  # type: ignore[no-untyped-def]
    """Fehlt ``net``, obwohl Windows-Geraete gepflegt werden?

    Nur dann ist der Hinweis angebracht. Auf einem Server ohne Windows-Geraete
    waere er Laerm, und das Paket waere unnoetig installiert.
    """
    if windowsops.available():
        return False
    return any(
        str(device.get("category") or "") == inventory.NON_SSH_CATEGORY
        for device in _eligible_devices(conn)
    )


@bp.post("/paket-nachinstallieren")
@permission_required("maintenance.execute")
def install_windows_tool():
    """Installiert das Paket, das der Fehlermeldung nach fehlt.

    Ohne diesen Weg endete der Neustart eines Windows-Geraetes in einer
    Sackgasse: die Meldung nannte das fehlende Paket, aus der Oberflaeche liess
    es sich aber nicht beschaffen.
    """
    sysops = core().sysops
    if sysops is None or not sysops.available:
        flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
        return redirect(url_for("maintenance.overview"))

    result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
    if result.ok:
        flash(f"{windowsops.REQUIRED_PACKAGE} installiert.", "success")
    else:
        flash(f"Installation fehlgeschlagen: {result.message}", "danger")
    with transaction() as conn:
        audit.log(
            conn,
            actor=current_user.username,
            actor_id=current_user.id,
            action="maintenance.package_installed",
            target=windowsops.REQUIRED_PACKAGE,
            detail="" if result.ok else result.message,
            ip=client_ip(),
            severity="notice" if result.ok else "warning",
        )
    return redirect(url_for("maintenance.overview"))


@bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def status():
    """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
    conn = db()
    jobs = service.list_jobs(conn, limit=30)
    running = any(job["state"] in {"pending", "running"} for job in jobs)
    return jsonify(
        {
            "running": running,
            "poll_ms": 2000 if running else 10000,
            "jobs": [
                {
                    "id": job["id"],
                    "kind": job["kind"],
                    "subject": job["subject"],
                    "state": job["state"],
                    "progress": job["progress"],
                    "message": job["message"],
                    "updated_at": job["updated_at"],
                }
                for job in jobs
            ],
        }
    )


@bp.get("/auftrag/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_detail(job_id: str):
    conn = db()
    job = service.get_job(conn, job_id)
    if job is None:
        abort(404)
    return render_template("tesm/maintenance_job.html", job=job)


@bp.get("/auftrag/<job_id>/ausgabe")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_output(job_id: str):
    conn = db()
    job = service.get_job(conn, job_id)
    if job is None:
        abort(404)
    return render_template("tesm/_maintenance_output.html", job=job)


def _start(kind: str) -> None:
    extension = core()
    conn = db()
    device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
    if not device_ids:
        flash("Es wurde kein Geraet ausgewaehlt.", "warning")
        return

    eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
    started = 0
    for device_id in device_ids:
        device = eligible.get(device_id)
        if device is None:
            continue
        # Vor dem Anlegen pruefen, nicht danach: sonst bliebe ein Auftrag
        # zurueck, der nie gestartet wird und ewig auf "wartet" steht.
        if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
            flash(
                f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. "
                "Windows-Geraete lassen sich hier nur neu starten.",
                "warning",
            )
            continue

        try:
            username, password = inventory.credential_secret(
                conn, extension.keystore, int(device["credential_id"])
            )
        except inventory.ValidationError as exc:
            flash(f"{device['name']}: {exc}", "danger")
            continue

        with transaction():
            handle = service.create_job(
                conn,
                kind=kind,
                subject=str(device["name"]),
                actor=current_user.username,
                message="Wartet auf Start",
            )
            audit.log(
                conn,
                actor=current_user.username,
                actor_id=current_user.id,
                action=kind,
                target=str(device["name"]),
                detail=f"Auftrag {handle.id}",
                ip=client_ip(),
                severity="notice",
            )

        if kind == service.JOB_KIND_UPDATE:
            service.start_background(
                service.run_update,
                database=extension.database,
                job_id=handle.id,
                device=dict(device),
                username=username,
                password=password,
                timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
            )
        else:
            service.start_background(
                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,
                # Hier steht in ``category`` die Kategorie der *Zugangsdaten*
                # (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
                # statt in run_reboot erraten: dort bedeutet derselbe
                # Schluessel je Herkunft etwas anderes.
                method=(
                    service.METHOD_RPC
                    if str(device["category"]) == inventory.NON_SSH_CATEGORY
                    else service.METHOD_SSH
                ),
            )
        started += 1

    if started:
        flash(f"{started} Auftrag/Auftraege gestartet.", "success")


@bp.post("/aktualisieren")
@permission_required("maintenance.execute")
def start_update():
    _start(service.JOB_KIND_UPDATE)
    return redirect(url_for("maintenance.overview"))


@bp.post("/neustarten")
@permission_required("maintenance.execute")
def start_reboot():
    _start(service.JOB_KIND_REBOOT)
    return redirect(url_for("maintenance.overview"))
