"""Uebersetzt einen Export des alten TESM in das Format dieser Anwendung.

Der Vorgaenger (``gitea:alientim/tesm``) schreibt seine Exportdatei so::

    {"app": "tesm", "version": 2, "exported_at": "...",
     "salt": "<b64url, 16 Byte>", "payload": "<Fernet-Token>"}

Der Schluessel entsteht aus Passphrase und Salt per PBKDF2-HMAC-SHA256 mit
390000 Runden, die Nutzlast ist ein Fernet-Token ueber JSON. Das neue Format
verwendet Argon2id und AES-GCM und bindet seinen Kopf als AAD ein. Die
Umschlaege sind also nicht ineinander ueberfuehrbar, wohl aber ihr Inhalt.

Absichtlich ein **Uebersetzer** und kein zweiter Importweg: die Daten werden
in das neue Format geschrieben und dann vom vorhandenen, geprueften Import
eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
die niemand pflegt.

Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
ist.
"""

from __future__ import annotations

import base64
import json
from dataclasses import dataclass, field
from typing import Any

#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
#: nicht aendern -- sonst laesst sich keine alte Datei mehr oeffnen.
LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm"

#: Diese Abschnitte werden uebersetzt.
SUPPORTED = ("credentials", "switches", "devices", "dhcp")

#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
KNOWN_UNSUPPORTED: dict[str, str] = {
    "users": (
        "Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
        "verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein "
        "Zweck. Konten neu anlegen."
    ),
    "groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
    "ldap": (
        "Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
        "Hand uebertragen."
    ),
    "nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
    "logs": "Auditlog: bleibt bewusst beim alten System.",
    "known_hosts": (
        "SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
        "ueber die Seite 'Host-Schluessel' freigegeben."
    ),
}

#: Kategorien der Zugangsdaten -- in beiden Anwendungen dieselben vier Werte.
VALID_CATEGORIES = ("switch", "linux", "windows", "router")
FALLBACK_CATEGORY = "switch"

#: DHCP-Einstellungen, die in beiden Anwendungen gleich heissen.
DHCP_SETTING_KEYS = ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")


class LegacyTransferError(Exception):
    """Fehler mit Klartextmeldung fuer Oberflaeche und Kommandozeile."""


@dataclass
class Report:
    """Was uebernommen wurde -- und was nicht, mit Grund."""

    counts: dict[str, int] = field(default_factory=dict)
    notes: list[str] = field(default_factory=list)

    def took(self, category: str, amount: int = 1) -> None:
        if amount:
            self.counts[category] = self.counts.get(category, 0) + amount

    def note(self, text: str) -> None:
        if text not in self.notes:
            self.notes.append(text)

    def as_text(self) -> str:
        zeilen = ["Uebersetzt:"]
        zeilen += [f"  {key}: {value}" for key, value in sorted(self.counts.items())] or [
            "  nichts"
        ]
        if self.notes:
            zeilen.append("")
            zeilen.append("Zu beachten:")
            zeilen += [f"  - {note}" for note in self.notes]
        return "\n".join(zeilen)


# --------------------------------------------------------------------------- #
# Alten Umschlag oeffnen
# --------------------------------------------------------------------------- #


def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
    """Entschluesselt eine Exportdatei des Vorgaengers."""
    from cryptography.fernet import Fernet, InvalidToken
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
    try:
        envelope = json.loads(text)
    except ValueError as exc:
        raise LegacyTransferError("Die Datei ist kein JSON.") from exc
    if not isinstance(envelope, dict):
        raise LegacyTransferError("Die Datei enthaelt kein Objekt.")
    if str(envelope.get("app") or "") != LEGACY_APP_TAG:
        raise LegacyTransferError(
            f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
            "nicht zu TESM."
        )
    for schluessel in ("salt", "payload"):
        if not envelope.get(schluessel):
            raise LegacyTransferError(f"Im Umschlag fehlt {schluessel!r}.")

    try:
        salt = base64.urlsafe_b64decode(str(envelope["salt"]))
    except (ValueError, TypeError) as exc:
        raise LegacyTransferError("Das Salt ist unlesbar.") from exc

    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
    )
    key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
    try:
        klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
    except (InvalidToken, ValueError) as exc:
        raise LegacyTransferError(
            "Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
        ) from exc

    try:
        payload = json.loads(klartext.decode("utf-8"))
    except ValueError as exc:
        raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
    if not isinstance(payload, dict):
        raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.")
    return payload


