"""Erreichbarkeitspruefung und automatischer PoE-Neustart.

Ersetzt drei Bausteine des Vorgaengers durch einen: ``poe.sh`` (Bash/expect),
``poe_wrapper.py`` und ``generate_ips.py``. Vorteile, die daraus folgen:

* **Ein Prozess, eine Sprache.** Kein Datenaustausch mehr ueber eine
  pipe-getrennte Textzeile pro Geraet, kein zweiter Interpreter, keine
  Passwoerter in Kommandozeilen.
* **Echte Nebenlaeufigkeit mit Grenze.** Der Vorgaenger startete pro Geraet eine
  Hintergrund-Subshell -- bei vielen Geraeten ein Fork-Sturm. Hier laeuft ein
  Thread-Pool mit fester Obergrenze.
* **Jeder Vorgang ist nachvollziehbar.** Erreichbarkeit landet in
  ``device_status``, jeder PoE-Vorgang mit Ergebnis und Transkript in
  ``restart_events``.
* **Zustandswechsel statt Dauerrauschen.** Ins laufende Protokoll wird
  geschrieben, wenn sich etwas *aendert* -- nicht bei jedem Durchlauf jede Zeile.

Beibehalten wird die Lizenzregel des Vorgaengers: der automatische Neustart ist
lizenzpflichtig, der manuelle Knopf im Dashboard nicht. Und: das Recht
``devices.execute`` gilt nur fuer den manuellen Knopf -- der automatische Lauf
gehoert dem System.
"""

from __future__ import annotations

import logging
import platform
import re
import shutil
import sqlite3
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

from tesm_core import audit
from tesm_core.db import Database

from . import inventory
from .ssh import SshError
from .switchcli import SwitchCli

logger = logging.getLogger(__name__)

DEFAULT_INTERVAL_SECONDS = 300
PING_TIMEOUT_SECONDS = 2
MAX_PARALLEL_CHECKS = 24
#: Ab wie vielen aufeinanderfolgenden Fehlschlaegen ein Neustart ausgeloest wird.
DEFAULT_FAILURES_BEFORE_RESTART = 2
#: Mindestabstand zwischen zwei automatischen Neustarts desselben Geraets.
RESTART_COOLDOWN_SECONDS = 900

_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)


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


@dataclass(frozen=True, slots=True)
class PingResult:
    ok: bool
    latency_ms: float | None = None
    detail: str = ""


def tcp_reachable(host: str, port: int, *, timeout: float = 3.0) -> bool:
    """Nimmt der Host auf diesem Port eine Verbindung an?

    Braucht -- anders als ICMP -- keinerlei Sonderrechte. Der Webprozess laeuft
    mit leerem ``CapabilityBoundingSet``; damit greift die Datei-Capability von
    ``/usr/bin/ping`` nicht mehr (``NoNewPrivileges`` schaltet sie ab) und jeder
    Ping schlaegt dort fehl. Ein Wartungsauftrag, der auf die Rueckkehr eines
    Geraets wartet, wuerde dadurch **immer** in die Zeitueberschreitung laufen,
    obwohl das Geraet laengst wieder da ist -- genau so beobachtet.

    Fuer die Wartung ist der TCP-Test ohnehin der treffendere: sie will nicht
    wissen, ob der Kernel wieder pingbar ist, sondern ob SSH wieder antwortet.
    """
    import socket

    try:
        with socket.create_connection((host, int(port)), timeout=timeout):
            return True
    except OSError:
        return False


def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
    """Ein einzelner ICMP-Versuch, wie im Vorgaenger (kein Retry).

    Ein Retry hier wuerde nur den Ausfall verschleiern; die Entscheidung
    "Neustart oder nicht" faellt ueber mehrere *Durchlaeufe*, nicht ueber
    mehrere Pakete.
    """
    binary = shutil.which("ping")
    if not binary:
        return PingResult(False, detail="ping ist nicht installiert")
    if platform.system() == "Windows":
        command = [binary, "-n", "1", "-w", str(timeout * 1000), host]
    else:
        command = [binary, "-c", "1", "-W", str(timeout), host]
    try:
        completed = subprocess.run(  # noqa: S603 - feste Argumentliste, keine Shell
            command, capture_output=True, text=True, timeout=timeout + 3, check=False
        )
    except subprocess.TimeoutExpired:
        return PingResult(False, detail="Zeitlimit ueberschritten")
    except OSError as exc:
        return PingResult(False, detail=str(exc))

    if completed.returncode != 0:
        return PingResult(False, detail="keine Antwort")
    match = _LATENCY_RE.search(completed.stdout)
    latency = float(match.group(1)) if match else None
    return PingResult(True, latency_ms=latency)


# --------------------------------------------------------------------------- #
# Laufendes Protokoll
# --------------------------------------------------------------------------- #


