"""DHCP-Server (Kea): Subnetze, Optionen, Reservierungen, Dienstzustand.

Lizenzpflichtiges Vollmodul -- ohne Lizenz verschwindet der Punkt aus der
Navigation **und** jede Route hier antwortet mit 404. Ein direkter URL-Aufruf
kommt damit genauso wenig durch wie ein fehlendes Menue.

Dienstaktionen (installieren, starten, stoppen) sind bewusst **nicht** an
"Speichern" gekoppelt: wer eine Konfiguration ablegt, will nicht zwangsweise
einen Dienst starten.
"""

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
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 kea

bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")


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


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


def _settings_values() -> dict[str, Any]:
    return core().settings.get_many(
        db(), ["dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path"]
    )


@bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
def overview():
    extension = core()
    conn = db()
    sysops = extension.sysops
    networks = kea.detect_networks(sysops) if sysops else []
    values = _settings_values()
    config, warnings = kea.render_config(conn, networks=networks, settings=values)

    # Nur Adressen in eingerichteten Bereichen: alles andere waere eine
    # Reservierung, die Kea nie ausliefert.
    skipped: list[dict[str, str]] = []
    reservations = kea.collect_reservations(
        conn, only_networks=kea.configured_networks(conn, networks), skipped=skipped
    )
    leases = kea.read_leases(sysops) if sysops else []
    # Zustand aus den laufenden Leases -- wie im Windows-DHCP.
    reservations = kea.annotate_reservations(reservations, leases)
    reserved_ips = {str(entry["ip"]) for entry in reservations}

    return render_template(
        "tesm/dhcp.html",
        subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
        options=Database.all(
            conn,
            "SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o "
            "LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
            "ORDER BY o.code",
        ),
        option_types=kea.OPTION_TYPES,
        device_option_values=_device_option_values(conn),
        reservations=reservations,
        skipped_reservations=skipped,
        manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
        leases=leases,
        reserved_ips=reserved_ips,
        networks=networks,
        default_gateway=kea.default_gateway(sysops) if sysops else "",
        service=kea.service_state(sysops) if sysops else {},
        values=values,
        preview=kea.config_to_text(config),
        warnings=warnings,
        lease_file=kea.LEASE_FILE,
        sysops_available=bool(sysops and sysops.available),
    )


# --------------------------------------------------------------------------- #
# Einstellungen und Subnetze
# --------------------------------------------------------------------------- #


@bp.post("/einstellungen")
@permission_required("dhcp.edit")
def save_settings():
    extension = core()
    conn = db()
    values = {
        key: request.form.get(key)
        for key in ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")
        if key in request.form
    }
    with transaction():
        changed = extension.settings.set_many(conn, values, actor=current_user.username)
    _audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
    flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info")
    return _write_and_redirect()


@bp.post("/subnetz")
@permission_required("dhcp.edit")
def add_subnet():
    extension = core()
    conn = db()
    sysops = extension.sysops
    networks = kea.detect_networks(sysops) if sysops else []
    interface = (request.form.get("interface") or "").strip()
    start = (request.form.get("range_start") or "").strip()
    end = (request.form.get("range_end") or "").strip()

    problem = kea.validate_subnet(networks, interface, start, end)
    if problem:
        flash(problem, "danger")
        return redirect(url_for("dhcp.overview"))

    with transaction():
        conn.execute(
            "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, comment, "
            "enabled, sort_order, created_at, updated_at) "
            "VALUES (?,?,?,?,?,?,1,?,datetime('now'),datetime('now'))",
            (
                interface,
                start,
                end,
                (request.form.get("gateway") or "").strip(),
                (request.form.get("dns") or "").strip(),
                (request.form.get("comment") or "").strip(),
                int(request.form.get("sort_order") or 0),
            ),
        )
    _audit("dhcp.subnet_added", f"{interface} {start}-{end}", severity="notice")
    flash("Subnetz angelegt.", "success")
    return _write_and_redirect()


