"""Wartung: Paketaktualisierung und Neustart, als nachvollziehbare Auftraege.

Klar getrennt vom PoE-Neustart: das Dashboard schaltet immer den Switch-Port,
die Wartung spricht immer direkt mit dem Geraet.

Der Weg dorthin haengt am Betriebssystem:

* **Linux** -- SSH. Host-Schluessel muss freigegeben sein, ``sudo`` bekommt bei
  Bedarf das hinterlegte Passwort ueber stdin.
* **Windows** -- RPC ueber :mod:`tesm.services.windowsops`. Kein SSH, kein
  Host-Schluessel; nur Neustart, keine Paketaktualisierung.

Aenderung gegenueber dem Vorgaenger: Auftragszustand lag dort ausschliesslich im
Prozessspeicher. Ein Dienst-Neustart verlor die Anzeige komplett -- inklusive der
Information, ob ueberhaupt etwas gelaufen war. Hier liegt jeder Auftrag in der
Tabelle ``jobs``: ueberlebt Neustarts, ist im Verlauf einsehbar und kann nach
einem Abbruch als "unklar beendet" erkannt werden.

Host-Schluessel werden hier **immer** streng geprueft. Bei einem Hintergrundlauf
sieht niemand zu; ein automatisches Akzeptieren waere ein Einfallstor.
"""

from __future__ import annotations

import logging
import re
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable

from tesm_core import audit
from tesm_core.db import Database

from . import windowsops
from .monitor import METHOD_SSH, record_restart, tcp_reachable
from .restart import METHOD_RPC
from .ssh import SshError, open_transport

logger = logging.getLogger(__name__)

JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot"

MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25
REBOOT_GRACE_SECONDS = 15
REBOOT_POLL_SECONDS = 5

#: Die Wartungsschritte. ``{sudo}`` wird zur Laufzeit eingesetzt -- je nachdem,
#: ob das Zielgeraet fuer ``sudo`` ein Passwort verlangt.
UPDATE_COMMANDS: tuple[tuple[str, str], ...] = (
    (
        "Paketlisten aktualisieren",
        "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get update",
    ),
    (
        "Pakete aktualisieren",
        "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y "
        '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
    ),
    ("Aufraeumen", "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y autoremove"),
)

REBOOT_COMMAND = "{sudo} reboot"

#: Windows wird **nicht** ueber SSH bedient -- siehe :mod:`tesm.services.windowsops`.
#: Diese Geraete haben in aller Regel keinen SSH-Dienst und damit auch keinen
#: Host-Schluessel, den jemand freigeben koennte.
WINDOWS_CATEGORY = "windows"

#: Woran ein Windows-Geraet als "wieder da" erkannt wird: Port 445 (SMB/RPC) --
#: derselbe Dienst, ueber den der Neustart ausgeloest wurde.
WINDOWS_PROBE_PORT = 445

#: ``-S`` liest das Passwort von stdin, ``-p ''`` unterdrueckt die Aufforderung
#: (sie landete sonst in der Ausgabe), ``-k`` verwirft einen zwischen-
#: gespeicherten sudo-Zeitstempel, damit der Ablauf reproduzierbar bleibt.
SUDO_WITH_PASSWORD = "sudo -S -k -p ''"
SUDO_PASSWORDLESS = "sudo -n"

COMPLETION_MARKER = "### TESM-WARTUNG-ENDE ###"

#: Fortschrittsgeschwaetz von dpkg. Ohne Terminal kann dpkg die Zeile nicht
#: ueberschreiben und schreibt stattdessen jede Stufe einzeln -- zwanzig Zeilen
#: "(Reading database ... 45%)" je Paket. Das blaeht das Protokoll so weit auf,
#: dass die eigentlichen Meldungen darin untergehen und die Zeilenbegrenzung
#: alles Interessante hinausschiebt.
NOISE_PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(r"^\(?Reading database \.\.\.\s*\d+%\)?\s*$"),
    re.compile(r"^\(?Reading database \.\.\.\s*$"),
    re.compile(r"^Progress: \[\s*\d+%\]"),
    re.compile(r"^\s*$"),
)


def is_noise(line: str) -> bool:
    """Reine Fortschrittsanzeige ohne Informationsgehalt?

    Die Abschlusszeile mit der Dateizahl bleibt erhalten -- sie sagt etwas aus.
    """
    return any(pattern.match(line) for pattern in NOISE_PATTERNS)


