← zurück zur Toolbox

subnet_coach_v1.py · Quelltext

132586 Bytes · SHA-256: 2bee4319f59b3deeba67bec8bd530f7043dc0afe55bbd08081bfc951a5519a2a

⤓ Download
#!/usr/home/jozapf/public_html/toolenv/bin/python
"""IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg - von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, plus Kalkulationstrainer mit teilbaren Links.

Subnetz-Kalkulator - Toolbox-Tool (jozapf.de).

Das Produkt ist der ERKLÄRTE WEG zum Ergebnis, nicht die Zahl am Ende:
jede Antwort kommt mit binärer Zerlegung (Netz-/Hostbits), Merksätzen und
der Quelle der angewandten Regel. Der Trainings-Modus erzeugt Aufgaben
deterministisch aus einem Seed im Query-String - gleiche URL = gleiche
Aufgabe, teilbar und reproduzierbar.

Angewandte Standards (jede Regel im Rechenweg nennt ihre Quelle):
  - RFC 950    Subnetting (Netz-/Hostanteil, Broadcast)
  - RFC 4632   CIDR / Präfix-Notation (löst die Klassen von RFC 791 ab)
  - RFC 1918   private Adressbereiche
  - RFC 3021   /31-Punkt-zu-Punkt-Netze (Sonderfall ohne Broadcast)
  - RFC 3927   Link-Local 169.254/16 (APIPA - "DHCP nicht erreicht")
  - RFC 6598   Carrier-Grade NAT 100.64/10

Architektur (bewusst modular, eine Datei für die Toolbox-Auslieferung):
  1. SCHEMA   - Aufgabentypen-Katalog, Stufen, wiederkehrende Textbausteine.
  2. DOMAIN   - reine Logik: Rechenweg-Engine + Aufgabengenerator (seed-fest).
  3. REQUEST  - Query-String -> CoachConfig (HTTP von Domäne entkoppelt).
  4. RENDER   - Domänen-Objekte -> HTML (funktioniert ohne JavaScript).
  5. ENTRY    - CGI (stdout), Dev-Server (--serve), Self-Test (--self-test).

Abgrenzung: organisationsweite VLAN-/Policy-Strukturen macht der
Net+VLAN Planner (Geschwister-Tool) - hier geht es um den Weg vom
Client-Bedarf zum Subnetz und um das Verstehen der Rechnung.

Deploy: als CGI wie die anderen Toolbox-Tools (gibt Content-Type-Header aus).
Lokal:  python3 subnet_coach_v1.py --serve 8080   -> http://localhost:8080
Test:   python3 subnet_coach_v1.py --self-test
"""
from __future__ import annotations

import html
import ipaddress
import os
import random
import re
import sys
from dataclasses import dataclass, field
from urllib.parse import parse_qs, urlencode

VERSION = "1.5.1"

# Kachel-Marker für die Toolbox-Landingpage: index.py scannt die ersten ~80 Zeilen
# aller *.py statisch nach genau dieser einen Kommentarzeile (JSON auf einer Zeile,
# das Tool wird dafuer nie ausgefuehrt). "desc" == TILE_DESCRIPTION - synchron halten.
# TOOLBOX-TILE: {"title": "Subnetz-Kalkulator", "desc": "IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg — von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, plus Kalkulationstrainer mit teilbaren Links.", "icon": "🧭", "type": "web", "example": "?ip=192.168.10.77&prefix=26", "order": 36}
TILE_DESCRIPTION = ("IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg — "
                    "von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, "
                    "plus Kalkulationstrainer mit teilbaren Links.")

# =============================================================================
# 0. CONFIG  -  alles Änderbare an einem Ort (Ziel: Übertragbarkeit)
# =============================================================================

TOOL_TITLE = "Subnetz-Kalkulator"
TOOLBOX_URL = "/"                          # Link "zur Toolbox" im Footer
PLANNER_URL = "/net_vlan_planner_v1.py"    # Geschwister-Tool (Abgrenzung s. Docstring)
FONTS_CSS = "https://assets.jozapf.de/css/fonts.css"
DEFAULT_EXAMPLE_IP = "192.168.10.77"       # Erstaufruf: Beispiel statt leerem Formular
DEFAULT_EXAMPLE_PREFIX = 26
SEED_MIN, SEED_MAX = 100000, 999999        # sechsstellige, gut diktierbare Seeds
MAX_HOSTS_INPUT = 1_000_000                # Obergrenze für "Präfix für n Clients"
MAX_DEMANDS = 20                           # Planen: max. Bereiche (URL-Länge, Übersicht)
MAX_NAME_LEN = 40                          # Planen: max. Länge eines Bereichsnamens
WATERMARK = "<!-- jozapf.de toolbox · rev jzt-7c3f9a2e -->"

# =============================================================================
# 1. SCHEMA  -  Aufgabentypen, Stufen, wiederkehrende Textbausteine
# =============================================================================

# Quellenangaben, auf die Rechenweg-Schritte verweisen (Kürzel -> Langform).
SOURCES = {
    "rfc950": "RFC 950 (Subnetting)",
    "rfc4632": "RFC 4632 (CIDR)",
    "rfc1918": "RFC 1918 (private Adressbereiche)",
    "rfc3021": "RFC 3021 (/31-Punkt-zu-Punkt)",
    "rfc3927": "RFC 3927 (Link-Local/APIPA)",
    "rfc6598": "RFC 6598 (Carrier-Grade NAT)",
    "rfc791": "RFC 791 (historische Klassen)",
    "rfc4291": "RFC 4291 (IPv6-Adressarchitektur)",
    "rfc5952": "RFC 5952 (IPv6-Textdarstellung)",
    "rfc4193": "RFC 4193 (Unique Local Addresses)",
    "rfc3849": "RFC 3849 (IPv6-Dokumentationsbereich)",
    "rfc4862": "RFC 4862 (SLAAC)",
}

TASK_TYPES = {
    # key: (Anzeigename, Kurzbeschreibung für die Typ-Auswahl)
    "netz": ("Netz & Broadcast", "Netzadresse, Broadcast, Hostbereich bestimmen"),
    "hosts": ("Hostanzahl", "Hosts zählen bzw. passendes Präfix wählen"),
    "maske": ("Maske umrechnen", "Präfix ↔ Dezimalmaske (+ Wildcard)"),
    "same": ("Gleiches Subnetz?", "Erreichen sich zwei Adressen ohne Router?"),
    "subnetze": ("Subnetze bilden", "Ein Netz in n gleich große Teile teilen"),
    "vlsm": ("VLSM", "Bedarfe unterschiedlicher Größe zuordnen"),
}
TASK_ORDER = ["netz", "hosts", "maske", "same", "subnetze", "vlsm"]

# IPv6-Aufgabentypen: Schlüssel DISJUNKT zu den v4-Typen - der Typ-String ist
# Teil des rng-Seeds und diskriminiert zugleich die Familie (kein eigener
# URL-Parameter nötig; TASK_ORDER bleibt unantastbar, sonst kippen alte
# mix-URLs). Katalog nach dreifacher Recherche: Curriculum-Kanon,
# Übungspraxis/Fehlerbilder, Generator-Architektur.
TASK_TYPES_V6 = {
    "notation": ("Notation", "Kürzen ↔ Expandieren nach RFC 5952"),
    "adresstyp": ("Adresstyp", "GUA, ULA, Link-Local & Co. zuordnen"),
    "netzpraefix": ("Netz-Präfix", "Netzanteil einer Adresse bestimmen"),
    "sub64": ("n-tes /64", "Subnetz-IDs hexadezimal bilden"),
    "teilen": ("Netz teilen", "in n Subnetze - auch abseits der 4-Bit-Grenze"),
}
TASK_ORDER_V6 = ["notation", "adresstyp", "netzpraefix", "sub64", "teilen"]
ALL_TASK_TYPES = {**TASK_TYPES, **TASK_TYPES_V6}

LEVELS = {1: "Grundlagen", 2: "Standard", 3: "anspruchsvoll"}

# Abteilungsnamen für VLSM-Aufgaben (bewusst generisch, keine echten Daten).
VLSM_NAMES = ["Verwaltung", "Vertrieb", "Produktion", "IT", "Lager", "Empfang",
              "Buchhaltung", "Aussendienst", "Schulungsraum", "Server-Segment"]

# =============================================================================
# 2. DOMAIN  -  Rechenweg-Engine + Aufgabengenerator (reine Logik, kein HTTP)
# =============================================================================


@dataclass
class BinRow:
    """Eine Zeile Binärdarstellung: 4 Oktette, optisch getrennt am Präfix."""
    label: str
    octets: list[str]          # ['11000000', '10101000', ...]
    dotted: str                # '192.168.10.77'
    split: int = -1            # Präfixlänge für die Netz-/Hostbit-Trennung (-1 = keine)


@dataclass
class Step:
    """Ein Schritt im Rechenweg. `text` ist Klartext; `Backticks` werden im
    RENDER zu <code>. `rows` ist eine optionale Binärtabelle, `source` das
    Kürzel der angewandten Regel (SCHEMA.SOURCES)."""
    title: str
    text: str
    rows: list[BinRow] = field(default_factory=list)
    source: str = ""


def _bin_octets(value: int) -> list[str]:
    return [format((value >> shift) & 0xFF, "08b") for shift in (24, 16, 8, 0)]


def _row(label: str, addr: int, dotted: str, split: int = -1) -> BinRow:
    return BinRow(label, _bin_octets(addr), dotted, split)


@dataclass
class BitStep:
    """Ein Schritt der Stellenwert-Methode (Dezimal -> Binär) für ein Oktett:
    Passt der Stellenwert noch in den Rest? Ja -> 1 und abziehen, Nein -> 0."""
    place: int         # 128, 64, 32, 16, 8, 4, 2, 1
    rest_before: int
    fits: bool
    rest_after: int


def octet_steps(value: int) -> list[BitStep]:
    """Die 8 Fragen der Stellenwert-Methode für eine Zahl 0-255 (Baustein
    'Umrechnen'): dieselbe Rechnung, die der Rechenweg binär voraussetzt."""
    if not 0 <= value <= 255:
        raise ValueError("Oktett muss zwischen 0 und 255 liegen")
    rest = value
    out: list[BitStep] = []
    for place in (128, 64, 32, 16, 8, 4, 2, 1):
        fits = rest >= place
        out.append(BitStep(place, rest, fits, rest - place if fits else rest))
        if fits:
            rest -= place
    return out


def hosts_for_prefix(prefix: int) -> int:
    """Nutzbare Host-Adressen je Präfix - mit den Sonderfällen /31 und /32."""
    if prefix >= 32:
        return 1
    if prefix == 31:
        return 2  # RFC 3021: Punkt-zu-Punkt, beide Adressen nutzbar
    return 2 ** (32 - prefix) - 2


def classify(ip: ipaddress.IPv4Address) -> list[tuple[str, str, str]]:
    """Einordnung einer Adresse als (Kurzlabel, Erklärung, Quellen-Kürzel).
    Wird kompakt als Badge-Zeile gerendert (Erklärung im title-Tooltip),
    nicht mehr als eigene Rechenweg-Schritte - spart vertikalen Raum."""
    out: list[tuple[str, str, str]] = []
    if ip in ipaddress.ip_network("10.0.0.0/8") or \
       ip in ipaddress.ip_network("172.16.0.0/12") or \
       ip in ipaddress.ip_network("192.168.0.0/16"):
        out.append(("privater Adressbereich", "im Internet nicht routbar, "
                    "typisch für Firmen- und Heimnetze", "rfc1918"))
    elif ip in ipaddress.ip_network("127.0.0.0/8"):
        out.append(("Loopback", "immer der eigene Rechner", "rfc791"))
    elif ip in ipaddress.ip_network("169.254.0.0/16"):
        out.append(("Link-Local/APIPA", "ein Client mit so einer Adresse hat "
                    "seinen DHCP-Server nicht erreicht (häufiges Störungsbild)", "rfc3927"))
    elif ip in ipaddress.ip_network("100.64.0.0/10"):
        out.append(("Carrier-Grade NAT", "Provider-interner Übergangsbereich", "rfc6598"))
    elif ip.is_multicast:
        out.append(("Multicast", "historisch Klasse D - keine Host-Adresse", "rfc791"))
    else:
        out.append(("öffentlich routbar", "weltweit eindeutige Adresse", "rfc4632"))
    first = int(ip) >> 24
    klass = ("A" if first < 128 else "B" if first < 192 else
             "C" if first < 224 else "D" if first < 240 else "E")
    out.append((f"Klasse {klass} (historisch)", "seit CIDR (1993) technisch "
                "bedeutungslos, als Begriff aber weiterhin verbreitet", "rfc4632"))
    return out


def _octet_step(ip: ipaddress.IPv4Address, net: ipaddress.IPv4Network) -> Step | None:
    """Der Dezimal-Schnellweg: Präfix-Grenze im Oktett verorten, Blockgröße
    ablesen, Netzadresse ohne Binärrechnung bestimmen. Genau dieser Schritt
    entscheidet bei Präfixen abseits der Oktettgrenzen (/17, /21, /26 ...)."""
    prefix = net.prefixlen
    if not 1 <= prefix <= 30:
        return None
    if prefix % 8 == 0:
        o = prefix // 8
        return Step(
            "Oktettgrenze nutzen",
            f"Die Präfix-Grenze von `/{prefix}` fällt genau auf die Grenze "
            f"zwischen {o}. und {o + 1}. Oktett: die ersten {o} Oktette der IP "
            f"bleiben unverändert Netzanteil, alle folgenden werden für die "
            f"Netzadresse 0 - ganz ohne Binärrechnung: `{net.network_address}`.",
            source="rfc4632",
        )
    o = prefix // 8 + 1
    mask_octet = int(str(net.netmask).split(".")[o - 1])
    block = 256 - mask_octet
    ip_octet = int(str(ip).split(".")[o - 1])
    lower = ip_octet // block * block
    reihe = ", ".join(str(v) for v in range(0, min(4 * block, 256), block))
    return Step(
        f"Oktettgrenze: /{prefix} liegt im {o}. Oktett",
        f"Schnellweg ohne Binärrechnung: Die Präfix-Grenze liegt mitten im "
        f"{o}. Oktett (Masken-Oktett dort `{mask_octet}`). Blockgröße = "
        f"256 − {mask_octet} = `{block}` - die Netzadressen springen im "
        f"{o}. Oktett also in {block}er-Schritten ({reihe}, …). Das "
        f"{o}. Oktett der IP ist `{ip_octet}`; das nächstkleinere Vielfache "
        f"von {block} ist `{lower}` → Netzadresse `{net.network_address}`.",
        source="rfc950",
    )


@dataclass
class Analyse:
    """Ergebnis von Baustein A: alle Kennwerte plus der Rechenweg dorthin."""
    ip: ipaddress.IPv4Address
    prefix: int
    network: ipaddress.IPv4Network
    steps: list[Step]

    @property
    def netmask(self) -> str:
        return str(self.network.netmask)

    @property
    def wildcard(self) -> str:
        return str(self.network.hostmask)

    @property
    def net_addr(self) -> str:
        return str(self.network.network_address)

    @property
    def broadcast(self) -> str:
        return str(self.network.broadcast_address)

    @property
    def usable(self) -> int:
        return hosts_for_prefix(self.prefix)

    @property
    def first_host(self) -> str:
        if self.prefix >= 31:
            return str(self.network.network_address)
        return str(self.network.network_address + 1)

    @property
    def last_host(self) -> str:
        if self.prefix >= 31:
            return str(self.network.broadcast_address)
        return str(self.network.broadcast_address - 1)


def analyse_subnetz(ip: ipaddress.IPv4Address, prefix: int) -> Analyse:
    """Der Kern von Baustein A: Kennwerte MIT vollständigem Rechenweg."""
    net = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    mask_int = int(net.netmask)
    steps: list[Step] = []

    steps.append(Step(
        "Netzadresse: IP UND Maske",
        f"`/{prefix}` heißt: die ersten {prefix} Bits sind Netzanteil, die "
        f"restlichen {32 - prefix} Hostanteil - als Dezimalmaske `{net.netmask}`. "
        "Die Netzadresse entsteht, indem jedes IP-Bit mit dem Masken-Bit "
        "UND-verknüpft wird - praktisch: Netzbits behalten, Hostbits auf 0 "
        f"setzen. Ergebnis: `{net.network_address}`.",
        rows=[
            _row("IP-Adresse", int(ip), str(ip), split=prefix),
            _row("Maske", mask_int, str(net.netmask), split=prefix),
            _row("Netzadresse", int(net.network_address), str(net.network_address), split=prefix),
        ],
        source="rfc950",
    ))
    quick = _octet_step(ip, net)
    if quick:
        steps.append(quick)
    if prefix == 31:
        steps.append(Step(
            "Sonderfall /31: Punkt-zu-Punkt",
            "Ein /31 hat nur 2 Adressen und KEINEN klassischen Broadcast - "
            "beide Adressen sind als Host-Adressen nutzbar. Gedacht für "
            "Router-zu-Router-Verbindungen. Die übliche Formel (2^n − 2) gilt "
            "hier ausdrücklich nicht.",
            source="rfc3021",
        ))
    elif prefix == 32:
        steps.append(Step(
            "Sonderfall /32: Einzeladresse",
            "Ein /32 bezeichnet genau EINE Adresse (Host-Route) - es gibt "
            "weder Netz- noch Broadcast-Adresse im üblichen Sinn.",
            source="rfc4632",
        ))
    else:
        usable = hosts_for_prefix(prefix)
        steps.append(Step(
            "Broadcast und Hostbereich",
            "Broadcast = das Gegenstück zur Netzadresse, alle Hostbits auf 1: "
            f"`{net.broadcast_address}`. Netz- und Broadcast-Adresse sind für "
            f"Clients tabu - daher nutzbare Hosts = 2^{32 - prefix} − 2 = "
            f"`{usable}`, vom ersten Host `{net.network_address + 1}` "
            f"(Netzadresse + 1) bis zum letzten `{net.broadcast_address - 1}` "
            "(Broadcast − 1).",
            rows=[_row("Broadcast", int(net.broadcast_address),
                       str(net.broadcast_address), split=prefix)],
            source="rfc950",
        ))
    return Analyse(ip=ip, prefix=prefix, network=net, steps=steps)