@bp.post("/subnetz/<int:subnet_id>")
@permission_required("dhcp.edit")
def update_subnet(subnet_id: int):
    extension = core()
    conn = db()
    sysops = extension.sysops
    networks = kea.detect_networks(sysops) if sysops else []
    interface = (request.form.get("interface") or "").strip()
    start = (request.form.get("range_start") or "").strip()
    end = (request.form.get("range_end") or "").strip()
    problem = kea.validate_subnet(networks, interface, start, end)
    if problem:
        flash(problem, "danger")
        return redirect(url_for("dhcp.overview"))

    with transaction():
        conn.execute(
            "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
            "comment=?, enabled=?, sort_order=?, updated_at=datetime('now') WHERE id=?",
            (
                interface,
                start,
                end,
                (request.form.get("gateway") or "").strip(),
                (request.form.get("dns") or "").strip(),
                (request.form.get("comment") or "").strip(),
                1 if request.form.get("enabled") == "1" else 0,
                int(request.form.get("sort_order") or 0),
                subnet_id,
            ),
        )
    _audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
    flash("Subnetz gespeichert.", "success")
    return _write_and_redirect()


@bp.post("/subnetz/<int:subnet_id>/loeschen")
@permission_required("dhcp.edit")
def delete_subnet(subnet_id: int):
    with transaction() as conn:
        conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
    _audit("dhcp.subnet_deleted", str(subnet_id), severity="warning")
    flash("Subnetz entfernt.", "success")
    return _write_and_redirect()


# --------------------------------------------------------------------------- #
# Optionen
# --------------------------------------------------------------------------- #


@bp.post("/option/wert")
@permission_required("dhcp.edit")
def set_option_value():
    conn = db()
    option_id = int(request.form.get("option_id") or 0)
    value = (request.form.get("value") or "").strip()
    option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
    if option is None:
        abort(404)
    with transaction():
        if value:
            conn.execute(
                "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?) "
                "ON CONFLICT(option_id) WHERE device_id IS NULL DO UPDATE SET value=excluded.value",
                (option_id, value),
            )
        else:
            conn.execute(
                "DELETE FROM dhcp_option_values WHERE option_id=? AND device_id IS NULL",
                (option_id,),
            )
    _audit("dhcp.option_set", str(option["name"]), value or "(geleert)")
    flash(f"Option {option['name']} gespeichert.", "success")
    return _write_and_redirect()


def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]:  # type: ignore[no-untyped-def]
    """Je-Geraet gesetzte Optionen, nach Geraet gruppiert -- fuer die Anzeige."""
    gruppen: dict[int, list[dict[str, Any]]] = {}
    for row in Database.all(
        conn,
        "SELECT v.id, v.device_id, v.value, o.code, o.name, o.id AS option_id "
        "FROM dhcp_option_values v JOIN dhcp_options o ON o.id = v.option_id "
        "WHERE v.device_id IS NOT NULL ORDER BY o.code",
    ):
        gruppen.setdefault(int(row["device_id"]), []).append(dict(row))
    return gruppen


@bp.post("/option/geraet")
@permission_required("dhcp.edit")
def set_device_option_value():
    """Setzt eine Option fuer ein einzelnes Geraet.

    Der Wert landet in der Reservierung dieses Geraets, nicht global. Genau
    dafuer war ``dhcp_option_values.device_id`` von Anfang an gedacht -- der
    Erzeuger hat es immer schon ausgewertet, nur liess sich der Wert nirgends
    eingeben.
    """
    conn = db()
    option_id = int(request.form.get("option_id") or 0)
    device_id = int(request.form.get("device_id") or 0)
    value = (request.form.get("value") or "").strip()

    option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
    device = Database.one(
        conn, "SELECT id, name FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
    )
    if option is None or device is None:
        abort(404)

    with transaction():
        if value:
            conn.execute(
                "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?) "
                "ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL "
                "DO UPDATE SET value=excluded.value",
                (option_id, device_id, value),
            )
        else:
            conn.execute(
                "DELETE FROM dhcp_option_values WHERE option_id=? AND device_id=?",
                (option_id, device_id),
            )
    _audit(
        "dhcp.device_option_set",
        f"{device['name']} / {option['name']}",
        value or "(geleert)",
    )
    flash(
        f"Option {option['name']} fuer {device['name']} "
        f"{'gespeichert' if value else 'entfernt'}.",
        "success",
    )
    return _write_and_redirect()