#: Fehlerbilder, die auf dem Zielgeraet liegen und dort behoben werden muessen.
#: Die rohe Meldung sagt zwar die Wahrheit, aber nicht, was zu tun ist.
HINTS: tuple[tuple[str, str], ...] = (
    (
        "incorrect password attempt",
        "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die "
        "SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein "
        "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
    ),
    (
        "is not in the sudoers file",
        "Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem "
        "Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
        "'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
        "NOPASSWD: entfaellt die Passwortabfrage ganz.",
    ),
    (
        "a password is required",
        "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
        "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
        "es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
    ),
    (
        "no tty present",
        "Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
        "Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
    ),
    (
        "command not found",
        "Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
        "Debian- oder Ubuntu-System mit apt-get.",
    ),
    (
        "Access is denied",
        "Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
        "Mitglied der lokalen Administratoren sein.",
    ),
    (
        "is not recognized as an internal or external command",
        "Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
        "richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
    ),
    (
        "Could not get lock",
        "Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
        "unattended-upgrades). Spaeter erneut versuchen.",
    ),
)


def hint_for(output: str) -> str:
    """Sucht zu einer Fehlerausgabe den passenden Hinweis -- oder gibt "" zurueck."""
    for needle, hint in HINTS:
        if needle in output:
            return hint
    return ""


def _now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


@dataclass(slots=True)
class JobHandle:
    id: str
    kind: str
    subject: str


def create_job(
    conn: sqlite3.Connection, *, kind: str, subject: str, actor: str, message: str = ""
) -> JobHandle:
    job_id = secrets.token_urlsafe(12)
    conn.execute(
        "INSERT INTO jobs (id, kind, subject, state, progress, message, output, started_by, "
        "created_at, updated_at) VALUES (?,?,?,'pending',0,?,'',?,?,?)",
        (job_id, kind, subject, message, actor, _now(), _now()),
    )
    return JobHandle(id=job_id, kind=kind, subject=subject)


def update_job(
    database: Any,
    job_id: str,
    *,
    state: str | None = None,
    progress: int | None = None,
    message: str | None = None,
    append_output: str = "",
    finished: bool = False,
) -> None:
    with database.session() as conn, database.transaction(conn):
        fields: list[str] = ["updated_at=?"]
        params: list[Any] = [_now()]
        if state is not None:
            fields.append("state=?")
            params.append(state)
        if progress is not None:
            fields.append("progress=?")
            params.append(max(0, min(100, progress)))
        if message is not None:
            fields.append("message=?")
            params.append(message[:500])
        if finished:
            fields.append("finished_at=?")
            params.append(_now())
        if append_output:
            current = str(
                Database.value(conn, "SELECT output FROM jobs WHERE id=?", (job_id,), default="")
                or ""
            )
            combined = current + append_output
            lines = combined.splitlines()
            trimmed = "\n".join(lines[-MAX_OUTPUT_LINES:])
            # splitlines() wirft ein abschliessendes Zeilenende weg. Ohne diese
            # Zeile klebte jede neue Ausgabe an der vorherigen, und das ganze
            # Protokoll stand am Ende auf einer einzigen Zeile.
            if combined.endswith("\n"):
                trimmed += "\n"
            fields.append("output=?")
            params.append(trimmed)
        params.append(job_id)
        conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", tuple(params))


def list_jobs(
    conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
) -> list[dict[str, Any]]:
    placeholders = ",".join("?" * len(kinds))
    return Database.all(
        conn,
        f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
        (*kinds, limit),
    )


def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
    return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))


def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
    """Auftraege, die beim Dienststart noch als "laufend" gelten, ehrlich kennzeichnen.

    Ein abgebrochener Webprozess weiss nicht, was auf dem Zielgeraet passiert
    ist -- also wird der Auftrag als "unklar" markiert statt als erfolgreich
    oder fehlgeschlagen.
    """
    cursor = conn.execute(
        "UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
        "WHERE state IN ('pending','running')",
        (
            "Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
            "passiert ist, laesst sich von hier nicht mehr feststellen.",
            _now(),
            _now(),
        ),
    )
    return cursor.rowcount or 0


# --------------------------------------------------------------------------- #
# Ausfuehrung
# --------------------------------------------------------------------------- #