def same_subnet(ip1: ipaddress.IPv4Address, ip2: ipaddress.IPv4Address,
                prefix: int) -> tuple[bool, list[Step]]:
    """Baustein A, Zusatzfrage: liegen zwei Adressen im selben Subnetz?"""
    n1 = ipaddress.ip_network(f"{ip1}/{prefix}", strict=False)
    n2 = ipaddress.ip_network(f"{ip2}/{prefix}", strict=False)
    equal = n1 == n2
    verdict = ("BEIDE im selben Subnetz - direkte Kommunikation ohne Router "
               "möglich." if equal else
               "VERSCHIEDENE Subnetze - Pakete müssen über ein Gateway/einen "
               "Router. Fehlt der Weg dorthin, sieht das im Alltag aus wie "
               "„Ziel nicht erreichbar“, obwohl beide Geräte laufen.")
    steps = [Step(
        "Beide Netzadressen bestimmen und vergleichen",
        f"Für beide Adressen gilt dieselbe Maske `/{prefix}`. IP UND Maske "
        f"liefert `{n1.network_address}` bzw. `{n2.network_address}`. {verdict}",
        rows=[
            _row(f"{ip1} → Netz", int(n1.network_address), str(n1.network_address), split=prefix),
            _row(f"{ip2} → Netz", int(n2.network_address), str(n2.network_address), split=prefix),
        ],
        source="rfc950",
    )]
    if 1 <= prefix <= 30 and prefix % 8:
        o = prefix // 8 + 1
        mask_octet = int(str(n1.netmask).split(".")[o - 1])
        block = 256 - mask_octet
        front1, front2 = str(ip1).split(".")[:o - 1], str(ip2).split(".")[:o - 1]
        v1 = int(str(ip1).split(".")[o - 1]) // block * block
        v2 = int(str(ip2).split(".")[o - 1]) // block * block
        if front1 != front2:
            befund = (f"Schon vor der Präfix-Grenze unterscheiden sich die Oktette "
                      f"({'.'.join(front1)} gegen {'.'.join(front2)}) - damit sind es "
                      "zwingend verschiedene Subnetze.")
        elif v1 == v2:
            befund = (f"`{ip1}` fällt dort in den Block ab {v1}, `{ip2}` ebenfalls - "
                      "gleicher Block, gleiches Subnetz.")
        else:
            befund = (f"`{ip1}` fällt dort in den Block ab {v1}, `{ip2}` in den Block "
                      f"ab {v2} - verschiedene Blöcke, verschiedene Subnetze.")
        steps.append(Step(
            f"Schnellweg: Blockgröße im {o}. Oktett",
            f"Die Präfix-Grenze `/{prefix}` liegt mitten im {o}. Oktett, "
            f"Masken-Oktett dort `{mask_octet}` → Blockgröße 256 − {mask_octet} "
            f"= `{block}`. Alle Oktette davor müssen identisch sein, und das "
            f"{o}. Oktett muss im selben {block}er-Block liegen. {befund}",
            source="rfc950",
        ))
    return equal, steps


def prefix_for_hosts(n: int) -> tuple[int, list[Step]]:
    """Baustein A, Zusatzfrage: kleinstes Präfix für n nutzbare Hosts."""
    host_bits = 2
    while hosts_for_prefix(32 - host_bits) < n:
        host_bits += 1
        if host_bits > 24:
            break
    prefix = 32 - host_bits
    steps = [Step(
        "Hostbits bestimmen",
        f"Gesucht ist die kleinste Zahl Hostbits h mit 2^h − 2 ≥ {n}. "
        f"2^{host_bits} − 2 = {2 ** host_bits - 2} ≥ {n} passt "
        f"(2^{host_bits - 1} − 2 = {2 ** (host_bits - 1) - 2} wäre zu klein). "
        f"Also {host_bits} Hostbits → Präfix `/{32 - host_bits}` mit "
        f"`{2 ** host_bits - 2}` nutzbaren Adressen - Reserve: "
        f"{2 ** host_bits - 2 - n}.",
        source="rfc950",
    )]
    if n <= 2:
        steps.append(Step(
            "Praxis-Hinweis",
            "Für reine Punkt-zu-Punkt-Strecken (2 Geräte) ist statt /30 auch "
            "/31 zulässig und üblich - das spart die Hälfte der Adressen.",
            source="rfc3021",
        ))
    return prefix, steps


def split_subnets(net: ipaddress.IPv4Network, count: int) -> tuple[list[ipaddress.IPv4Network], int, list[Step]]:
    """Baustein B: ein Netz in `count` gleich große Subnetze teilen (count = 2^k)."""
    extra_bits = (count - 1).bit_length()
    new_prefix = net.prefixlen + extra_bits
    if new_prefix > 30:
        raise ValueError(f"/{net.prefixlen} lässt sich nicht in {count} nutzbare Subnetze teilen")
    subnets = list(net.subnets(new_prefix=new_prefix))
    step_octet = (new_prefix + 7) // 8  # Oktett des letzten Netzbits = dort springt die Netzadresse
    steps = [
        Step(
            "Zusätzliche Netzbits",
            f"{count} Subnetze brauchen {extra_bits} zusätzliche Netzbits "
            f"(2^{extra_bits} = {2 ** extra_bits}). Neues Präfix: "
            f"`/{net.prefixlen}` + {extra_bits} = `/{new_prefix}` "
            f"(Maske `{subnets[0].netmask}`).",
            source="rfc950",
        ),
        Step(
            "Schrittweite und Subnetz-Liste",
            f"Jedes Subnetz umfasst 2^{32 - new_prefix} = {2 ** (32 - new_prefix)} "
            f"Adressen, davon `{hosts_for_prefix(new_prefix)}` nutzbar. Die "
            f"Netzadressen springen daher im {step_octet}. Oktett in "
            f"{max(1, 2 ** (32 - new_prefix) >> (8 * (4 - step_octet)))}er-Schritten: "
            + ", ".join(f"`{s.network_address}`" for s in subnets[:4])
            + (" …" if count > 4 else "") + ".",
            source="rfc4632",
        ),
    ]
    return subnets, new_prefix, steps


@dataclass
class VlsmAlloc:
    name: str
    hosts_needed: int
    network: ipaddress.IPv4Network

    @property
    def usable(self) -> int:
        return hosts_for_prefix(self.network.prefixlen)


def vlsm_plan(net: ipaddress.IPv4Network,
              demands: list[tuple[str, int]]) -> tuple[list[VlsmAlloc], list[Step]]:
    """Baustein B: VLSM - Bedarfe absteigend sortiert lückenlos zuteilen."""
    ordered = sorted(demands, key=lambda d: d[1], reverse=True)
    steps = [Step(
        "Sortieren: größter Bedarf zuerst",
        "VLSM vergibt die Blöcke von groß nach klein - so bleiben keine "
        "unbrauchbaren Lücken zwischen den Subnetzen. Reihenfolge: "
        + ", ".join(f"{name} ({n} Hosts)" for name, n in ordered) + ".",
        source="rfc4632",
    )]
    allocs: list[VlsmAlloc] = []
    cursor = int(net.network_address)
    end = int(net.broadcast_address) + 1
    for name, n in ordered:
        prefix, _ = prefix_for_hosts(n)
        size = 2 ** (32 - prefix)
        # auf die eigene Blockgröße ausrichten (bei absteigender Vergabe
        # ist der Cursor bereits ausgerichtet - das Alignment ist die Regel,
        # auf die es in Stufe-3-Aufgaben ankommt)
        if cursor % size:
            cursor += size - (cursor % size)
        if cursor + size > end:
            raise ValueError(f"Bedarf '{name}' passt nicht mehr in {net}")
        alloc = ipaddress.ip_network((cursor, prefix))
        allocs.append(VlsmAlloc(name, n, alloc))
        steps.append(Step(
            f"{name}: {n} Hosts → /{prefix}",
            f"Kleinstes Präfix mit 2^h − 2 ≥ {n}: `/{prefix}` "
            f"({hosts_for_prefix(prefix)} nutzbar). Nächster freier, "
            f"passend ausgerichteter Block: `{alloc}` "
            f"(Hostbereich `{alloc.network_address + 1}` – "
            f"`{alloc.broadcast_address - 1}`).",
            source="rfc4632",
        ))
        cursor += size
    rest = end - cursor
    steps.append(Step(
        "Ergebnis prüfen",
        f"Vergeben: {cursor - int(net.network_address)} von "
        f"{end - int(net.network_address)} Adressen in `{net}` - "
        f"Reserve für Wachstum: {rest} Adressen.",
        source="rfc4632",
    ))
    return allocs, steps


# --- IPv6-Grundlagen (Baustein D) --------------------------------------------
# Bewusst Grundlagen, kein v4-Parity: Notation, Adresstyp, Präfix-Logik,
# Subnetz-Denke. Prüf-Reihenfolge der Typen ist relevant (2001:db8::/32
# liegt INNERHALB von 2000::/3 und muss davor erkannt werden).

V6_TYPES: list[tuple[str, str, str, str]] = [
    ("::1/128", "Loopback", "immer der eigene Rechner - das Gegenstück zu 127.0.0.1", "rfc4291"),
    ("::/128", "Unspezifiziert", "„noch keine Adresse“ - das Gegenstück zu 0.0.0.0", "rfc4291"),
    ("2001:db8::/32", "Dokumentationsbereich", "reserviert für Beispiele und Anleitungen - "
     "taucht in echten Netzen nie auf", "rfc3849"),
    ("fe80::/10", "Link-Local", "entsteht automatisch auf jeder Schnittstelle - anders als "
     "APIPA bei v4 kein Störungsbild, sondern Normalzustand", "rfc4291"),
    ("fc00::/7", "ULA (Unique Local)", "privater Bereich, das Gegenstück zu RFC 1918 - "
     "in der Praxis beginnen ULAs mit fd", "rfc4193"),
    ("ff00::/8", "Multicast", "ersetzt bei IPv6 auch den Broadcast", "rfc4291"),
    ("2000::/3", "Global Unicast (GUA)", "öffentlich routbar - das Gegenstück zur "
     "öffentlichen v4-Adresse", "rfc4291"),
]


def classify_v6(ip: ipaddress.IPv6Address) -> tuple[str, str, str]:
    """Adresstyp einer IPv6-Adresse: (Kurzlabel, Erklärung, Quellen-Kürzel)."""
    for net, label, detail, source in V6_TYPES:
        if ip in ipaddress.ip_network(net):
            return label, detail, source
    return ("Sonder-/Reserviert-Bereich",
            "außerhalb der gängigen Zuweisungen (Unicast 2000::/3, ULA, Link-Local, Multicast)",
            "rfc4291")


def notation_steps_v6(ip: ipaddress.IPv6Address) -> list[Step]:
    """Die drei Notations-Schritte (Vollform, beide Kürzungsregeln) - von
    analyse_v6 UND dem Aufgabentyp 'notation' genutzt (eine Quelle)."""
    blocks = ip.exploded.split(":")
    steps = [Step(
        "Vollform: 8 Blöcke à 16 Bit",
        f"Eine IPv6-Adresse hat 128 Bit, geschrieben als 8 Blöcke mit je 4 "
        f"Hexadezimalstellen (1 Hexstelle = 4 Bit): `{ip.exploded}`.",
        source="rfc4291",
    )]
    trimmed = [b.lstrip("0") or "0" for b in blocks]
    steps.append(Step(
        "Kürzungsregel 1: führende Nullen je Block streichen",
        "In jedem Block dürfen führende Nullen weg (ein Block bleibt aber "
        f"mindestens eine Stelle): `{':'.join(trimmed)}`.",
        source="rfc5952",
    ))
    runs: list[tuple[int, int]] = []          # (start, laenge) der Nullblock-Folgen
    i = 0
    while i < 8:
        if int(blocks[i], 16) == 0:
            j = i
            while j < 8 and int(blocks[j], 16) == 0:
                j += 1
            runs.append((i, j - i))
            i = j
        else:
            i += 1
    best = max(runs, key=lambda r: r[1], default=(0, 0))
    if best[1] >= 2:
        steps.append(Step(
            "Kürzungsregel 2: längste Nullfolge wird ::",
            f"Die längste Folge von Nullblöcken (Block {best[0] + 1} bis "
            f"{best[0] + best[1]}, {best[1]} Blöcke) wird genau EINMAL durch "
            "`::` ersetzt - bei Gleichstand die erste Folge, und nie an zwei "
            f"Stellen (sonst wäre die Adresse mehrdeutig). Kürzform: `{ip.compressed}`.",
            source="rfc5952",
        ))
    else:
        steps.append(Step(
            "Kürzungsregel 2: hier kein ::",
            "`::` ersetzt nur Folgen von MINDESTENS zwei Nullblöcken - ein "
            "einzelner Nullblock wird als `0` geschrieben. Kürzform: "
            f"`{ip.compressed}`.",
            source="rfc5952",
        ))
    return steps


def classify_step_v6(ip: ipaddress.IPv6Address) -> Step:
    """classify_v6 als Rechenweg-Schritt (für den Aufgabentyp 'adresstyp')."""
    label, detail, source = classify_v6(ip)
    return Step(f"{ip.compressed} → {label}",
                f"{detail}. Erkennungsweg: das Präfix der Adresse mit den "
                "bekannten Bereichen abgleichen - Sonderbereiche (wie der "
                "Dokumentationsbereich) VOR dem großen Unicast-Bereich prüfen.",
                source=source)


def nth_subnet_v6(net: ipaddress.IPv6Network, index: int,
                  new_prefix: int = 64) -> tuple[ipaddress.IPv6Network, list[Step]]:
    """Das n-te Subnetz (Standard: /64) aus einem Block - arithmetisch statt
    Aufzählung (ein /48 hat 65 536 Subnetze). Rechenweg: Subnetz-Bits
    verorten, Index nach Hex wandeln, einsetzen."""
    sub_bits = new_prefix - net.prefixlen
    if sub_bits <= 0 or index >= 2 ** sub_bits:
        raise ValueError("Index passt nicht in die Subnetz-Bits")
    sub = ipaddress.IPv6Network((int(net.network_address)
                                 + (index << (128 - new_prefix)), new_prefix))
    hex_id = format(index, "x")
    steps = [Step(
        f"Subnetz-Bits verorten: /{net.prefixlen} → /{new_prefix}",
        f"Zwischen Präfix und Interface-Anteil liegen {sub_bits} Subnetz-Bits "
        f"(= {sub_bits // 4} Hexstellen{' + Rest' if sub_bits % 4 else ''}). "
        f"Dort wird die Subnetz-ID eingetragen - gezählt wird hexadezimal.",
        source="rfc4291",
    ), Step(
        f"Index {index} → Hexadezimal",
        f"Dezimal {index} = Hex `{hex_id}` (bei Bedarf mit führenden Nullen "
        f"auf die Stellenzahl der Subnetz-ID auffüllen). Eingesetzt ergibt "
        f"das `{sub.compressed}`.",
        source="rfc4291",
    )]
    return sub, steps


def split_v6(net: ipaddress.IPv6Network,
             count: int) -> tuple[list[ipaddress.IPv6Network], int, int, list[Step]]:
    """Ein v6-Netz in `count` gleich große Subnetze teilen: (erste vier
    Subnetze, neues Präfix, Schrittweite im betroffenen Block, Rechenweg).
    Deckt auch den Fall ABSEITS der Nibble-Grenze ab (z. B. /56 → 4 x /58,
    Schrittweite 0x40) - dort scheitern Lernende laut Praxis am häufigsten:
    die Präfixlänge gelingt, die konkreten Subnetzadressen nicht."""
    extra_bits = (count - 1).bit_length()
    new_prefix = net.prefixlen + extra_bits
    if new_prefix > 64:
        raise ValueError("Teilung würde unter /64 gehen")
    size = 1 << (128 - new_prefix)
    subnets = [ipaddress.IPv6Network((int(net.network_address) + i * size, new_prefix))
               for i in range(min(count, 4))]
    block_i = (new_prefix - 1) // 16          # Block, in dem sich die Adressen ändern
    step_in_block = (size >> (16 * (7 - block_i))) & 0xFFFF
    steps = [Step(
        "Neues Präfix bestimmen",
        f"{count} Subnetze brauchen {extra_bits} zusätzliche Bits "
        f"(2^{extra_bits} = {2 ** extra_bits}): `/{net.prefixlen}` + {extra_bits} "
        f"= `/{new_prefix}`.",
        source="rfc4291",
    )]
    if new_prefix % 4:
        steps.append(Step(
            "Achtung: Grenze mitten in der Hexstelle",
            f"/{new_prefix} ist kein Vielfaches von 4 - die Grenze läuft durch "
            f"eine Hexstelle von Block {block_i + 1}. Deshalb binär denken: "
            f"die Schrittweite ist 2^{(16 * (block_i + 1)) - new_prefix} an der "
            f"Blockposition, hexadezimal `0x{step_in_block:x}`. (Die Betriebspraxis "
            "bevorzugt Teilungen an 4-Bit-Grenzen, weil genau hier Fehler "
            "entstehen - rechnen können muss man es trotzdem.)",
            source="rfc4291",
        ))
    else:
        steps.append(Step(
            "Schrittweite an der Hex-Grenze",
            f"Die Grenze fällt auf eine ganze Hexstelle - die Subnetz-IDs "
            f"zählen in Block {block_i + 1} einfach hexadezimal hoch "
            f"(Schrittweite `0x{step_in_block:x}`).",
            source="rfc4291",
        ))
    steps.append(Step(
        "Subnetze aufzählen",
        "Die ersten Subnetze: " + ", ".join(f"`{s.compressed}`" for s in subnets)
        + (" …" if count > 4 else "") + f" - insgesamt {count} Stück.",
        source="rfc4291",
    ))
    return subnets, new_prefix, step_in_block, steps
def analyse_v6(ip: ipaddress.IPv6Address, prefix: int) -> tuple[ipaddress.IPv6Network, list[Step]]:
    """Der IPv6-Rechenweg: Notation (RFC 5952), Präfix-Trennung, Subnetz-Denke."""
    net = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    steps = notation_steps_v6(ip)
    if prefix % 16 == 0:
        grenze = (f"Die Grenze fällt auf eine Blockgrenze: die ersten "
                  f"{prefix // 16} Blöcke sind Netzanteil.")
    else:
        grenze = (f"Die Grenze läuft mitten durch Block {prefix // 16 + 1} - "
                  "Präfixe, die kein Vielfaches von 16 sind, teilen einen Block.")
    steps.append(Step(
        f"Präfix /{prefix}: Netz- und Interface-Anteil trennen",
        f"Wie bei v4 gilt: die ersten {prefix} Bit sind Netzanteil. {grenze} "
        f"Netz-Präfix: `{net.compressed}`.",
        source="rfc4291",
    ))
    if prefix < 64:
        steps.append(Step(
            "Subnetze zählen statt Hosts",
            f"Aus einem /{prefix} lassen sich 2^{64 - prefix} = "
            f"`{2 ** (64 - prefix):,}`".replace(",", " ") + " Standard-Subnetze "
            "(/64) bilden. Typische Struktur: /48 für den Standort, 16 Bit "
            "Subnetz-ID, /64 je Netzsegment.",
            source="rfc4291",
        ))
    elif prefix == 64:
        steps.append(Step(
            "Das /64-Endnetz: Hostzählerei entfällt",
            "64 Bit Interface-ID = 2^64 Adressen je Subnetz - niemand zählt "
            "mehr, ob es reicht. Es gibt keinen Broadcast (Multicast übernimmt) "
            "und keine −2-Formel. Geplant wird in Subnetzen, nicht in Hosts; "
            "die automatische Adressvergabe (SLAAC) setzt genau diese "
            "64 Interface-Bits voraus.",
            source="rfc4862",
        ))
    else:
        steps.append(Step(
            f"Achtung: /{prefix} ist länger als /64",
            "Endnetze sind bei IPv6 per Konvention /64 - längere Präfixe sind "
            "Punkt-zu-Punkt-Sonderfälle und brechen die automatische "
            "Adressvergabe (SLAAC).",
            source="rfc4862",
        ))
    return net, steps