@bp.post("/option/neu")
@permission_required("dhcp.edit")
def add_custom_option():
    conn = db()
    try:
        code = int(request.form.get("code") or 0)
    except ValueError:
        flash("Der Optionscode muss eine Zahl sein.", "danger")
        return redirect(url_for("dhcp.overview"))
    name = (request.form.get("name") or "").strip()
    option_type = (request.form.get("type") or "").strip()
    if not 1 <= code <= 254:
        flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger")
        return redirect(url_for("dhcp.overview"))
    if not name or option_type not in kea.OPTION_TYPES:
        flash("Name und Typ sind erforderlich.", "danger")
        return redirect(url_for("dhcp.overview"))
    if Database.one(conn, "SELECT 1 FROM dhcp_options WHERE code=? OR name=?", (code, name)):
        flash("Code oder Name existiert bereits.", "danger")
        return redirect(url_for("dhcp.overview"))
    with transaction():
        conn.execute(
            "INSERT INTO dhcp_options (code, name, type, description, is_standard) "
            "VALUES (?,?,?,?,0)",
            (code, name, option_type, (request.form.get("description") or "").strip()),
        )
    _audit("dhcp.option_created", name, severity="notice")
    flash("Eigene Option angelegt.", "success")
    return redirect(url_for("dhcp.overview"))


@bp.post("/option/<int:option_id>/loeschen")
@permission_required("dhcp.edit")
def delete_custom_option(option_id: int):
    conn = db()
    option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
    if option is None:
        abort(404)
    if int(option["is_standard"] or 0):
        flash(
            "Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.",
            "danger",
        )
        return redirect(url_for("dhcp.overview"))
    with transaction():
        conn.execute("DELETE FROM dhcp_options WHERE id=?", (option_id,))
    _audit("dhcp.option_deleted", str(option["name"]), severity="warning")
    flash("Option entfernt.", "success")
    return _write_and_redirect()


# --------------------------------------------------------------------------- #
# Reservierungen
# --------------------------------------------------------------------------- #


@bp.post("/reservierung")
@permission_required("dhcp.edit")
def add_reservation():
    conn = db()
    mac = kea.normalize_mac(request.form.get("mac", ""))
    ip = (request.form.get("ip") or "").strip()
    if not mac:
        flash("Die MAC-Adresse ist ungueltig.", "danger")
        return redirect(url_for("dhcp.overview"))

    # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
    # aus. Sie anzunehmen und stillschweigend zu verwerfen waere die
    # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
    if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
        flash(
            "Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- "
            "ohne eines wird keine Reservierung ausgeliefert.",
            "danger",
        )
        return redirect(url_for("dhcp.overview"))

    sysops = core().sysops
    if sysops is None or not sysops.available:
        # Ohne Systemhelfer ist das Netz des Hosts nicht feststellbar. Dann hier
        # nicht blockieren: sonst waere in einer eingeschraenkten Umgebung gar
        # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
        # bleibt.
        flash(
            "Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz "
            "dieses Servers nicht feststellbar.",
            "warning",
        )
    else:
        netze = kea.configured_networks(conn, kea.detect_networks(sysops))
        if netze and not kea.in_configured_network(ip, netze):
            bereiche = ", ".join(str(netz) for netz in netze)
            flash(
                f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
                f"({bereiche}). Die Reservierung waere ohne Wirkung.",
                "danger",
            )
            return redirect(url_for("dhcp.overview"))

    with transaction():
        conn.execute(
            "INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
            "VALUES (?,?,?,?,datetime('now')) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, "
            "hostname=excluded.hostname, comment=excluded.comment",
            (
                mac,
                ip,
                (request.form.get("hostname") or "").strip(),
                (request.form.get("comment") or "").strip(),
            ),
        )
    _audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
    flash("Reservierung gespeichert.", "success")
    return _write_and_redirect()