def _run_remote_command(
    transport: Any,
    command: str,
    *,
    timeout: int,
    on_line: Callable[[str], None],
    stdin_text: str = "",
) -> int:
    """Fuehrt ein Kommando aus und liefert dessen Rueckgabewert.

    ``stdin_text`` geht ueber den Kanal an das Kommando -- so und nur so bekommt
    ``sudo -S`` sein Passwort. Auf der Kommandozeile stuende es auf dem
    Zielgeraet in ``ps``, in der Umgebung in ``/proc/<pid>/environ``.
    """
    channel = transport.open_session()
    channel.settimeout(timeout)
    channel.set_combine_stderr(True)
    channel.exec_command(command)
    if stdin_text:
        try:
            channel.sendall(stdin_text.encode("utf-8"))
            channel.shutdown_write()
        except Exception as exc:  # noqa: BLE001
            on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]")
    deadline = time.monotonic() + timeout
    pending = b""
    while True:
        if channel.recv_ready():
            chunk = channel.recv(32768)
            if not chunk:
                break
            pending += chunk
            while b"\n" in pending:
                line, pending = pending.split(b"\n", 1)
                on_line(line.decode("utf-8", errors="replace"))
        elif channel.exit_status_ready():
            break
        elif time.monotonic() > deadline:
            on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]")
            channel.close()
            return 124
        else:
            time.sleep(0.1)
    if pending:
        on_line(pending.decode("utf-8", errors="replace"))
    status = channel.recv_exit_status()
    channel.close()
    return int(status)


def _sudo_mode(
    transport: Any, password: str, on_line: Callable[[str], None]
) -> tuple[str, str]:
    """Bestimmt, wie ``sudo`` auf diesem Geraet aufzurufen ist.

    Rueckgabe: ``(praefix, stdin)`` -- entweder ``sudo -n`` ohne Eingabe oder
    ``sudo -S`` mit dem Passwort auf stdin.

    Die Reihenfolge ist Absicht: geht es ohne Passwort (sudoers-Regel mit
    NOPASSWD), verlaesst das Passwort den Server gar nicht erst. Erst wenn
    ``sudo`` eines verlangt, wird das in den Zugangsdaten hinterlegte
    weitergereicht -- ueber stdin der bereits verschluesselten Verbindung.
    """
    swallowed: list[str] = []
    try:
        status = _run_remote_command(
            transport, f"{SUDO_PASSWORDLESS} true", timeout=20, on_line=swallowed.append
        )
    except Exception:  # noqa: BLE001 - dann eben der Weg mit Passwort
        status = 1
    if status == 0:
        on_line("sudo ist ohne Passwort moeglich.")
        return SUDO_PASSWORDLESS, ""
    if not password:
        on_line(
            "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
        )
        return SUDO_PASSWORDLESS, ""
    on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.")
    return SUDO_WITH_PASSWORD, password + "\n"


def run_update(
    database: Any,
    *,
    job_id: str,
    device: dict[str, Any],
    username: str,
    password: str,
    timeout: int,
) -> None:
    """Fuehrt die Paketaktualisierung aus. Laeuft in einem eigenen Thread."""
    name = str(device.get("name") or device.get("ip"))

    def emit(line: str) -> None:
        stamp = datetime.now().strftime("%H:%M:%S")
        update_job(database, job_id, append_output=f"{stamp}  {line}\n")

    update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")
    try:
        with database.session() as conn:
            transport = open_transport(
                conn,
                host=str(device["ip"]),
                port=int(device.get("ssh_port") or 22),
                username=username,
                password=password,
            )
    except SshError as exc:
        emit(f"FEHLER: {exc}")
        update_job(database, job_id, state="failed", message=str(exc), finished=True)
        return

    failed = False
    problem = ""
    sudo, sudo_stdin = _sudo_mode(transport, password, emit)
    try:
        total = len(UPDATE_COMMANDS)
        for index, (label, template) in enumerate(UPDATE_COMMANDS, start=1):
            update_job(
                database,
                job_id,
                progress=int(5 + (index - 1) / total * 90),
                message=f"Schritt {index}/{total}: {label}",
            )
            emit(f"--- {label} ---")
            collected: list[str] = []

            def capture(line: str) -> None:
                # Fuer die Fehlersuche wird alles gesammelt, ins Protokoll
                # kommt nur, was etwas aussagt.
                collected.append(line)
                if not is_noise(line):
                    emit(line)

            command = template.format(sudo=sudo)
            status = _run_remote_command(
                transport, command, timeout=timeout, on_line=capture, stdin_text=sudo_stdin
            )
            if status != 0:
                failed = True
                emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.")
                problem = hint_for("\n".join(collected))
                if problem:
                    emit("")
                    emit(f"Hinweis: {problem}")
                break
    finally:
        try:
            transport.close()
        except Exception:  # noqa: BLE001
            pass

    emit(COMPLETION_MARKER)
    update_job(
        database,
        job_id,
        state="failed" if failed else "succeeded",
        progress=100,
        message=(
            (problem or "Ein Schritt ist fehlgeschlagen.")
            if failed
            else "Aktualisierung abgeschlossen."
        )[:500],
        finished=True,
    )
    with database.session() as conn, database.transaction(conn):
        audit.log(
            conn,
            actor="system",
            action="maintenance.update_finished",
            target=name,
            detail="fehlgeschlagen" if failed else "erfolgreich",
            severity="warning" if failed else "notice",
        )