# --------------------------------------------------------------------------- #
# Uebersetzen
# --------------------------------------------------------------------------- #


def _liste(value: Any) -> list[dict[str, Any]]:
    return [entry for entry in value if isinstance(entry, dict)] if isinstance(value, list) else []


def _category(value: Any, report: Report, subject: str) -> str:
    kategorie = str(value or "").strip().lower()
    if kategorie in VALID_CATEGORIES:
        return kategorie
    if kategorie:
        report.note(
            f"Unbekannte Kategorie {kategorie!r} bei {subject} auf {FALLBACK_CATEGORY!r} gesetzt."
        )
    return FALLBACK_CATEGORY


def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
    ergebnis: list[dict[str, Any]] = []
    ohne_passwort = 0
    for entry in _liste(items):
        name = str(entry.get("name") or "").strip()
        if not name:
            report.note("Zugangsdaten ohne Namen uebersprungen.")
            continue
        umgesetzt: dict[str, Any] = {
            "name": name,
            "username": str(entry.get("username") or ""),
            "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
            # Der Vorgaenger kennt kein Beschreibungsfeld.
            "description": "aus altem TESM uebernommen",
        }
        # Der alte Export entschluesselt das Passwort und legt es im Klartext in
        # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
        # verschluesselt es der Schluesselspeicher sofort wieder.
        if entry.get("password"):
            umgesetzt["secret"] = str(entry["password"])
        else:
            ohne_passwort += 1
        ergebnis.append(umgesetzt)
    if ohne_passwort:
        report.note(
            f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- "
            "sie muessen nachgetragen werden."
        )
    report.took("credentials", len(ergebnis))
    return ergebnis


def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
    ergebnis: list[dict[str, Any]] = []
    for entry in _liste(items):
        hostname = str(entry.get("hostname") or "").strip()
        if not hostname:
            report.note("Switch ohne Hostnamen uebersprungen.")
            continue
        ergebnis.append(
            {
                "hostname": hostname,
                "ip": str(entry.get("ip") or ""),
                "ssh_port": int(entry.get("ssh_port") or 22),
                "credential_name": entry.get("credential_name"),
                # Diese Felder gibt es im Vorgaenger nicht.
                "model": "",
                "location": "",
                "notes": "",
            }
        )
    report.took("switches", len(ergebnis))
    return ergebnis


def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> list[dict[str, Any]]:
    # Der Vorgaenger kennt keine Geraetekategorie. Sie steuert hier, was mit
    # einem Geraet moeglich ist (Linux ueber SSH, Windows ueber RPC), und laesst
    # sich aus den zugeordneten Zugangsdaten ableiten. Das ist besser als leer:
    # ohne Kategorie erscheint das Geraet auf der Wartungsseite gar nicht.
    kategorie_je_zugang = {str(c["name"]): str(c["category"]) for c in credentials}
    ergebnis: list[dict[str, Any]] = []
    abgeleitet = 0
    for entry in _liste(items):
        mac = str(entry.get("mac") or "").strip()
        if not mac:
            report.note("Client ohne MAC-Adresse uebersprungen.")
            continue
        zugang = entry.get("credential_name")
        kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
        if kategorie:
            abgeleitet += 1
        ergebnis.append(
            {
                "mac": mac,
                "ip": str(entry.get("ip") or ""),
                "name": str(entry.get("name") or mac),
                "port": str(entry.get("port") or ""),
                "is_active": int(bool(entry.get("is_active", 1))),
                "ssh_port": int(entry.get("ssh_port") or 22),
                "switch_hostname": entry.get("switch_hostname"),
                "credential_name": zugang,
                "category": kategorie,
                "location": "",
                "notes": "",
                # Im Vorgaenger war der automatische Neustart nicht abschaltbar.
                "auto_restart": 1,
            }
        )
    if abgeleitet:
        report.note(
            f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
            "abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen."
        )
    report.took("devices", len(ergebnis))
    return ergebnis