class LiveLog:
    """Schreibt das laufende Protokoll.

    Eine Datei, fortlaufend beschrieben; Rotation macht ``logrotate`` mit
    ``copytruncate`` (keiner der schreibenden Prozesse kann ein Reopen-Signal
    verarbeiten). Zeilen sind bewusst maschinenlesbar aufgebaut:
    ``ZEIT  EBENE  BEREICH  MELDUNG``.
    """

    def __init__(self, path: Path) -> None:
        self.path = Path(path)
        self._lock = threading.Lock()

    def write(self, level: str, scope: str, message: str) -> None:
        line = f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}  {level:<7} {scope:<10} {message}\n"
        try:
            self.path.parent.mkdir(parents=True, exist_ok=True)
            with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
                stream.write(line)
        except OSError as exc:  # pragma: no cover - vollgelaufene Platte o. Ae.
            logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)

    def info(self, scope: str, message: str) -> None:
        self.write("INFO", scope, message)

    def warning(self, scope: str, message: str) -> None:
        self.write("WARNUNG", scope, message)

    def error(self, scope: str, message: str) -> None:
        self.write("FEHLER", scope, message)

    def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
        """Liest die letzten Zeilen -- ohne die ganze Datei in den Speicher zu holen."""
        if not self.path.is_file():
            return []
        try:
            size = self.path.stat().st_size
            block = min(size, max(8192, lines * 200))
            with self.path.open("rb") as stream:
                stream.seek(max(0, size - block))
                content = stream.read().decode("utf-8", errors="replace")
        except OSError:
            return []
        result = content.splitlines()
        if needle:
            lowered = needle.lower()
            result = [line for line in result if lowered in line.lower()]
        return result[-lines:]


# --------------------------------------------------------------------------- #
# Geraetepruefung
# --------------------------------------------------------------------------- #


def load_monitored_devices(conn: sqlite3.Connection) -> list[dict[str, Any]]:
    """Alle aktiven Geraete mit ihren Switch- und Zugangsdaten."""
    return Database.all(
        conn,
        """
        SELECT d.id, d.mac, d.ip, d.name, d.port, d.auto_restart, d.switch_id,
               s.hostname AS switch_hostname, s.ip AS switch_ip, s.ssh_port AS switch_ssh_port,
               s.credential_id AS switch_credential_id,
               st.state, st.fail_count, st.last_restart_at
          FROM devices d
          LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
          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
        """,
    )


def _within_restart_grace(row: Any) -> bool:
    """Liegt der letzte Neustart noch im Kulanzfenster?"""
    if not row:
        return False
    moment = (row or {}).get("last_restart_at")
    if not moment:
        return False
    try:
        stamp = datetime.fromisoformat(str(moment))
    except ValueError:
        return False
    if stamp.tzinfo is None:
        stamp = stamp.replace(tzinfo=timezone.utc)
    return (datetime.now(timezone.utc) - stamp).total_seconds() < RESTART_GRACE_SECONDS


def record_status(
    conn: sqlite3.Connection, device_id: int, result: PingResult
) -> tuple[str, str]:
    """Schreibt den Zustand fort. Rueckgabe: ``(vorher, nachher)``."""
    row = Database.one(
        conn,
        "SELECT state, fail_count, last_restart_at FROM device_status WHERE device_id=?",
        (device_id,),
    )
    previous = str(row["state"]) if row else "unknown"
    new_state = "online" if result.ok else "offline"
    # Ein Geraet, das gerade neu startet, ist nicht "nicht erreichbar" -- es ist
    # erwartbar kurz weg. Erst wenn es nach dem Kulanzfenster noch schweigt,
    # wird daraus ein Ausfall.
    if not result.ok and previous == STATE_RESTARTING and _within_restart_grace(row):
        new_state = STATE_RESTARTING
    fail_count = 0 if result.ok else int((row or {}).get("fail_count") or 0) + 1
    changed = previous != new_state

    conn.execute(
        """
        INSERT INTO device_status (device_id, state, latency_ms, fail_count, last_check_at,
                                   last_ok_at, last_change_at, detail)
        VALUES (?,?,?,?,?,?,?,?)
        ON CONFLICT(device_id) DO UPDATE SET
            state=excluded.state,
            latency_ms=excluded.latency_ms,
            fail_count=excluded.fail_count,
            last_check_at=excluded.last_check_at,
            last_ok_at=COALESCE(excluded.last_ok_at, device_status.last_ok_at),
            last_change_at=CASE WHEN device_status.state <> excluded.state
                                THEN excluded.last_change_at ELSE device_status.last_change_at END,
            detail=excluded.detail
        """,
        (
            device_id,
            new_state,
            result.latency_ms,
            fail_count,
            _now(),
            _now() if result.ok else None,
            _now(),
            result.detail,
        ),
    )
    return previous, new_state if changed else previous