@bp.post("/reservierung/<int:reservation_id>/loeschen")
@permission_required("dhcp.edit")
def delete_reservation(reservation_id: int):
    with transaction() as conn:
        conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
    _audit("dhcp.reservation_deleted", str(reservation_id), severity="notice")
    flash("Reservierung entfernt.", "success")
    return _write_and_redirect()


# --------------------------------------------------------------------------- #
# Datei schreiben und Dienst steuern
# --------------------------------------------------------------------------- #


def _write_config() -> dict[str, Any]:
    """Schreibt die Konfiguration und startet den Dienst neu, falls er laeuft.

    Ein gestoppter Dienst wird **nicht** automatisch gestartet -- das bleibt eine
    ausdrueckliche Entscheidung.
    """
    extension = core()
    sysops = extension.sysops
    conn = db()
    if sysops is None or not sysops.available:
        return {"ok": False, "message": "Systemhelfer nicht verfuegbar."}

    networks = kea.detect_networks(sysops)
    config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
    if not config["Dhcp4"]["subnet4"]:
        return {
            "ok": False,
            "message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.",
            "warnings": warnings,
        }
    result = sysops.write_kea_config(kea.config_to_text(config))
    if not result.ok:
        return {"ok": False, "message": result.message, "warnings": warnings}

    state = sysops.service_state(kea.SERVICE_UNIT)
    restarted = False
    if state["active"] == "active":
        restart = sysops.service("restart", kea.SERVICE_UNIT)
        restarted = restart.ok
        if not restart.ok:
            return {
                "ok": False,
                "message": f"Datei geschrieben, Neustart fehlgeschlagen: {restart.message}",
                "warnings": warnings,
            }
    return {
        "ok": True,
        "message": "Konfiguration geschrieben." + (" Dienst neu gestartet." if restarted else ""),
        "warnings": warnings,
    }


def _write_and_redirect():
    result = _write_config()
    for warning in result.get("warnings", []):
        flash(warning, "warning")
    if not result["ok"] and result.get("message"):
        flash(result["message"], "warning")
    elif result["ok"]:
        flash(result["message"], "info")
    return redirect(url_for("dhcp.overview"))


@bp.post("/schreiben")
@permission_required("dhcp.edit")
def write_config():
    return _write_and_redirect()


@bp.post("/dienst/<action>")
@permission_required("dhcp.execute")
def service_action(action: str):
    allowed = {"install", "start", "stop"}
    if action not in allowed:
        abort(404)
    extension = core()
    sysops = extension.sysops
    if sysops is None or not sysops.available:
        flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
        return redirect(url_for("dhcp.overview"))

    if action == "install":
        result = sysops.apt_install(kea.PACKAGE_NAME)
        message = "Kea installiert." if result.ok else f"Installation fehlgeschlagen: {result.message}"
    elif action == "start":
        enable = sysops.service("enable", kea.SERVICE_UNIT)
        result = sysops.service("start", kea.SERVICE_UNIT)
        message = (
            "Dienst aktiviert und gestartet."
            if result.ok and enable.ok
            else f"Start fehlgeschlagen: {result.message or enable.message}"
        )
    else:
        sysops.service("disable", kea.SERVICE_UNIT)
        result = sysops.service("stop", kea.SERVICE_UNIT)
        message = "Dienst gestoppt und deaktiviert." if result.ok else f"Stoppen fehlgeschlagen: {result.message}"

    _audit(f"dhcp.service_{action}", detail=message, severity="notice" if result.ok else "warning")
    flash(message, "success" if result.ok else "danger")
    return redirect(url_for("dhcp.overview"))