@dataclass
class KonzeptZeile:
    """Eine Zeile des Adresskonzepts (Baustein 'Planen')."""
    name: str
    bedarf: int                # eingegebene Clientzahl
    mit_reserve: int           # Bedarf inkl. Wachstumsreserve (das plant VLSM)
    network: ipaddress.IPv4Network
    gateway: str
    static_range: str          # "x – y" oder "–"
    dhcp_range: str            # "x – y" oder "–"

    @property
    def usable(self) -> int:
        return hosts_for_prefix(self.network.prefixlen)

    @property
    def auslastung(self) -> int:
        return round(self.mit_reserve / self.usable * 100)


def adresskonzept(net: ipaddress.IPv4Network, demands: list[tuple[str, int]],
                  reserve_pct: int, gw_last: bool,
                  static_per: int) -> tuple[list[KonzeptZeile], list[Step], int]:
    """Baustein 'Planen': VLSM-Zuteilung + Bereichsaufteilung je Subnetz
    (Gateway-Konvention, statischer Block, DHCP-Pool). Liefert (Zeilen,
    Rechenweg, Rest-Adressen im Basisnetz)."""
    eff = [(name, n, (n * (100 + reserve_pct) + 99) // 100) for name, n in demands]
    steps: list[Step] = []
    if reserve_pct:
        steps.append(Step(
            f"Wachstumsreserve {reserve_pct} % aufschlagen",
            "Geplant wird nicht der heutige Bedarf, sondern Bedarf + Reserve: "
            + "; ".join(f"{name} {n} → `{m}`" for name, n, m in eff) + ".",
            source="rfc4632",
        ))
    allocs, vlsm_steps = vlsm_plan(net, [(name, m) for name, n, m in eff])
    steps.extend(vlsm_steps)
    by_name = {name: (n, m) for name, n, m in eff}
    rows: list[KonzeptZeile] = []
    for al in allocs:
        n, m = by_name[al.name]
        first = al.network.network_address + 1
        last = al.network.broadcast_address - 1
        usable = hosts_for_prefix(al.network.prefixlen)
        gw = last if gw_last else first
        pool_start = first if gw_last else first + 1
        pool_end = last - 1 if gw_last else last
        static_n = max(0, min(static_per, usable - 1))
        if static_n:
            static_range = (f"{pool_start} – {pool_start + static_n - 1}"
                            if static_n > 1 else str(pool_start))
        else:
            static_range = "–"
        dhcp_start = pool_start + static_n
        dhcp_range = (f"{dhcp_start} – {pool_end}" if dhcp_start < pool_end
                      else (str(dhcp_start) if dhcp_start == pool_end else "–"))
        rows.append(KonzeptZeile(al.name, n, m, al.network, str(gw),
                                 static_range, dhcp_range))
    rest = net.num_addresses - sum(r.network.num_addresses for r in rows)
    steps.append(Step(
        "Bereiche im Subnetz aufteilen",
        f"Konvention: Gateway = {'letzte' if gw_last else 'erste'} nutzbare "
        f"Adresse, danach {static_per} statische Adressen (Server, Drucker, "
        "Management), der Rest ist DHCP-Pool. Bei kleinen Subnetzen wird der "
        "statische Block automatisch gekürzt.",
        source="rfc950",
    ))
    return rows, steps, rest


def min_base_prefix(net: ipaddress.IPv4Network,
                    demands: list[tuple[str, int]]) -> int | None:
    """Wenn die Bedarfe nicht in `net` passen: das größte (= zahlenmäßig
    kleinste) Präfix finden, in das sie ab derselben Basis passen würden."""
    for p in range(net.prefixlen - 1, 7, -1):
        candidate = net.supernet(new_prefix=p)
        try:
            vlsm_plan(candidate, demands)
            return p
        except ValueError:
            continue
    return None


# --- Aufgabengenerator (Baustein B) ------------------------------------------
# Deterministisch: random.Random(seed). Gleicher Seed + Typ + Stufe = gleiche
# Aufgabe. Die LÖSUNG wird nie separat erzeugt, sondern immer über dieselben
# DOMAIN-Funktionen berechnet wie im Erklär-Modus - eine Quelle, kein Drift.


@dataclass
class Task:
    typ: str
    level: int
    seed: int
    question: str                 # Fragetext (Klartext, `Backticks` -> code)
    subquestions: list[str]       # Teilfragen a), b), ...
    solution_steps: list[Step]    # vollständiger Rechenweg
    answers: list[tuple[str, str]]  # (Bezeichnung, Kurzantwort) für die Schnellkontrolle


def _rand_host_ip(rng: random.Random, net: ipaddress.IPv4Network) -> ipaddress.IPv4Address:
    """Zufällige Host-Adresse (nie Netz-/Broadcast-Adresse) aus einem Netz."""
    return ipaddress.IPv4Address(
        rng.randrange(int(net.network_address) + 1, int(net.broadcast_address)))


def _base_net(rng: random.Random, level: int) -> tuple[ipaddress.IPv4Address, int]:
    """IP + Präfix für Analyse-Aufgaben, nach Stufe kalibriert."""
    if level == 1:
        # Rechnen nur im 4. Oktett, privater Klassiker
        prefix = rng.choice([25, 26, 27, 28, 29, 30])
        base = ipaddress.IPv4Address(f"192.168.{rng.randrange(0, 255)}.0")
        net = ipaddress.ip_network(f"{base}/24")
    elif level == 2:
        # Oktettgrenze im 3. Oktett kommt ins Spiel
        prefix = rng.choice([20, 21, 22, 23, 25, 26, 27, 28])
        first = rng.choice([10, 172, 192])
        second = rng.randrange(16, 32) if first == 172 else (
            168 if first == 192 else rng.randrange(0, 255))
        net = ipaddress.ip_network(f"{first}.{second}.0.0/16")
    else:
        prefix = rng.choice([17, 18, 19, 20, 21, 22, 26, 27, 28, 29])
        net = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.0.0/16")
    ip = ipaddress.IPv4Address(
        rng.randrange(int(net.network_address) + 1, int(net.broadcast_address)))
    return ip, prefix


def _gen_netz(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    ip, prefix = _base_net(rng, level)
    a = analyse_subnetz(ip, prefix)
    q = (f"Ein Client hat die Adresse `{ip}/{prefix}` "
         f"(Maske `{a.netmask}`).")
    subs = ["Netzadresse?", "Broadcast-Adresse?",
            "Erster und letzter nutzbarer Host?", "Anzahl nutzbarer Host-Adressen?"]
    answers = [("Netzadresse", a.net_addr), ("Broadcast", a.broadcast),
               ("Hostbereich", f"{a.first_host} – {a.last_host}"),
               ("nutzbare Hosts", str(a.usable))]
    return q, subs, a.steps, answers


def _gen_hosts(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    if level == 1 or rng.random() < 0.4:
        prefix = rng.choice([22, 23, 24, 25, 26, 27, 28, 29, 30])
        q = f"Gegeben ist ein Subnetz mit dem Präfix `/{prefix}`."
        subs = ["Wie viele nutzbare Host-Adressen bietet es?"]
        usable = hosts_for_prefix(prefix)
        steps = [Step(
            "Hostbits zählen",
            f"`/{prefix}` lässt 32 − {prefix} = {32 - prefix} Hostbits. "
            f"Nutzbar: 2^{32 - prefix} − 2 = {2 ** (32 - prefix)} − 2 = "
            f"`{usable}` (Netz- und Broadcast-Adresse abziehen).",
            source="rfc950",
        )]
        answers = [("nutzbare Hosts", str(usable))]
        return q, subs, steps, answers
    n = rng.choice([10, 14, 25, 30, 50, 60, 100, 120, 200, 250, 500, 1000][
                   : 8 if level == 2 else 12])
    prefix, steps = prefix_for_hosts(n)
    q = (f"Eine Abteilung braucht Adressen für `{n}` Geräte "
         "(Gateway schon eingerechnet).")
    subs = ["Welches ist das kleinste passende Präfix?",
            "Wie viele Adressen bleiben als Reserve?"]
    answers = [("Präfix", f"/{prefix}"),
               ("Reserve", str(hosts_for_prefix(prefix) - n))]
    return q, subs, steps, answers


def _gen_maske(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    prefix = rng.choice([8, 16, 24, 25, 26, 27] if level == 1 else
                        list(range(17, 31)))
    net = ipaddress.ip_network(f"0.0.0.0/{prefix}")
    to_dotted = rng.random() < 0.5
    binrow = _row("Maske", int(net.netmask), str(net.netmask), split=prefix)
    if to_dotted:
        q = f"Gegeben ist die Präfix-Schreibweise `/{prefix}`."
        subs = ["Wie lautet die Subnetzmaske in Dezimalschreibweise?"]
    else:
        q = f"Gegeben ist die Subnetzmaske `{net.netmask}`."
        subs = ["Wie lautet die Präfix-Schreibweise (CIDR)?"]
    steps = [Step(
        "Bits übersetzen",
        f"`/{prefix}` = {prefix} Einsen von links, Rest Nullen. Oktettweise "
        f"ausgerechnet ergibt das `{net.netmask}`. Merkhilfe: die möglichen "
        "Oktett-Werte sind 0, 128, 192, 224, 240, 248, 252, 254, 255 - "
        "jede weitere 1 addiert die nächstkleinere Zweierpotenz.",
        rows=[binrow],
        source="rfc4632",
    )]
    answers = [("Maske", str(net.netmask)), ("Präfix", f"/{prefix}")]
    if level >= 2:
        subs.append("Zusatz: Wie lautet die Wildcard-Maske (Invertierung)?")
        steps.append(Step(
            "Wildcard = Maske invertiert",
            f"Jedes Bit kippen: `{net.hostmask}`. Gebraucht wird das u. a. in "
            "ACLs (Router-Zugriffslisten).",
            source="rfc4632",
        ))
        answers.append(("Wildcard", str(net.hostmask)))
    return q, subs, steps, answers


def _gen_same(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    if level == 1:
        prefix = rng.choice([25, 26, 27])
        supernet = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
    else:
        prefix = rng.choice([21, 22, 23, 25, 26, 27, 28])
        supernet = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.0.0/16")
    subnets = list(supernet.subnets(new_prefix=prefix))
    n1 = rng.choice(subnets[:-1])
    if rng.random() < 0.5:
        n2 = n1                                   # gleiches Subnetz
    else:
        n2 = subnets[subnets.index(n1) + 1]       # das Nachbar-Subnetz - knapp daneben
    ip1, ip2 = _rand_host_ip(rng, n1), _rand_host_ip(rng, n2)
    equal, steps = same_subnet(ip1, ip2, prefix)
    mask = ipaddress.ip_network(f"0.0.0.0/{prefix}").netmask
    q = (f"Client A (`{ip1}`) erreicht das Gateway (`{ip2}`) nicht. "
         f"Beide sind mit der Maske `{mask}` (= `/{prefix}`) konfiguriert "
         "und hängen am selben Switch.")
    subs = ["Liegen beide Adressen im selben Subnetz?",
            "Was bedeutet das Ergebnis für die Fehlersuche?"]
    answers = [("gleiches Subnetz?", "ja" if equal else "nein")]
    return q, subs, steps, answers


def _gen_subnetze(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    if level == 1:
        net = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
        count = rng.choice([2, 4, 8])
    elif level == 2:
        net = rng.choice([
            ipaddress.ip_network(f"172.{rng.randrange(16, 32)}.0.0/16"),
            ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24"),
        ])
        count = rng.choice([4, 8, 16])
    else:
        net = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.{rng.choice([0, 16, 32, 64, 128])}.0/20")
        count = rng.choice([8, 16, 32])
    subnets, new_prefix, steps = split_subnets(net, count)
    q = f"Das Netz `{net}` soll in `{count}` gleich große Subnetze geteilt werden."
    subs = ["Wie lautet die neue Präfixlänge (und Maske)?",
            "Nenne die Netzadressen der ersten vier Subnetze.",
            "Wie viele nutzbare Hosts hat jedes Subnetz?"]
    answers = [("neues Präfix", f"/{new_prefix} ({subnets[0].netmask})"),
               ("erste Subnetze", ", ".join(str(s.network_address) for s in subnets[:4])),
               ("Hosts je Subnetz", str(hosts_for_prefix(new_prefix)))]
    return q, subs, steps, answers


def _gen_vlsm(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
    if level <= 2:
        net = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
        pool = [100, 60, 50, 25, 20, 10, 5, 2]
        k = 3
    else:
        net = ipaddress.ip_network(f"172.{rng.randrange(16, 32)}.{rng.choice([0, 2, 4, 8])}.0/23")
        pool = [200, 120, 100, 60, 50, 25, 20, 10, 2]
        k = 4
    names = rng.sample(VLSM_NAMES, k)
    # Bedarfe wählen, bis die Blockgrößen ins Netz passen (deterministisch,
    # weil ausschließlich rng-gesteuert weitergewürfelt wird).
    for _ in range(50):
        demands = list(zip(names, sorted(rng.sample(pool, k), reverse=True)))
        need = sum(2 ** (32 - prefix_for_hosts(n)[0]) for _, n in demands)
        if need <= net.num_addresses:
            break
    allocs, steps = vlsm_plan(net, demands)
    q = (f"Das Netz `{net}` ist per VLSM aufzuteilen. Bedarf: "
         + "; ".join(f"{name} `{n}` Hosts" for name, n in demands) + ".")
    subs = ["Welche Bedarfsreihenfolge gilt (und warum)?",
            "Welches Subnetz (Netzadresse/Präfix) bekommt jeder Bereich?",
            "Wie viele Adressen bleiben als Reserve?"]
    rest = net.num_addresses - sum(a.network.num_addresses for a in allocs)
    answers = [(a.name, f"{a.network} ({a.usable} nutzbar)") for a in allocs]
    answers.append(("Reserve", f"{rest} Adressen"))
    return q, subs, steps, answers


# --- IPv6-Generatoren --------------------------------------------------------
# Adress-Material: Basis 2001:db8::/32 (Dokumentationsbereich), Nullblock-
# Choreografie statt Gleichverteilung - rein zufällige Blöcke wären fast nie
# kürzbar und die Notations-Aufgabe trivial.


def _rand_v6(rng: random.Random, level: int) -> ipaddress.IPv6Address:
    """Kürzbare Doku-Adresse: Nullfolgen-Muster nach Stufe."""
    blocks = [0x2001, 0x0db8] + [rng.randrange(0x1000, 0xFFFF) for _ in range(6)]
    lead0 = rng.sample(range(2, 8), 2)                 # 1-2 Blöcke mit führenden Nullen
    for i in lead0:
        blocks[i] = rng.randrange(0x001, 0xFFF)
    if level == 1:
        start = rng.randrange(2, 6)
        run = rng.choice([2, 3])
        for i in range(start, start + run):
            blocks[i] = 0
    elif level == 2:
        start = rng.randrange(2, 5)
        for i in range(start, start + 2):
            blocks[i] = 0
        single = rng.choice([i for i in range(2, 8) if not start <= i <= start + 2
                             and i != start - 1])
        blocks[single] = 0
    else:
        first_run = rng.choice([2, 3])                 # bei 2: Gleichstand -> erste gewinnt
        for i in range(2, 4):
            blocks[i] = 0
        for i in range(5, 5 + first_run):
            if i < 8:
                blocks[i] = 0
    return ipaddress.IPv6Address(":".join(f"{b:04x}" for b in blocks))


def _gen_v6_notation(rng: random.Random, level: int):
    ip = _rand_v6(rng, level)
    to_short = rng.random() < 0.5
    steps = notation_steps_v6(ip)
    if to_short:
        q = f"Gegeben ist die Vollform `{ip.exploded}`."
        subs = ["Wie lautet die Kürzform nach RFC 5952?",
                "Warum darf `::` nur an genau einer Stelle stehen?"]
    else:
        q = f"Gegeben ist die Kürzform `{ip.compressed}`."
        subs = ["Wie lautet die Vollform (alle 8 Blöcke, 4 Stellen je Block)?",
                "Für wie viele Nullblöcke steht das `::` hier?"]
    visible = ip.compressed.replace("::", ":!:").split(":")
    hidden = 8 - len([b for b in visible if b and b != "!"])
    answers = [("Kürzform", ip.compressed), ("Vollform", ip.exploded),
               ("`::` steht für", f"{hidden} Nullblöcke")]
    return q, subs, steps, answers


def _gen_v6_adresstyp(rng: random.Random, level: int):
    def doku():
        return f"2001:db8:{rng.randrange(0, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}"
    pools = {
        1: [lambda: "::1", lambda: f"fe80::{rng.randrange(1, 0xFFFF):x}", doku,
            lambda: "ff02::1"],
        2: [lambda: f"fd{rng.randrange(0, 0xFF):02x}::{rng.randrange(1, 0xFFFF):x}",
            lambda: f"2a00:{rng.randrange(1, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}",
            lambda: f"fe80::{rng.randrange(1, 0xFFFF):x}", doku, lambda: "::"],
        3: [doku,                                       # liegt in 2000::/3 - Doku gewinnt
            lambda: f"fc{rng.randrange(0, 0xFF):02x}::{rng.randrange(1, 0xFFFF):x}",
            lambda: f"fec0::{rng.randrange(1, 0xFFFF):x}",   # veraltet Site-Local -> Sonderbereich
            lambda: f"ff05::{rng.randrange(1, 0xFF):x}",
            lambda: f"2003:{rng.randrange(1, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}"],
    }
    picks = rng.sample(pools[level], 3)
    ips = [ipaddress.IPv6Address(p()) for p in picks]
    q = "Ordne jeder Adresse ihren Typ zu: " + "; ".join(
        f"{chr(97 + i)}) `{ip.compressed}`" for i, ip in enumerate(ips))
    subs = ["Welcher Adresstyp ist das jeweils - und woran erkennt man ihn?"]
    steps = [classify_step_v6(ip) for ip in ips]
    answers = [(f"{chr(97 + i)}) {ip.compressed}",
                f"{classify_v6(ip)[0]}") for i, ip in enumerate(ips)]
    return q, subs, steps, answers


def _gen_v6_netzpraefix(rng: random.Random, level: int):
    ip = _rand_v6(rng, level if level < 3 else 2)
    prefix = rng.choice({1: [32, 48, 64], 2: [52, 56, 60], 3: [52, 56, 60]}[level])
    net, steps = analyse_v6(ip, prefix)
    q = f"Ein Interface hat die Adresse `{ip.compressed}/{prefix}`."
    subs = ["Wie lautet das Netz-Präfix?",
            "Fällt die Präfix-Grenze auf eine Blockgrenze?"]
    answers = [("Netz-Präfix", net.compressed),
               ("Blockgrenze?", "ja" if prefix % 16 == 0 else
                f"nein - mitten in Block {prefix // 16 + 1}")]
    if level == 3:
        other = ipaddress.IPv6Address(int(net.network_address)
                                      + (rng.choice([0, 1]) << (128 - prefix))
                                      + rng.randrange(1, 1 << 64))
        net2, steps2 = analyse_v6(other, prefix)
        steps = steps + steps2
        subs.append(f"Liegt `{other.compressed}` im selben Netz?")
        answers.append(("gleiches Netz?", "ja" if net2 == net else "nein"))
    return q, subs, steps, answers


def _gen_v6_sub64(rng: random.Random, level: int):
    if level <= 2:
        base = ipaddress.IPv6Network(f"2001:db8:{rng.randrange(1, 0xFFFF):x}::/48")
        n = rng.randrange(0, 16) if level == 1 else rng.randrange(16, 4096)
    else:
        base = ipaddress.IPv6Network(
            f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.randrange(0, 0xFF):02x}00::/56")
        n = rng.randrange(1, 256)
    sub, steps = nth_subnet_v6(base, n)
    total = 2 ** (64 - base.prefixlen)
    last, _ = nth_subnet_v6(base, total - 1)
    q = (f"Ein Standort hat `{base.compressed}`. Jedes Netzsegment bekommt "
         "ein /64. Gezählt wird ab 0.")
    subs = [f"Wie lautet Subnetz Nr. `{n}`?", "Wie lautet das letzte mögliche /64?",
            "Wie viele /64 gibt es insgesamt?"]
    steps.append(Step(
        "Letztes Subnetz und Gesamtzahl",
        f"{base.prefixlen} → 64 lässt {64 - base.prefixlen} Subnetz-Bits: "
        f"2^{64 - base.prefixlen} = `{total:,}`".replace(",", " ")
        + f" Subnetze; das letzte hat die höchste Subnetz-ID: `{last.compressed}`.",
        source="rfc4291",
    ))
    answers = [(f"Subnetz {n}", sub.compressed), ("letztes /64", last.compressed),
               ("Anzahl", f"{total:,}".replace(",", " "))]
    return q, subs, steps, answers


def _gen_v6_teilen(rng: random.Random, level: int):
    if level == 1:
        base = ipaddress.IPv6Network(f"2001:db8:{rng.randrange(1, 0xFFFF):x}::/48")
        count = 16
    elif level == 2:
        base = ipaddress.IPv6Network(
            f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.choice(['00', '40', '80'])}00::/56")
        count = 16
    else:
        # der Praxis-Klassiker: Teilung ABSEITS der 4-Bit-Grenze (/56 -> /57-/59)
        base = ipaddress.IPv6Network(
            f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.choice(['a0', 'b0', 'e0'])}00::/56")
        count = rng.choice([2, 4, 8])
    subnets, new_prefix, step_in_block, steps = split_v6(base, count)
    q = f"Das Netz `{base.compressed}` soll in `{count}` gleich große Subnetze geteilt werden."
    subs = ["Wie lautet die neue Präfixlänge?",
            "Nenne die ersten vier Subnetze (Netzadresse/Präfix)."
            if count >= 4 else "Nenne alle Subnetze (Netzadresse/Präfix).",
            "Welche Schrittweite gilt im betroffenen Block?"]
    answers = [("neues Präfix", f"/{new_prefix}"),
               ("Subnetze", ", ".join(s.compressed for s in subnets)),
               ("Schrittweite", f"0x{step_in_block:x} in Block {(new_prefix - 1) // 16 + 1}")]
    return q, subs, steps, answers


_GENERATORS = {
    "netz": _gen_netz,
    "hosts": _gen_hosts,
    "maske": _gen_maske,
    "same": _gen_same,
    "subnetze": _gen_subnetze,
    "vlsm": _gen_vlsm,
    "notation": _gen_v6_notation,
    "adresstyp": _gen_v6_adresstyp,
    "netzpraefix": _gen_v6_netzpraefix,
    "sub64": _gen_v6_sub64,
    "teilen": _gen_v6_teilen,
}


def generate_task(typ: str, level: int, seed: int) -> Task:
    """Deterministische Aufgabe: (typ, level, seed) legt alles fest."""
    rng = random.Random(f"{typ}:{level}:{seed}")
    if typ == "mix":
        typ = rng.choice(TASK_ORDER)
    elif typ == "mix6":
        typ = rng.choice(TASK_ORDER_V6)
    question, subs, steps, answers = _GENERATORS[typ](rng, level)
    return Task(typ=typ, level=level, seed=seed, question=question,
                subquestions=subs, solution_steps=steps, answers=answers)


# =============================================================================
# 3. REQUEST  -  Query-String -> CoachConfig (HTTP von Domäne entkoppelt)
# =============================================================================


@dataclass
class CoachConfig:
    mode: str = "erklaeren"            # erklaeren | binaer | ueben
    submitted: bool = False
    # Berechnen-/Umrechnen-Modus
    ip: ipaddress.IPv4Address | None = None
    ip2: ipaddress.IPv4Address | None = None
    prefix: int | None = None
    hosts: int | None = None           # "Präfix für n Clients"
    single_value: int | None = None    # Umrechnen: einzelne Zahl 0-255 statt IP
    # IPv6-Modus
    ip6: ipaddress.IPv6Address | None = None
    prefix6: int | None = None
    # Planen-Modus (Mini-Adresskonzept)
    base_net: ipaddress.IPv4Network | None = None
    demands: list[tuple[str, int]] = field(default_factory=list)
    reserve_pct: int = 0
    gw_last: bool = False              # Gateway-Konvention: letzte statt erste Adresse
    static_per: int = 10               # statische Adressen je Bereich (nach dem Gateway)
    export: str = ""                   # "", "md", "csv"
    # Übungs-Modus
    typ: str = "mix"
    level: int = 2
    seed: int | None = None
    # Diagnose
    warnings: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)


def _get1(q: dict, key: str, default: str = "") -> str:
    return q.get(key, [default])[0].strip()


def parse_config(query: str) -> CoachConfig:
    q = parse_qs(query, keep_blank_values=True)
    cfg = CoachConfig()
    raw_mode = _get1(q, "mode")
    cfg.mode = raw_mode if raw_mode in ("ueben", "binaer", "planen", "ipv6") else "erklaeren"

    if cfg.mode == "ipv6":
        raw = _get1(q, "ip")
        cfg.submitted = bool(raw)
        if not cfg.submitted:
            return cfg
        addr_part, _, prefix_part = raw.partition("/")
        addr_part = addr_part.strip()
        try:
            cfg.ip6 = ipaddress.IPv6Address(addr_part)
        except ValueError:
            cfg.errors.append(f"„{html.escape(addr_part)}“ ist keine gültige IPv6-Adresse.")
            return cfg
        if prefix_part:
            try:
                p = int(prefix_part)
                if not 0 <= p <= 128:
                    raise ValueError
                cfg.prefix6 = p
            except ValueError:
                cfg.errors.append(f"Präfix „{html.escape(prefix_part)}“ muss zwischen 0 und 128 liegen.")
                return cfg
        else:
            cfg.prefix6 = 64
            cfg.warnings.append("Kein Präfix angegeben - /64 als Standard-Endnetz angenommen.")
        return cfg

    if cfg.mode == "planen":
        raw_net, raw_bedarf = _get1(q, "netz"), _get1(q, "bedarf")
        cfg.submitted = bool(raw_net or raw_bedarf)
        raw_export = _get1(q, "export")
        cfg.export = raw_export if raw_export in ("md", "csv") else ""
        cfg.gw_last = _get1(q, "gateway") == "letzte"
        try:
            cfg.reserve_pct = max(0, min(200, int(_get1(q, "reserve", "0") or "0")))
        except ValueError:
            cfg.reserve_pct = 0
        try:
            cfg.static_per = max(0, min(1000, int(_get1(q, "statisch", "10") or "10")))
        except ValueError:
            cfg.static_per = 10
        if not cfg.submitted:
            return cfg
        try:
            if "/" not in raw_net:
                raise ValueError
            net = ipaddress.ip_network(raw_net, strict=False)
        except ValueError:
            cfg.errors.append(f"„{html.escape(raw_net)}“ ist kein gültiges Basisnetz - "
                              "bitte mit Präfix angeben, z. B. 192.168.0.0/24.")
            return cfg
        if net.prefixlen > 30:
            cfg.errors.append(f"Basisnetz /{net.prefixlen} ist zu klein, um Bereiche aufzunehmen.")
            return cfg
        if str(net.network_address) not in raw_net:
            cfg.warnings.append(f"Eingabe auf die Netzadresse {net.network_address}/"
                                f"{net.prefixlen} normalisiert (Hostbits waren gesetzt).")
        cfg.base_net = net
        lines = [ln.strip() for ln in raw_bedarf.splitlines() if ln.strip()]
        if not lines:
            cfg.errors.append("Mindestens eine Bedarfszeile angeben - Format je Zeile: "
                              "Name, Clientzahl (z. B. „Verwaltung, 50“).")
            return cfg
        if len(lines) > MAX_DEMANDS:
            cfg.errors.append(f"Höchstens {MAX_DEMANDS} Bereiche - {len(lines)} angegeben.")
            return cfg
        for i, line in enumerate(lines, start=1):
            m = re.match(r"^(.*?)[\s,;:]*(\d+)\s*$", line)
            if not m or not 1 <= int(m.group(2)) <= MAX_HOSTS_INPUT:
                cfg.errors.append(f"Zeile {i} („{html.escape(line[:60])}“) nicht lesbar - "
                                  "erwartet: Name, Clientzahl.")
                continue
            name = m.group(1).strip(" ,;:\t") or f"Bereich {i}"
            cfg.demands.append((name[:MAX_NAME_LEN], int(m.group(2))))
        return cfg

    if cfg.mode == "binaer":
        raw = _get1(q, "ip")
        cfg.submitted = bool(raw)
        if not cfg.submitted:
            return cfg
        if "." not in raw:
            try:
                n = int(raw)
                if not 0 <= n <= 255:
                    raise ValueError
                cfg.single_value = n
            except ValueError:
                cfg.errors.append(f"„{html.escape(raw)}“ ist weder eine IPv4-Adresse "
                                  "noch eine Zahl zwischen 0 und 255.")
            return cfg
        try:
            cfg.ip = ipaddress.IPv4Address(raw)
        except ValueError:
            cfg.errors.append(f"„{html.escape(raw)}“ ist keine gültige IPv4-Adresse.")
            return cfg
        raw_prefix, raw_mask = _get1(q, "prefix"), _get1(q, "mask")
        if raw_prefix:
            try:
                p = int(raw_prefix.lstrip("/"))
                if not 0 <= p <= 32:
                    raise ValueError
                cfg.prefix = p
            except ValueError:
                cfg.errors.append(f"Präfix „{html.escape(raw_prefix)}“ muss zwischen 0 und 32 liegen.")
        elif raw_mask:
            try:
                cfg.prefix = ipaddress.ip_network(f"0.0.0.0/{raw_mask}").prefixlen
            except ValueError:
                cfg.errors.append(f"„{html.escape(raw_mask)}“ ist keine gültige Subnetzmaske.")
        return cfg

    if cfg.mode == "ueben":
        typ = _get1(q, "typ", "mix")
        cfg.typ = typ if typ in _GENERATORS or typ in ("mix", "mix6") else "mix"
        try:
            cfg.level = int(_get1(q, "stufe", "2"))
        except ValueError:
            cfg.level = 2
        if cfg.level not in LEVELS:
            cfg.level = 2
        raw_seed = _get1(q, "seed")
        if raw_seed.isdigit():
            cfg.seed = int(raw_seed)
        return cfg

    raw_ip, raw_ip2 = _get1(q, "ip"), _get1(q, "ip2")
    raw_prefix, raw_mask = _get1(q, "prefix"), _get1(q, "mask")
    raw_hosts = _get1(q, "hosts")
    cfg.submitted = bool(raw_ip or raw_hosts)
    if not cfg.submitted:
        return cfg

    if raw_hosts and not raw_ip:
        try:
            n = int(raw_hosts)
            if not 1 <= n <= MAX_HOSTS_INPUT:
                raise ValueError
            cfg.hosts = n
        except ValueError:
            cfg.errors.append(f"Geräteanzahl „{html.escape(raw_hosts)}“ ist keine "
                              f"Zahl zwischen 1 und {MAX_HOSTS_INPUT}.")
        return cfg

    try:
        cfg.ip = ipaddress.IPv4Address(raw_ip)
    except ValueError:
        cfg.errors.append(f"„{html.escape(raw_ip)}“ ist keine gültige IPv4-Adresse.")
        return cfg

    if raw_prefix:
        try:
            p = int(raw_prefix.lstrip("/"))
            if not 0 <= p <= 32:
                raise ValueError
            cfg.prefix = p
        except ValueError:
            cfg.errors.append(f"Präfix „{html.escape(raw_prefix)}“ muss zwischen 0 und 32 liegen.")
            return cfg
        if raw_mask:
            cfg.warnings.append("Präfix und Maske angegeben - das Präfix gewinnt.")
    elif raw_mask:
        try:
            cfg.prefix = ipaddress.ip_network(f"0.0.0.0/{raw_mask}").prefixlen
        except ValueError:
            cfg.errors.append(f"„{html.escape(raw_mask)}“ ist keine gültige Subnetzmaske "
                              "(die Einsen müssen zusammenhängen).")
            return cfg
    else:
        cfg.prefix = 24
        cfg.warnings.append("Keine Maske angegeben - /24 als üblicher Standard angenommen.")

    if raw_ip2:
        try:
            cfg.ip2 = ipaddress.IPv4Address(raw_ip2)
        except ValueError:
            cfg.errors.append(f"Vergleichsadresse „{html.escape(raw_ip2)}“ ist keine gültige IPv4-Adresse.")
    return cfg


def needs_seed_redirect(cfg: CoachConfig) -> str | None:
    """Übungsmodus ohne Seed -> Redirect-Ziel mit frischem Seed, damit die
    Adresszeile IMMER eine reproduzierbare, teilbare Aufgabe zeigt."""
    if cfg.mode == "ueben" and cfg.seed is None:
        seed = random.SystemRandom().randrange(SEED_MIN, SEED_MAX + 1)
        return "?" + urlencode({"mode": "ueben", "typ": cfg.typ,
                                "stufe": cfg.level, "seed": seed})
    return None


# =============================================================================
# 4. RENDER  -  Domänen-Objekte -> HTML (Toolbox-Design-System, kein JS nötig)
# =============================================================================

CSS = """
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--text-primary:#f1f5f9;--text-secondary:#94a3b8;--accent:#3b82f6;--accent-secondary:#8b5cf6;--border:#334155;--code-bg:#0f172a;--success:#10b981;--warn:#f59e0b;--error:#ef4444;}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6;min-height:100vh;padding:2rem}
a{color:var(--accent);text-decoration:none;font-weight:500}
.container{max-width:1000px;margin:0 auto}
h1{font-size:2rem;font-weight:700;margin-bottom:.5rem;background:linear-gradient(135deg,var(--accent),var(--accent-secondary));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.subtitle{color:var(--text-secondary);margin-bottom:1.5rem}
h2{font-size:1.25rem;font-weight:600;margin-bottom:1rem}
.card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
.error-card{border-left:4px solid var(--error)}.error-card h2{color:var(--error)}
.warn-note{color:var(--warn);font-size:.9rem;margin-bottom:.75rem}
form{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:.35rem}
.field label{font-size:.8rem;color:var(--text-secondary);font-weight:600}
input[type=text],input[type=number],select,textarea{padding:.7rem .9rem;border-radius:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font-size:1rem;font-family:inherit}
textarea{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.9rem;resize:vertical}
input[type=text]::placeholder,textarea::placeholder{color:var(--text-secondary)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.2)}
.w-sm{width:110px}.w-md{width:200px}
button{padding:.7rem 1.5rem;border-radius:8px;border:none;cursor:pointer;background:var(--accent);color:#fff;font-size:1rem;font-weight:600;font-family:inherit}
button:hover{background:#2563eb}
.form-hint{width:100%;font-size:.85rem;color:var(--text-secondary);margin-top:.5rem}
.formgrid{display:flex;gap:2rem;flex-wrap:wrap;align-items:flex-start}
.formcol{flex:1 1 380px}.formcol-narrow{flex:0 1 300px}
.cardhead{display:flex;align-items:baseline;gap:.8rem;flex-wrap:wrap;margin-bottom:1rem}
.cardhead h2{margin-bottom:0}
.badges{display:inline-flex;gap:.4rem;flex-wrap:wrap}
.badges .src{margin-top:0;cursor:help}
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem;flex-wrap:wrap}
.tab{font-size:.95rem;font-weight:600;padding:.5em 1.2em;border-radius:999px;text-decoration:none;color:var(--text-secondary);background:rgba(148,163,184,.12);border:1px solid transparent}
.tab:hover{color:var(--text-primary);border-color:var(--border)}
.tab-on{background:rgba(59,130,246,.15);color:var(--accent);border-color:var(--accent)}
.preset-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;width:100%;margin-top:.25rem}
.preset-lbl{font-size:.8rem;color:var(--text-secondary);font-weight:600}
.preset{font-size:.8rem;font-weight:600;padding:.25em .8em;border-radius:999px;text-decoration:none;color:var(--text-secondary);background:rgba(148,163,184,.12);border:1px solid transparent}
.preset:hover{color:var(--text-primary);border-color:var(--border)}
.preset-on{background:rgba(59,130,246,.15);color:var(--accent);border-color:var(--accent)}
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid var(--border)}
table{border-collapse:collapse;width:100%;font-size:.9rem}
th,td{padding:.55rem .7rem;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap}
thead th{background:var(--bg-primary);color:var(--text-secondary);font-weight:600}
tbody tr:last-child td{border-bottom:none}
td.rowhead{font-weight:600;background:rgba(148,163,184,.06)}
code{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.85em;background:var(--code-bg);color:#e879f9;padding:.15em .4em;border-radius:4px}
.steps{list-style:none;counter-reset:step;display:grid;gap:.75rem}
.steps li{padding:.8rem .9rem .8rem 3rem;position:relative;background:rgba(148,163,184,.05);border:1px solid var(--border);border-radius:10px}
.steps li::before{counter-increment:step;content:counter(step);position:absolute;left:.85rem;top:.85rem;width:1.6rem;height:1.6rem;border-radius:50%;background:var(--accent);color:#fff;font-weight:700;font-size:.85rem;display:flex;align-items:center;justify-content:center}
.steps h3{font-size:.95rem;font-weight:600;margin-bottom:.35rem}
.steps p{font-size:.9rem;color:var(--text-secondary)}
.steps p strong{color:var(--text-primary)}
.src{display:inline-block;margin-top:.4rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:.15em .6em;border-radius:999px;background:rgba(139,92,246,.15);color:var(--accent-secondary)}
.bintab{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.82rem;margin-top:.7rem;border-collapse:collapse}
.bintab td{border:none;padding:.15rem .5rem .15rem 0;white-space:nowrap}
.bintab .lbl{color:var(--text-secondary);font-size:.78rem;padding-right:.9rem}
.bintab .dot{color:var(--text-secondary);padding-left:.9rem}
.nb{color:#22d3ee}
.hb{color:var(--text-secondary)}
.bsep{color:var(--accent-secondary);font-weight:700}
.legend-row{display:flex;gap:1.1rem;flex-wrap:wrap;margin-top:.6rem;font-size:.8rem;color:var(--text-secondary)}
.wide-toggle{position:absolute;left:-9999px}
.exportrow{display:flex;gap:.4rem;justify-content:flex-end;margin:-.6rem 0 .9rem}
.wide-btn{margin-left:auto;cursor:pointer;background:rgba(139,92,246,.15);color:var(--accent-secondary);border:1px solid var(--accent-secondary)}
.wide-btn:hover{background:rgba(139,92,246,.3);color:#c4b5fd;border-color:var(--accent-secondary)}
.wide-btn .lbl-off{display:none}
.wide-toggle:checked + .card .wide-btn{background:var(--accent-secondary);color:#fff}
.wide-toggle:checked + .card .wide-btn .lbl-on{display:none}
.wide-toggle:checked + .card .wide-btn .lbl-off{display:inline}
.wide-toggle:checked + .card{position:relative;left:50%;transform:translateX(-50%);width:max-content;min-width:100%;max-width:calc(100vw - 3rem)}
.wide-toggle:focus-visible + .card .wide-btn{border-color:var(--accent);color:var(--accent)}
@media(max-width:768px){.wide-btn{display:none}}
.octtab th,.octtab td{text-align:center;font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.82rem;padding:.3rem .5rem;white-space:nowrap}
.octtab th.rowhead,.octtab td.rowhead{text-align:left;font-family:'Montserrat',sans-serif;font-size:.78rem}
.b1{font-weight:700;color:var(--text-primary)}
.b0{color:var(--text-secondary)}
.octctl{margin-top:.5rem;font-size:.85rem;color:var(--text-secondary)}
.octctl code{color:#e879f9}
details.oct{margin-top:.6rem}
details.oct summary{display:flex;gap:.7rem;flex-wrap:wrap;align-items:center;cursor:pointer;font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.85rem;padding:.5rem .9rem;border:1px solid var(--border);border-radius:8px;color:var(--text-secondary);list-style:none;user-select:none}
details.oct summary:hover{border-color:var(--accent);color:var(--text-primary)}
details.oct summary::before{content:'\\25B8';display:inline-block;font-size:.9em;transition:transform .15s;color:var(--accent)}
details.oct[open] summary::before{transform:rotate(90deg)}
details.oct summary::-webkit-details-marker{display:none}
details.oct .octbody{margin-top:.6rem;padding-left:.2rem}
.taskmeta{display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem;font-size:.85rem;color:var(--text-secondary)}
.taskmeta .count{font-weight:600;padding:.15em .7em;border-radius:6px;background:rgba(148,163,184,.12)}
.subq{list-style:none;display:grid;gap:.4rem;margin-top:.8rem}
.subq li{padding-left:1.6rem;position:relative;color:var(--text-primary)}
.subq li::before{content:counter(sq,lower-alpha) ')';counter-increment:sq;position:absolute;left:0;color:var(--accent);font-weight:700}
.subq{counter-reset:sq}
details.sol{margin-top:1.25rem;border-top:1px solid var(--border);padding-top:1rem}
details.sol summary{display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;font-size:.9rem;font-weight:600;color:var(--accent);background:rgba(59,130,246,.1);border:1px solid var(--accent);border-radius:8px;padding:.55rem 1rem;list-style:none;user-select:none}
details.sol summary:hover{background:rgba(59,130,246,.22)}
details.sol summary::before{content:'\\25B8';display:inline-block;font-size:.9em;transition:transform .15s}
details.sol[open] summary::before{transform:rotate(90deg)}
details.sol summary::-webkit-details-marker{display:none}
.sol-body{margin-top:1rem}
.answer-table{margin-bottom:1rem}
.permalink{font-size:.82rem;color:var(--text-secondary);margin-top:1rem;word-break:break-all;white-space:normal}
.permalink code{white-space:normal}
.note{color:var(--text-secondary);font-size:.88rem;margin-top:.6rem}
footer{color:var(--text-secondary);font-size:.8rem;text-align:center;margin-top:2rem}
footer a{color:var(--accent);text-decoration:none}
@media(max-width:768px){body{padding:1rem}h1{font-size:1.4rem}.card{padding:1rem}}
"""


def esc(s) -> str:
    return html.escape(str(s), quote=True)


def fmt(text: str) -> str:
    """Klartext mit `Backtick`-Auszeichnung -> escaptes HTML mit <code>."""
    parts = text.split("`")
    out = []
    for i, part in enumerate(parts):
        out.append(f"<code>{esc(part)}</code>" if i % 2 else esc(part))
    return "".join(out)


def render_binrow_cells(row: BinRow) -> str:
    """Ein Oktett-Quartett mit farblicher Netz-/Hostbit-Trennung."""
    cells = []
    for i, octet in enumerate(row.octets):
        start = i * 8
        marked = []
        for j, bit in enumerate(octet):
            pos = start + j
            cls = "nb" if row.split >= 0 and pos < row.split else "hb"
            if row.split >= 0 and pos == row.split and j > 0:
                marked.append('<span class="bsep">│</span>')
            marked.append(f'<span class="{cls}">{bit}</span>')
        if row.split >= 0 and row.split == start and i > 0:
            cells.append('<td><span class="bsep">│</span>' + "".join(marked) + "</td>")
        else:
            cells.append("<td>" + "".join(marked) + "</td>")
    return (f'<td class="lbl">{esc(row.label)}</td>' + ".".join(cells).replace("</td>.<td", "</td><td class=\"dotsep\">.</td><td")
            + f'<td class="dot">= {esc(row.dotted)}</td>')


def render_steps(steps: list[Step]) -> str:
    items = []
    legend_done = False  # die Legende nur einmal zeigen, nicht je Binärtabelle
    for s in steps:
        rows_html = ""
        if s.rows:
            body = "".join(f"<tr>{render_binrow_cells(r)}</tr>" for r in s.rows)
            rows_html = f'<table class="bintab">{body}</table>'
            if not legend_done:
                rows_html += ('<div class="legend-row">'
                              '<span><span class="nb">cyan</span> = Netzanteil</span>'
                              '<span><span class="hb">grau</span> = Hostanteil</span>'
                              '<span><span class="bsep">│</span> = Präfix-Grenze</span></div>')
                legend_done = True
        src = f'<span class="src">{esc(SOURCES[s.source])}</span>' if s.source else ""
        items.append(f"<li><h3>{esc(s.title)}</h3><p>{fmt(s.text)}</p>{rows_html}{src}</li>")
    return '<ol class="steps">' + "".join(items) + "</ol>"


def render_badges(ip: ipaddress.IPv4Address) -> str:
    """Die Einordnung der Adresse als kompakte Badge-Zeile (statt eigener
    Rechenweg-Schritte); die Erklärung steckt im title-Tooltip."""
    badges = []
    for label, detail, source in classify(ip):
        rfc = SOURCES[source].split(" ")[0] + " " + SOURCES[source].split(" ")[1]
        badges.append(f'<span class="src" title="{esc(detail)} — {esc(SOURCES[source])}">'
                      f'{esc(label)} · {esc(rfc)}</span>')
    return '<span class="badges">' + "".join(badges) + "</span>"


def render_tabs(cfg: CoachConfig) -> str:
    # ip/prefix wandern zwischen Berechnen und Umrechnen mit - die Eingabe
    # bleibt beim Tab-Wechsel erhalten (Nutzerwunsch V1.1)
    ctx: dict = {}
    if cfg.mode in ("erklaeren", "binaer") and cfg.ip is not None:
        ctx["ip"] = str(cfg.ip)
        if cfg.prefix is not None:
            ctx["prefix"] = cfg.prefix
    er = "tab tab-on" if cfg.mode == "erklaeren" else "tab"
    bi = "tab tab-on" if cfg.mode == "binaer" else "tab"
    pl = "tab tab-on" if cfg.mode == "planen" else "tab"
    v6 = "tab tab-on" if cfg.mode == "ipv6" else "tab"
    ue = "tab tab-on" if cfg.mode == "ueben" else "tab"
    href_er = "?" + urlencode(ctx) if ctx else "?"
    href_bi = "?" + urlencode({"mode": "binaer", **ctx})
    return (f'<nav class="tabs"><a class="{er}" href="{esc(href_er)}">Berechnen</a>'
            f'<a class="{bi}" href="{esc(href_bi)}">Umrechnen</a>'
            f'<a class="{pl}" href="?mode=planen">Planen</a>'
            f'<a class="{v6}" href="?mode=ipv6">IPv6</a>'
            f'<a class="{ue}" href="?mode=ueben">Trainieren</a></nav>')


def render_form(cfg: CoachConfig) -> str:
    ip_val = esc(cfg.ip) if cfg.ip else ""
    ip2_val = esc(cfg.ip2) if cfg.ip2 else ""
    prefix_val = esc(cfg.prefix) if cfg.prefix is not None else ""
    return f"""<div class="card"><div class="formgrid">
<div class="formcol"><h2>Adresse untersuchen</h2>
<form method="get" action="">
<div class="field"><label for="ip">IP-Adresse</label>
<input class="w-md" type="text" id="ip" name="ip" value="{ip_val}" placeholder="192.168.10.77" required></div>
<div class="field"><label for="prefix">Präfix (0–32)</label>
<input class="w-sm" type="text" id="prefix" name="prefix" value="{prefix_val}" placeholder="26"></div>
<div class="field"><label for="mask">… oder Maske</label>
<input class="w-md" type="text" id="mask" name="mask" placeholder="255.255.255.192"></div>
<div class="field"><label for="ip2">Vergleichs-IP (optional)</label>
<input class="w-md" type="text" id="ip2" name="ip2" value="{ip2_val}" placeholder="gleiches Subnetz?"></div>
<button type="submit">Rechenweg zeigen</button>
<p class="form-hint">Präfix oder Maske reicht. Mit Vergleichs-IP wird zusätzlich geprüft, ob beide Adressen im selben Subnetz liegen.</p>
</form></div>
<div class="formcol formcol-narrow"><h2>Präfix für n Geräte</h2>
<form method="get" action="">
<div class="field"><label for="hosts">Anzahl Geräte (inkl. Gateway)</label>
<input class="w-sm" type="number" id="hosts" name="hosts" min="1" max="{MAX_HOSTS_INPUT}" placeholder="50" required></div>
<button type="submit">Präfix herleiten</button>
<p class="form-hint">„Welches Subnetz brauche ich für n Clients?“ - mit Rechenweg und Reserve.</p>
</form></div>
</div></div>"""


def render_kennwerte(a: Analyse, note: str = "") -> str:
    rows = [
        ("Eingabe", f"<code>{esc(a.ip)}/{a.prefix}</code>"),
        ("Netzadresse", f"<code>{esc(a.net_addr)}</code>"),
        ("Subnetzmaske", f"<code>{esc(a.netmask)}</code> (Wildcard <code>{esc(a.wildcard)}</code>)"),
    ]
    if a.prefix == 32:
        rows.append(("Charakter", "Einzeladresse (Host-Route) - kein Netz im üblichen Sinn"))
    elif a.prefix == 31:
        rows.append(("Adressen", "<code>2</code> - beide nutzbar, kein Broadcast (RFC 3021)"))
        rows.append(("Hostbereich", f"<code>{esc(a.first_host)}</code> – <code>{esc(a.last_host)}</code>"))
    else:
        rows.append(("Broadcast", f"<code>{esc(a.broadcast)}</code>"))
        rows.append(("Hostbereich", f"<code>{esc(a.first_host)}</code> – <code>{esc(a.last_host)}</code>"))
        rows.append(("Nutzbare Hosts", f"<code>{a.usable}</code>"))
    body = "".join(f'<tr><td class="rowhead">{k}</td><td>{v}</td></tr>' for k, v in rows)
    note_html = f'<span class="note">{esc(note)}</span>' if note else ""
    return (f'<div class="card"><div class="cardhead"><h2>Kennwerte</h2>{note_html}</div>'
            f'<div class="table-wrap"><table><tbody>{body}</tbody></table></div></div>')


def render_erklaeren(cfg: CoachConfig) -> str:
    parts = [render_form(cfg)]
    if cfg.warnings:
        parts.append('<div class="card">' + "".join(
            f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")

    if cfg.hosts is not None:
        prefix, steps = prefix_for_hosts(cfg.hosts)
        parts.append(f'<div class="card"><h2>Ergebnis: /{prefix} für {cfg.hosts} Geräte</h2>'
                     f'<p class="note">Maske <code>{esc(ipaddress.ip_network(f"0.0.0.0/{prefix}").netmask)}</code> '
                     f'· <code>{hosts_for_prefix(prefix)}</code> nutzbare Adressen</p></div>')
        parts.append('<div class="card"><h2>Rechenweg</h2>' + render_steps(steps) + "</div>")
        return "".join(parts)

    example = not cfg.submitted
    if example:
        cfg = CoachConfig(ip=ipaddress.IPv4Address(DEFAULT_EXAMPLE_IP),
                          prefix=DEFAULT_EXAMPLE_PREFIX, submitted=True)

    a = analyse_subnetz(cfg.ip, cfg.prefix)
    parts.append(render_kennwerte(
        a, note=(f"Beispiel {DEFAULT_EXAMPLE_IP}/{DEFAULT_EXAMPLE_PREFIX} - "
                 "eigene Werte oben eintragen." if example else "")))
    steps = list(a.steps)
    if cfg.ip2 is not None:
        _, cmp_steps = same_subnet(cfg.ip, cfg.ip2, cfg.prefix)
        steps.extend(cmp_steps)
    parts.append('<div class="card"><div class="cardhead"><h2>Rechenweg</h2>'
                 + render_badges(cfg.ip) + '</div>' + render_steps(steps) + "</div>")
    return "".join(parts)


def render_ueben(cfg: CoachConfig) -> str:
    task = generate_task(cfg.typ, cfg.level, cfg.seed)
    is_v6 = cfg.typ in TASK_ORDER_V6 or cfg.typ == "mix6"
    family_mix = "mix6" if is_v6 else "mix"
    order = TASK_ORDER_V6 if is_v6 else TASK_ORDER

    fam_links = []
    for fam_label, fam_typ, on in (("IPv4", "mix", not is_v6), ("IPv6", "mix6", is_v6)):
        cls = "preset preset-on" if on else "preset"
        href = "?" + urlencode({"mode": "ueben", "typ": fam_typ,
                                "stufe": cfg.level, "seed": cfg.seed})
        fam_links.append(f'<a class="{cls}" href="{esc(href)}">{fam_label}</a>')
    typ_links = []
    for key in [family_mix] + order:
        label = "Zufällig" if key in ("mix", "mix6") else ALL_TASK_TYPES[key][0]
        cls = "preset preset-on" if cfg.typ == key else "preset"
        href = "?" + urlencode({"mode": "ueben", "typ": key, "stufe": cfg.level, "seed": cfg.seed})
        typ_links.append(f'<a class="{cls}" href="{esc(href)}">{esc(label)}</a>')
    lvl_links = []
    for lvl, name in LEVELS.items():
        cls = "preset preset-on" if cfg.level == lvl else "preset"
        href = "?" + urlencode({"mode": "ueben", "typ": cfg.typ, "stufe": lvl, "seed": cfg.seed})
        lvl_links.append(f'<a class="{cls}" href="{esc(href)}">{lvl} · {esc(name)}</a>')
    new_href = "?" + urlencode({"mode": "ueben", "typ": cfg.typ, "stufe": cfg.level})

    controls = (f'<div class="card"><h2>Aufgabentrainer</h2>'
                f'<div class="preset-row"><span class="preset-lbl">Version:</span>{"".join(fam_links)}</div>'
                f'<div class="preset-row"><span class="preset-lbl">Typ:</span>{"".join(typ_links)}</div>'
                f'<div class="preset-row"><span class="preset-lbl">Stufe:</span>{"".join(lvl_links)}</div>'
                f'<div class="preset-row" style="margin-top:.9rem">'
                f'<a class="tab tab-on" href="{esc(new_href)}">Neue Aufgabe ↻</a></div>'
                '<p class="form-hint">Jede Aufgabe steckt vollständig in ihrer URL (Seed) - '
                'Link teilen genügt, und alle sehen exakt dieselbe Aufgabe. '
                'Die Lösung bleibt eingeklappt, bis sie aufgeklappt wird.</p></div>')

    subs = "".join(f"<li>{fmt(s)}</li>" for s in task.subquestions)
    answers = "".join(f'<tr><td class="rowhead">{esc(k)}</td><td>{esc(v)}</td></tr>'
                      for k, v in task.answers)
    permalink = "?" + urlencode({"mode": "ueben", "typ": cfg.typ,
                                 "stufe": cfg.level, "seed": cfg.seed})
    task_card = (
        '<div class="card"><h2>Aufgabe</h2>'
        '<div class="taskmeta">'
        f'<span class="count">Typ: {"IPv6" if is_v6 else "IPv4"} · {esc(ALL_TASK_TYPES[task.typ][0])}</span>'
        f'<span class="count">Stufe {cfg.level} · {esc(LEVELS[cfg.level])}</span>'
        f'<span class="count">Seed {cfg.seed}</span></div>'
        f'<p>{fmt(task.question)}</p><ol class="subq">{subs}</ol>'
        '<details class="sol"><summary>Lösung mit Rechenweg anzeigen</summary>'
        '<div class="sol-body">'
        '<div class="table-wrap answer-table"><table><thead><tr><th>Gefragt</th><th>Antwort</th></tr></thead>'
        f'<tbody>{answers}</tbody></table></div>'
        + render_steps(task.solution_steps) +
        '</div></details>'
        f'<p class="permalink">Aufgabe teilen: <code>{esc(permalink)}</code></p>'
        '</div>')
    return controls + task_card


def render_binaer_form(cfg: CoachConfig) -> str:
    val = str(cfg.ip) if cfg.ip else (str(cfg.single_value) if cfg.single_value is not None else "")
    prefix_val = esc(cfg.prefix) if cfg.prefix is not None else ""
    return f"""<div class="card"><h2>Dezimal → Binär, Schritt für Schritt</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="binaer">
<div class="field"><label for="bip">IP-Adresse oder Zahl (0–255)</label>
<input class="w-md" type="text" id="bip" name="ip" value="{esc(val)}" placeholder="192.168.10.77" required></div>
<div class="field"><label for="bprefix">Präfix (optional)</label>
<input class="w-sm" type="text" id="bprefix" name="prefix" value="{prefix_val}" placeholder="26"></div>
<button type="submit">Umrechnen</button>
<p class="form-hint">Zeigt die Stellenwert-Methode: von 128 abwärts fragen „passt der Stellenwert noch in den Rest?“. Mit Präfix färbt das Ergebnis Netz- und Hostanteil und verlinkt zum Berechnen-Tab.</p>
</form></div>"""


def _octet_calc(value: int, octet_index: int | None, prefix: int | None) -> tuple[str, str, str]:
    """Die Stellenwert-Tabelle für EIN Oktett: (Tabellen-HTML, Bit-String,
    Kontroll-Text). Feste 8 Spalten erzwingen die führenden Nullen; die
    Rest-Zeile protokolliert jeden Schritt (die zwei häufigsten
    Anfängerfehler laut Lehrmaterial-Recherche)."""
    steps = octet_steps(value)
    bits = "".join("1" if s.fits else "0" for s in steps)
    head = '<tr><th class="rowhead">Stellenwert</th>' + "".join(
        f"<th>{s.place}</th>" for s in steps) + "</tr>"
    frage = '<tr><td class="rowhead">Frage: Rest ≥ Stellenwert?</td>' + "".join(
        f"<td>{s.rest_before}≥{s.place}? {'Ja' if s.fits else 'Nein'}</td>" for s in steps) + "</tr>"
    bit_cells = []
    for col, s in enumerate(steps):
        cls = "b1" if s.fits else "b0"
        if prefix is not None and octet_index is not None:
            pos = octet_index * 8 + col
            cls += " nb" if pos < prefix else " hb"
        bit_cells.append(f'<td class="{cls}">{1 if s.fits else 0}</td>')
    bit = '<tr><td class="rowhead">Bit</td>' + "".join(bit_cells) + "</tr>"
    rest = '<tr><td class="rowhead">Rest danach</td>' + "".join(
        f"<td>{s.rest_after}</td>" for s in steps) + "</tr>"
    table = (f'<div class="table-wrap"><table class="octtab"><thead>{head}</thead>'
             f'<tbody>{frage}{bit}{rest}</tbody></table></div>')
    gesetzt = [str(s.place) for s in steps if s.fits]
    kontrolle = (" + ".join(gesetzt) + f" = {value}") if gesetzt else f"keine 1 gesetzt → {value}"
    return table, bits, kontrolle


def _bits_span(bits: str, octet_index: int, prefix: int | None) -> str:
    """Bit-String für die Summary-Zeile - mit Präfix in Netz-/Hostfarben."""
    if prefix is None:
        return f"<code>{bits}</code>"
    out = []
    for col, b in enumerate(bits):
        pos = octet_index * 8 + col
        out.append(f'<span class="{"nb" if pos < prefix else "hb"}">{b}</span>')
    return "<code>" + "".join(out) + "</code>"


def render_binaer(cfg: CoachConfig) -> str:
    parts = [render_binaer_form(cfg)]
    example = not cfg.submitted
    if example:
        cfg = CoachConfig(mode="binaer", ip=ipaddress.IPv4Address(DEFAULT_EXAMPLE_IP),
                          prefix=DEFAULT_EXAMPLE_PREFIX, submitted=True)

    merksatz = ('<p class="note">Von 128 abwärts dieselbe Frage stellen: <strong>passt der '
                'Stellenwert noch in den Rest?</strong> Ja → 1 aufschreiben und abziehen, '
                'Nein → 0 und weiter. Immer alle 8 Bits notieren - auch führende Nullen. '
                'Kontrolle: die Stellenwerte mit einer 1 addieren; Merkanker: acht Einsen = 255.</p>')

    if cfg.single_value is not None:
        table, bits, kontrolle = _octet_calc(cfg.single_value, None, None)
        note = "Beispiel - eigene Werte oben eintragen." if example else ""
        parts.append(
            f'<div class="card"><div class="cardhead"><h2>{cfg.single_value} → <code>{bits}</code></h2>'
            + (f'<span class="note">{esc(note)}</span>' if note else "") + '</div>'
            + merksatz + table
            + f'<p class="octctl">Kontrolle (Rückrichtung): <code>{esc(kontrolle)}</code></p></div>')
        return "".join(parts)

    octets = [int(o) for o in str(cfg.ip).split(".")]
    note_html = ('<span class="note">Beispiel '
                 f'{DEFAULT_EXAMPLE_IP}/{DEFAULT_EXAMPLE_PREFIX} - eigene Werte oben eintragen.</span>'
                 if example else "")
    inner = [merksatz]

    # Oktett 1 als ausformuliertes Lehrbeispiel ...
    table1, bits1, ctl1 = _octet_calc(octets[0], 0, cfg.prefix)
    inner.append(f'<h3 style="margin-top:.8rem">1. Oktett · {octets[0]} - dieselbe Frage 8×, von 128 bis 1:</h3>')
    inner.append(table1)
    inner.append(f'<p class="octctl">Ergebnis {_bits_span(bits1, 0, cfg.prefix)} · '
                 f'Kontrolle: <code>{esc(ctl1)}</code></p>')

    # ... die Oktette 2-4 kompakt zum Aufklappen (Selbstkontrolle)
    for i, value in enumerate(octets[1:], start=1):
        table, bits, ctl = _octet_calc(value, i, cfg.prefix)
        inner.append(
            f'<details class="oct"><summary><span>{i + 1}. Oktett · {value}</span>'
            f'<span>→ {_bits_span(bits, i, cfg.prefix)}</span>'
            f'<span>Kontrolle: {esc(ctl)}</span></summary>'
            f'<div class="octbody">{table}</div></details>')

    # Ergebnis-Zeile in der Optik des Berechnen-Tabs (mit Präfix inkl. Grenze)
    split = cfg.prefix if cfg.prefix is not None else -1
    row = _row("Ergebnis", int(cfg.ip), str(cfg.ip), split=split)
    inner.append(f'<table class="bintab" style="margin-top:1rem"><tr>{render_binrow_cells(row)}</tr></table>')
    if cfg.prefix is not None:
        inner.append('<div class="legend-row">'
                     '<span><span class="nb">cyan</span> = Netzanteil</span>'
                     '<span><span class="hb">grau</span> = Hostanteil</span>'
                     '<span><span class="bsep">│</span> = Präfix-Grenze</span></div>')
        deep = "?" + urlencode({"ip": str(cfg.ip), "prefix": cfg.prefix})
        inner.append(f'<p class="octctl">Genau so liest der Subnetz-Rechner die Adresse: '
                     f'<a class="preset" href="{esc(deep)}">→ mit dieser Eingabe berechnen</a></p>')

    parts.append('<div class="card"><div class="cardhead">'
                 f'<h2>Dezimal → Binär: {esc(cfg.ip)}</h2>{note_html}</div>'
                 + "".join(inner) + '</div>')
    return "".join(parts)


DEMO_BEDARF = "Verwaltung, 100\nVertrieb, 50\nProduktion, 20\nUplink, 2"
DEMO_V6 = "2001:db8:0:42:0:0:0:7/64"      # Dokumentationsbereich (RFC 3849), zeigt beide Kürzungsregeln


def render_ipv6_form(cfg: CoachConfig) -> str:
    val = ""
    if cfg.ip6 is not None:
        val = str(cfg.ip6) + (f"/{cfg.prefix6}" if cfg.prefix6 is not None else "")
    return f"""<div class="card"><h2>IPv6-Adresse verstehen</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="ipv6">
<div class="field"><label for="v6ip">IPv6-Adresse (mit oder ohne /Präfix)</label>
<input class="w-lg" style="width:340px;max-width:100%" type="text" id="v6ip" name="ip" value="{esc(val)}" placeholder="2001:db8:0:42::7/64" required></div>
<button type="submit">Rechenweg zeigen</button>
<p class="form-hint">Grundlagen-Baustein: Notation (Vollform ↔ Kürzung nach RFC 5952), Adresstyp, Präfix-Logik und warum die v4-Hostzählerei entfällt - bewusst kein vollständiges IPv6-Werkzeug. IPv4 und IPv6 sind getrennte Protokolle - eine Umwandlung gibt es nicht.</p>
</form></div>"""


def _v6_colored_exploded(ip: ipaddress.IPv6Address, prefix: int) -> str:
    """Vollform mit Netz-/Interface-Färbung auf Hexstellen-Ebene (1 Stelle = 4 Bit)."""
    out = []
    pos = 0
    for block_i, block in enumerate(ip.exploded.split(":")):
        if block_i:
            out.append('<span class="bsep">:</span>' if pos == prefix else ":")
        for ziffer in block:
            end = pos + 4
            cls = "nb" if end <= prefix else ("hb" if pos >= prefix else "bsep")
            out.append(f'<span class="{cls}">{ziffer}</span>')
            pos = end
    return '<code style="font-size:.95em">' + "".join(out) + "</code>"


def render_ipv6(cfg: CoachConfig) -> str:
    parts = [render_ipv6_form(cfg)]
    if cfg.warnings:
        parts.append('<div class="card">' + "".join(
            f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")
    example = not cfg.submitted
    if example:
        cfg = parse_config("mode=ipv6&ip=" + DEMO_V6.replace("/", "%2F"))

    net, steps = analyse_v6(cfg.ip6, cfg.prefix6)
    label, detail, source = classify_v6(cfg.ip6)
    rfc_short = " ".join(SOURCES[source].split(" ")[:2])
    badge = (f'<span class="badges"><span class="src" title="{esc(detail)} — '
             f'{esc(SOURCES[source])}">{esc(label)} · {esc(rfc_short)}</span></span>')
    note = "Beispiel - eigene Adresse oben eintragen." if example else ""

    if cfg.prefix6 < 64:
        budget = f"2^{64 - cfg.prefix6} /64-Subnetze"
    elif cfg.prefix6 == 64:
        budget = "Standard-Endnetz (64 Bit Interface-ID)"
    else:
        budget = f"Sonderfall länger als /64 ({128 - cfg.prefix6} Interface-Bits)"
    rows = [
        ("Eingabe", f"<code>{esc(cfg.ip6)}/{cfg.prefix6}</code>"),
        ("Vollform", f"<code>{esc(cfg.ip6.exploded)}</code>"),
        ("Kürzform (RFC 5952)", f"<code>{esc(cfg.ip6.compressed)}</code>"),
        ("Adresstyp", esc(label)),
        ("Netz-Präfix", f"<code>{esc(net.compressed)}</code>"),
        ("Aufteilung", esc(budget)),
        ("Netz-/Interface-Anteil", _v6_colored_exploded(cfg.ip6, cfg.prefix6)),
    ]
    body = "".join(f'<tr><td class="rowhead">{k}</td><td>{v}</td></tr>' for k, v in rows)
    parts.append('<div class="card"><div class="cardhead"><h2>Kennwerte</h2>'
                 + (f'<span class="note">{esc(note)}</span>' if note else "")
                 + '</div><div class="table-wrap"><table><tbody>' + body
                 + '</tbody></table></div>'
                 '<div class="legend-row"><span><span class="nb">cyan</span> = Netzanteil</span>'
                 '<span><span class="hb">grau</span> = Interface-Anteil</span></div></div>')
    parts.append('<div class="card"><div class="cardhead"><h2>Rechenweg</h2>'
                 + badge + '</div>' + render_steps(steps) + "</div>")

    typ_rows = "".join(
        f'<tr><td class="rowhead">{esc(l)}</td><td><code>{esc(n)}</code></td>'
        f'<td style="white-space:normal">{esc(d)}</td></tr>'
        for n, l, d, _ in V6_TYPES if n not in ("::1/128", "::/128"))
    parts.append(
        '<div class="card"><h2>Adresstypen im Überblick</h2>'
        '<div class="table-wrap"><table><thead><tr><th>Typ</th><th>Präfix</th>'
        '<th>Bedeutung / v4-Gegenstück</th></tr></thead>'
        f'<tbody>{typ_rows}</tbody></table></div>'
        '<p class="note">Dazu: <code>::1</code> = Loopback (127.0.0.1) und '
        '<code>::</code> = unspezifiziert (0.0.0.0). Grundlagen-Baustein - '
        'für IPv4-Subnetting: die Tabs Berechnen und Planen.</p></div>')
    return "".join(parts)


def _planen_query(cfg: CoachConfig) -> dict:
    """Der Plan als Query-Paare - Permalink, Export-Links und Formular
    speisen sich aus derselben Quelle."""
    q = {"mode": "planen",
         "netz": f"{cfg.base_net.network_address}/{cfg.base_net.prefixlen}",
         "bedarf": "\n".join(f"{name}, {n}" for name, n in cfg.demands)}
    if cfg.reserve_pct:
        q["reserve"] = cfg.reserve_pct
    if cfg.gw_last:
        q["gateway"] = "letzte"
    if cfg.static_per != 10:
        q["statisch"] = cfg.static_per
    return q


def render_planen_form(cfg: CoachConfig) -> str:
    netz_val = (f"{cfg.base_net.network_address}/{cfg.base_net.prefixlen}"
                if cfg.base_net else "")
    bedarf_val = "\n".join(f"{name}, {n}" for name, n in cfg.demands)
    gw_first = "" if cfg.gw_last else " selected"
    gw_last = " selected" if cfg.gw_last else ""
    return f"""<div class="card"><h2>Adresskonzept planen (VLSM)</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="planen">
<div class="field"><label for="pnetz">Basisnetz</label>
<input class="w-md" type="text" id="pnetz" name="netz" value="{esc(netz_val)}" placeholder="192.168.0.0/24" required></div>
<div class="field"><label for="pbedarf">Bereiche - eine Zeile je Bereich: Name, Clientzahl</label>
<textarea id="pbedarf" name="bedarf" rows="4" cols="34" placeholder="Verwaltung, 100&#10;Vertrieb, 50&#10;Uplink, 2" required>{esc(bedarf_val)}</textarea></div>
<div class="field"><label for="preserve">Reserve %</label>
<input class="w-sm" type="number" id="preserve" name="reserve" value="{cfg.reserve_pct}" min="0" max="200"></div>
<div class="field"><label for="pgw">Gateway</label>
<select id="pgw" name="gateway"><option value="erste"{gw_first}>erste Adresse</option><option value="letzte"{gw_last}>letzte Adresse</option></select></div>
<div class="field"><label for="pstat">Statisch je Bereich</label>
<input class="w-sm" type="number" id="pstat" name="statisch" value="{cfg.static_per}" min="0" max="1000"></div>
<button type="submit">Konzept erstellen</button>
<p class="form-hint">VLSM vergibt vom größten zum kleinsten Bedarf. Reserve = Wachstumsaufschlag auf jede Clientzahl (üblich 20–30 %). Je Subnetz gilt: Gateway, dann der statische Block (Server, Drucker), der Rest wird DHCP-Pool.</p>
</form></div>"""


def _konzept_data(cfg: CoachConfig):
    return adresskonzept(cfg.base_net, cfg.demands, cfg.reserve_pct,
                         cfg.gw_last, cfg.static_per)


def export_konzept(cfg: CoachConfig) -> tuple[str, str, str]:
    """Adresskonzept als Markdown oder CSV: (Body, Content-Type, Dateiname).
    Das Artefakt gehört dem Nutzer - inklusive Permalink zur Quelle."""
    rows, _, rest = _konzept_data(cfg)
    permalink = "https://toolbox.jozapf.de/subnet_coach_v1.py?" + urlencode(_planen_query(cfg))
    stem = f"adresskonzept_{str(cfg.base_net.network_address).replace('.', '-')}_{cfg.base_net.prefixlen}"
    if cfg.export == "csv":
        lines = ["Bereich;Bedarf;mit Reserve;Netz;Gateway;Statisch;DHCP-Pool;Broadcast;Nutzbar;Auslastung %"]
        for r in rows:
            lines.append(f"{r.name};{r.bedarf};{r.mit_reserve};{r.network};{r.gateway};"
                         f"{r.static_range};{r.dhcp_range};{r.network.broadcast_address};"
                         f"{r.usable};{r.auslastung}")
        lines.append(f"Reserve Basisnetz;;;;;;;;{rest};")
        lines.append(f"Quelle;{permalink};;;;;;;;")
        return "\r\n".join(lines) + "\r\n", "text/csv; charset=utf-8", stem + ".csv"
    lines = [f"# Adresskonzept {cfg.base_net}", "",
             f"Gateway-Konvention: {'letzte' if cfg.gw_last else 'erste'} nutzbare Adresse · "
             f"Reserve {cfg.reserve_pct} % · statischer Block {cfg.static_per} Adressen je Bereich", "",
             "| Bereich | Bedarf | mit Reserve | Netz | Gateway | Statisch | DHCP-Pool | Broadcast | Nutzbar | Auslastung |",
             "|---|---|---|---|---|---|---|---|---|---|"]
    for r in rows:
        lines.append(f"| {r.name} | {r.bedarf} | {r.mit_reserve} | `{r.network}` | `{r.gateway}` | "
                     f"`{r.static_range}` | `{r.dhcp_range}` | `{r.network.broadcast_address}` | "
                     f"{r.usable} | {r.auslastung} % |")
    lines += ["", f"Freie Reserve im Basisnetz: {rest} Adressen", "",
              f"Erzeugt mit dem Subnetz-Kalkulator: <{permalink}>"]
    return "\n".join(lines) + "\n", "text/markdown; charset=utf-8", stem + ".md"


def render_planen(cfg: CoachConfig) -> str:
    parts = [render_planen_form(cfg)]
    if cfg.warnings:
        parts.append('<div class="card">' + "".join(
            f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")
    example = not cfg.submitted
    if example:
        cfg = parse_config("mode=planen&" + urlencode({"netz": "192.168.0.0/24",
                                                       "bedarf": DEMO_BEDARF}))
    try:
        rows, steps, rest = _konzept_data(cfg)
    except ValueError:
        tipp = min_base_prefix(cfg.base_net, [(n, (h * (100 + cfg.reserve_pct) + 99) // 100)
                                              for n, h in cfg.demands])
        tipp_html = (f" Ab einem Basisnetz <code>/{tipp}</code> würde es passen "
                     f"(z. B. <code>{cfg.base_net.supernet(new_prefix=tipp)}</code>)."
                     if tipp else "")
        parts.append('<div class="card error-card"><h2>Bedarfe passen nicht</h2>'
                     f'<p>Die Bereiche brauchen zusammen mehr Adressraum, als '
                     f'<code>{esc(cfg.base_net)}</code> hergibt.{tipp_html}</p></div>')
        return "".join(parts)

    base_query = _planen_query(cfg)
    md_href = "?" + urlencode({**base_query, "export": "md"})
    csv_href = "?" + urlencode({**base_query, "export": "csv"})
    note = "Beispiel - eigene Werte oben eintragen." if example else ""
    head = ('<div class="cardhead"><h2>Adresskonzept '
            f'{esc(cfg.base_net)}</h2>'
            + (f'<span class="note">{esc(note)}</span>' if note else "")
            + '<label class="preset wide-btn" for="tblwide" '
            'title="Tabelle ohne Scrollleiste in voller Breite anzeigen (und zurück)">'
            '<span class="lbl-on">⤢ Volle Breite</span>'
            '<span class="lbl-off">⤡ Schmale Ansicht</span></label></div>'
            f'<div class="exportrow"><a class="preset" href="{esc(md_href)}">Export Markdown</a>'
            f'<a class="preset" href="{esc(csv_href)}">Export CSV</a></div>')
    body_rows = "".join(
        f'<tr><td class="rowhead">{esc(r.name)}</td>'
        f'<td>{r.bedarf}{f" → {r.mit_reserve}" if r.mit_reserve != r.bedarf else ""}</td>'
        f'<td><code>{r.network}</code></td><td><code>{esc(r.gateway)}</code></td>'
        f'<td><code>{esc(r.static_range)}</code></td><td><code>{esc(r.dhcp_range)}</code></td>'
        f'<td><code>{r.network.broadcast_address}</code></td>'
        f'<td>{r.usable}</td><td>{r.auslastung} %</td></tr>'
        for r in rows)
    table = ('<div class="table-wrap"><table><thead><tr><th>Bereich</th><th>Bedarf</th>'
             '<th>Netz</th><th>Gateway</th><th>Statisch</th><th>DHCP-Pool</th>'
             '<th>Broadcast</th><th>Nutzbar</th><th>Auslastung</th></tr></thead>'
             f'<tbody>{body_rows}</tbody></table></div>'
             f'<p class="note">Freie Reserve im Basisnetz: {rest} Adressen. '
             'Für organisationsweite VLAN-Strukturen: der '
             f'<a href="{esc(PLANNER_URL)}">Net+VLAN Planner</a>.</p>')
    # Checkbox als direktes Geschwister VOR der Karte - der CSS-Toggle
    # (.wide-toggle:checked + .card) hängt an genau dieser Reihenfolge
    parts.append(f'<input type="checkbox" id="tblwide" class="wide-toggle">'
                 f'<div class="card">{head}{table}</div>')
    parts.append('<div class="card"><h2>Rechenweg</h2>' + render_steps(steps) + "</div>")
    return "".join(parts)


def _page(body: str, mode_title: str) -> str:
    return f"""<!DOCTYPE html>
{WATERMARK}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{esc(TILE_DESCRIPTION)}">
<title>Jo Zapf Toolbox - {esc(TOOL_TITLE)} · {esc(mode_title)}</title>
<link rel="preconnect" href="https://assets.jozapf.de" crossorigin>
<link rel="stylesheet" href="{esc(FONTS_CSS)}">
<style>{CSS}</style>
</head>
<body>
<div class="container">
<h1>toolbox.jozapf.de | {esc(TOOL_TITLE)} v{VERSION}</h1>
<p class="subtitle">Subnetting mit vollständigem Rechenweg - binäre Herleitung statt bloßer Ergebnisse</p>
{body}
<footer>Regeln belegt nach RFC 950 &middot; 1918 &middot; 3021 &middot; 4632 &middot; 4291 &middot; 5952 &middot; keine Datenspeicherung, Aufgaben stecken komplett in der URL &middot; für organisationsweite VLAN-Strukturen: <a href="{esc(PLANNER_URL)}">Net+VLAN Planner</a> &middot; <a href="{esc(TOOLBOX_URL)}">zur Toolbox</a></footer>
</div>
</body>
</html>"""


def render(cfg: CoachConfig) -> str:
    if cfg.errors:
        err = '<div class="card error-card"><h2>Eingabe prüfen</h2>' + "".join(
            f"<p>{e}</p>" for e in cfg.errors) + "</div>"
        form = {"erklaeren": render_form, "binaer": render_binaer_form,
                "planen": render_planen_form, "ipv6": render_ipv6_form}.get(cfg.mode)
        return _page(render_tabs(cfg) + err + (form(cfg) if form else ""), "Eingabe prüfen")
    if cfg.mode == "ueben":
        return _page(render_tabs(cfg) + render_ueben(cfg), "Trainieren")
    if cfg.mode == "binaer":
        return _page(render_tabs(cfg) + render_binaer(cfg), "Umrechnen")
    if cfg.mode == "planen":
        return _page(render_tabs(cfg) + render_planen(cfg), "Planen")
    if cfg.mode == "ipv6":
        return _page(render_tabs(cfg) + render_ipv6(cfg), "IPv6")
    return _page(render_tabs(cfg) + render_erklaeren(cfg), "Berechnen")


# =============================================================================
# 5. ENTRY  -  CGI (stdout), Dev-Server (--serve), Self-Test (--self-test)
# =============================================================================


def respond(query: str) -> tuple[int, list[tuple[str, str]], str]:
    """Gemeinsamer Kern für CGI und Dev-Server: Status, Header, Body."""
    cfg = parse_config(query)
    target = needs_seed_redirect(cfg)
    if target:
        return 302, [("Location", target)], ""
    if (cfg.mode == "planen" and cfg.export and cfg.submitted
            and not cfg.errors and cfg.base_net and cfg.demands):
        try:
            body, ctype, fname = export_konzept(cfg)
            return 200, [("Content-Type", ctype),
                         ("Content-Disposition", f'attachment; filename="{fname}"')], body
        except ValueError:
            pass  # passt nicht ins Basisnetz -> HTML-Seite erklärt es
    return 200, [("Content-Type", "text/html; charset=utf-8")], render(cfg)


def handle_cgi() -> None:
    status, headers, body = respond(os.environ.get("QUERY_STRING", ""))
    if status == 302:
        sys.stdout.write(f"Status: 302 Found\r\nLocation: {headers[0][1]}\r\n\r\n")
        return
    sys.stdout.write("".join(f"{k}: {v}\r\n" for k, v in headers) + "\r\n")
    sys.stdout.write(body)


def run_dev_server(port: int) -> None:
    """Lokaler Testserver - kein Deploy-Weg, nur zum Ausprobieren im Browser."""
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import urlsplit

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):  # noqa: N802
            status, headers, body = respond(urlsplit(self.path).query)
            raw = body.encode("utf-8")
            self.send_response(status)
            for k, v in headers:
                self.send_header(k, v)
            if status == 200:
                self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)

        def log_message(self, *args):  # ruhig halten
            pass

    print(f"{TOOL_TITLE} v{VERSION} -> http://localhost:{port}  (Strg+C beendet)")
    HTTPServer(("127.0.0.1", port), Handler).serve_forever()


def self_test() -> int:
    """Kompakte Zusicherungen - die Fixtures sind bekannte Standardaufgaben."""
    failures: list[str] = []

    def check(cond: bool, msg: str):
        if not cond:
            failures.append(msg)

    # --- Baustein A: Analyse (der Klassiker 192.168.10.77/26) ---------------
    a = analyse_subnetz(ipaddress.IPv4Address("192.168.10.77"), 26)
    check(a.net_addr == "192.168.10.64", f"Netzadresse falsch: {a.net_addr}")
    check(a.broadcast == "192.168.10.127", f"Broadcast falsch: {a.broadcast}")
    check(a.first_host == "192.168.10.65" and a.last_host == "192.168.10.126",
          "Hostbereich falsch")
    check(a.usable == 62, f"62 Hosts erwartet, {a.usable} bekommen")
    check(a.netmask == "255.255.255.192" and a.wildcard == "0.0.0.63",
          "Maske/Wildcard falsch")
    check(any(src == "rfc1918" for *_, src in classify(a.ip)),
          "RFC-1918-Einordnung fehlt")
    # Einordnung ist KEIN Rechenweg-Schritt mehr (kompakte Badge-Zeile)
    check(not any(s.title == "Einordnung der Adresse" for s in a.steps),
          "Einordnung darf kein eigener Schritt mehr sein")
    check(len(a.steps) == 3, f"Analyse /26 soll 3 kompakte Schritte haben, hat {len(a.steps)}")
    # Blockgrößen-Schritt: /26 -> 4. Oktett, 64er-Schritte
    check(any(s.title.startswith("Oktettgrenze") and "`64`" in s.text and "4. Oktett" in s.title
              for s in a.steps), "Blockgrößen-Schritt /26 fehlt oder falsch")

    # Oktettgrenze im 3. Oktett (Stufe-2-Klassiker)
    b = analyse_subnetz(ipaddress.IPv4Address("172.16.130.44"), 20)
    check(b.net_addr == "172.16.128.0" and b.broadcast == "172.16.143.255",
          f"/20-Rechnung falsch: {b.net_addr}/{b.broadcast}")
    check(b.usable == 4094, "/20 muss 4094 nutzbare Hosts haben")
    # Blockgrößen-Schritt an der Oktettgrenzen-Baustelle: /20 -> 3. Oktett,
    # Masken-Oktett 240, Blockgröße 16, 130 -> Block ab 128
    check(any("3. Oktett" in s.title and "`16`" in s.text and "`128`" in s.text
              for s in b.steps), "Blockgrößen-Schritt /20 fehlt oder falsch")
    # Glatte Präfixe: eigener Oktettgrenzen-Hinweis statt Blockgrößen-Rechnung
    c24 = analyse_subnetz(ipaddress.IPv4Address("192.168.10.77"), 24)
    check(any(s.title == "Oktettgrenze nutzen" and "3. und 4. Oktett" in s.text
              for s in c24.steps), "/24 muss den Oktettgrenzen-Hinweis tragen")

    # Sonderfälle /31, /32, /30
    check(hosts_for_prefix(31) == 2, "/31 hat 2 nutzbare Adressen (RFC 3021)")
    check(hosts_for_prefix(32) == 1, "/32 ist genau eine Adresse")
    check(hosts_for_prefix(30) == 2, "/30 hat 2 nutzbare Hosts")
    a31 = analyse_subnetz(ipaddress.IPv4Address("10.0.0.4"), 31)
    check(any(s.source == "rfc3021" for s in a31.steps), "/31 ohne RFC-3021-Schritt")
    check(a31.first_host == "10.0.0.4" and a31.last_host == "10.0.0.5",
          "/31: beide Adressen sind der Hostbereich")

    # Einordnung: APIPA als Praxis-Störungsbild, CGN, öffentlich
    check(any("DHCP" in detail for _, detail, _ in classify(ipaddress.IPv4Address("169.254.7.9"))),
          "APIPA-Hinweis fehlt")
    check(any(src == "rfc6598" for *_, src in classify(ipaddress.IPv4Address("100.64.0.1"))),
          "CGN-Einordnung fehlt")
    check(any("öffentlich" in label for label, *_ in classify(ipaddress.IPv4Address("8.8.8.8"))),
          "8.8.8.8 muss als öffentlich gelten")

    # Maske <-> Präfix, Wildcard
    check(ipaddress.ip_network("0.0.0.0/255.255.240.0").prefixlen == 20,
          "255.255.240.0 muss /20 sein")

    # prefix_for_hosts: Klassiker + Grenzwerte
    check(prefix_for_hosts(50)[0] == 26, "50 Hosts -> /26")
    check(prefix_for_hosts(2)[0] == 30, "2 Hosts -> /30 (klassisch)")
    check(prefix_for_hosts(126)[0] == 25, "126 Hosts -> /25")
    check(prefix_for_hosts(127)[0] == 24, "127 Hosts -> /24 (126 reicht nicht)")
    check(any(s.source == "rfc3021" for s in prefix_for_hosts(2)[1]),
          "PtP-Hinweis bei n=2 fehlt")

    # split_subnets: 4er- und 8er-Klassiker
    subs4, np4, _ = split_subnets(ipaddress.ip_network("192.168.0.0/24"), 4)
    check(np4 == 26 and [str(s.network_address) for s in subs4] ==
          ["192.168.0.0", "192.168.0.64", "192.168.0.128", "192.168.0.192"],
          "192.168.0.0/24 in 4 -> /26-Viertel")
    subs8, np8, steps8 = split_subnets(ipaddress.ip_network("172.16.0.0/16"), 8)
    check(np8 == 19 and hosts_for_prefix(np8) == 8190, "172.16.0.0/16 in 8 -> /19 mit 8190")
    # Der Erklärtext muss das richtige Oktett und die richtige Schrittweite nennen
    check("3. Oktett in 32er-Schritten" in steps8[1].text,
          f"Schrittweiten-Text falsch: {steps8[1].text}")
    _, _, steps4 = split_subnets(ipaddress.ip_network("192.168.0.0/24"), 4)
    check("4. Oktett in 64er-Schritten" in steps4[1].text,
          f"Schrittweiten-Text falsch: {steps4[1].text}")

    # same_subnet: knapp daneben ist auch vorbei
    eq, _ = same_subnet(ipaddress.IPv4Address("10.0.5.130"),
                        ipaddress.IPv4Address("10.0.5.190"), 26)
    check(eq, "10.0.5.130 und .190 liegen beide in 10.0.5.128/26")
    eq2, st2 = same_subnet(ipaddress.IPv4Address("10.0.5.190"),
                           ipaddress.IPv4Address("10.0.5.193"), 26)
    check(not eq2, ".190 und .193 liegen in verschiedenen /26")
    check(any("Blockgröße" in s.title and "verschiedene Blöcke" in s.text
              for s in st2), "same_subnet: Blockgrößen-Schnellweg fehlt")
    # Vordere Oktette verschieden: Blockvergleich allein darf nicht täuschen
    eq3, st3 = same_subnet(ipaddress.IPv4Address("10.1.130.5"),
                           ipaddress.IPv4Address("10.2.130.5"), 26)
    check(not eq3 and any("vor der Präfix-Grenze" in s.text for s in st3),
          "same_subnet: Vorbedingung 'vordere Oktette gleich' fehlt")

    # VLSM: der Lehrbuchfall - und Sortierung unabhängig von der Eingabe
    net = ipaddress.ip_network("192.168.0.0/24")
    allocs, _ = vlsm_plan(net, [("D", 2), ("A", 100), ("C", 20), ("B", 50)])
    got = [(al.name, str(al.network)) for al in allocs]
    check(got == [("A", "192.168.0.0/25"), ("B", "192.168.0.128/26"),
                  ("C", "192.168.0.192/27"), ("D", "192.168.0.224/30")],
          f"VLSM-Zuteilung falsch: {got}")

    # GOLDENE REGRESSIONSWERTE (vor dem v6-Ausbau gemessen): alte Aufgaben-
    # URLs müssen für immer dieselben Aufgaben liefern - TASK_ORDER und die
    # v4-Typ-Strings sind deshalb unantastbar.
    check(generate_task("mix", 2, 7).typ == "maske", "GOLDEN: mix/2/7 muss 'maske' bleiben")
    check(generate_task("netz", 2, 424242).answers[0] == ("Netzadresse", "172.27.36.96"),
          "GOLDEN: netz/2/424242 hat sich verändert")
    check(generate_task("vlsm", 3, 555555).answers[0] == ("Lager", "172.31.8.0/25 (126 nutzbar)"),
          "GOLDEN: vlsm/3/555555 hat sich verändert")

    # Generator: deterministisch je (typ, stufe, seed), alle Typen x Stufen laufen
    t1 = generate_task("netz", 2, 424242)
    t2 = generate_task("netz", 2, 424242)
    check(t1.question == t2.question and t1.answers == t2.answers,
          "Gleicher Seed muss dieselbe Aufgabe liefern")
    t3 = generate_task("netz", 2, 424243)
    check(t1.question != t3.question, "Anderer Seed sollte andere Aufgabe liefern")
    for typ in TASK_ORDER + ["mix"]:
        for level in LEVELS:
            for seed in (1, 2, 3, 4, 5):
                task = generate_task(typ, level, seed)
                check(bool(task.question and task.solution_steps and task.answers),
                      f"Generator {typ}/S{level}/seed{seed} unvollständig")
    # mix wählt deterministisch einen konkreten Typ
    check(generate_task("mix", 2, 7).typ == generate_task("mix", 2, 7).typ
          and generate_task("mix", 2, 7).typ in TASK_ORDER, "mix-Auflösung kaputt")

    # --- Umrechnen (V1.1): Stellenwert-Methode ------------------------------
    def bits_of(v):
        return "".join("1" if s.fits else "0" for s in octet_steps(v))
    check(bits_of(192) == "11000000", "192 -> 11000000")
    st192 = octet_steps(192)
    check(st192[0].rest_after == 64 and st192[1].rest_after == 0, "Reste von 192 falsch")
    check(bits_of(77) == "01001101", "77 -> 01001101")
    check(bits_of(0) == "00000000" and bits_of(255) == "11111111", "Randwerte 0/255 falsch")
    check(sum(s.place for s in octet_steps(173) if s.fits) == 173, "Rückaddition 173 falsch")
    try:
        octet_steps(256)
        check(False, "256 muss ValueError geben")
    except ValueError:
        pass
    cb = parse_config("mode=binaer&ip=192.168.10.77&prefix=26")
    check(cb.mode == "binaer" and cb.ip is not None and cb.prefix == 26, "binaer-Parse IP+Präfix")
    check(parse_config("mode=binaer&ip=192").single_value == 192, "binaer-Parse Einzelzahl")
    check(parse_config("mode=binaer&ip=192.168.10.77&mask=255.255.255.192").prefix == 26,
          "binaer: Maske muss als /26 ankommen")
    check(parse_config("mode=binaer&ip=300").errors, "300 muss Fehler geben")
    check(parse_config("mode=binaer&ip=1.2.3.999").errors, "ungültige IP muss Fehler geben")
    sb, _, bb = respond("mode=binaer&ip=192.168.10.77&prefix=26")
    check(sb == 200 and "192≥128? Ja" in bb, "Umrechnen-Seite unvollständig")
    check("128 + 64 = 192" in bb, "Kontrollzeile fehlt")
    check(bb.count('<details class="oct"') == 3, "Oktett 2-4 müssen Akkordeons sein")
    check('class="nb"' in bb, "Präfix-Färbung fehlt bei Angabe von /26")
    _, _, bb_np = respond("mode=binaer&ip=192.168.10.77")
    check("<code>11000000</code>" in bb_np, "Bit-String 11000000 fehlt (ohne Präfix)")
    check('class="nb"' not in bb_np, "ohne Präfix darf nichts als Netzanteil gefärbt sein")
    sb2, _, bb2 = respond("mode=binaer")
    check(sb2 == 200 and DEFAULT_EXAMPLE_IP in bb2, "Umrechnen-Erstaufruf ohne Beispiel")
    check("mode=binaer" in respond("ip=192.168.10.77&prefix=26")[2],
          "Berechnen-Tab muss Kontext an den Umrechnen-Tab durchreichen")
    _, _, bsingle = respond("mode=binaer&ip=173")
    check("10101101" in bsingle, "Einzelzahl 173 -> 10101101 fehlt")

    # --- Planen (V1.2): Adresskonzept ---------------------------------------
    net24 = ipaddress.ip_network("192.168.0.0/24")
    demo = [("Verwaltung", 100), ("Vertrieb", 50), ("Produktion", 20), ("Uplink", 2)]
    rows, psteps, prest = adresskonzept(net24, demo, 0, False, 10)
    check([str(r.network) for r in rows] ==
          ["192.168.0.0/25", "192.168.0.128/26", "192.168.0.192/27", "192.168.0.224/30"],
          "Konzept: VLSM-Zuteilung falsch")
    verw = rows[0]
    check(verw.gateway == "192.168.0.1", "Gateway muss die erste Adresse sein")
    check(verw.static_range == "192.168.0.2 – 192.168.0.11", f"Statischer Block falsch: {verw.static_range}")
    check(verw.dhcp_range == "192.168.0.12 – 192.168.0.126", f"DHCP-Pool falsch: {verw.dhcp_range}")
    check(rows[3].usable == 2 and rows[3].static_range == "192.168.0.226"
          and rows[3].dhcp_range == "–", "Kleines /30: statisch klemmen, kein DHCP")
    check(verw.auslastung == 79, f"Auslastung 100/126 muss 79 %% sein, ist {verw.auslastung}")
    check(prest == 256 - 128 - 64 - 32 - 4, "Rest im Basisnetz falsch")
    rows_gl, _, _ = adresskonzept(net24, demo, 0, True, 10)
    check(rows_gl[0].gateway == "192.168.0.126", "gw_last: Gateway muss letzte Adresse sein")
    rows_res, steps_res, _ = adresskonzept(net24, [("A", 100)], 25, False, 10)
    check(rows_res[0].mit_reserve == 125 and rows_res[0].network.prefixlen == 25,
          "Reserve 25 %% auf 100 -> 125 in /25")
    check(any("Wachstumsreserve" in s.title for s in steps_res), "Reserve-Schritt fehlt")
    check(min_base_prefix(net24, [("A", 200), ("B", 200)]) == 23,
          "2x200 Hosts brauchen mindestens /23")
    # REQUEST planen: Zeilenformate, Normalisierung, Fehler
    pc = parse_config("mode=planen&netz=192.168.0.5%2F24&bedarf=Verwaltung%2C%2050%0ATechnik%2030%0ALager%3B10")
    check(pc.base_net is not None and str(pc.base_net) == "192.168.0.0/24" and pc.warnings,
          "planen: Normalisierung auf Netzadresse + Warnung")
    check(pc.demands == [("Verwaltung", 50), ("Technik", 30), ("Lager", 10)],
          f"planen: Zeilen-Parser falsch: {pc.demands}")
    check(parse_config("mode=planen&netz=192.168.0.0&bedarf=A%2C1").errors,
          "planen: Netz ohne Präfix muss Fehler geben")
    check(parse_config("mode=planen&netz=10.0.0.0%2F31&bedarf=A%2C1").errors,
          "planen: /31 als Basisnetz muss Fehler geben")
    check(parse_config("mode=planen&netz=10.0.0.0%2F24&bedarf=kaputt").errors,
          "planen: unlesbare Bedarfszeile muss Fehler geben")
    # RENDER/EXPORT planen
    sp, _, bp = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=" +
                        DEMO_BEDARF.replace(", ", "%2C%20").replace("\n", "%0A"))
    check(sp == 200 and "Adresskonzept" in bp and "Export Markdown" in bp
          and "192.168.0.224/30" in bp, "Planen-Seite unvollständig")
    # Breit-Umschalter: Checkbox muss DIREKT vor der Konzept-Karte stehen
    # (der CSS-Selektor .wide-toggle:checked + .card hängt daran)
    check('<input type="checkbox" id="tblwide" class="wide-toggle"><div class="card">' in bp,
          "Breit-Toggle: Checkbox nicht direkt vor der Karte")
    check('for="tblwide"' in bp and "Volle Breite" in bp, "Breit-Toggle: Label fehlt")
    sm, hm, bm = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=md")
    check(sm == 200 and hm[0][1].startswith("text/markdown")
          and "| A | 50 |" in bm and "attachment" in hm[1][1], "Markdown-Export defekt")
    sc_, hc, bc = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=csv")
    check(sc_ == 200 and hc[0][1].startswith("text/csv") and "A;50;50;" in bc,
          "CSV-Export defekt")
    check(bm == respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=md")[2],
          "Export muss deterministisch sein")
    sp0, _, bp0 = respond("mode=planen")
    check(sp0 == 200 and "Beispiel" in bp0 and "Uplink" in bp0, "Planen-Erstaufruf ohne Beispiel")
    spb, _, bpb = respond("mode=planen&netz=192.168.0.0%2F26&bedarf=A%2C200")
    check(spb == 200 and "passen nicht" in bpb and "/24" in bpb,
          "Zu-klein-Fall muss Mindest-Präfix-Tipp nennen")

    # --- IPv6 (V1.3): Notation, Typen, Präfix-Logik -------------------------
    v6 = ipaddress.IPv6Address("2001:db8:0:42::7")
    check(v6.exploded == "2001:0db8:0000:0042:0000:0000:0000:0007"
          and v6.compressed == "2001:db8:0:42::7", "v6-Fixture Voll-/Kürzform")
    net6, steps6 = analyse_v6(v6, 64)
    check(str(net6) == "2001:db8:0:42::/64", "v6-Netzpräfix falsch")
    check(any("längste Nullfolge" in s.title for s in steps6), "::-Regel-Schritt fehlt")
    check(any(s.source == "rfc4862" for s in steps6), "/64 muss den SLAAC-Schritt tragen")
    # Einzelner Nullblock wird laut RFC 5952 NICHT zu ::
    single0 = ipaddress.IPv6Address("2001:db8:0:1:1:1:1:1")
    check(single0.compressed == "2001:db8:0:1:1:1:1:1", "einzelner Nullblock darf kein :: werden")
    check(any("kein ::" in s.title for s in analyse_v6(single0, 64)[1]),
          "Hinweis auf Nicht-Kürzung fehlt")
    _, steps48 = analyse_v6(ipaddress.IPv6Address("2001:db8::"), 48)
    check(any("65 536" in s.text for s in steps48), "/48 muss 65 536 /64-Subnetze nennen")
    _, steps52 = analyse_v6(ipaddress.IPv6Address("2001:db8::"), 52)
    check(any("mitten durch Block 4" in s.text for s in steps52),
          "/52: Grenzblock-Hinweis fehlt")
    # Typen: Doku-Bereich MUSS vor GUA erkannt werden (liegt in 2000::/3)
    check(classify_v6(ipaddress.IPv6Address("2001:db8::1"))[0] == "Dokumentationsbereich",
          "2001:db8:: muss Doku-Bereich sein, nicht GUA")
    check(classify_v6(ipaddress.IPv6Address("fd12::1"))[0].startswith("ULA"), "fd -> ULA")
    check(classify_v6(ipaddress.IPv6Address("fe80::1"))[0] == "Link-Local", "fe80 -> Link-Local")
    check(classify_v6(ipaddress.IPv6Address("2a00::1"))[0].startswith("Global"), "2a00 -> GUA")
    check(classify_v6(ipaddress.IPv6Address("ff02::1"))[0] == "Multicast", "ff02 -> Multicast")
    check(classify_v6(ipaddress.IPv6Address("::1"))[0] == "Loopback", ":: 1 -> Loopback")
    # REQUEST ipv6
    c6 = parse_config("mode=ipv6&ip=2001%3Adb8%3A%3A7%2F48")
    check(c6.ip6 is not None and c6.prefix6 == 48, "ipv6-Parse mit /48")
    c6d = parse_config("mode=ipv6&ip=2001%3Adb8%3A%3A7")
    check(c6d.prefix6 == 64 and c6d.warnings, "ipv6 ohne Präfix: /64 + Hinweis")
    check(parse_config("mode=ipv6&ip=kaputt").errors, "ungültige v6-Adresse muss Fehler geben")
    check(parse_config("mode=ipv6&ip=%3A%3A1%2F200").errors, "/200 muss Fehler geben")
    # RENDER ipv6
    s6, _, b6 = respond("mode=ipv6&ip=" + DEMO_V6.replace("/", "%2F"))
    check(s6 == 200 and "2001:0db8:0000:0042:0000:0000:0000:0007" in b6
          and "Dokumentationsbereich" in b6, "IPv6-Seite unvollständig")
    check("Adresstypen im Überblick" in b6, "Typen-Tabelle fehlt")
    s60, _, b60 = respond("mode=ipv6")
    check(s60 == 200 and "Beispiel" in b60, "IPv6-Erstaufruf ohne Beispiel")
    check(">IPv6</a>" in respond("")[2], "IPv6-Tab fehlt in der Navigation")
    # IPv4 im IPv6-Tab: ZURÜCKGEBAUT (Nutzerentscheid 2026-07-29).
    # Fachliche Basis: v4/v6 haben getrennte Adressräume (RFC 4291); die
    # zuvor gezeigten Formate ::ffff:/64:ff9b:: sind Einbettungen für
    # Dual-Stack bzw. Übersetzung (RFC 4291 §2.5.5.2, RFC 6052), keine
    # Adress-Umwandlung - der Tab soll das Missverständnis nicht nähren.
    check(parse_config("mode=ipv6&ip=192.168.10.77").errors,
          "v4 im ipv6-Modus muss abgelehnt werden")
    s4, _, b4 = respond("mode=ipv6&ip=192.168.10.77")
    check(s4 == 200 and "Eingabe prüfen" in b4 and "keine gültige IPv6-Adresse" in b4,
          "v4-Eingabe muss die Fehlerkarte zeigen")
    check("analyse_v4_in_v6" not in globals(), "v4-in-v6-Funktion muss entfernt sein")

    # --- Trainer v6 (V1.5): Familien-Umschalter + 5 Aufgabentypen -----------
    sub300, s300 = nth_subnet_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 300)
    check(str(sub300) == "2001:db8:2f:12c::/64", f"Subnetz 300 falsch: {sub300}")
    check(any("12c" in s.text for s in s300), "Dez->Hex-Schritt fehlt")
    last48, _ = nth_subnet_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 65535)
    check(str(last48) == "2001:db8:2f:ffff::/64", "letztes /64 falsch")
    # der Praxis-Klassiker: /56 -> 4 x /58, Schrittweite 0x40
    subs58, np58, step58, steps58 = split_v6(
        ipaddress.IPv6Network("2001:db8:aaaa:bb00::/56"), 4)
    check(np58 == 58 and step58 == 0x40, f"/56 in 4 -> /58 mit 0x40, bekam /{np58}, 0x{step58:x}")
    check([s.compressed for s in subs58] ==
          ["2001:db8:aaaa:bb00::/58", "2001:db8:aaaa:bb40::/58",
           "2001:db8:aaaa:bb80::/58", "2001:db8:aaaa:bbc0::/58"],
          "die vier /58-Subnetzadressen sind falsch")
    check(any("mitten in der Hexstelle" in s.title for s in steps58),
          "Nicht-Nibble-Warnschritt fehlt")
    subs52, np52, step52, steps52 = split_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 16)
    check(np52 == 52 and step52 == 0x1000
          and not any("mitten" in s.title for s in steps52),
          "Nibble-Teilung /48->16 falsch oder fälschlich gewarnt")
    # Generatoren: deterministisch, mix6 bleibt in der v6-Familie
    for typ in TASK_ORDER_V6 + ["mix6"]:
        for level in LEVELS:
            t_a = generate_task(typ, level, 4711)
            t_b = generate_task(typ, level, 4711)
            check(t_a.question == t_b.question and t_a.answers == t_b.answers,
                  f"v6-Generator {typ}/S{level} nicht deterministisch")
            check(t_a.typ in TASK_ORDER_V6, f"{typ} muss in der v6-Familie bleiben")
    # Notation-Aufgabe: Adresse ist immer kürzbar (sonst trivial)
    for seed in range(1, 30):
        tno = generate_task("notation", 1, seed)
        check("::" in dict(tno.answers)["Kürzform"],
              f"notation/S1/seed{seed}: Adresse nicht kürzbar")
    # adresstyp Stufe 3 enthält den Doku-vor-GUA-Fallstrick im Pool (Smoke)
    t_at = generate_task("adresstyp", 3, 12)
    check(len(t_at.answers) == 3 and all(a[1] for a in t_at.answers),
          "adresstyp: drei Zuordnungen erwartet")
    # REQUEST/RENDER: mix6-Whitelist, Familien-Leiste, Alt-URLs unberührt
    check(parse_config("mode=ueben&typ=mix6&seed=1").typ == "mix6", "mix6 muss erlaubt sein")
    check(parse_config("mode=ueben&typ=quatsch&seed=1").typ == "mix",
          "unbekannter Typ fällt weiter auf mix (v4) zurück")
    sr6, hr6, _ = respond("mode=ueben&typ=mix6")
    check(sr6 == 302 and "typ=mix6" in hr6[0][1], "mix6 ohne Seed muss redirecten")
    _, _, bt6 = respond("mode=ueben&typ=teilen&stufe=3&seed=777001")
    check("IPv6 · Netz teilen" in bt6 and ">IPv4</a>" in bt6 and ">IPv6</a>" in bt6,
          "Trainer-Familienleiste oder v6-Typlabel fehlt")
    _, _, bt4 = respond("mode=ueben&typ=netz&stufe=2&seed=424242")
    check("IPv4 · Netz &amp; Broadcast" in bt4 and "172.27.36.96" in bt4,
          "v4-Trainer verändert (GOLDEN-URL)")

    # REQUEST: Validierung und Redirect-Regel
    check(parse_config("ip=999.1.2.3").errors, "Ungültige IP muss Fehler geben")
    check(parse_config("ip=10.0.0.1&mask=255.0.255.0").errors,
          "Nicht zusammenhängende Maske muss Fehler geben")
    check(parse_config("ip=10.0.0.1&prefix=26&mask=255.255.255.0").warnings,
          "prefix+mask zusammen muss warnen")
    check(parse_config("ip=10.0.0.1").warnings, "fehlende Maske muss /24-Hinweis geben")
    check(parse_config("ip=10.0.0.1&mask=255.255.255.192").prefix == 26,
          "Maske muss als /26 ankommen")
    check(not parse_config("").submitted, "Leerer Aufruf darf nicht 'submitted' sein")
    check(needs_seed_redirect(parse_config("mode=ueben")) is not None,
          "Üben ohne Seed muss auf Seed-URL umleiten")
    check(needs_seed_redirect(parse_config("mode=ueben&seed=123456")) is None,
          "Üben mit Seed darf nicht umleiten")

    # RENDER/ENTRY: Wasserzeichen, Determinismus der Seite, Redirect-Antwort
    status, headers, body = respond("ip=192.168.10.77&prefix=26")
    check(status == 200 and "jzt-7c3f9a2e" in body.splitlines()[1],
          "Wasserzeichen muss direkt nach <!DOCTYPE html> stehen")
    check("192.168.10.64" in body, "Analyse-Ergebnis fehlt im HTML")
    check("privater Adressbereich · RFC 1918" in body,
          "Einordnungs-Badge fehlt im HTML")
    check(body.count("= Netzanteil") == 1,
          "Binär-Legende soll genau einmal erscheinen")
    s2, h2, b2 = respond("mode=ueben&typ=vlsm&stufe=3&seed=555555")
    s3, h3, b3 = respond("mode=ueben&typ=vlsm&stufe=3&seed=555555")
    check(s2 == 200 and b2 == b3, "Gleiche Übungs-URL muss identisches HTML liefern")
    sr, hr, _ = respond("mode=ueben")
    check(sr == 302 and hr[0][0] == "Location" and "seed=" in hr[0][1],
          "Üben ohne Seed: 302 auf Seed-URL erwartet")
    check("TOOLBOX-TILE" not in render(parse_config("")), "Marker gehört nicht in die Seite")

    if failures:
        print("SELF-TEST FEHLGESCHLAGEN:")
        for f in failures:
            print("  -", f)
        return 1
    print("SELF-TEST OK - alle Zusicherungen erfüllt.")
    return 0


def main(argv: list[str]) -> int:
    if "--self-test" in argv:
        return self_test()
    if "--serve" in argv:
        i = argv.index("--serve")
        port = int(argv[i + 1]) if len(argv) > i + 1 and argv[i + 1].isdigit() else 8080
        run_dev_server(port)
        return 0
    # CGI-Modus (Default): auch ohne Webserver druckt es die Seite auf stdout.
    handle_cgi()
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))