#: Wie ein Geraet neu gestartet wurde.
METHOD_POE = "poe"
METHOD_SSH = "ssh"
METHOD_RPC = "rpc"
METHOD_LABELS = {
    METHOD_POE: "PoE-Port",
    METHOD_SSH: "Neustart ueber SSH",
    METHOD_RPC: "Neustart ueber RPC",
}

#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
#:
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
#: beim naechsten Pruefdurchlauf, und der laeuft standardmaessig alle fuenf
#: Minuten -- ein Neustart ueber SSH ist in vierzig Sekunden durch. Das Geraet
#: war also weg und wieder da, ohne dass es je jemand gesehen haette.
STATE_RESTARTING = "restarting"

#: Wie lange ein Geraet als "startet neu" gilt, wenn es nicht antwortet.
#: Danach ist es schlicht nicht erreichbar -- ein Neustart, der zehn Minuten
#: dauert, ist keiner mehr.
RESTART_GRACE_SECONDS = 300


def record_restart(
    conn: sqlite3.Connection,
    *,
    device_id: int,
    action: str,
    result: str,
    trigger: str,
    actor: str,
    duration_ms: int,
    detail: str,
    method: str = METHOD_POE,
) -> None:
    """Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.

    PoE-Portzyklus und ``reboot`` ueber SSH sind fuer den Betrieb dasselbe
    Ereignis. Sie landen deshalb in derselben Tabelle und unterscheiden sich
    nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
    neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
    auf.
    """
    conn.execute(
        "INSERT INTO restart_events "
        "(device_id, ts, trigger, actor, action, result, duration_ms, detail, method) "
        "VALUES (?,?,?,?,?,?,?,?,?)",
        (device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
    )
    if result == "ok":
        # Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen:
        # PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle
        # waere eine, die beim naechsten Verfahren vergessen wird.
        conn.execute(
            "UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
            "state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
            (_now(), STATE_RESTARTING, _now(), device_id),
        )


# --------------------------------------------------------------------------- #
# PoE-Neustart
# --------------------------------------------------------------------------- #


def restart_device_poe(
    conn: sqlite3.Connection,
    device: dict[str, Any],
    *,
    keystore: Any,
    trigger: str,
    actor: str,
    live: LiveLog | None = None,
    database: Any = None,
) -> dict[str, Any]:
    """Fuehrt einen PoE-Neustart durch und protokolliert ihn.

    Rueckgabe: ``{"ok": bool, "message": str, "transcript": str}``.

    ``database`` bestimmt, wie geschrieben wird. Wird es uebergeben, oeffnet
    jede Schreiboperation ihre **eigene kurze** Transaktion -- die SSH-Sitzung
    zum Switch laeuft dann ausserhalb. Das ist fuer den Ueberwachungsdienst
    entscheidend: ein Durchlauf mit drei nicht erreichbaren Switchen dauert
    knapp 40 Sekunden, und solange lag die Schreibsperre auf der gesamten
    Datenbank. Die Oberflaeche antwortete in dieser Zeit mit "database is
    locked".

    Ohne ``database`` (aus einem Request heraus, wo ohnehin schon eine kurze
    Transaktion offen ist) wird direkt auf ``conn`` geschrieben.
    """
    from contextlib import nullcontext

    def write():  # type: ignore[no-untyped-def]
        return database.transaction(conn) if database is not None else nullcontext()

    name = str(device.get("name") or device.get("mac") or device.get("id"))
    port = str(device.get("port") or "")
    switch_ip = str(device.get("switch_ip") or "")
    credential_id = device.get("switch_credential_id")

    if not port or not switch_ip:
        message = (
            "Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
        )
        with write():
            record_restart(
                conn,
            device_id=int(device["id"]),
            action="cycle",
            result="skipped",
            trigger=trigger,
            actor=actor,
            duration_ms=0,
            detail=message,
        )
        return {"ok": False, "message": message, "transcript": ""}

    if not credential_id:
        message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
        with write():
            record_restart(
                conn,
            device_id=int(device["id"]),
            action="cycle",
            result="error",
            trigger=trigger,
            actor=actor,
            duration_ms=0,
            detail=message,
        )
        return {"ok": False, "message": message, "transcript": ""}

    # Bewusst kein Rueckruf: der wuerde die vorhandene Verbindung nicht kennen
    # und sich eine neue oeffnen -- mitten in dieser Schreibtransaktion. Genau
    # daran ist der Ueberwachungsdienst gestorben.
    username, password = inventory.credential_secret(conn, keystore, int(credential_id))
    started = time.monotonic()
    try:
        with SwitchCli(
            conn,
            host=switch_ip,
            port=int(device.get("switch_ssh_port") or 22),
            username=username,
            password=password,
        ) as cli:
            outcome = cli.cycle_poe(port)
    except SshError as exc:
        duration = int((time.monotonic() - started) * 1000)
        with write():
            record_restart(
                conn,
            device_id=int(device["id"]),
            action="cycle",
            result="error",
            trigger=trigger,
            actor=actor,
            duration_ms=duration,
            detail=str(exc),
        )
        if live:
            live.error("poe", f"{name}: {exc}")
        return {"ok": False, "message": str(exc), "transcript": ""}

    duration = int((time.monotonic() - started) * 1000)
    with write():
        record_restart(
            conn,
        device_id=int(device["id"]),
        action="cycle",
        result="ok" if outcome.ok else "error",
        trigger=trigger,
        actor=actor,
        duration_ms=duration,
        detail=(outcome.error or "; ".join(outcome.steps))[:8000],
    )
    if live:
        if outcome.ok:
            live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
        else:
            live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
    return {
        "ok": outcome.ok,
        "message": outcome.error or "PoE-Neustart durchgefuehrt.",
        "transcript": outcome.transcript,
    }


# --------------------------------------------------------------------------- #
# Durchlauf
# --------------------------------------------------------------------------- #


@dataclass(slots=True)
class SweepReport:
    checked: int = 0
    online: int = 0
    offline: int = 0
    restarts_triggered: int = 0
    restarts_ok: int = 0
    skipped_unlicensed: int = 0
    duration_ms: int = 0


def run_sweep(
    *,
    database: Any,
    live: LiveLog,
    keystore: Any,
    license_allows_auto_restart: bool,
    failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
) -> SweepReport:
    """Ein vollstaendiger Ueberwachungsdurchlauf.

    Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
    danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
    keine halb geschriebenen Durchlaeufe.
    """
    report = SweepReport()
    started = time.monotonic()

    with database.session() as conn:
        devices = load_monitored_devices(conn)

    if not devices:
        report.duration_ms = int((time.monotonic() - started) * 1000)
        return report

    with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
        results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))

    restart_candidates: list[dict[str, Any]] = []
    with database.session() as conn:
        with database.transaction(conn):
            for device, result in results:
                report.checked += 1
                previous, current = record_status(conn, int(device["id"]), result)
                if result.ok:
                    report.online += 1
                else:
                    report.offline += 1

                if previous != current:
                    name = str(device["name"])
                    if current == "online":
                        live.info("ping", f"{name} ({device['ip']}) ist wieder erreichbar")
                    else:
                        live.warning("ping", f"{name} ({device['ip']}) antwortet nicht")

                if result.ok or not int(device.get("auto_restart") or 0):
                    continue
                fail_count = int((device.get("fail_count") or 0)) + 1
                if fail_count < failures_before_restart:
                    continue
                if _in_cooldown(device.get("last_restart_at")):
                    continue
                restart_candidates.append(device)

    for device in restart_candidates:
        name = str(device["name"])
        if not license_allows_auto_restart:
            report.skipped_unlicensed += 1
            live.warning(
                "poe",
                f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.",
            )
            continue
        report.restarts_triggered += 1
        # Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
        # SSH-Sitzung zum Switch, und die dauert bei einem nicht erreichbaren
        # Geraet zehn Sekunden und mehr. Lag die Schreibsperre solange auf der
        # Datenbank, antwortete die Oberflaeche mit "database is locked". Die
        # Aufzeichnung schreibt jetzt in eigenen, kurzen Transaktionen.
        with database.session() as conn:
            outcome = restart_device_poe(
                conn,
                device,
                keystore=keystore,
                trigger="auto",
                actor="system",
                live=live,
                database=database,
            )
            with database.transaction(conn):
                audit.log(
                    conn,
                    actor="system",
                    action="device.poe_auto_restart",
                    target=name,
                    detail=outcome["message"],
                    severity="notice" if outcome["ok"] else "warning",
                )
        if outcome["ok"]:
            report.restarts_ok += 1

    report.duration_ms = int((time.monotonic() - started) * 1000)
    return report


def _in_cooldown(last_restart_at: Any) -> bool:
    if not last_restart_at:
        return False
    try:
        moment = datetime.fromisoformat(str(last_restart_at))
    except ValueError:
        return False
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=timezone.utc)
    return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS


def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
    row = Database.one(
        conn,
        """
        SELECT
          COUNT(*) AS total,
          SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
          SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
          SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
          SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
          SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
        FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
        WHERE d.deleted_at IS NULL
        """,
    )
    row = row or {}
    return {
        key: int(row.get(key) or 0)
        for key in ("total", "online", "offline", "unknown", "disabled", "restarting")
    }