def _reboot_windows(
    database: Any,
    *,
    job_id: str,
    device: dict[str, Any],
    username: str,
    password: str,
    actor: str,
    emit: Callable[[str], None],
    elapsed: Callable[[], int],
) -> bool:
    """Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.

    Getrennt gehalten, weil hier fast nichts gemeinsam ist: keine
    SSH-Verbindung, kein Host-Schluessel, kein sudo. Nur das Warten auf die
    Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
    """
    host = str(device["ip"])
    emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
    result = windowsops.reboot(host, username, password)

    for line in result.output.splitlines():
        if not is_noise(line):
            emit(line)
    if result.ok:
        return True

    detail = result.hint or result.output or "Der Neustart wurde abgewiesen."
    if result.hint:
        emit("")
        emit(f"Hinweis: {result.hint}")
    update_job(database, job_id, state="failed", progress=100, message=detail[:500], finished=True)
    _log_restart(
        database,
        device,
        result="error",
        actor=actor,
        duration_ms=elapsed(),
        detail=detail,
        method=METHOD_RPC,
    )
    return False


def run_reboot(
    database: Any,
    *,
    job_id: str,
    device: dict[str, Any],
    username: str,
    password: str,
    wait_seconds: int,
    actor: str = "system",
    method: str = METHOD_SSH,
) -> None:
    """Loest einen Neustart aus und wartet auf die Rueckkehr.

    Der Verbindungsabbruch direkt nach dem Kommando ist der **erwartete** Fall
    und gilt als Erfolg. Nur ein Fehler *vor* dem Kommando (Anmeldung,
    Erreichbarkeit) ist ein echter Fehlschlag.
    """
    name = str(device.get("name") or device.get("ip"))
    started = time.monotonic()

    def emit(line: str) -> None:
        stamp = datetime.now().strftime("%H:%M:%S")
        update_job(database, job_id, append_output=f"{stamp}  {line}\n")

    def elapsed() -> int:
        return int((time.monotonic() - started) * 1000)

    host = str(device["ip"])
    # Der Weg wird **uebergeben**, nicht aus dem Geraet abgeleitet. Vorher stand
    # hier ``device["category"]`` -- und dieser Schluessel bedeutet je Herkunft
    # etwas anderes: die Wartungsabfrage legt die Kategorie der *Zugangsdaten*
    # darunter, ``inventory.get_device`` die des *Geraets*. Ueber die
    # Statusuebersicht kam damit ein Windows-Geraet mit leerer Geraetekategorie
    # an, wurde fuer Linux gehalten und ueber SSH auf Port 22 angesprochen --
    # bis zum Zeitlimit. Die Entscheidung faellt jetzt einmal beim Aufrufer
    # (``restart.resolve``) und wird hier nur noch befolgt.
    is_windows = method == METHOD_RPC
    # Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
    # den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows.
    probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22)

    update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")

    collected: list[str] = []

    def capture(line: str) -> None:
        collected.append(line)
        if not is_noise(line):
            emit(line)

    if is_windows:
        # Windows wird nicht ueber SSH bedient -- kein Transport, kein
        # Host-Schluessel, kein sudo.
        if not _reboot_windows(
            database,
            job_id=job_id,
            device=device,
            username=username,
            password=password,
            actor=actor,
            emit=emit,
            elapsed=elapsed,
        ):
            return
    else:
        try:
            with database.session() as conn:
                transport = open_transport(
                    conn,
                    host=host,
                    port=int(device.get("ssh_port") or 22),
                    username=username,
                    password=password,
                )
        except SshError as exc:
            emit(f"FEHLER: {exc}")
            update_job(database, job_id, state="failed", message=str(exc), finished=True)
            _log_restart(
                database,
                device,
                result="error",
                actor=actor,
                duration_ms=elapsed(),
                detail=str(exc),
                method=method,
            )
            return

        sudo, sudo_stdin = _sudo_mode(transport, password, emit)
        command = REBOOT_COMMAND.format(sudo=sudo)
        try:
            emit(f"Neustart wird ausgeloest ({command})")
            try:
                _run_remote_command(
                    transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
                )
            except Exception as exc:  # noqa: BLE001 - Abbruch ist hier normal
                emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.")
        finally:
            try:
                transport.close()
            except Exception:  # noqa: BLE001
                pass

        # Scheitert schon das Kommando, geht das Geraet gar nicht erst herunter.
        # Dann waere die Wartezeit reine Zeitverschwendung -- und die Meldung
        # "nicht zurueckgekehrt" waere irrefuehrend: es war nie weg.
        problem = hint_for("\n".join(collected))
        if problem:
            emit("")
            emit(f"Hinweis: {problem}")
            update_job(
                database,
                job_id,
                state="failed",
                progress=100,
                message=problem[:500],
                finished=True,
            )
            _log_restart(
                database,
                device,
                result="error",
                actor=actor,
                duration_ms=elapsed(),
                detail=problem,
                method=method,
            )
            return

    update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets")
    time.sleep(REBOOT_GRACE_SECONDS)
    deadline = time.monotonic() + wait_seconds
    ssh_port = probe_port
    emit(f"Warte darauf, dass {host}:{ssh_port} wieder antwortet.")
    while time.monotonic() < deadline:
        # TCP statt ICMP: der Webprozess laeuft ohne CAP_NET_RAW, dort schlaegt
        # jeder Ping fehl -- der Auftrag lief deshalb immer in die
        # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
        # "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
        if tcp_reachable(host, ssh_port):
            emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).")
            update_job(
                database,
                job_id,
                state="succeeded",
                progress=100,
                message="Neustart abgeschlossen.",
                finished=True,
            )
            with database.session() as conn, database.transaction(conn):
                audit.log(
                    conn,
                    actor="system",
                    action="maintenance.reboot_finished",
                    target=name,
                    detail="erfolgreich",
                    severity="notice",
                )
            _log_restart(
                database,
                device,
                result="ok",
                actor=actor,
                duration_ms=elapsed(),
                detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
                method=method,
            )
            return
        time.sleep(REBOOT_POLL_SECONDS)

    emit(f"{host}:{ssh_port} hat innerhalb der Wartezeit nicht wieder geantwortet.")
    update_job(
        database,
        job_id,
        state="failed",
        progress=100,
        message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
        finished=True,
    )
    _log_restart(
        database,
        device,
        result="error",
        actor=actor,
        duration_ms=elapsed(),
        detail=(
            f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
            f"{wait_seconds}s nicht wieder geantwortet."
        ),
        method=method,
            )


def _log_restart(
    database: Any,
    device: dict[str, Any],
    *,
    result: str,
    actor: str,
    duration_ms: int,
    detail: str,
    method: str = METHOD_SSH,
) -> None:
    """Traegt den Neustart in die geraeteweite Neustart-Historie ein.

    Der Wartungsauftrag steht ohnehin in ``jobs`` -- aber wer wissen will,
    warum ein *Geraet* weg war, sieht in dessen Neustart-Historie nach, nicht
    in einer Auftragsliste. Vorher tauchten dort nur PoE-Neustarts auf.
    """
    device_id = device.get("id")
    if not device_id:
        return
    try:
        with database.session() as conn, database.transaction(conn):
            record_restart(
                conn,
                device_id=int(device_id),
                action="reboot",
                result=result,
                trigger="maintenance",
                actor=actor,
                duration_ms=duration_ms,
                detail=detail,
                method=method,
            )
    except Exception:  # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
        logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id)


def start_background(target: Callable[..., None], **kwargs: Any) -> None:
    thread = threading.Thread(target=target, kwargs=kwargs, daemon=True)
    thread.start()