def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
    """DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
    if not isinstance(data, dict):
        return {}, {}

    subnets = [
        {
            "interface": str(entry.get("interface") or ""),
            "range_start": str(entry.get("range_start") or ""),
            "range_end": str(entry.get("range_end") or ""),
            "gateway": str(entry.get("gateway") or ""),
            "dns": str(entry.get("dns") or ""),
            # Der Vorgaenger hat kein Kommentarfeld am Subnetz.
            "comment": "",
            "enabled": int(bool(entry.get("enabled", 1))),
            "sort_order": int(entry.get("sort_order") or 0),
        }
        for entry in _liste(data.get("subnets"))
    ]

    # Der Vorgaenger fuehrt am Eintrag ein Feld "name"; hier heisst es
    # "hostname", weil genau das im Kea-Eintrag landet.
    reservations = []
    for entry in _liste(data.get("reservations")):
        mac = str(entry.get("mac") or "").strip()
        if not mac:
            report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.")
            continue
        reservations.append(
            {
                "mac": mac,
                "ip": str(entry.get("ip") or ""),
                "hostname": str(entry.get("name") or ""),
                "comment": "aus altem TESM uebernommen",
            }
        )

    # Der Vorgaenger schachtelt die Werte unter der Definition, hier steht je
    # Wert eine Zeile mit den Feldern der Definition daneben.
    options: list[dict[str, Any]] = []
    ohne_wert = 0
    for definition in _liste(data.get("options")):
        gemeinsam = {
            "code": int(definition.get("code") or 0),
            "name": str(definition.get("name") or ""),
            "type": str(definition.get("type") or "string"),
            "description": str(definition.get("description") or ""),
            "is_standard": int(bool(definition.get("is_standard"))),
        }
        if not gemeinsam["code"] or not gemeinsam["name"]:
            report.note("DHCP-Option ohne Code oder Namen uebersprungen.")
            continue
        werte = _liste(definition.get("values"))
        if not werte:
            ohne_wert += 1
            continue
        for wert in werte:
            options.append(
                {
                    **gemeinsam,
                    "value": str(wert.get("value") or ""),
                    # Leerer Wert = globale Geltung; sonst die MAC des Geraets.
                    "device_mac": str(wert.get("device_mac") or "") or None,
                }
            )
    if ohne_wert:
        report.note(
            f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert "
            "gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
        )

    dhcp: dict[str, Any] = {}
    if subnets:
        dhcp["subnets"] = subnets
    if reservations:
        dhcp["reservations"] = reservations
    if options:
        dhcp["options"] = options

    report.took("dhcp-Subnetze", len(subnets))
    report.took("dhcp-Reservierungen", len(reservations))
    report.took("dhcp-Optionswerte", len(options))

    alt_settings = data.get("settings") if isinstance(data.get("settings"), dict) else {}
    settings = {
        key: str(alt_settings[key])
        for key in DHCP_SETTING_KEYS
        if alt_settings.get(key) not in (None, "")
    }
    report.took("Einstellungen", len(settings))
    return dhcp, settings


def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
    """Baut aus der alten Nutzlast eine Nutzlast im neuen Format."""
    report = Report()
    payload: dict[str, Any] = {}

    credentials = _credentials(legacy.get("credentials"), report)
    if credentials:
        payload["credentials"] = credentials

    switches = _switches(legacy.get("switches"), report)
    if switches:
        payload["switches"] = switches

    devices = _devices(legacy.get("devices"), credentials, report)
    if devices:
        payload["devices"] = devices

    dhcp, settings = _dhcp(legacy.get("dhcp"), report)
    if dhcp:
        payload["dhcp"] = dhcp
    if settings:
        # Nur die vier DHCP-Schluessel, nichts sonst: alles andere heisst hier
        # anders oder bedeutet etwas anderes.
        payload["settings"] = settings

    # Zugeordnete Zugangsdaten, die nicht mitexportiert wurden, laufen beim
    # Import ins Leere -- er loest sie ueber den Namen auf und setzt sonst NULL.
    # Lieber vorher sagen als hinterher suchen.
    bekannt = {str(c["name"]) for c in credentials}
    fehlend = sorted(
        {
            str(entry["credential_name"])
            for entry in (*switches, *devices)
            if entry.get("credential_name") and str(entry["credential_name"]) not in bekannt
        }
    )
    if fehlend:
        report.note(
            "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
            f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
            "Kategorie 'Zugangsdaten' mitwaehlen."
        )

    for schluessel, grund in KNOWN_UNSUPPORTED.items():
        if legacy.get(schluessel):
            report.note(grund)
    unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
    if unbekannt:
        report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.")

    if not payload:
        raise LegacyTransferError(
            "Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
            "Beim Export im alten TESM diese Kategorien mitwaehlen."
        )
    return payload, report
