#!/usr/bin/env python3
"""
Slatewise
=========

A console for configuring Cisco IOS switches and routers in a classroom lab,
over a console cable. Every line is checked before it reaches the device, and anything that would survive a power cycle (saving,
erasing, flash changes, boot settings) is blocked, so the devices come back
clean every morning.

This is a guardrail, not a security boundary: anyone with another terminal
program and a console cable can bypass it.

Needs Python 3.10 or later and pyserial (py -m pip install pyserial).
Instructors set their password once with:
    py slatewise.pyw --set-instructor-password

Copyright (c) 2026 Parakalix Studios. Free to use under the license below.
Cisco, Catalyst and IOS are trademarks of Cisco Systems, Inc. Slatewise is
an independent project, not affiliated with or endorsed by Cisco.
"""

# Slatewise license
#
# Copyright (c) 2026 Parakalix Studios. All rights reserved.
# Slatewise is free to use. It is licensed, not sold.
#
# 1. What you may do. If you got Slatewise from parakalix.net, you may use it
#    and copy it onto the computers that you, your school or your students use
#    for lab work. You may change your own copy for your own lab.
# 2. What you may not do. You may not sell Slatewise or charge for it, publish
#    or share it anywhere else, or share a changed copy.
# 3. Ownership. Parakalix Studios keeps all rights in Slatewise.
# 4. No warranty. Slatewise is provided "as is", without warranty of any kind.
#    You are responsible for the devices you use it with and the commands you
#    send. Parakalix Studios is not liable for any loss or damage from using it.
# 5. If you break these terms, this license ends and you must delete your copies.
#
# Changing the program: the rules are in CLAUDE.md beside it in the source, and
# the tests run with python tests/run_all.py.

from __future__ import annotations

import base64
import hashlib
import hmac
import ipaddress
import json
import os
import queue
import re
import struct
import subprocess
import sys
import threading
import time
import traceback
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

try:
    import tkinter as tk
    import tkinter.font as tkfont
    from tkinter import filedialog, messagebox, simpledialog, ttk
except ImportError:  # the guard and transports still work without a GUI
    tk = None

APP_NAME = "Slatewise"
VERSION = "2.23"


# ===========================================================================
# Safety guard
# ===========================================================================

ALLOW, NOTE, WARN, BLOCK = 0, 1, 2, 3

# rule id: (level, title, explanation shown to the student)
RULES = {
    "save": (BLOCK, "Saving the configuration is disabled",
             "Devices in this lab return to a clean state when they power off, so "
             "the configuration is never saved. Your changes are already active; "
             "check them with 'show running-config'. (On a production network you "
             "would save with 'copy running-config startup-config'.)"),
    "erase": (BLOCK, "Erasing configuration or storage is disabled",
              "Erasing NVRAM or flash is an instructor task. It can remove the "
              "baseline configuration or the IOS image."),
    "flash": (BLOCK, "Changing files in flash is disabled",
              "Deleting, renaming or formatting files can remove the IOS image and "
              "leave the device unable to boot. You can still look around with 'dir' "
              "or 'show flash:'. The one file you may delete is the VLAN database: "
              "delete vlan.dat"),
    "copy": (BLOCK, "Copying into startup-config, NVRAM or flash is disabled",
             "Copies into startup-config, NVRAM or flash would survive a power "
             "cycle. Copying to running-config or to a TFTP, FTP or SCP server is "
             "allowed."),
    "copy_in": (BLOCK, "Loading a configuration file is disabled",
                "Copying a file into the running-config, or 'configure replace' / "
                "'configure network', runs every command in that file without the "
                "safeguards seeing them first, so it could save the configuration or "
                "change boot settings. Type or paste the commands instead."),
    "copy_ambiguous": (BLOCK, "Give the copy command a destination",
                       "Type the whole command, for example 'copy running-config "
                       "tftp:'. Without a destination the device asks for one, and "
                       "the default is often startup-config."),
    "boot": (BLOCK, "Changing boot settings is disabled",
             "Boot variables, the configuration register and system settings are "
             "kept outside the running-config and take effect at the next "
             "power-up, so they would carry over to the next class."),
    "automation": (BLOCK, "This feature is disabled in the lab",
                   "Aliases, kron, EEM applets, config archiving, 'file prompt' and "
                   "Tcl can run or hide commands automatically, including a hidden "
                   "save, so they are turned off."),
    "redirect": (BLOCK, "Writing command output to a file is disabled",
                 "'| redirect', '| append' and '| tee' write files to flash or "
                 "NVRAM. Show the output on screen instead."),
    "software": (BLOCK, "Software and license changes are disabled",
                 "Installing or upgrading IOS and changing licenses are instructor "
                 "tasks."),
    "pw_recovery": (BLOCK, "Disabling password recovery is not allowed",
                    "'no service password-recovery' can lock the instructor out of "
                    "the device for good."),
    "prompt": (BLOCK, "Changing the CLI prompt is disabled",
               "The console reads the device's normal prompt to keep track of what "
               "the device is doing, so the prompt has to stay as it is."),
    "rommon": (BLOCK, "This ROMMON / boot loader command is disabled",
               "ROMMON variables, the configuration register and boot loader files "
               "persist across power cycles."),
    "reload": (WARN, "Reload the device?",
               "A reload clears everything configured since the device booted, "
               "because nothing is saved in this lab. If the device asks "
               "'Save? [yes/no]', answer no."),
    "vtp": (WARN, "VTP settings survive a power cycle",
            "VTP mode, domain and password are stored in flash:vlan.dat, not the "
            "running-config, so they persist even without saving. A VTP server "
            "with a higher revision number can also overwrite the VLANs on other "
            "lab switches. Continue?"),
    "sdm": (WARN, "SDM templates are stored for the next boot",
            "'sdm prefer' is kept by the switch and applies at the next reload, so "
            "it carries over to the next class. Continue?"),
    "telnet": (WARN, "Telnet sends passwords in clear text",
               "'transport input telnet' or 'transport input all' lets anyone on the "
               "network read the session, passwords included. Use 'transport input ssh'. "
               "Continue anyway?"),
    "sshv1": (WARN, "SSH version 1 is insecure",
              "SSHv1 has known weaknesses and is not used in this class. Use "
              "'ip ssh version 2'. Continue anyway?"),
    "storage_write": (WARN, "This writes a file to flash",
                      "This command stores something in the device's flash, which "
                      "isn't cleared when the device powers off. Continue?"),
    "remote_write": (WARN, "This lets other computers change the device",
                     "SNMP write access, NETCONF, RESTCONF, the SCP server and the web "
                     "server (ip http) let another computer save the configuration or "
                     "copy files into flash, outside these safeguards. Continue?"),
    "vlan": (NOTE, "VLANs are stored in flash",
             "Switches keep VLANs in flash:vlan.dat, which updates on its own and "
             "survives a power cycle. To start clean next time, run 'delete vlan.dat' "
             "after your last VLAN change; the VLANs stay active until the switch "
             "powers off."),
    "prompt_save": (BLOCK, "Answer 'no': saving is disabled",
                    "The device is asking whether to save the configuration. In this "
                    "lab the answer is always no."),
    "prompt_setup": (BLOCK, "Don't save the setup dialog's configuration",
                     "Saving is disabled in this lab. At 'Enter your selection [2]' "
                     "choose [0] to go to the command line without saving; at 'Use this "
                     "configuration? [yes/no]' answer no."),
    "prompt_confirm": (BLOCK, "This confirmation is blocked",
                       "The device is asking you to confirm an erase, delete, "
                       "overwrite or save. Press Ctrl-C to cancel it."),
}


@dataclass
class Verdict:
    level: int = ALLOW
    rule: str = ""
    line: str = ""

    @property
    def title(self) -> str:
        return RULES[self.rule][1] if self.rule else ""

    @property
    def detail(self) -> str:
        return RULES[self.rule][2] if self.rule else ""


def _v(rule: str, line: str) -> Verdict:
    return Verdict(RULES[rule][0], rule, line)


def _abbr(tok: str, word: str, min_len: int) -> bool:
    """True if tok is an IOS-style abbreviation of word (at least min_len chars)."""
    return len(tok) >= min_len and word.startswith(tok)


_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Typographic characters that lab handouts pasted from Word or a PDF often contain.
_PLAIN_LOOKALIKES = str.maketrans({"\u2018": "'", "\u2019": "'", "\u201c": '"',
                                   "\u201d": '"', "\u2013": "-", "\u2014": "-",
                                   "\u2212": "-"})


def sanitize_line(text: str) -> str:
    """Make a line safe to send and check: printable ASCII only, and no '?'.
    Control characters (Ctrl-Z, Tab, Esc, a pasted newline...) become spaces.
    Other characters become their plain ASCII form where there is one (a
    non-breaking space, curly quotes, an accented letter) and are dropped
    otherwise: the console reads 7-bit characters by default, so a byte above 127
    can reach the device as a different letter or a control key. '?' is removed
    because IOS treats it as a help request that drops out of the line. The device
    then runs exactly the text that was checked. (Banner text loses its '?' too;
    that's the price of never guessing wrong.)"""
    text = unicodedata.normalize("NFKD", text.translate(_PLAIN_LOOKALIKES))
    text = text.encode("ascii", "ignore").decode("ascii")
    return _CONTROL_RE.sub(" ", text).replace("?", "")


def _write_rule(args):
    sub = next((a for a in args if not a.startswith("/")), "")
    if not sub or _abbr(sub, "memory", 1):
        return "save"
    if _abbr(sub, "erase", 1):
        return "erase"
    if _abbr(sub, "core", 2):
        return "flash"           # a core dump can be written into flash
    if _abbr(sub, "terminal", 1) or _abbr(sub, "network", 1):
        return None
    return "save"


def _safe_copy_dest(d: str) -> bool:
    if len(d) >= 2 and "running-config".startswith(d):
        return True
    if d in ("system:running-config", "system:/running-config"):
        return True
    return re.match(r"^(tftp|ftp|scp|sftp|rcp|http|https|null):", d) is not None


def _copy_rule(args):
    opts = [a for a in args if a.startswith("/")]
    rest = [a for a in args if not a.startswith("/")]
    if any(_abbr(o, "/erase", 3) for o in opts):
        return "erase"
    if len(rest) < 2:
        return "copy_ambiguous"
    src, dest = rest[0], rest[1]
    if "startup-config".startswith(dest) or dest.startswith("nvram:"):
        return "save"
    if not _safe_copy_dest(dest):
        return "copy"
    if _is_running_config(dest) and not (
            (len(src) >= 3 and "startup-config".startswith(src))
            or src == "nvram:startup-config"):
        return "copy_in"          # a file's commands would run unchecked
    return None


def _is_running_config(d: str) -> bool:
    return (len(d) >= 2 and "running-config".startswith(d)) or d.startswith("system:")


# The VLAN database is the one file students may delete: it holds VLANs and VTP
# settings that otherwise survive a power cycle. Any other file, or any extra
# option or wildcard, stays blocked.
_VLAN_DAT_RE = re.compile(r"^((flash\d*|const_nvram):/?)?vlan\.dat$", re.I)


def is_vlan_dat(path: str) -> bool:
    return bool(_VLAN_DAT_RE.match(path.strip().strip('"')))


def _delete_rule(args):
    opts = [a for a in args if a.startswith("/")]
    files = [a for a in args if not a.startswith("/")]
    if (len(files) == 1 and is_vlan_dat(files[0])
            and all(_abbr(o, "/force", 2) for o in opts)):
        return None
    return "flash"


def _reload_rule(args):
    return None if args and _abbr(args[0], "cancel", 1) else "reload"


def _transport_rule(args):
    # line vty: transport input telnet | all | telnet ssh ...
    if len(args) >= 2 and _abbr(args[0], "input", 2):
        if any(_abbr(a, "telnet", 1) or _abbr(a, "all", 1) for a in args[1:]):
            return "telnet"
    return None


def _ip_rule(args):
    # ip ssh version 1
    if (len(args) >= 3 and _abbr(args[0], "ssh", 2) and _abbr(args[1], "version", 1)
            and args[2] == "1"):
        return "sshv1"
    return _ip_scp_rule(args)


def _configure_rule(args):
    if not args:
        return None
    # 'configure overwrite-network' loads a file from TFTP straight into NVRAM
    if _abbr(args[0], "overwrite-network", 1):
        return "copy"
    # 'configure replace FILE' / 'configure network' run a file's commands unchecked
    if _abbr(args[0], "replace", 3) or _abbr(args[0], "network", 1):
        return "copy_in"
    return None


def _confreg_rule(args):
    # Abbreviations like 'conf 0x2142' in config mode; 'conf t' is configure terminal
    return "boot" if args and re.fullmatch(r"0x[0-9a-f]{1,4}|\d+", args[0]) else None


def _snmp_rule(args):
    # community ... rw, or a group with a write view
    if "rw" in args:
        return "remote_write"
    if len(args) >= 3 and _abbr(args[0], "group", 1):
        # group NAME v1|v2c|v3 [auth|noauth|priv], then keyword and value pairs:
        # read V, write V, notify V, access [ipv6] ACL, context C, match exact|prefix
        i = 4 if args[2] == "v3" else 3
        while i < len(args):
            if _abbr(args[i], "write", 1):
                return "remote_write"
            i += 3 if _abbr(args[i], "access", 1) and args[i + 1:i + 2] == ["ipv6"] else 2
    return None


def _ip_scp_rule(args):
    if (len(args) >= 3 and _abbr(args[0], "scp", 2) and _abbr(args[1], "server", 2)
            and _abbr(args[2], "enable", 1)):
        return "remote_write"
    # ip http server / ip http secure-server: the web interface can save the config
    if (len(args) >= 2 and _abbr(args[0], "http", 2)
            and (_abbr(args[1], "server", 2) or _abbr(args[1], "secure-server", 2))):
        return "remote_write"
    return None


def _switch_rule(args):
    # switch 1 priority 15 / switch 1 renumber 2: stack settings kept for the next boot
    if (len(args) >= 2 and args[0].isdigit()
            and (_abbr(args[1], "priority", 1) or _abbr(args[1], "renumber", 1))):
        return "boot"
    return None


EXEC_RULES = [
    ("write", 2, _write_rule),
    ("copy", 3, _copy_rule),
    ("configure", 4, _configure_rule),
    ("erase", 2, "erase"),
    ("delete", 3, _delete_rule),
    ("undelete", 3, "flash"),
    ("format", 2, "flash"),
    ("squeeze", 2, "flash"),
    ("rename", 3, "flash"),
    ("mkdir", 2, "flash"),
    ("rmdir", 2, "flash"),
    ("fsck", 2, "flash"),
    ("archive", 2, lambda a: "software" if a and a[0][:2] in ("do", "ta", "up")
                              else "automation"),
    ("hw-module", 4, "software"),
    ("software", 3, "software"),
    ("install", 4, "software"),
    ("upgrade", 3, "software"),
    ("license", 3, "software"),
    ("request", 3, "software"),
    ("tclsh", 3, "automation"),
    ("reload", 3, _reload_rule),
    ("vlan", 2, "vlan"),
    ("switch", 2, _switch_rule),
]

CONFIG_RULES = [
    ("boot", 2, "boot"),
    ("config-register", 8, "boot"),
    ("config-register", 2, _confreg_rule),
    ("system", 2, "boot"),
    ("sdm", 2, "sdm"),
    ("alias", 2, "automation"),
    ("macro", 4, "automation"),
    ("kron", 2, "automation"),
    ("event", 2, "automation"),
    ("archive", 2, "automation"),
    ("file", 2, "automation"),
    ("license", 3, "software"),
    ("prompt", 4, "prompt"),
    ("vtp", 2, "vtp"),
    ("vlan", 2, "vlan"),
    ("transport", 2, _transport_rule),
    ("ip", 2, _ip_rule),
    ("snmp-server", 4, _snmp_rule),
    ("netconf-yang", 4, "remote_write"),      # netconf-yang (IOS XE), netconf ssh (IOS 15)
    ("restconf", 4, "remote_write"),
]

ROMMON_RULES = [
    ("confreg", 3, "rommon"),
    ("unset", 3, "rommon"),
    ("priv", 4, "rommon"),
    ("delete", 3, _delete_rule),
    ("rename", 3, "flash"),
    ("format", 2, "flash"),
    ("copy", 3, "flash"),
    ("mkdir", 2, "flash"),
    ("rmdir", 2, "flash"),
    ("fsck", 2, "flash"),
    ("tftpdnld", 5, "software"),
    ("xmodem", 2, "software"),
    ("o/r", 3, "rommon"),
]

# ROMMON commands that never clash with IOS ones, checked everywhere in case the
# console hasn't recognized the ROMMON prompt yet.
ROMMON_ALWAYS = [("confreg", 7, "rommon"), ("tftpdnld", 8, "software"),
                 ("xmodem", 6, "software"), ("o/r", 3, "rommon")]  # full words only
_ROMMON_VAR_RE = re.compile(r"^[a-z_][a-z0-9_]*=")

# Local storage a command might write to.
_STORAGE_RE = re.compile(r"\b(flash\d*|bootflash|nvram|const_nvram|disk\d+|usbflash\d*|"
                         r"slot\d+|sdflash|crashinfo|webui|startup-config)\s*:")
# Commands that only read storage (copy has its own rule).
_READ_ONLY = [("show", 2), ("s", 1), ("dir", 2), ("more", 2), ("verify", 2), ("cd", 2),
              ("pwd", 2), ("copy", 3), ("delete", 3), ("tftp-server", 5)]
# (copy and delete have their own rules)

# Commands whose arguments are free text, so '|' in them is not a pipe.
PIPE_EXEMPT = [("banner", 3), ("description", 4), ("name", 2), ("remark", 3)]


def _match_table(toks, table, raw):
    for word, min_len, action in table:
        if _abbr(toks[0], word, min_len):
            rule = action(toks[1:]) if callable(action) else action
            return _v(rule, raw) if rule else Verdict()
    return Verdict()


def _check_config(toks, raw):
    t = list(toks)
    while t and (t[0] == "no" or _abbr(t[0], "default", 3)):
        t = t[1:]
    if not t:
        return Verdict()
    if (toks[0] == "no" and _abbr(t[0], "service", 3) and len(t) > 1
            and _abbr(t[1], "password-recovery", 10)):
        return _v("pw_recovery", raw)
    v = _match_table(t, CONFIG_RULES, raw)
    if v.rule == "remote_write" and toks[0] == "no":
        return Verdict()          # turning a remote-write service off is fine
    return v


def _check_rommon(low, raw):
    toks = low.split()
    if not toks:
        return Verdict()
    if "=" in toks[0] and not toks[0].startswith("="):  # VAR=value
        return _v("rommon", raw)
    if toks[0] == "set" and len(toks) > 1:              # set BOOT flash:...
        return _v("rommon", raw)
    # IOS exec save/erase/flash rules too, in case the prompt was misread
    return max(_match_table(toks, ROMMON_RULES, raw), _match_table(toks, EXEC_RULES, raw),
               key=lambda v: v.level)


def _banner_text(line: str):
    """For a banner command, the text after the banner type (which starts with the
    delimiter); None if the line isn't a banner command. IOS accepts abbreviated
    types, so 'banner m X' means banner motd with delimiter X."""
    toks = line.split()
    if not toks or not _abbr(toks[0].lower(), "banner", 3):
        return None
    skip = 1
    if len(toks) > 1 and any(t.startswith(toks[1].lower()) for t in BANNER_TYPES):
        skip = 2
    parts = line.strip().split(None, skip)
    return parts[skip].strip() if len(parts) > skip else ""


def banner_tail(line: str) -> str:
    """Text after a one-line banner's closing delimiter (banner motd #hi# TEXT)."""
    rest = _banner_text(line)
    if rest and rest[0] in rest[1:]:
        return rest[1:].split(rest[0], 1)[1]
    return ""


def check_command(line: str, context: str = "ios") -> Verdict:
    """Check one command line. context is 'ios' (exec or config mode) or 'rommon'.
    The mode is not needed: exec and config rules don't collide, so both run."""
    raw = sanitize_line(line).strip()
    if not raw or raw.startswith("!"):
        return Verdict()
    low = raw.lower()
    if context == "rommon":
        return _check_rommon(low, raw)
    segments = low.split("|")
    toks = segments[0].split()
    if toks and (toks[0] == "do" or _abbr(toks[0], "do-exec", 3)):
        toks = toks[1:]           # 'do-exec' too, in case the IOS release offers it
    if not toks:
        return Verdict()
    results = [_check_exec(toks, raw), _check_config(toks, raw),
               _match_table(toks, ROMMON_ALWAYS, raw)]
    if _ROMMON_VAR_RE.match(toks[0]):
        results.append(_v("rommon", raw))
    if len(segments) > 1 and not any(_abbr(toks[0], w, m) for w, m in PIPE_EXEMPT):
        for seg in segments[1:]:
            st = seg.split()
            if st and any(_abbr(st[0], w, 1) for w in ("redirect", "append", "tee")):
                results.append(_v("redirect", raw))
    tail = banner_tail(raw)
    if tail.strip():
        results.append(check_command(tail, context))
    best = max(results, key=lambda v: v.level)
    if (best.level < WARN and _STORAGE_RE.search(low)
            and not any(_abbr(toks[0], w, m) for w, m in _READ_ONLY)):
        best = _v("storage_write", raw)
    return best


def _check_exec(toks, raw):
    return _match_table(toks, EXEC_RULES, raw)


# Questions the device asks that must not be answered "the wrong way".
# (pattern on the last line of output, rule id, reply -> True if blocked)
_ANSWER = r"(\[yes/no\]|\[y/n\]|\(y/n\)|\(yes/no\))\s*:?\s*$"


def _yes(r):
    return r.strip().lower().startswith("y")


def _yes_or_enter(r):
    return r.strip() == "" or _yes(r)


# (pattern on the last line of output, rule id, (reply, match) -> True if blocked)
DANGER_PROMPTS = [
    # delete vlan.dat:  "Delete filename [vlan.dat]?"  then  "Delete flash:/vlan.dat? [confirm]"
    (re.compile(r"delete filename \[([^\]]*)\]\?\s*$", re.I), "prompt_confirm",
     lambda r, m: not is_vlan_dat(r.strip() or m.group(1))),
    (re.compile(r"^delete (\S+?)\?\s*\[confirm\]\s*$", re.I), "prompt_confirm",
     lambda r, m: _yes_or_enter(r) and not is_vlan_dat(m.group(1))),
    # 2960 boot loader:  Are you sure you want to delete "flash:vlan.dat" (y/n)?
    (re.compile(r"want to delete \"?([^\"\s]+)\"?\s*\(y/n\)\??\s*$", re.I), "prompt_confirm",
     lambda r, m: _yes(r) and not is_vlan_dat(m.group(1))),
    (re.compile(r"\bsave\b[^\n]*" + _ANSWER, re.I), "prompt_save",
     lambda r, m: _yes(r)),
    (re.compile(r"\bsave\b[^\n]*\[confirm\]\s*$", re.I), "prompt_save",
     lambda r, m: _yes_or_enter(r)),
    (re.compile(r"enter your selection \[2\]:?\s*$", re.I), "prompt_setup",
     lambda r, m: r.strip() in ("", "2")),
    (re.compile(r"use this configuration\?\s*" + _ANSWER, re.I), "prompt_setup",
     lambda r, m: _yes(r)),
    (re.compile(r"destination filename \[(startup-config|nvram:\S*)\]\?\s*$", re.I),
     "prompt_confirm", lambda r, m: True),
    (re.compile(r"(eras|delet|format|overwrit|over write|squeez|remov)[^\n]*"
                r"(\[confirm\]|\[yes/no\]:?|\[y/n\]:?|\(y/n\)\??|\?)\s*$", re.I),
     "prompt_confirm", lambda r, m: _yes_or_enter(r)),
]


def check_reply(question_line: str, reply: str):
    """If the device is asking a dangerous question, check the answer."""
    reply = sanitize_line(reply)
    for rx, rule, blocked in DANGER_PROMPTS:
        m = rx.search(question_line or "")
        if m:
            if blocked(reply, m):
                return _v(rule, reply.strip() or "(Enter)")
            return None
    return None


BANNER_TYPES = {"motd", "login", "exec", "incoming", "slip-ppp",
                "prompt-timeout", "config-save"}


def banner_start_delim(line: str):
    """If line opens a multi-line banner (banner motd #), return its delimiter."""
    rest = _banner_text(line)
    if not rest:
        return None
    return None if rest[0] in rest[1:] else rest[0]


def lines_to_check(lines, delim=None):
    """Every line is checked as a command, even banner text: guessing that a line
    is harmless text is how a pasted 'write memory' could slip through after a
    banner command the device rejected. When a line closes a multi-line banner,
    whatever follows the delimiter is checked as well. Lines are sanitized first,
    so the delimiter is the one the device will see."""
    out = []
    for ln in lines:
        ln = sanitize_line(ln)
        out.append(ln)
        if delim:
            if delim in ln:
                tail = ln.split(delim, 1)[1]
                if tail.strip():
                    out.append(tail)
                delim = None
            continue
        delim = banner_start_delim(ln)
    return out


# ===========================================================================
# Terminal stream handling
# ===========================================================================

ANSI_RE = re.compile(r"\x1b(\[[0-9;?]*[ -/]*[@-~]|[()][A-Za-z0-9]|[=>78DEHMc])")
_PARTIAL_RE = re.compile(r"(\x1b(\[[0-9;?]*[ -/]*|[()])?|\r)$")
PROMPT_RE = re.compile(
    r"^(?:[A-Za-z0-9][\w.\-/@:]*(?:\([\w.\-/: ]+\))?[>#]"
    r"|rommon\s*\d*\s*>|switch\s*:|loader\s*>)$", re.I)
PROMPT_PREFIX_RE = re.compile(r"^[A-Za-z0-9][\w.\-/@:]*(?:\([\w.\-/: ]+\))?[>#]")
ROMMON_RE = re.compile(r"^(rommon\s*\d*\s*>|switch\s*:|loader\s*>)$", re.I)
QUESTION_RE = re.compile(
    r"(\[yes/no\]:?|\[confirm\]|\]\s*\?|\]\s*:|\(y/n\)\??|\?|"
    r"(password|username|secret)\s*:)\s*$", re.I)
PASSWORD_PROMPT_RE = re.compile(r"^(password|enter password|secret)\s*:\s*$", re.I)
MORE_RE = re.compile(r"-+\s*more\s*-+\s*$", re.I)
DEVICE_ERROR_RE = re.compile(
    r"^%\s*(Invalid input|Incomplete command|Ambiguous command|Unrecognized command|"
    r"Unknown command|Invalid |Bad |Error)[^\n]*", re.I | re.M)
# Plain-language hints for common device messages: (key, pattern, hint)
HINT_RULES = [
    ("dns", re.compile(r'Translating "[^"]*"', re.I),
     "The device is looking that word up as a hostname, usually because of a typo at "
     "the prompt. Press Ctrl-Shift-6 to stop it; 'no ip domain lookup' prevents it."),
    ("invalid", re.compile(r"% Invalid input detected|% Unrecognized command", re.I),
     "IOS didn't recognize the command at the ^ marker. Check the spelling and the mode: "
     "R1# is privileged exec, R1(config)# is global config. Type part of the command and "
     "press ? to see the options."),
    ("incomplete", re.compile(r"% Incomplete command", re.I),
     "The command needs more words. Type it again with a space and ? to see what's missing."),
    ("ambiguous", re.compile(r"% Ambiguous command", re.I),
     "That abbreviation matches more than one command. Type a few more letters."),
    ("overlap", re.compile(r"overlaps with", re.I),
     "Another interface on this device already uses that subnet. Each interface needs its "
     "own subnet."),
    ("errdisable", re.compile(r"err-disable|PSECURE_VIOLATION|BLOCK_BPDUGUARD", re.I),
     "A port was err-disabled (port security or BPDU Guard). Fix the cause, then enter "
     "'shutdown' and 'no shutdown' on that port."),
    ("native", re.compile(r"NATIVE_VLAN_MISMATCH", re.I),
     "The two ends of a trunk use different native VLANs. Set the same 'switchport trunk "
     "native vlan' on both ends."),
    ("duplex", re.compile(r"DUPLEX_MISMATCH", re.I),
     "Duplex mismatch: the two ends disagree on half/full duplex. Set both to auto, or "
     "both to the same value."),
    ("macflap", re.compile(r"MACFLAP_NOTIF", re.I),
     "A MAC address keeps moving between two ports. That usually means a switching loop "
     "or a device connected twice; check the cabling and spanning tree."),
    ("ec", re.compile(r"%EC-5-(CANNOT_BUNDLE|L3DONTBNDL)|suspended: incompatible", re.I),
     "An EtherChannel member couldn't join the bundle. Members must match speed, duplex, "
     "switchport mode and VLANs, and the two ends need compatible modes (active/passive, "
     "desirable/auto, on/on)."),
    ("encap", re.compile(r'trunk encapsulation is "Auto"', re.I),
     "This switch needs 'switchport trunk encapsulation dot1q' before 'switchport mode "
     "trunk'. Tick the encapsulation box in the trunk template."),
    ("dhcpsnoop", re.compile(r"DHCP_SNOOPING", re.I),
     "DHCP snooping dropped a DHCP message. The port toward the DHCP server or relay needs "
     "'ip dhcp snooping trust'."),
    ("dai", re.compile(r"%SW_DAI", re.I),
     "Dynamic ARP Inspection dropped an ARP packet. The host needs a DHCP snooping binding "
     "(or an ARP ACL for a static address), and uplinks must be trusted."),
    ("dupaddr", re.compile(r"DUPADDR|duplicate address", re.I),
     "Two devices are using the same IP address. Change one of them."),
    ("ping0", re.compile(r"Success rate is 0 percent", re.I),
     "The ping failed completely. Check the IP addresses and masks on both ends, the hosts' "
     "default gateways, that interfaces are up (show ip interface brief), and that each "
     "router has a route back."),
    ("refused", re.compile(r"Connection refused by remote host", re.I),
     "The remote device refused the connection. On it, check 'transport input ssh', "
     "'login local', the username and the RSA keys (show ip ssh)."),
    ("hostname", re.compile(r"define a hostname other than", re.I),
     "RSA keys need a hostname first: set one with 'hostname', then generate the keys "
     "again."),
    ("domain", re.compile(r"define a domain-name first", re.I),
     "RSA keys need a domain name first ('ip domain name lab.local'), then generate the "
     "keys again."),
    ("rsa_size", re.compile(r"COMPLIANCE_VIOLATION_RSA_KEY_SIZE", re.I),
     "This IOS XE wants an RSA key of 2048 bits or more; from 17.11 on, SSH stays off with "
     "a smaller one. Make a new key with 'crypto key generate rsa general-keys modulus "
     "2048' and answer yes to replace the old one."),
    ("ospf_rid", re.compile(r'use "clear ip ospf process" command', re.I),
     "The new OSPF router ID only applies after 'clear ip ospf process' (answer yes) or "
     "a reload."),
    ("ospf_dup", re.compile(r"DUP_RTRID|duplicate router.?id", re.I),
     "Two OSPF routers have the same router ID. Give each a unique 'router-id', then run "
     "'clear ip ospf process'."),
    ("ospf_mismatch", re.compile(r"OSPF-4-ERRRCV|mismatch(ed)? (area|hello|dead)", re.I),
     "OSPF neighbors must agree on area, subnet, hello/dead timers, network type and "
     "authentication. Compare 'show ip ospf interface' on both ends."),
    ("vtpclient", re.compile(r"not allowed when device is in CLIENT mode", re.I),
     "This switch is a VTP client, so it can't create VLANs. Create them on the VTP server "
     "or change 'vtp mode'."),
    ("hsrp_vip", re.compile(r"(HSRP|STANDBY)-\d-DIFFVIP1", re.I),
     "The HSRP routers in this group have different virtual IPs. Use the same 'standby "
     "<group> ip' address on every router in the group."),
    ("ipv6_llhop", re.compile(r"link-local next-?\s?hop", re.I),
     "A link-local next hop (fe80::) only works with its exit interface, for example: "
     "ipv6 route ::/0 g0/0/1 fe80::2"),
    ("ntp_unsync", re.compile(r"Clock is unsynchronized", re.I),
     "NTP hasn't synced yet; it can take several minutes. Check that the server answers a "
     "ping and shows in 'show ntp associations'. The time source itself needs 'ntp "
     "master' or its own NTP server."),
]


# Hints for a command the device rejected, picked by the echoed command above the
# ^ marker: (key, regex for that command, hint). They replace the general one.
REJECTED_HINTS = [
    ("encap_none", re.compile(r"\btr\w*\s+enc", re.I),
     "This switch supports only 802.1Q trunks, so it has no encapsulation command. "
     "Leave the line out (untick 'Set encapsulation dot1q') and use "
     "'switchport mode trunk'."),
]


def hints_for(text: str):
    """Hints for device output. Lines that start with a prompt are the student's own
    typing echoed back, so they're skipped, except to see which command IOS rejected."""
    raw = text.split("\n")
    lines = [l for l in raw if not PROMPT_PREFIX_RE.match(l.strip())]
    body = "\n".join(lines)
    found = [(key, hint) for key, rx, hint in HINT_RULES if rx.search(body)]
    for i, line in enumerate(raw):
        if i >= 2 and line.strip().startswith("% Invalid input") and raw[i - 1].strip() == "^":
            for key, rx, hint in REJECTED_HINTS:
                if rx.search(raw[i - 2]) and (key, hint) not in found:
                    found = [f for f in found if f[0] != "invalid"] + [(key, hint)]
    return found


BANNER_MSG_RE = re.compile(r"Enter TEXT message\.\s+End with the character '(.)'\.")


class StreamCleaner:
    """Turn raw device output into plain text plus two marks the consumer
    applies: '\\b' erases one character, '\\r' clears the current line."""

    def __init__(self):
        self.carry = ""

    def feed(self, text: str) -> str:
        text = self.carry + text
        self.carry = ""
        m = _PARTIAL_RE.search(text)
        if m and m.group(0):
            self.carry = text[m.start():]
            text = text[:m.start()]
        text = ANSI_RE.sub("", text)
        text = text.replace("\r\n", "\n").replace("\n\r", "\n")
        return re.sub(r"[\x00\x07\x0e\x0f]", "", text)


def apply_marks(buf: str, text: str) -> str:
    for piece in re.split(r"([\b\r])", text):
        if piece == "\b":
            if buf and buf[-1] != "\n":
                buf = buf[:-1]
        elif piece == "\r":
            buf = buf[:buf.rfind("\n") + 1]
        elif piece:
            buf += piece
    return buf


# ===========================================================================
# Transports
# ===========================================================================

PYSERIAL_MISSING = ("Console cables need the pyserial package.\n\n"
                    "Install it with:  pip install pyserial")


# pyserial also opens URLs (socket://host:port and the like), which reach the
# network. Only the tests turn them on, for tests/fake_device.py; everyone else
# gets console ports only.
ALLOW_URL_PORTS = False
URL_PORT_REFUSED = "Slatewise connects through a console port only. Pick one from the list."


class SerialTransport:
    eol = "\r"

    def __init__(self, port: str, baud: int = 9600):
        self.port, self.baud, self.ser = port, baud, None
        self.label = f"{port} @ {baud}"

    def open(self):
        try:
            import serial  # pyserial
        except ImportError:
            raise RuntimeError(PYSERIAL_MISSING) from None
        try:
            if "://" in self.port:
                if not ALLOW_URL_PORTS:
                    raise RuntimeError(URL_PORT_REFUSED)
                # e.g. socket://localhost:2323, the test device in tests/
                self.ser = serial.serial_for_url(self.port, timeout=0.1)
            else:
                self.ser = serial.Serial(self.port, self.baud, bytesize=8, parity="N",
                                         stopbits=1, timeout=0.1, xonxoff=False,
                                         rtscts=False, dsrdtr=False)
        except serial.SerialException as exc:
            msg = str(exc)
            if "denied" in msg.lower() or "busy" in msg.lower():
                raise RuntimeError(f"{self.port} is already open in another program. "
                                   "Close it there and try again.") from None
            raise RuntimeError(f"Couldn't open {self.port}: {msg}") from None

    def read(self) -> bytes:
        n = self.ser.in_waiting
        return self.ser.read(n if n else 1)

    def write(self, data: bytes):
        self.ser.write(data)

    def send_break(self):
        try:
            self.ser.send_break(0.5)
        except (NotImplementedError, AttributeError):
            raise RuntimeError("This connection can't send a break.") from None

    def close(self):
        try:
            self.ser.close()
        except Exception:
            pass


# ===========================================================================
# Session: reader thread, prompt tracking, guarded sending
# ===========================================================================

class Session:
    TAIL_MAX = 4000

    def __init__(self, transport, on_data=None, on_closed=None):
        self.transport = transport
        self.eol = transport.eol
        self.on_data = on_data or (lambda text: None)
        self.on_closed = on_closed or (lambda reason: None)
        self.banner_delim = None
        self._banner_expect = None
        self._dirty = False
        self._capture = None          # output collected for the current batch line
        self.batch_errors = []        # (line, device error) from the last batch
        self.alive = True
        self._cleaner = StreamCleaner()
        self._tail = ""
        self._seq = 0
        self._last_rx = 0.0
        self._cond = threading.Condition()
        self._wlock = threading.Lock()
        self._thread = threading.Thread(target=self._reader, daemon=True)
        self._thread.start()

    # -- receiving ---------------------------------------------------------
    def _reader(self):
        reason = "connection closed"
        try:
            while self.alive:
                data = self.transport.read()
                if not data:
                    continue
                raw = data.decode("utf-8", "replace")
                text = self._cleaner.feed(raw)
                m = BANNER_MSG_RE.search(raw)
                with self._cond:
                    # Only believe banner text mode right after we sent a banner
                    # command, so device output can't fake it.
                    if m and self._banner_expect == m.group(1):
                        self.banner_delim = m.group(1)
                        self._banner_expect = None
                    self._tail = apply_marks(self._tail, text)[-self.TAIL_MAX:]
                    if self._capture is not None:
                        self._capture.append(text)
                    self._seq += 1
                    self._last_rx = time.monotonic()
                    self._cond.notify_all()
                if text:
                    self.on_data(text)
        except Exception as exc:
            reason = str(exc) or exc.__class__.__name__
        finally:
            was_alive, self.alive = self.alive, False
            with self._cond:
                self._cond.notify_all()
            if was_alive:
                self.on_closed(reason)

    def seq(self) -> int:
        with self._cond:
            return self._seq

    def last_line(self, strip: bool = True) -> str:
        with self._cond:
            line = self._tail.rsplit("\n", 1)[-1]
        return line.rstrip() if strip else line

    def prompt(self) -> str:
        line = self.last_line()
        return line if PROMPT_RE.match(line) else ""

    def at_prompt(self) -> bool:
        return bool(self.prompt())

    def help_prompt(self) -> str:
        """The prompt, also while text typed there for '?' or Tab is still on the
        device's line (R1#show ip), so help can be asked again; "" anywhere else."""
        if self.at_prompt():
            return self.prompt()
        m = PROMPT_PREFIX_RE.match(self.last_line(strip=False))
        return m.group(0) if self._dirty and m else ""

    def context(self) -> str:
        return "rommon" if ROMMON_RE.match(self.prompt()) else "ios"

    # -- sending -----------------------------------------------------------
    def write(self, data: bytes):
        with self._wlock:
            self.transport.write(data)

    def _line_prefix(self) -> bytes:
        """Ctrl-U clears anything left on the device's input line (from '?' help or
        Tab completion) so the device runs exactly the checked text. Not sent when
        the device is asking a question, where a stray key could answer it."""
        last = self.last_line()
        more = MORE_RE.search(last)
        if self._dirty:             # we typed partial text at a prompt: always clear
            return b"\x15\x15" if more else b"\x15"
        if self.banner_delim or ROMMON_RE.match(last):
            return b""
        if more:
            return b"\x15\x15"      # the first one ends the --More-- pager
        if PROMPT_PREFIX_RE.match(last) and not QUESTION_RE.search(last):
            return b"\x15"
        return b""

    def send_partial(self, text: str, key: bytes):
        """Type text plus '?' or Tab at the prompt without pressing Enter."""
        self.write(b"\x15" + sanitize_line(text).encode("ascii") + key)
        self._dirty = True

    def send_line(self, text: str):
        text = sanitize_line(text)
        prefix = self._line_prefix()
        self._dirty = False
        in_banner = self.banner_delim is not None
        self._banner_expect = None if in_banner else banner_start_delim(text)
        self.write(prefix + (text + self.eol).encode("ascii"))
        if in_banner and self.banner_delim in text:
            self.banner_delim = None

    def send_break(self):
        with self._wlock:
            self.transport.send_break()

    def wait_ready(self, since: int, timeout: float = 30.0, quiet: float = 0.15,
                   settle: float = 2.0, stop_event=None) -> str:
        """Wait for the device to finish answering. Returns 'prompt', 'question',
        'quiet' (output stopped without a prompt), 'timeout', 'stopped' or 'closed'."""
        deadline = time.monotonic() + timeout
        while True:
            if stop_event is not None and stop_event.is_set():
                return "stopped"
            with self._cond:
                self._cond.wait(0.05)
                alive, seq = self.alive, self._seq
                idle = time.monotonic() - self._last_rx
                last = self._tail.rsplit("\n", 1)[-1].rstrip()
            if not alive:
                return "closed"
            got = seq > since
            if got and idle >= quiet:
                if MORE_RE.search(last):
                    since = seq
                    self.write(b" ")
                    continue
                if PROMPT_RE.match(last):
                    return "prompt"
                if QUESTION_RE.search(last) and idle >= 0.3:
                    return "question"
                if idle >= (0.3 if self.banner_delim else settle):
                    return "quiet"
            if time.monotonic() > deadline:
                return "quiet" if got else "timeout"

    def run_batch(self, lines, stop_event, progress=None, auto_confirm=False,
                  line_timeout=30.0):
        """Send lines one at a time, waiting for the device after each.
        Returns (lines_sent, status): status is 'done', 'stopped', 'question',
        'timeout' or 'closed'. Only instructor macros use auto_confirm. Lines the
        device rejected (% Invalid input...) are listed in self.batch_errors."""
        self.batch_errors = []
        for i, line in enumerate(lines):
            if stop_event.is_set():
                return i, "stopped"
            if not self.alive:
                return i, "closed"
            last = self.last_line()
            # Never let a batch line become the answer to a question the device
            # is asking (Save? [yes/no], Password:, [confirm]...).
            if not auto_confirm and (check_reply(last, line) or (
                    QUESTION_RE.search(last) and not PROMPT_RE.match(last)
                    and not self.banner_delim)):
                return i, "question"
            since = self.seq()
            with self._cond:
                self._capture = []
            self.send_line(line)
            if progress:
                progress(i, line)
            while True:
                status = self.wait_ready(since, timeout=line_timeout,
                                         stop_event=stop_event)
                if (status == "question" and auto_confirm
                        and self.last_line().endswith("[confirm]")):
                    since = self.seq()
                    self.write(self.eol.encode())
                    continue
                break
            with self._cond:
                out, self._capture = "".join(self._capture or []), None
            err = DEVICE_ERROR_RE.search(apply_marks("", out))
            if err:
                self.batch_errors.append((line, err.group(0).strip()))
            if status in ("question", "timeout", "closed", "stopped"):
                return i + 1, status
            time.sleep(0.03)
        return len(lines), "done"

    def close(self):
        self.alive = False
        self.transport.close()


# ===========================================================================
# Practice devices: a simple router and switch inside the program
# ===========================================================================

PRACTICE_PORTS = {"Practice router (no cable needed)": "router",
                  "Practice switch (no cable needed)": "switch"}
PRACTICE_UNMODELED = ("% The practice device doesn't imitate this command. Try it on a "
                      "real device.")

# What the practice devices accept. Only the first word of a line is checked, and the
# second after no, default, ip, ipv6, switchport, spanning-tree and show; the rest is
# kept as typed.
_P_USER = ["connect", "disable", "enable", "exit", "logout", "ping", "show", "ssh",
           "telnet", "terminal", "traceroute"]
_P_EXEC = sorted(set(_P_USER) | {"clear", "clock", "configure", "copy", "debug", "delete",
                                  "dir", "erase", "more", "no", "reload", "setup", "undebug",
                                  "verify", "write"})
_P_COMMON = ["default", "do", "end", "exit", "no"]
_P_WORDS = {
    "config": ["aaa", "access-list", "alias", "archive", "banner", "boot", "cdp",
               "class-map", "clock", "config-register", "crypto", "enable", "errdisable",
               "hostname", "interface", "ip", "ipv6", "key", "line", "lldp", "logging",
               "mac", "mls", "monitor", "ntp", "policy-map", "router", "sdm", "security",
               "service", "snmp-server", "spanning-tree", "username", "vlan", "vtp"],
    "if": ["bandwidth", "cdp", "channel-group", "delay", "description", "duplex",
           "encapsulation", "ip", "ipv6", "keepalive", "lldp", "mdix", "mls", "mtu",
           "negotiation", "power", "service-policy", "shutdown", "spanning-tree", "speed",
           "standby", "storm-control", "switchport", "vrrp"],
    "vlan": ["mtu", "name", "remote-span", "shutdown", "state"],
    "line": ["access-class", "exec-timeout", "history", "length", "logging", "login",
             "password", "privilege", "session-timeout", "speed", "stopbits", "transport",
             "width"],
    "router": ["area", "auto-cost", "auto-summary", "default-information", "distance",
               "eigrp", "log-adjacency-changes", "maximum-paths", "neighbor", "network",
               "passive-interface", "redistribute", "router-id", "timers", "version"],
    "acl": ["deny", "permit", "remark"],
    "dhcp": ["default-router", "dns-server", "domain-name", "lease", "netbios-name-server",
             "network", "option"],
}
_P_MODE = {   # mode: (prompt part, keyword list)
    "config": ("(config)", "config"), "if": ("(config-if)", "if"),
    "subif": ("(config-subif)", "if"), "range": ("(config-if-range)", "if"),
    "vlan": ("(config-vlan)", "vlan"), "line": ("(config-line)", "line"),
    "router": ("(config-router)", "router"), "rtr": ("(config-rtr)", "router"),
    "std-nacl": ("(config-std-nacl)", "acl"), "ext-nacl": ("(config-ext-nacl)", "acl"),
    "dhcp": ("(dhcp-config)", "dhcp"),
}
_P_SUB = {   # (keyword list, keyword): the words that may follow it
    ("config", "ip"): ["access-list", "arp", "cef", "default-gateway", "default-network",
                       "dhcp", "domain", "domain-lookup", "domain-name", "flow", "ftp",
                       "host", "http", "igmp", "local", "multicast-routing", "name-server",
                       "nat", "prefix-list", "route", "routing", "scp", "sla",
                       "source-route", "ssh", "tcp", "tftp"],
    ("if", "ip"): ["access-group", "address", "arp", "dhcp", "directed-broadcast", "flow",
                   "helper-address", "igmp", "mtu", "nat", "ospf", "pim", "policy",
                   "proxy-arp", "redirects", "tcp", "unreachables", "verify",
                   "virtual-reassembly"],
    ("config", "ipv6"): ["access-list", "dhcp", "host", "route", "router",
                         "unicast-routing"],
    ("if", "ipv6"): ["address", "dhcp", "eigrp", "enable", "nd", "ospf", "traffic-filter"],
    ("config", "spanning-tree"): ["backbonefast", "extend", "loopguard", "mode",
                                  "pathcost", "portfast", "uplinkfast", "vlan"],
    ("if", "spanning-tree"): ["bpdufilter", "bpduguard", "cost", "guard", "link-type",
                              "port-priority", "portfast", "vlan"],
    ("if", "switchport"): ["access", "block", "host", "mode", "nonegotiate",
                           "port-security", "priority", "protected", "trunk", "voice"],
}
_P_THIRD = {("switchport", "mode"): ["access", "dynamic", "trunk"],
            ("switchport", "access"): ["vlan"],
            ("switchport", "trunk"): ["allowed", "native", "pruning"],
            ("switchport", "port-security"): ["mac-address", "maximum", "violation"],
            ("ip", "domain"): ["lookup", "name"]}
# Words a 2960 doesn't have, although bigger switches do: switchport trunk
# encapsulation exists only on switches that also run ISL (3560, 3750).
_P_NOT_ON_2960 = {("switchport", "trunk"): ("encapsulation", 1)}
_P_NEEDS_ARG = {"access-class", "access-list", "area", "banner", "channel-group", "crypto",
                "default-router", "description", "dns-server", "deny", "encapsulation",
                "enable", "exec-timeout", "hostname", "interface", "line", "logging",
                "name", "neighbor", "network", "ntp", "passive-interface", "password",
                "permit", "remark", "router", "router-id", "service", "snmp-server",
                "standby", "transport", "username", "vlan"}
_P_SHOW = ["access-lists", "arp", "boot", "cdp", "clock", "controllers", "crypto",
           "debugging", "errdisable", "etherchannel", "flash", "history", "hosts",
           "interfaces", "inventory", "ip", "ipv6", "license", "line", "lldp", "logging",
           "mac", "memory", "ntp", "port-security", "privilege", "processes", "protocols",
           "running-config", "sdm", "spanning-tree", "ssh", "standby", "startup-config",
           "storm-control", "tech-support", "terminal", "users", "version", "vlan", "vtp"]
_P_SHOW_IP = ["access-lists", "arp", "bgp", "dhcp", "eigrp", "interface", "nat", "ospf",
              "protocols", "rip", "route", "ssh"]
# Settings where a new value replaces the old one: how many leading words name them.
_P_ONE_VALUE = {"hostname": 1, "description": 1, "password": 1, "login": 1,
                "exec-timeout": 1, "encapsulation": 1, "speed": 1, "duplex": 1,
                "router-id": 1, "name": 1, "default-router": 1, "dns-server": 1,
                "domain-name": 1, "lease": 1, "bandwidth": 1, "channel-group": 1,
                "ip address": 2, "ip default-gateway": 2, "ip domain-name": 2,
                "switchport mode": 2, "enable secret": 2, "enable password": 2,
                "spanning-tree mode": 2, "transport input": 2, "logging synchronous": 2,
                "username": 2, "switchport access": 3, "switchport trunk": 3,
                "switchport voice": 3, "ip ssh": 3, "ip domain": 3, "standby": 3,
                "ip ospf": 3, "spanning-tree vlan": 3}
_P_IF_TYPES = ["Ethernet", "FastEthernet", "GigabitEthernet", "Loopback", "Port-channel",
               "Serial", "TenGigabitEthernet", "Tunnel", "Vlan"]
_P_PHYSICAL = ("Ethernet", "FastEthernet", "GigabitEthernet", "Serial",
               "TenGigabitEthernet")
_P_XLAT = "dsfd;kfoA,.iyewrkldJKDHSUBsgvca69834ncxv9873254k;fg87"


def _p_match(word, words):
    """The keyword that word abbreviates: (keyword, None) or (None, 'ambiguous' or
    'invalid')."""
    w = word.lower()
    if w in words:
        return w, None
    hits = [k for k in words if k.startswith(w)]
    if len(hits) == 1:
        return hits[0], None
    return None, ("ambiguous" if hits else "invalid")


def _p_ifname(text):
    """'g0/1' -> 'GigabitEthernet0/1', 'vlan 99' -> 'Vlan99', or None."""
    m = re.match(r"^([a-z-]+)\s*(\d+(?:/\d+)*(?:\.\d+)?)$", text.strip(), re.I)
    if not m:
        return None
    t = m.group(1).lower()
    hits = [n for n in _P_IF_TYPES if n.lower() == t] or [
        n for n in _P_IF_TYPES if n.lower().startswith(t)]
    if len(hits) != 1:
        return None
    if hits[0] == "Vlan" and not 1 <= int(m.group(2)) <= 4094:
        return None
    return hits[0] + m.group(2)


def _p_if_range(text):
    """'f0/1 - 4, f0/6' -> the interface names, or None."""
    names = []
    for part in text.split(","):
        m = re.match(r"^\s*([a-z-]+)\s*((?:\d+/)*)(\d+)\s*(?:-\s*(\d+))?\s*$", part, re.I)
        if not m:
            return None
        first = int(m.group(3))
        last = int(m.group(4)) if m.group(4) else first
        if last < first or last - first > 64:
            return None
        for n in range(first, last + 1):
            name = _p_ifname(f"{m.group(1)}{m.group(2)}{n}")
            if not name:
                return None
            names.append(name)
    return names


def _p_vlans(text):
    """'10,20,30-32' -> [10, 20, 30, 31, 32], or None."""
    out = []
    for part in text.split(","):
        m = re.match(r"^\s*(\d+)\s*(?:-\s*(\d+))?\s*$", part)
        if not m:
            return None
        first, last = int(m.group(1)), int(m.group(2) or m.group(1))
        if not 1 <= first <= last <= 4094:
            return None
        out.extend(range(first, last + 1))
    return out


def _p_vlan_text(vids):
    """[1, 10, 20, 21, 22] -> '1,10,20-22'."""
    out, vids = [], sorted(set(vids))
    i = 0
    while i < len(vids):
        j = i
        while j + 1 < len(vids) and vids[j + 1] == vids[j] + 1:
            j += 1
        out.append(str(vids[i]) if i == j else f"{vids[i]}-{vids[j]}")
        i = j + 1
    return ",".join(out)


def _p_key(words):
    low = [w.lower() for w in words]
    if low and low[0] == "no":
        low = low[1:]
    n = _P_ONE_VALUE.get(" ".join(low[:2])) or _P_ONE_VALUE.get(low[0] if low else "")
    return tuple(low[:n]) if n else tuple(low)


def _p_short(name):
    for long, short in (("TenGigabitEthernet", "Te"), ("GigabitEthernet", "Gi"),
                        ("FastEthernet", "Fa"), ("Ethernet", "Et"), ("Port-channel", "Po"),
                        ("Loopback", "Lo"), ("Vlan", "Vl"), ("Serial", "Se"), ("Tunnel", "Tu")):
        if name.startswith(long):
            return short + name[len(long):]
    return name


def _p_secret(line, encrypt):
    """How show running-config prints a line with a password in it."""
    m = re.match(r"^((?:enable|username \S+(?: privilege \d+)?) secret)(?: 0)? (\S+)$", line)
    if m:
        h = hashlib.sha256(m.group(2).encode()).hexdigest()
        return f"{m.group(1)} 5 $1${h[:4]}${h[4:26]}"
    if encrypt:
        m = re.match(r"^((?:enable |username \S+ (?:privilege \d+ )?)?password)(?: 0)? (\S+)$",
                     line)
        if m:
            pw = m.group(2)
            return f"{m.group(1)} 7 02" + "".join(
                f"{ord(ch) ^ ord(_P_XLAT[(2 + i) % len(_P_XLAT)]):02X}"
                for i, ch in enumerate(pw))
    return line


def _p_filter(lines, spec):
    """Apply '| include x', '| exclude x', '| begin x' or '| section x'."""
    parts = spec.split(None, 1)
    if len(parts) < 2:
        return lines
    how = parts[0].lower()
    pattern = parts[1]
    # A repeated group or many repeats can take minutes to match and freeze the
    # window; such a pattern is matched as plain text instead.
    if re.search(r"\)[*+{]", pattern) or len(re.findall(r"[*+{]", pattern)) > 3:
        pattern = re.escape(pattern)
    try:
        rx = re.compile(pattern)
    except re.error:
        return lines
    if "include".startswith(how):
        return [l for l in lines if rx.search(l)]
    if "exclude".startswith(how):
        return [l for l in lines if not rx.search(l)]
    if "begin".startswith(how):
        for i, l in enumerate(lines):
            if rx.search(l):
                return lines[i:]
        return []
    if "section".startswith(how):
        out, keep = [], False
        for l in lines:
            if not l.startswith(" "):
                keep = bool(rx.search(l))
            if keep:
                out.append(l)
        return out
    return lines


class _PError(Exception):
    def __init__(self, kind, pos=0):
        super().__init__(kind)
        self.kind, self.pos = kind, pos


class PracticeDevice:
    """A simple imitation of a Cisco router or switch for students without one. It
    keeps modes, prompts, passwords, the hostname, interfaces and VLANs, answers the
    common show commands and asks the questions IOS asks. Only the first word or two
    of each line are checked; the rest is kept as typed. Nothing is ever kept after
    reset(), like a lab device after a power cycle. events lists what the student has
    done, for the Practice tab's steps."""
    strict = False                   # tests set this to see errors instead of a message

    def __init__(self, kind="router", model="ios"):
        self.kind = kind
        self.model = model           # a key of PRACTICE_MODELS
        self.events = []
        self._lock = threading.Lock()
        self.reset()

    def power_cycle(self) -> str:
        """Start over from defaults (the Practice tab's Reset). Returns what the
        console shows."""
        with self._lock:
            self.events.clear()
            return self.reset()

    def reset(self) -> str:
        sw = self.kind == "switch"
        self.default_name = self.hostname = "Switch" if sw else "Router"
        self.state, self.mode = "idle", "user"
        self.ask = None              # (kind, data) while the device asks a question
        self.cur = []                # what the current config mode is changing
        self.buf = ""
        self.more = None             # lines still to show after --More--
        self.length = 24
        self.last_cr = False
        self.banner_in = None        # (kind, delimiter, lines) while typing a banner
        self.banners = {}
        self.globals = []
        self.sections = {}           # header -> lines: router, ACL, DHCP pool, line
        self.startup = None
        self.changed = False
        self.domain_lookup = True
        self.boot = time.monotonic()
        self.history = []            # exec-mode lines, for show history
        self.vlan_dat = False        # vlan.dat is written by the first VLAN or VTP change
        self.vtp_rev = 0
        self.rsa_bits = 0
        self.vlans = {}
        self.ifs = {}
        if sw:
            self.vlans = {1: "default", 1002: "fddi-default", 1003: "token-ring-default",
                          1004: "fddinet-default", 1005: "trnet-default"}
            if self.model == "xe":                      # Catalyst 9200-24T
                for n in range(1, 25):
                    self._new_if(f"GigabitEthernet1/0/{n}")
                for n in range(1, 5):
                    self._new_if(f"GigabitEthernet1/1/{n}")
            else:                                       # Catalyst 2960-24TT
                for n in range(1, 25):
                    self._new_if(f"FastEthernet0/{n}")
                for n in (1, 2):
                    self._new_if(f"GigabitEthernet0/{n}")
            self._new_if("Vlan1")
            self.ifs["Vlan1"]["shut"] = True
            self.sections = {"line con 0": [], "line vty 0 4": ["login"],
                             "line vty 5 15": ["login"]}
        else:
            names = ([f"GigabitEthernet0/0/{n}" for n in range(4)] if self.model == "xe"
                     else [f"GigabitEthernet0/{n}" for n in range(3)])   # C8200-1N-4T or 2911
            for name in names:
                self._new_if(name)
            self.sections = {"line con 0": [], "line aux 0": [], "line vty 0 4": ["login"]}
        return "\r\n\r\nPress RETURN to get started!\r\n"

    def _new_if(self, name):
        shut = self.kind == "router" and name.startswith(_P_PHYSICAL) and "." not in name
        self.ifs[name] = {"shut": shut, "ip": None, "method": "unset", "lines": [],
                          "swmode": None, "vlan": 1}

    def note(self, event: str):
        self.events.append(event)

    def prompt(self) -> str:
        if self.mode == "user":
            return self.hostname + ">"
        if self.mode == "exec":
            return self.hostname + "#"
        return self.hostname + _P_MODE[self.mode][0] + "#"

    # -- keys ----------------------------------------------------------------
    def feed(self, data: bytes) -> bytes:
        with self._lock:
            out = [self._key(ch) for ch in data.decode("latin-1")]
        return "".join(out).encode("latin-1")

    def _hidden(self):
        return bool(self.ask) and self.ask[0] in ("enable_pw", "login_pw", "login_pw2")

    def _key(self, ch):
        if ch == "\n" and self.last_cr:
            self.last_cr = False
            return ""
        self.last_cr = ch == "\r"
        if self.more is not None:
            return self._more_key(ch)
        if self.ask and self.ask[0] == "confirm" and ch not in "\r\n":
            if ch in "yY":
                return "\r\n" + self._enter("")
            self.ask, self.buf = None, ""         # any other key cancels a [confirm]
            return "\r\n" + self.prompt()
        if ch in "\r\n":
            line, self.buf = self.buf, ""
            return "\r\n" + self._enter(line)
        if ch == "\x03":                          # Ctrl-C
            self.buf = ""
            if self.state == "idle":
                return ""
            if self.ask or self.banner_in:
                self.ask = self.banner_in = None
            elif self.mode not in ("user", "exec"):
                self.mode, self.cur = "exec", []
            self.note("prompt")
            return "\r\n" + self.prompt()
        if ch == "\x1a":                          # Ctrl-Z
            self.buf = ""
            if self.state == "idle" or self.ask or self.mode in ("user", "exec"):
                return ""
            return "^Z\r\n" + self._to_exec() + self.prompt()
        if ch == "\x15":                          # Ctrl-U
            n, self.buf = len(self.buf), ""
            return "" if self._hidden() else "\b \b" * n
        if ch in "\x08\x7f":
            if not self.buf:
                return ""
            self.buf = self.buf[:-1]
            return "" if self._hidden() else "\b \b"
        quiet = self.state == "idle" or self.ask or self.banner_in
        if ch == "?" and not quiet:
            return self._help()
        if ch == "\t":
            return "\x07" if quiet else self._complete()
        if ch < " ":
            return ""
        self.buf += ch
        return "" if self._hidden() else ch

    def _more_key(self, ch):
        erase = "\b" * 10 + " " * 10 + "\b" * 10
        if ch == " ":
            n = max(self.length - 1, 1)
            chunk, self.more = self.more[:n], self.more[n:]
        elif ch in "\r\n":
            chunk, self.more = self.more[:1], self.more[1:]
        else:
            self.more = None
            return erase + self.prompt()
        text = erase + "\r\n".join(chunk)
        if self.more:
            return text + "\r\n --More-- "
        self.more = None
        return text + "\r\n" + self.prompt()

    def _page(self, lines):
        n = self.length - 1 if self.length else 0
        if not n or len(lines) <= n:
            return "".join(l + "\r\n" for l in lines) + self.prompt()
        self.more = lines[n:]
        return "\r\n".join(lines[:n]) + "\r\n --More-- "

    def _stamp(self, msg):
        t = time.monotonic() - self.boot
        h, rest = divmod(int(t), 3600)
        m, s = divmod(rest, 60)
        return f"*Mar  1 {h % 24:02d}:{m:02d}:{s:02d}.{int(t % 1 * 1000):03d}: {msg}\r\n"

    # -- help and Tab --------------------------------------------------------
    def _options(self, words):
        """The keywords that may come after words, or None."""
        if self.mode in ("user", "exec"):
            first = _P_USER if self.mode == "user" else _P_EXEC
        else:
            key = _P_MODE[self.mode][1]
            first = sorted(_P_WORDS[key] + _P_COMMON)
            if words and _p_match(words[0], first)[0] == "do":
                words, first = words[1:], _P_EXEC
        if not words:
            return first
        kw = _p_match(words[0], first)[0]
        if kw == "show":
            return _P_SHOW if len(words) == 1 else (
                _P_SHOW_IP if len(words) == 2 and _p_match(words[1], _P_SHOW)[0] == "ip"
                else None)
        if self.mode in ("user", "exec") or not kw:
            return None
        key = _P_MODE[self.mode][1]
        if kw in ("no", "default"):
            if len(words) == 1:
                return sorted(_P_WORDS[key])
            kw, words = _p_match(words[1], _P_WORDS[key])[0], words[1:]
        sub = _P_SUB.get((key, kw))
        if sub and len(words) == 1:
            return sub
        if sub and len(words) == 2:
            return _P_THIRD.get((kw, _p_match(words[1], sub)[0]))
        return None

    def _help(self):
        words = self.buf.split()
        partial = bool(words) and not self.buf.endswith(" ")
        options = self._options(words[:-1] if partial else words)
        if partial:
            hits = [o for o in options or [] if o.startswith(words[-1].lower())]
            body = "  ".join(hits) if hits else "% Unrecognized command"
        else:
            body = "\r\n".join("  " + o for o in options) if options else "  <cr>"
        return "?\r\n" + body + "\r\n\r\n" + self.prompt() + self.buf

    def _complete(self):
        words = self.buf.split()
        if not words or self.buf.endswith(" "):
            return "\x07"
        hits = [o for o in self._options(words[:-1]) or []
                if o.startswith(words[-1].lower())]
        if len(hits) != 1:
            return "\x07"
        add = hits[0][len(words[-1]):] + " "
        self.buf += add
        return add

    # -- lines ---------------------------------------------------------------
    def _enter(self, line):
        if self.state == "idle":
            return self._wake()
        if self.ask:
            kind, data = self.ask
            self.ask = None
            return self._answer(kind, data, line.strip())
        if self.banner_in:
            return self._banner_line(line)
        words = [(m.start(), m.group()) for m in re.finditer(r"\S+", line)]
        if not words or words[0][1].startswith("!"):
            return self.prompt()
        if self.mode in ("user", "exec"):
            self.history = (self.history + [line.strip()])[-10:]
        try:
            if self.mode in ("user", "exec"):
                return self._exec(line, words)
            return self._config(line, words)
        except _PError as e:
            return self._error(e.kind, e.pos, line)
        except (IndexError, KeyError, ValueError):     # a form this imitation doesn't know
            if self.strict:
                raise
            return PRACTICE_UNMODELED + "\r\n" + self.prompt()

    def _error(self, kind, pos=0, line=""):
        p = self.prompt()
        if kind == "invalid":
            self.note("invalid")
            return " " * (len(p) + pos) + "^\r\n% Invalid input detected at '^' marker.\r\n\r\n" + p
        if kind == "ambiguous":
            return f'% Ambiguous command:  "{line.strip()}"\r\n' + p
        return "% Incomplete command.\r\n\r\n" + p

    def _wake(self):
        self.state = "cli"
        out = ""
        if "motd" in self.banners:
            out += self.banners["motd"].replace("\n", "\r\n") + "\r\n"
        con = self.sections.get("line con 0", [])
        if "login local" in con:
            self.ask = ("login_user", None)
            return out + "\r\nUser Access Verification\r\n\r\nUsername: "
        if "login" in con and self._line_pw(con) is not None:
            self.ask = ("login_pw", 0)
            return out + "\r\nUser Access Verification\r\n\r\nPassword: "
        self.mode = "user"
        self.note("prompt")
        return out + self.prompt()

    def _idle(self):
        self.state, self.mode, self.cur = "idle", "user", []
        return (f"\r\n{self.hostname} con0 is now available\r\n\r\n\r\n\r\n\r\n\r\n"
                "Press RETURN to get started.\r\n\r\n\r\n")

    def _to_exec(self):
        self.mode, self.cur = "exec", []
        return self._stamp("%SYS-5-CONFIG_I: Configured from console by console")

    @staticmethod
    def _line_pw(lines):
        for l in lines:
            if l.startswith("password "):
                return l.split()[-1]
        return None

    def _enable_pw(self):
        for start in ("enable secret ", "enable password "):
            for l in self.globals:
                if l.startswith(start):
                    return l.split()[-1]
        return None

    def _domain(self):
        for l in self.globals:
            m = re.match(r"^ip domain(?: |-)name (\S+)", l)
            if m:
                return m.group(1)
        return None

    def _answer(self, kind, data, ans):
        p = self.prompt
        if kind == "login_user":
            self.ask = ("login_pw2", ans)
            return "Password: "
        if kind == "login_pw2":
            for l in self.globals:
                m = re.match(r"^username (\S+) .*(?:secret|password) (\S+)$", l)
                if m and m.group(1) == data and m.group(2) == ans:
                    self.mode = "user"
                    self.note("prompt")
                    return p()
            self.ask = ("login_user", None)
            return "% Login invalid\r\n\r\nUsername: "
        if kind == "login_pw":
            if ans == self._line_pw(self.sections.get("line con 0", [])):
                self.mode = "user"
                self.note("prompt")
                return p()
            if data >= 2:
                return "% Bad passwords\r\n" + self._idle()
            self.ask = ("login_pw", data + 1)
            return "Password: "
        if kind == "enable_pw":
            if ans == self._enable_pw():
                self.mode = "exec"
                self.note("exec")
                return p()
            if data >= 2:
                return "% Bad secrets\r\n\r\n" + p()
            self.ask = ("enable_pw", data + 1)
            return "Password: "
        if kind == "config_from":
            if not ans or "terminal".startswith(ans.lower()):
                return self._enter_config()
            return PRACTICE_UNMODELED + "\r\n" + p()
        if kind == "save":
            a = ans.lower()
            if a and ("yes".startswith(a) or "no".startswith(a)):
                self.ask = ("confirm", "reload")
                if "yes".startswith(a):
                    self._save()
                    return "Building configuration...\r\n[OK]\r\nProceed with reload? [confirm]"
                return "Proceed with reload? [confirm]"
            self.ask = ("save", None)
            return ("% Please answer 'yes' or 'no'.\r\n"
                    "System configuration has been modified. Save? [yes/no]: ")
        if kind == "confirm":
            return self._confirmed(data)
        if kind == "delname":
            self.ask = ("confirm", "delete " + (ans or data))
            return f"Delete flash:/{ans or data}? [confirm]"
        if kind == "copydest":
            if not ans or ans == "startup-config":
                self._save()
                return "Building configuration...\r\n[OK]\r\n" + p()
            return PRACTICE_UNMODELED + "\r\n" + p()
        if kind == "modulus":
            return self._keys(ans or "512") + p()
        return p()

    def _confirmed(self, action):
        if action == "reload":
            text = self._stamp("%SYS-5-RELOAD: Reload requested by console. Reload "
                               "Reason: Reload Command.")
            return text + self.reset()
        if action == "erase":
            self.startup = None
            return "[OK]\r\nErase of nvram: complete\r\n" + self.prompt()
        name = action.split(" ", 1)[1]
        if self.kind == "switch" and name == "vlan.dat" and self.vlan_dat:
            self.vlan_dat = False
            return self.prompt()
        return (f"%Error deleting flash:/{name} (No such file or directory)\r\n\r\n"
                + self.prompt())

    def _save(self):
        self.startup = self._running()[2:]
        self.changed = False

    # -- exec mode -----------------------------------------------------------
    def _translate(self, word):
        lookup = "...domain server (255.255.255.255)" if self.domain_lookup else ""
        return (f'Translating "{word}"{lookup}\r\n% Unknown command or computer name, or '
                "unable to find computer address\r\n" + self.prompt())

    def _exec(self, line, words, from_config=False):
        p = self.prompt
        pos, first = words[0]
        kw, err = _p_match(first, _P_USER if self.mode == "user" else _P_EXEC)
        if err == "ambiguous" or (err and from_config):
            raise _PError(err, pos)
        if err:
            if len(words) > 1:
                raise _PError("invalid", words[1][0])
            return self._translate(first)
        args = [w for _, w in words[1:]]
        low = [a.lower() for a in args]
        if kw == "enable":
            if self.mode != "user":
                return p()
            if self._enable_pw() is not None:
                self.ask = ("enable_pw", 0)
                return "Password: "
            self.mode = "exec"
            self.note("exec")
            return p()
        if kw == "disable":
            if not from_config:
                self.mode = "user"
            return p()
        if kw in ("exit", "logout"):
            return p() if from_config else self._idle()
        if kw == "configure":
            if from_config:
                raise _PError("invalid", pos)
            if not args:
                self.ask = ("config_from", None)
                return "Configuring from terminal, memory, or network [terminal]? "
            sub, err = _p_match(args[0], ["confirm", "memory", "network", "overwrite-network",
                                          "replace", "revert", "terminal"])
            if err:
                raise _PError(err, words[1][0])
            if sub == "terminal":
                return self._enter_config()
            return PRACTICE_UNMODELED + "\r\n" + p()
        if kw == "show":
            return self._show(line, words[1:])
        if kw == "terminal":
            if len(low) > 1 and "length".startswith(low[0]) and low[1].isdigit():
                self.length = int(low[1])
            return p()
        if kw == "ping":
            return self._ping(args)
        if kw == "write":
            sub = low[0] if low else "memory"
            if "memory".startswith(sub):
                self._save()
                return "Building configuration...\r\n[OK]\r\n" + p()
            if "erase".startswith(sub):
                self.ask = ("confirm", "erase")
                return ("Erasing the nvram filesystem will remove all configuration files! "
                        "Continue? [confirm]")
            if "terminal".startswith(sub):
                return self._page(self._running())
            raise _PError("invalid", words[1][0])
        if kw == "copy":
            if (len(low) >= 2 and "running-config".startswith(low[0])
                    and "startup-config".startswith(low[1])):
                self.ask = ("copydest", None)
                return "Destination filename [startup-config]? "
            return PRACTICE_UNMODELED + "\r\n" + p()
        if kw == "erase":
            if low and (low[0] == "nvram:" or "startup-config".startswith(low[0])):
                self.ask = ("confirm", "erase")
                return ("Erasing the nvram filesystem will remove all configuration files! "
                        "Continue? [confirm]")
            return PRACTICE_UNMODELED + "\r\n" + p()
        if kw == "delete":
            if not args:
                raise _PError("incomplete")
            target = args[-1].split(":")[-1].lstrip("/")
            if "/force" in low:
                return self._confirmed("delete " + target)
            self.ask = ("delname", target)
            return f"Delete filename [{target}]? "
        if kw == "reload":
            if args:
                return PRACTICE_UNMODELED + "\r\n" + p()
            if self.changed:
                self.ask = ("save", None)
                return "\r\nSystem configuration has been modified. Save? [yes/no]: "
            self.ask = ("confirm", "reload")
            return "Proceed with reload? [confirm]"
        if kw == "clear":
            return p()
        if kw == "undebug" or kw == "no" and low and "debug".startswith(low[0]):
            return "All possible debugging has been turned off\r\n" + p()
        if kw == "dir" and (not low or low[0].startswith(("flash", "/all"))):
            return self._page(self._dir())
        return PRACTICE_UNMODELED + "\r\n" + p()

    def _enter_config(self):
        self.mode = "config"
        self.note("config")
        return "Enter configuration commands, one per line.  End with CNTL/Z.\r\n" + self.prompt()

    def _show(self, line, words):
        pipe = None
        for i, (_, w) in enumerate(words):
            if w.startswith("|"):
                pipe = line[words[i][0] + 1:].strip()
                words = words[:i]
                break
        if not words:
            return '% Type "show ?" for a list of subcommands\r\n' + self.prompt()
        pos, w = words[0]
        if w.lower().startswith("flash") and w.endswith(":"):        # show flash:
            w = w[:-1]
        options = _P_SHOW if self.mode != "user" else [
            s for s in _P_SHOW if s not in ("running-config", "startup-config", "tech-support")]
        if len(w) > 4 and "mac-address-table".startswith(w.lower()):    # the older spelling
            sub, err = "mac-address-table", None
        else:
            sub, err = _p_match(w, options)
        if err:
            raise _PError(err, pos)
        sw = self.kind == "switch"
        if sub == "vlan" and not sw:
            raise _PError("invalid", pos)
        rest = words[1:]
        low = [x.lower() for _, x in rest]
        lines = None
        if sub == "running-config":
            self.note("show_run")
            lines = self._running_if(rest) if low and "interface".startswith(low[0]) \
                else self._running()
        elif sub == "startup-config":
            lines = (["Using 1234 out of 262144 bytes"] + self.startup if self.startup
                     else ["startup-config is not present"])
        elif sub == "ip":
            if not rest:
                raise _PError("incomplete")
            sub2, err = _p_match(rest[0][1], _P_SHOW_IP)
            if err:
                raise _PError(err, rest[0][0])
            more = low[1:]
            if sub2 == "interface" and more and "brief".startswith(more[0]):
                self.note("show_ip_brief")
                lines = self._ip_brief()
            elif sub2 == "route":
                lines = self._ip_route()
            elif sub2 == "access-lists":
                lines = self._acls([x for _, x in rest[1:]])
            elif sub2 == "protocols":
                lines = self._ip_protocols()
            elif sub2 == "ospf" and more and "neighbor".startswith(more[0]):
                lines = self._ospf_neighbors()
            elif sub2 == "dhcp" and more and "binding".startswith(more[0]):
                lines = ["Bindings from all pools not associated with VRF:",
                         "IP address      Client-ID/              Lease expiration        "
                         "Type       State      Interface",
                         "                Hardware address/", "                User name"]
            elif sub2 == "nat" and more and "translations".startswith(more[0]):
                lines = self._nat_translations()
            elif sub2 == "nat" and more and "statistics".startswith(more[0]):
                lines = self._nat_statistics()
            elif sub2 == "ssh":
                lines = self._ip_ssh()
        elif sub == "vlan" and (not rest or "brief".startswith(low[0])):
            self.note("show_vlan")
            lines = self._vlan_brief()
        elif sub == "version":
            lines = self._version()
        elif sub == "clock":
            lines = [self._clock()]
        elif sub == "history":
            lines = ["  " + h for h in self.history]
        elif sub == "users":
            lines = self._users()
        elif sub == "flash":
            lines = self._dir()
        elif sub == "interfaces":
            lines = self._show_interfaces(rest)
        elif sub == "access-lists":
            lines = self._acls([x for _, x in rest])
        elif sub == "cdp":
            lines = self._cdp(low)
        elif sub == "ssh":
            lines = ["%No SSHv2 server connections running."]
        elif sub == "standby" and low and "brief".startswith(low[0]):
            lines = self._standby_brief()
        elif sub == "etherchannel" and low and "summary".startswith(low[0]):
            lines = self._etherchannel()
        elif sw and sub == "mac" and low and "address-table".startswith(low[0]):
            lines = self._mac_table(low[1:])
        elif sw and sub == "mac-address-table":
            lines = self._mac_table(low)
        elif sw and sub == "spanning-tree":
            lines = self._stp(low)
        elif sw and sub == "port-security":
            lines = self._port_security(rest)
        elif sw and sub == "vtp" and low and "status".startswith(low[0]):
            lines = self._vtp()
        if lines is None:
            return PRACTICE_UNMODELED + "\r\n" + self.prompt()
        if pipe:
            lines = _p_filter(lines, pipe)
        return self._page(lines)

    def _ping(self, args):
        p = self.prompt()
        if not args:
            return PRACTICE_UNMODELED + "\r\n" + p
        try:
            addr = str(ipaddress.IPv4Address(args[0]))
        except ValueError:
            lookup = (f'Translating "{args[0]}"...domain server (255.255.255.255)\r\n'
                      if self.domain_lookup else "")
            return lookup + "% Unrecognized host or address, or protocol not running.\r\n\r\n" + p
        head = (f"Type escape sequence to abort.\r\nSending 5, 100-byte ICMP Echos to {addr}, "
                "timeout is 2 seconds:\r\n")
        if any(i["ip"] and i["ip"][0] == addr and self._status(n) == ("up", "up")
               for n, i in self.ifs.items()):
            return (head + "!!!!!\r\nSuccess rate is 100 percent (5/5), round-trip "
                    "min/avg/max = 1/1/1 ms\r\n" + p)
        return head + ".....\r\nSuccess rate is 0 percent (0/5)\r\n" + p

    # -- configuration -------------------------------------------------------
    def _parse(self, words, mode):
        """Check the first words of a config line for this mode. Returns the keywords
        they stand for, or raises _PError."""
        key = _P_MODE[mode][1]
        allowed = _P_WORDS[key] + _P_COMMON
        if key == "acl" and words[0][1].isdigit():           # a sequence number
            return [words[0][1]]
        pos, w = words[0]
        kw, err = _p_match(w, allowed)
        if err:
            raise _PError(err, pos)
        kws, i = [kw], 0
        if kw in ("no", "default"):
            if len(words) < 2:
                raise _PError("incomplete")
            pos, w = words[1]
            kw, err = _p_match(w, _P_WORDS[key])
            if err:
                raise _PError(err, pos)
            kws.append(kw)
            i = 1
        sub = _P_SUB.get((key, kw))
        if sub:
            if len(words) <= i + 1:
                raise _PError("incomplete")
            pos, w = words[i + 1]
            kw2, err = _p_match(w, sub)
            if err:
                raise _PError(err, pos)
            kws.append(kw2)
            lacks = _P_NOT_ON_2960.get((kw, kw2))
            if (lacks and len(words) > i + 2
                    and _abbr(words[i + 2][1].lower(), *lacks)):
                raise _PError("invalid", words[i + 2][0])
            third = _P_THIRD.get((kw, kw2))
            if third and len(words) > i + 2:
                kw3 = _p_match(words[i + 2][1], third)[0]
                if kw3:
                    kws.append(kw3)
        elif kw in _P_NEEDS_ARG and len(words) <= i + 1 and i == 0:
            raise _PError("incomplete")
        return kws

    def _config(self, line, words):
        prev = (self.mode, self.cur)
        try:
            kws = self._parse(words, self.mode)
        except _PError as sub_err:
            if self.mode == "config":
                raise
            # IOS also tries a line as a global command, and leaves the submode if it is
            try:
                kws = self._parse(words, "config")
            except _PError as global_err:
                # the ^ goes where the line got furthest
                raise max(sub_err, global_err, key=lambda e: e.pos) from None
            self.mode, self.cur = "config", []
        if kws[0] == "do":
            if len(words) < 2:
                raise _PError("incomplete")
            return self._exec(line, words[1:], from_config=True)
        before = {n: (self._status(n), i["ip"]) for n, i in self.ifs.items()}
        try:
            out = self._apply(kws, words, line)
        except _PError:
            # a rejected line leaves the device in the mode it was in
            self.mode, self.cur = prev
            raise
        if self.ask or self.banner_in:
            return out
        out += self._changes(before)
        return out + self.prompt()

    def _changes(self, before):
        """Log messages for interfaces whose state changed, like IOS prints them."""
        out = ""
        for name, i in self.ifs.items():
            new = self._status(name)
            old = before[name][0] if name in before else None
            if i["ip"] and new == ("up", "up") and (
                    not before.get(name) or before[name] != (new, i["ip"])):
                self.note("if_up")
            if old == new or (old is None and new[1] != "up"):
                continue
            old = old or ("down", "down")
            if new[0] != old[0]:
                out += self._stamp(
                    f"%LINK-5-CHANGED: Interface {name}, changed state to administratively down"
                    if new[0] == "administratively down"
                    else f"%LINK-3-UPDOWN: Interface {name}, changed state to {new[0]}")
            if new[1] != old[1]:
                out += self._stamp(f"%LINEPROTO-5-UPDOWN: Line protocol on Interface {name}, "
                                   f"changed state to {new[1]}")
        return out

    def _store(self, lines, kws, args):
        """Keep a config line for show running-config, replacing an older value."""
        neg = kws[0] in ("no", "default")
        words = (kws[1:] if neg else kws) + args
        key = _p_key(words)
        old = [l for l in lines if _p_key(l.split()) == key]
        for l in old:
            lines.remove(l)
        if not neg:
            lines.append(" ".join(words))
        elif kws[0] == "no" and all(l.startswith("no ") for l in old):
            lines.append("no " + " ".join(words))

    def _apply(self, kws, words, line):
        """Carry out a checked config line. Returns any text to show before the prompt."""
        self.changed = True
        neg = kws[0] in ("no", "default")
        body = kws[1:] if neg else kws
        kw = body[0]
        args = [w for _, w in words[len(kws):]]
        apos = words[len(kws)][0] if len(words) > len(kws) else len(line)
        if kw == "end":
            return self._to_exec()
        if kw == "exit":
            if self.mode == "config":
                return self._to_exec()
            self.mode, self.cur = "config", []
            return ""
        if self.mode == "config":
            return self._global(kws, kw, neg, args, apos, words, line)
        if self.mode in ("if", "subif", "range"):
            return self._interface_cmd(kws, body, neg, args, apos)
        if self.mode == "vlan":
            if kw == "name":
                self._vlan_changed()
                for v in self.cur:
                    self.vlans[v] = args[0] if args and not neg else f"VLAN{v:04d}"
                if not neg and any(v not in (1, 1002, 1003, 1004, 1005) for v in self.cur):
                    self.note("vlan_named")
            return ""
        self._store(self.sections.setdefault(self.cur[0], []), kws, args)
        return ""

    def _global(self, kws, kw, neg, args, apos, words, line):
        sub = kws[2] if neg and len(kws) > 2 else (kws[1] if len(kws) > 1 else "")
        low = [a.lower() for a in args]
        if kw == "hostname":
            self.hostname = args[0] if args and not neg else self.default_name
            if self.hostname != self.default_name:
                self.note("hostname")
            return ""
        if kw == "interface":
            return self._interface(kws, args, apos)
        if kw == "vlan":
            vids = _p_vlans("".join(args))
            if vids is None:
                raise _PError("invalid", apos)
            if neg:
                for v in vids:
                    if v == 1 or 1002 <= v <= 1005:
                        return f"%Default VLAN {v} may not be deleted.\r\n"
                if any(v in self.vlans for v in vids):
                    self._vlan_changed()
                for v in vids:
                    self.vlans.pop(v, None)
                return ""
            if any(v not in self.vlans for v in vids):
                self._vlan_changed()
            for v in vids:
                self.vlans.setdefault(v, f"VLAN{v:04d}")
            self.mode, self.cur = "vlan", vids
            return ""
        if kw == "vtp":
            self.vlan_dat = True
        if kw == "line":
            if not args:
                raise _PError("incomplete")
            t, err = _p_match(args[0], ["aux", "console", "vty"])
            if err:
                raise _PError(err, apos)
            nums = args[1:]
            if t == "vty" and not nums:
                raise _PError("incomplete")
            header = {"aux": "line aux 0", "console": f"line con {nums[0] if nums else 0}",
                      "vty": "line vty " + " ".join(nums)}[t]
            self.sections.setdefault(header, ["login"] if t == "vty" else [])
            self.mode, self.cur = "line", [header]
            return ""
        header, mode = None, None
        if kw == "router":
            proto = _p_match(args[0], ["bgp", "eigrp", "isis", "ospf", "rip"])[0] if args else None
            header, mode = "router " + " ".join([proto] + args[1:] if proto else args), "router"
        elif kw == "ip" and sub == "access-list" and args and _p_match(
                args[0], ["extended", "standard"])[0]:
            acl = _p_match(args[0], ["extended", "standard"])[0]
            header, mode = f"ip access-list {acl} {' '.join(args[1:])}", "std-nacl" if acl == "standard" else "ext-nacl"
        elif kw == "ip" and sub == "dhcp" and low[:1] == ["pool"]:
            header, mode = "ip dhcp pool " + " ".join(args[1:]), "dhcp"
        elif kw == "ipv6" and sub == "router":
            header, mode = "ipv6 router " + " ".join(args), "rtr"
        if header:
            if neg:
                self.sections.pop(header, None)
                return ""
            self.sections.setdefault(header, [])
            self.mode, self.cur = mode, [header]
            return ""
        if kw == "banner":
            return self._banner(neg, args, words, line)
        if kw == "crypto" and len(low) >= 2 and "key".startswith(low[0]) \
                and "generate".startswith(low[1]):
            return self._key_cmd(low)
        if kw == "ip" and sub == "domain" and ("lookup" in kws or "lookup" in low[:1]) \
                or kw == "ip" and sub == "domain-lookup":
            self.domain_lookup = not neg
        self._store(self.globals, kws, args)
        return ""

    def _interface(self, kws, args, apos):
        if not args:
            raise _PError("incomplete")
        if kws[0] == "default":
            name = _p_ifname("".join(args))
            if name not in self.ifs:
                raise _PError("invalid", apos)
            self.ifs[name]["lines"] = []
            self.ifs[name].update(ip=None, swmode=None, vlan=1)
            return f"Interface {name} set to default configuration\r\n"
        if args[0].lower() != "range" and "range".startswith(args[0].lower()):
            args = ["range"] + args[1:]
        if args[0].lower() == "range":
            names = _p_if_range(" ".join(args[1:]))
            mode = "range"
        else:
            name = _p_ifname(" ".join(args))
            names = [name] if name else None
            mode = "subif" if name and "." in name else "if"
        if not names:
            raise _PError("invalid", apos)
        if kws[0] == "no":
            for n in names:
                if not n.startswith(_P_PHYSICAL) or "." in n:
                    self.ifs.pop(n, None)
            return ""
        for n in names:
            if n not in self.ifs:
                self._new_if(n)
        self.mode, self.cur = mode, names
        return ""

    def _interface_cmd(self, kws, body, neg, args, apos):
        kw = body[0]
        sub = body[1] if len(body) > 1 else ""
        out = ""
        if kw == "shutdown":
            for n in self.cur:
                self.ifs[n]["shut"] = not neg
            return ""
        if kw == "ip" and sub == "address":
            if neg:
                for n in self.cur:
                    self.ifs[n]["ip"] = None
            elif args and args[0].lower() == "dhcp":
                for n in self.cur:
                    self.ifs[n].update(ip=None, method="DHCP")
            else:
                err = self._set_ip(args, apos)
                if err:
                    return err
        if kw == "switchport" and sub == "mode":
            mode = None if neg else (body[2] if len(body) > 2 else None)
            for n in self.cur:
                self.ifs[n]["swmode"] = mode
        if kw == "switchport" and sub == "access" and len(body) > 2:
            vid = 1
            if not neg:
                vids = _p_vlans(args[0]) if args else None
                if not vids or len(vids) != 1:
                    raise _PError("invalid", apos)
                vid = vids[0]
                if self.kind == "switch" and vid not in self.vlans:
                    self._vlan_changed()
                    self.vlans[vid] = f"VLAN{vid:04d}"
                    out += f"% Access VLAN does not exist. Creating vlan {vid}\r\n"
                if vid != 1:
                    self.note("port_vlan")
            for n in self.cur:
                self.ifs[n]["vlan"] = vid
        if kw == "channel-group" and not neg and args and args[0].isdigit():
            pc = "Port-channel" + args[0]
            if pc not in self.ifs:
                self._new_if(pc)
                out += f"Creating a port-channel interface Port-channel {args[0]}\r\n"
        for n in self.cur:
            self._store(self.ifs[n]["lines"], kws, args)
        return out

    def _set_ip(self, args, apos):
        if len(args) < 2:
            raise _PError("incomplete")
        try:
            addr = ipaddress.IPv4Address(args[0])
        except ValueError:
            raise _PError("invalid", apos) from None
        try:
            mask = int(ipaddress.IPv4Address(args[1]))
        except ValueError:
            raise _PError("invalid", apos + len(args[0]) + 1) from None
        inv = mask ^ 0xFFFFFFFF
        if inv & (inv + 1):
            return f"% Bad mask 0x{mask:X} for address {addr}\r\n"
        net = ipaddress.IPv4Network(f"{addr}/{args[1]}", strict=False)
        if net.prefixlen < 31 and addr in (net.network_address, net.broadcast_address):
            return f"% Bad mask /{net.prefixlen} for address {addr}\r\n"
        for other, i in self.ifs.items():
            if other in self.cur or not i["ip"]:
                continue
            if net.overlaps(ipaddress.IPv4Network("/".join(i["ip"]), strict=False)):
                return f"% {net.network_address} overlaps with {other}\r\n"
        for n in self.cur:
            self.ifs[n].update(ip=(str(addr), args[1]), method="manual")
        return ""

    def _banner(self, neg, args, words, line):
        kinds = ["exec", "incoming", "login", "motd", "prompt-timeout", "slip-ppp"]
        n = 2 if neg else 1                     # words[n] is where the banner starts
        kind = _p_match(args[0], kinds)[0] if args else None
        if kind:
            n += 1
        kind = kind or "motd"
        if neg:
            self.banners.pop(kind, None)
            return ""
        if len(words) <= n:
            raise _PError("incomplete")
        raw = line[words[n][0]:]
        delim, body = raw[0], raw[1:]
        if delim in body:
            self.banners[kind] = body[:body.index(delim)]
            return ""
        self.banner_in = (kind, delim, [body] if body else [])
        return f"Enter TEXT message.  End with the character '{delim}'.\r\n"

    def _banner_line(self, line):
        kind, delim, lines = self.banner_in
        if delim not in line:
            lines.append(line)
            return ""
        lines.append(line[:line.index(delim)])
        self.banners[kind] = "\n".join(lines)
        self.banner_in = None
        return self.prompt()

    def _key_cmd(self, low):
        if self.hostname == self.default_name:
            return f"% Please define a hostname other than {self.default_name}.\r\n"
        if not self._domain():
            return "% Please define a domain-name first.\r\n"
        out = f"The name for the keys will be: {self.hostname}.{self._domain()}\r\n"
        if "modulus" in low[:-1]:
            return out + self._keys(low[low.index("modulus") + 1])
        self.ask = ("modulus", None)
        return out + ("Choose the size of the key modulus in the range of 360 to 4096 for "
                      "your\r\n  General Purpose Keys. Choosing a key modulus greater than "
                      "512 may take\r\n  a few minutes.\r\n\r\nHow many bits in the modulus "
                      "[512]: ")

    def _keys(self, bits):
        self.rsa_bits = int(bits) if bits.isdigit() else 512
        return (f"% The key modulus size is {bits} bits\r\n% Generating {bits} bit RSA keys, "
                "keys will be non-exportable...\r\n[OK] (elapsed time was 1 seconds)\r\n"
                + self._stamp("%SSH-5-ENABLED: SSH 1.99 has been enabled"))

    # -- interface state and show output ----------------------------------------
    def _physical(self, name):
        return name.startswith(_P_PHYSICAL) and "." not in name

    def _status(self, name):
        i = self.ifs[name]
        if i["shut"]:
            return ("administratively down", "down")
        if name.startswith("Vlan"):
            vid = int(name[4:])
            up = vid in self.vlans and any(
                self._physical(p) and not j["shut"] and "no switchport" not in j["lines"]
                and (j["swmode"] == "trunk" or j["vlan"] == vid)
                for p, j in self.ifs.items())
            return ("up", "up" if up else "down")
        if "." in name:
            parent = self.ifs.get(name.split(".")[0])
            return ("down", "down") if parent and parent["shut"] else ("up", "up")
        if name.startswith("Port-channel"):
            num = name[len("Port-channel"):]
            members = any(not j["shut"] and any(l.lower().startswith(f"channel-group {num} ")
                                                for l in j["lines"])
                          for j in self.ifs.values())
            return ("up", "up") if members else ("down", "down")
        if name.startswith("Tunnel"):
            return ("up", "down")
        return ("up", "up")

    def _if_order(self, brief=False):
        def key(n):
            t = re.match(r"[A-Za-z-]+", n).group(0)
            nums = tuple(int(x) for x in re.findall(r"\d+", n))
            return (0 if brief and self.kind == "switch" and t == "Vlan" else 1, t, nums)
        return sorted(self.ifs, key=key)

    def _ip_brief(self):
        rows = [f"{'Interface':<23}{'IP-Address':<16}{'OK?':<4}{'Method':<7}{'Status':<22}"
                "Protocol"]
        for name in self._if_order(brief=True):
            i = self.ifs[name]
            status, proto = self._status(name)
            ip = i["ip"][0] if i["ip"] else "unassigned"
            rows.append(f"{name:<22} {ip:<15} YES {i['method']:<6} {status:<21} {proto}")
        return rows

    def _ip_route(self):
        routes = [l.split() for l in self.globals if l.startswith("ip route ")]
        if self.kind == "switch" and "ip routing" not in self.globals:
            gw = next((l.split()[2] for l in self.globals
                       if l.startswith("ip default-gateway ")), None)
            return [f"Default gateway is {gw}" if gw else "Default gateway is not set", "",
                    "Host               Gateway           Last Use    Total Uses  Interface",
                    "ICMP redirect cache is empty"]
        entries = []
        for name in self._if_order():
            i = self.ifs[name]
            if i["ip"] and self._status(name) == ("up", "up"):
                net = ipaddress.IPv4Network("/".join(i["ip"]), strict=False)
                entries.append((net, f"C        {net} is directly connected, {name}"))
                entries.append((ipaddress.IPv4Network(i["ip"][0] + "/32"),
                                f"L        {i['ip'][0]}/32 is directly connected, {name}"))
        default = None
        for r in routes:
            try:
                net = ipaddress.IPv4Network(f"{r[2]}/{r[3]}", strict=False)
            except (ValueError, IndexError):
                continue
            via = r[4] if len(r) > 4 else ""
            dist = r[5] if len(r) > 5 and r[5].isdigit() else "1"
            how = (f"[{dist}/0] via {via}" if re.match(r"^\d+\.", via)
                   else f"is directly connected, {via}")
            code = "S*" if net.prefixlen == 0 else "S"
            if net.prefixlen == 0:
                default = via
            entries.append((net, f"{code:<9}{net} {how}"))
        entries.sort(key=lambda e: (int(e[0].network_address), e[0].prefixlen))
        return (["Codes: L - local, C - connected, S - static, R - RIP, O - OSPF, "
                 "* - candidate default", ""]
                + [f"Gateway of last resort is {default} to network 0.0.0.0" if default
                   else "Gateway of last resort is not set", ""]
                + [e[1] for e in entries])

    def _vlan_brief(self):
        rows = ["", "VLAN Name                             Status    Ports",
                "---- -------------------------------- --------- -------------------------------"]
        for vid in sorted(self.vlans):
            ports = [_p_short(p) for p in self._if_order()
                     if self._physical(p) and self.ifs[p]["vlan"] == vid
                     and self.ifs[p]["swmode"] != "trunk"
                     and "no switchport" not in self.ifs[p]["lines"]]
            chunks = [", ".join(ports[i:i + 4]) for i in range(0, len(ports), 4)] or [""]
            status = "act/unsup" if 1002 <= vid <= 1005 else "active"
            rows.append(f"{vid:<4} {self.vlans[vid][:32]:<32} {status:<9} {chunks[0]}".rstrip())
            rows += [" " * 48 + c for c in chunks[1:]]
        return rows

    def _running(self):
        enc = "service password-encryption" in self.globals
        late = ("ip route", "ipv6 route", "ip default-gateway", "access-list", "logging",
                "ntp", "snmp-server", "ip nat inside source", "ip nat pool")
        g = [_p_secret(l, enc) for l in self.globals]
        services = [l for l in g if l.startswith(("service ", "no service "))]
        early = [l for l in g if l not in services and not l.startswith(late)]
        tail = [l for l in g if l.startswith(late)]
        body = ["!", "version 17.9" if self.model == "xe" else "version 15.2"] + services + ["!", f"hostname {self.hostname}", "!"]
        body += early + (["!"] if early else [])
        for name in self._if_order():
            i = self.ifs[name]
            body.append(f"interface {name}")
            body += [" " + _p_secret(l, enc) for l in i["lines"]]
            if not any(l.startswith("ip address") for l in i["lines"]) and (
                    self.kind == "router" or name.startswith("Vlan")
                    or "no switchport" in i["lines"]):
                body.append(" no ip address")
            if i["shut"]:
                body.append(" shutdown")
            body.append("!")
        for header, lines in self.sections.items():
            if not header.startswith("line "):
                body += [header] + [" " + _p_secret(l, enc) for l in lines] + ["!"]
        body += tail + (["!"] if tail else [])
        for kind, text in self.banners.items():
            body += f"banner {kind} ^C{text}^C".split("\n")
        body.append("!")
        for header, lines in self.sections.items():
            if header.startswith("line "):
                body += [header] + [" " + _p_secret(l, enc) for l in lines]
        body += ["!", "end"]
        size = sum(len(l) + 1 for l in body)
        return ["Building configuration...", "", f"Current configuration : {size} bytes"] + body

    # -- more show output ----------------------------------------------------------
    def _mac(self, n=0):
        base = "001a.2b3c.4d" if self.kind == "switch" else "0021.5a3c.10"
        return f"{base}{n:02x}"

    def _port_num(self, name):
        ports = [n for n in self._if_order() if self._physical(n)]
        return ports.index(name) + 1 if name in ports else len(ports) + 1

    def _vlan_changed(self):
        self.vlan_dat = True
        self.vtp_rev += 1

    @staticmethod
    def _last_val(lines, *head):
        """The words after head in the last line that starts with it, or None."""
        for l in reversed(lines):
            t = l.split()
            if [w.lower() for w in t[:len(head)]] == list(head) and len(t) > len(head):
                return t[len(head):]
        return None

    def _desc(self, name):
        v = self._last_val(self.ifs[name]["lines"], "description")
        return " ".join(v) if v else ""

    def _uptime(self):
        mins = int(time.monotonic() - self.boot) // 60
        h, m = divmod(mins, 60)
        parts = [f"{h} hour{'s' if h != 1 else ''}"] if h else []
        return ", ".join(parts + [f"{m} minute{'s' if m != 1 else ''}"])

    def _clock(self):
        t = time.monotonic() - self.boot
        h, rest = divmod(int(t), 3600)
        m, s = divmod(rest, 60)
        return f"*{h % 24:02d}:{m:02d}:{s:02d}.{int(t % 1 * 1000):03d} UTC Mon Mar 1 1993"

    def _version(self):
        mac = self._mac().replace(".", "")
        mac = ":".join(mac[i:i + 2] for i in range(0, 12, 2)).upper()
        gi = sum(1 for n in self.ifs if n.startswith("GigabitEthernet") and "." not in n)
        xe = ["Cisco IOS XE Software, Version 17.09.05"]
        if self.kind == "switch" and self.model == "xe":
            top = xe + ["Cisco IOS Software [Cupertino], Catalyst L3 Switch Software "
                        "(CAT9K_LITE_IOSXE), Version 17.9.5, RELEASE SOFTWARE (fc4)"]
            image, reg = "flash:packages.conf", "0x102"
            hw = ["cisco C9200-24T (ARM64) processor with 1386782K/6147K bytes of memory.",
                  f"{gi} Gigabit Ethernet interfaces",
                  "2048K bytes of non-volatile configuration memory.",
                  f"Base Ethernet MAC Address          : {mac}",
                  "Model Number                       : C9200-24T"]
        elif self.model == "xe":
            top = xe + ["Cisco IOS Software [Cupertino], c8000be Software "
                        "(X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.9.5, RELEASE SOFTWARE "
                        "(fc4)"]
            image, reg = "bootflash:c8000be-universalk9.17.09.05.SPA.bin", "0x2102"
            hw = ["cisco C8200-1N-4T (1RU) processor with 3753964K/6147K bytes of memory.",
                  f"{gi} Gigabit Ethernet interfaces",
                  "32768K bytes of non-volatile configuration memory."]
        elif self.kind == "switch":
            top = ["Cisco IOS Software, C2960 Software (C2960-LANBASEK9-M), Version "
                   "15.2(7)E3, RELEASE SOFTWARE (fc3)"]
            image, reg = "flash:/c2960-lanbasek9-mz.152-7.E3.bin", "0xF"
            hw = ["cisco WS-C2960-24TT-L (PowerPC405) processor (revision B0) with 65536K "
                  "bytes of memory.", "24 FastEthernet interfaces",
                  "2 Gigabit Ethernet interfaces",
                  "64K bytes of flash-simulated non-volatile configuration memory.",
                  f"Base ethernet MAC Address       : {mac}",
                  "Model number                    : WS-C2960-24TT-L"]
        else:
            top = ["Cisco IOS Software, C2900 Software (C2900-UNIVERSALK9-M), Version "
                   "15.2(4)M, RELEASE SOFTWARE (fc1)"]
            image, reg = "flash0:c2900-universalk9-mz.SPA.152-4.M.bin", "0x2102"
            hw = ["Cisco CISCO2911/K9 (revision 1.0) with 491520K/32768K bytes of memory.",
                  f"{gi} Gigabit Ethernet interfaces",
                  "255K bytes of non-volatile configuration memory."]
        return top + [
                "(Practice device: an imitation for learning, not Cisco software.)", "",
                f"{self.hostname} uptime is {self._uptime()}",
                "System returned to ROM by power-on", f'System image file is "{image}"', ""
                ] + hw + ["", f"Configuration register is {reg}"]

    def _users(self):
        return ["    Line       User       Host(s)              Idle       Location",
                "*  0 con 0                idle                 00:00:00", "",
                "  Interface    User               Mode         Idle     Peer Address"]

    def _dir(self):
        xe = self.model == "xe"
        if self.kind == "switch":
            fs, total = "flash:/", 1621966848 if xe else 64016384
            files = ([("packages.conf", 7556), ("cat9k_lite_iosxe.17.09.05.SPA.bin", 469473734)]
                     if xe else [("c2960-lanbasek9-mz.152-7.E3.bin", 15628800)])
            if self.vlan_dat:
                files.append(("vlan.dat", 736))
        elif xe:
            fs, total = "bootflash:/", 6646460416
            files = [("c8000be-universalk9.17.09.05.SPA.bin", 727269376)]
        else:
            fs, total = "flash0:/", 256487424
            files = [("c2900-universalk9-mz.SPA.152-4.M.bin", 97794040)]
        rows = [f"Directory of {fs}", ""]
        for n, (name, size) in enumerate(files, 1):
            rows.append(f"{n:>5}  -rw-  {size:>10}  Mar 1 1993 00:00:00 +00:00  {name}")
        used = sum(size for _, size in files)
        return rows + ["", f"{total} bytes total ({total - used} bytes free)"]

    def _running_if(self, rest):
        if len(rest) < 2:
            raise _PError("incomplete")
        name = _p_ifname(" ".join(w for _, w in rest[1:]))
        if name not in self.ifs:
            raise _PError("invalid", rest[1][0])
        body = self._running()
        start = body.index(f"interface {name}")
        block = ["!"] + body[start:body.index("!", start)] + ["end"]
        size = sum(len(l) + 1 for l in block)
        return ["Building configuration...", "", f"Current configuration : {size} bytes"] + block

    def _show_interfaces(self, rest):
        words = [w for _, w in rest]
        low = [w.lower() for w in words]
        sw = self.kind == "switch"
        if not words:
            return self._if_detail(self._if_order())
        if not re.search(r"\d", low[0]):
            kw, err = _p_match(low[0], ["description", "status", "switchport", "trunk"])
            if err == "ambiguous":
                raise _PError("ambiguous")
            if kw == "description":
                return self._if_description()
            if kw and not sw:
                return None
            if kw == "status":
                if len(low) > 1 and "err-disabled".startswith(low[1]):
                    return ["Port      Name               Status       Reason               "
                            "Err-disabled Vlans"]
                return self._if_status()
            if kw == "switchport":
                return self._switchport(self._if_order())
            if kw == "trunk":
                return self._trunks()
        tail = len(low) > 1 and len(low[-1]) > 1 and "switchport".startswith(low[-1])
        name = _p_ifname(" ".join(words[:-1] if tail else words))
        if name not in self.ifs:
            raise _PError("invalid", rest[0][0])
        if tail:
            return self._switchport([name]) if sw else None
        return self._if_detail([name])

    def _if_detail(self, names):
        rows = []
        for name in names:
            i = self.ifs[name]
            status, proto = self._status(name)
            fast = name.startswith("FastEthernet")
            if name.startswith("Loopback"):
                hw, bw, dly, mtu, enc = "Loopback", 8000000, 5000, 1514, "LOOPBACK"
            elif name.startswith("Tunnel"):
                hw, bw, dly, mtu, enc = "Tunnel", 100, 50000, 1514, "TUNNEL"
            elif name.startswith("Serial"):
                hw, bw, dly, mtu, enc = "WIC MBRD Serial", 1544, 20000, 1500, "HDLC"
            elif name.startswith("Vlan"):
                hw, bw, dly, mtu, enc = "EtherSVI", 1000000, 10, 1500, "ARPA"
            elif name.startswith("Port-channel"):
                hw, bw, dly, mtu, enc = "EtherChannel", 200000, 100, 1500, "ARPA"
            elif fast:
                hw, bw, dly, mtu, enc = "Fast Ethernet", 100000, 100, 1500, "ARPA"
            else:
                hw = ("CN Gigabit Ethernet" if self.kind == "router" and self.model == "ios"
                      else "Gigabit Ethernet")
                bw, dly, mtu, enc = 1000000, 10, 1500, "ARPA"
            rows.append(f"{name} is {status}, line protocol is {proto}")
            if hw in ("Loopback", "Tunnel") or name.startswith("Serial"):
                rows.append(f"  Hardware is {hw}")
            else:
                mac = self._mac(self._port_num(name))
                rows.append(f"  Hardware is {hw}, address is {mac} (bia {mac})")
            if self._desc(name):
                rows.append(f"  Description: {self._desc(name)}")
            if i["ip"]:
                net = ipaddress.IPv4Network("/".join(i["ip"]), strict=False)
                rows.append(f"  Internet address is {i['ip'][0]}/{net.prefixlen}")
            rows += [f"  MTU {mtu} bytes, BW {bw} Kbit/sec, DLY {dly} usec,",
                     "     reliability 255/255, txload 1/255, rxload 1/255"]
            dot1q = self._last_val(i["lines"], "encapsulation")
            if "." in name and dot1q:
                vid = next((w for w in dot1q if w.isdigit()), "1")
                rows.append(f"  Encapsulation 802.1Q Virtual LAN, Vlan ID  {vid}.")
            else:
                rows.append(f"  Encapsulation {enc}, loopback not set")
            if self._physical(name) and not name.startswith("Serial"):
                media = "10/100BaseTX" if fast else "10/100/1000BaseTX"
                speed = "100Mb/s" if fast else "1000Mb/s"
                rows.append(f"  Full-duplex, {speed}, media type is {media}" if proto == "up"
                            else f"  Auto-duplex, Auto-speed, media type is {media}")
            rows += ["  5 minute input rate 0 bits/sec, 0 packets/sec",
                     "  5 minute output rate 0 bits/sec, 0 packets/sec"]
        return rows

    def _if_description(self):
        rows = ["Interface                      Status         Protocol Description"]
        for name in self._if_order():
            status, proto = self._status(name)
            status = "admin down" if status.startswith("admin") else status
            rows.append(f"{_p_short(name):<31}{status:<15}{proto:<9}{self._desc(name)}".rstrip())
        return rows

    def _if_status(self):
        rows = ["Port      Name               Status       Vlan       Duplex  Speed Type"]
        for name in self._if_order():
            if not (self._physical(name) or name.startswith("Port-channel")):
                continue
            i = self.ifs[name]
            proto = self._status(name)[1]
            state = "disabled" if i["shut"] else ("connected" if proto == "up" else "notconnect")
            vlan = ("trunk" if i["swmode"] == "trunk" else
                    "routed" if "no switchport" in i["lines"] else str(i["vlan"]))
            fast = name.startswith("FastEthernet")
            duplex, speed = (("a-full", "a-100" if fast else "a-1000") if state == "connected"
                             else ("auto", "auto"))
            kind = ("10/100BaseTX" if fast else "10/100/1000BaseTX") if self._physical(name) else ""
            rows.append(f"{_p_short(name):<10}{self._desc(name)[:18]:<19}{state:<13}{vlan:<10}"
                        f"{duplex:>6} {speed:>6} {kind}".rstrip())
        return rows

    def _allowed(self, i):
        v = self._last_val(i["lines"], "switchport", "trunk", "allowed")
        vids = _p_vlans(v[-1]) if v else None
        return vids

    def _switchport(self, names):
        rows = []
        vname = lambda v: self.vlans.get(v, "Inactive")
        for name in names:
            if not (self._physical(name) or name.startswith("Port-channel")):
                continue
            i = self.ifs[name]
            rows.append(f"Name: {_p_short(name)}")
            if "no switchport" in i["lines"]:
                rows += ["Switchport: Disabled", ""]
                continue
            mode = i["swmode"]
            dyn = self._last_val(i["lines"], "switchport", "mode", "dynamic")
            admin = {"access": "static access", "trunk": "trunk"}.get(
                mode, "dynamic desirable" if dyn and "desirable".startswith(dyn[0].lower())
                else "dynamic auto")
            oper = "down" if i["shut"] else "trunk" if mode == "trunk" else "static access"
            nego = "Off" if mode == "access" or "switchport nonegotiate" in i["lines"] else "On"
            nat = self._last_val(i["lines"], "switchport", "trunk", "native")
            native = int(nat[-1]) if nat and nat[-1].isdigit() else 1
            voice = self._last_val(i["lines"], "switchport", "voice")
            allowed = self._allowed(i)
            rows += ["Switchport: Enabled", f"Administrative Mode: {admin}",
                     f"Operational Mode: {oper}",
                     "Administrative Trunking Encapsulation: dot1q",
                     f"Operational Trunking Encapsulation: {'dot1q' if mode == 'trunk' else 'native'}",
                     f"Negotiation of Trunking: {nego}",
                     f"Access Mode VLAN: {i['vlan']} ({vname(i['vlan'])})",
                     f"Trunking Native Mode VLAN: {native} ({vname(native)})",
                     f"Voice VLAN: {voice[-1] if voice else 'none'}",
                     f"Trunking VLANs Enabled: {_p_vlan_text(allowed) if allowed else 'ALL'}", ""]
        return rows

    def _trunk_ports(self):
        return [n for n in self._if_order()
                if (self._physical(n) or n.startswith("Port-channel"))
                and self.ifs[n]["swmode"] == "trunk" and self._status(n)[1] == "up"
                and not any(l.startswith("channel-group ") for l in self.ifs[n]["lines"])]

    def _trunks(self):
        ports = self._trunk_ports()
        if not ports:
            return []
        rows = ["", "Port        Mode             Encapsulation  Status        Native vlan"]
        allowed, active = [], []
        for n in ports:
            i = self.ifs[n]
            nat = self._last_val(i["lines"], "switchport", "trunk", "native")
            rows.append(f"{_p_short(n):<12}{'on':<17}{'802.1q':<15}{'trunking':<14}"
                        f"{nat[-1] if nat else 1}")
            vids = self._allowed(i)
            allowed.append(f"{_p_short(n):<12}{_p_vlan_text(vids) if vids else '1-4094'}")
            act = [v for v in sorted(self.vlans) if not 1002 <= v <= 1005
                   and (vids is None or v in vids)]
            active.append(f"{_p_short(n):<12}{_p_vlan_text(act) or 'none'}")
        return (rows + ["", "Port        Vlans allowed on trunk"] + allowed
                + ["", "Port        Vlans allowed and active in management domain"] + active
                + ["", "Port        Vlans in spanning tree forwarding state and not pruned"]
                + active)

    def _acls(self, args):
        acls = {}
        for l in self.globals:
            t = l.split()
            if t[0] == "access-list" and len(t) > 2 and t[1].isdigit():
                acls.setdefault(t[1], []).append(" ".join(t[2:]))
        blocks = []
        for num in sorted(acls, key=int):
            n = int(num)
            blocks.append((num, n < 100 or 1300 <= n <= 1999, acls[num]))
        for header, lines in self.sections.items():
            t = header.split()
            if header.startswith("ip access-list ") and len(t) > 3:
                blocks.append((" ".join(t[3:]), t[2] == "standard", lines))
        rows = []
        for name, std, entries in blocks:
            if args and name != args[0]:
                continue
            rows.append(f"{'Standard' if std else 'Extended'} IP access list {name}")
            seq = 0
            for e in entries:
                t = e.split()
                if t and t[0].isdigit():
                    seq, t = int(t[0]), t[1:]
                else:
                    seq += 10
                if not t or t[0].lower() == "remark":
                    continue
                if std and len(t) == 3 and t[1].lower() == "host":
                    t = [t[0], t[2]]
                elif std and len(t) == 3:
                    t = [t[0], f"{t[1]}, wildcard bits {t[2]}"]
                rows.append(f"    {seq} {t[0]:<6} {' '.join(t[1:])}".rstrip())
        return rows

    def _cdp(self, low):
        if "no cdp run" in self.globals:
            return ["% CDP is not enabled"]
        if not low:
            return ["Global CDP information:", "        Sending CDP packets every 60 seconds",
                    "        Sending a holdtime value of 180 seconds",
                    "        Sending CDPv2 advertisements is  enabled"]
        if not "neighbors".startswith(low[0]):
            return None
        if len(low) > 1 and "detail".startswith(low[1]):
            return ["", "Total cdp entries displayed : 0"]
        return ["Capability Codes: R - Router, T - Trans Bridge, B - Source Route Bridge",
                "                  S - Switch, H - Host, I - IGMP, r - Repeater, P - Phone,",
                "                  D - Remote, C - CVTA, M - Two-port Mac Relay", "",
                "Device ID        Local Intrfce     Holdtme    Capability  Platform  Port ID",
                "", "Total cdp entries displayed : 0"]

    def _stp_ports(self, vid):
        out = []
        for name in self._if_order():
            i = self.ifs[name]
            if not (self._physical(name) or name.startswith("Port-channel")) \
                    or self._status(name)[1] != "up" or "no switchport" in i["lines"] \
                    or any(l.startswith("channel-group ") for l in i["lines"]):
                continue
            vids = self._allowed(i)
            if (vids is None or vid in vids) if i["swmode"] == "trunk" else i["vlan"] == vid:
                out.append(name)
        return out

    def _stp_vlans(self):
        return [v for v in sorted(self.vlans) if not 1002 <= v <= 1005 and self._stp_ports(v)]

    def _stp_prio(self, vid):
        prio = 32768
        for l in self.globals:
            t = l.lower().split()
            if len(t) >= 5 and t[:2] == ["spanning-tree", "vlan"] and vid in (_p_vlans(t[2]) or []):
                if "priority".startswith(t[3]) and t[4].isdigit():
                    prio = int(t[4])
                elif t[3] == "root":
                    prio = 24576 if "primary".startswith(t[4]) else 28672
        return prio

    def _stp_rapid(self):
        return any(l.lower().startswith("spanning-tree mode rapid") for l in self.globals)

    def _stp_edge(self, i):
        if i["swmode"] == "trunk" or "spanning-tree portfast disable" in i["lines"]:
            return False
        return any(l.startswith("spanning-tree portfast") for l in i["lines"]) or any(
            l.startswith("spanning-tree portfast") and l.endswith("default")
            for l in self.globals)

    def _stp(self, low):
        if low and "summary".startswith(low[0]):
            return self._stp_summary()
        vids = self._stp_vlans()
        if low and "vlan".startswith(low[0]):
            want = _p_vlans(low[1]) if len(low) > 1 else None
            if not want:
                raise _PError("incomplete")
            if want[0] not in vids:
                return [f"Spanning tree instance(s) for vlan {want[0]} does not exist."]
            vids = want[:1]
        elif low:
            return None
        if not vids:
            return ["No spanning tree instance exists."]
        rows = []
        for v in vids:
            prio = self._stp_prio(v)
            timers = "             Hello Time   2 sec  Max Age 20 sec  Forward Delay 15 sec"
            rows += [f"VLAN{v:04d}",
                     f"  Spanning tree enabled protocol {'rstp' if self._stp_rapid() else 'ieee'}",
                     f"  Root ID    Priority    {prio + v}",
                     f"             Address     {self._mac()}",
                     "             This bridge is the root", timers, "",
                     f"  Bridge ID  Priority    {prio + v}  (priority {prio} sys-id-ext {v})",
                     f"             Address     {self._mac()}", timers,
                     "             Aging Time  300 sec", "",
                     "Interface           Role Sts Cost      Prio.Nbr Type",
                     "------------------- ---- --- --------- -------- "
                     "--------------------------------"]
            for n in self._stp_ports(v):
                cost = 19 if n.startswith("FastEthernet") else 4 if n.startswith("Gigabit") else 12
                edge = " Edge" if self._stp_edge(self.ifs[n]) else ""
                rows.append(f"{_p_short(n):<20}Desg FWD {cost:<10}{'128.' + str(self._port_num(n)):<9}"
                            f"P2p{edge}")
            rows.append("")
        return rows

    def _stp_summary(self):
        vids = self._stp_vlans()
        pf = any(l.startswith("spanning-tree portfast") and l.endswith("default")
                 for l in self.globals)
        rows = [f"Switch is in {'rapid-pvst' if self._stp_rapid() else 'pvst'} mode",
                "Root bridge for: " + (", ".join(f"VLAN{v:04d}" for v in vids) or "none"),
                "Extended system ID           is enabled",
                f"Portfast Default             is {'enabled' if pf else 'disabled'}",
                "PortFast BPDU Guard Default  is disabled",
                "Portfast BPDU Filter Default is disabled",
                "Loopguard Default            is disabled",
                "EtherChannel misconfig guard is enabled",
                "UplinkFast                   is disabled",
                "BackboneFast                 is disabled",
                "Configured Pathcost method used is short", "",
                "Name                   Blocking Listening Learning Forwarding STP Active",
                "---------------------- -------- --------- -------- ---------- ----------"]
        total = 0
        for v in vids:
            n = len(self._stp_ports(v))
            total += n
            rows.append(f"VLAN{v:04d}".ljust(22) + f"{0:>9}{0:>10}{0:>9}{n:>11}{n:>11}")
        label = f"{len(vids)} vlan{'s' if len(vids) != 1 else ''}"
        return rows + ["---------------------- -------- --------- -------- ---------- ----------",
                       label.ljust(22) + f"{0:>9}{0:>10}{0:>9}{total:>11}{total:>11}"]

    def _port_security(self, rest):
        low = [w.lower() for _, w in rest]
        secure = [n for n in self._if_order() if "switchport port-security" in self.ifs[n]["lines"]]

        def setting(n, key, default):
            v = self._last_val(self.ifs[n]["lines"], "switchport", "port-security", key)
            return v[-1] if v else default
        totals = ["Total Addresses in System (excluding one mac per port)     : 0",
                  "Max Addresses limit in System (excluding one mac per port) : 4096"]
        line = "-" * 75
        if low and "interface".startswith(low[0]):
            name = _p_ifname(" ".join(w for _, w in rest[1:])) if len(rest) > 1 else None
            if name not in self.ifs:
                raise _PError("incomplete" if len(rest) < 2 else "invalid", rest[-1][0])
            on = name in secure
            status = ("Secure-shutdown" if self.ifs[name]["shut"] else "Secure-up") if on \
                else "Secure-down"
            return [f"Port Security              : {'Enabled' if on else 'Disabled'}",
                    f"Port Status                : {status}",
                    f"Violation Mode             : {setting(name, 'violation', 'shutdown').title()}",
                    "Aging Time                 : 0 mins",
                    "Aging Type                 : Absolute",
                    "SecureStatic Address Aging : Disabled",
                    f"Maximum MAC Addresses      : {setting(name, 'maximum', '1')}",
                    "Total MAC Addresses        : 0", "Configured MAC Addresses   : 0",
                    "Sticky MAC Addresses       : 0",
                    "Last Source Address:Vlan   : 0000.0000.0000:0",
                    "Security Violation Count   : 0"]
        if low and "address".startswith(low[0]):
            return ["               Secure Mac Address Table", "-" * 77,
                    "Vlan    Mac Address       Type                          Ports   Remaining Age",
                    "                                                                   (mins)",
                    "----    -----------       ----                          -----   -------------",
                    "-" * 77] + totals
        if low:
            return None
        rows = ["Secure Port  MaxSecureAddr  CurrentAddr  SecurityViolation  Security Action",
                "                (Count)       (Count)          (Count)", line]
        for n in secure:
            rows.append(f"{_p_short(n):>11}{setting(n, 'maximum', '1'):>15}{0:>13}{0:>19}"
                        f"{setting(n, 'violation', 'shutdown').title():>18}")
        return rows + [line] + totals

    def _etherchannel(self):
        groups = {}
        for n in self._if_order():
            v = self._last_val(self.ifs[n]["lines"], "channel-group")
            if v and v[0].isdigit():
                mode = _p_match(v[-1], ["active", "auto", "desirable", "on", "passive"])[0]
                groups.setdefault(int(v[0]), []).append((n, mode or "on"))
        rows = ["Flags:  D - down        P - bundled in port-channel",
                "        I - stand-alone s - suspended",
                "        H - Hot-standby (LACP only)",
                "        R - Layer3      S - Layer2",
                "        U - in use      f - failed to allocate aggregator", "",
                "        M - not in use, minimum links not met",
                "        u - unsuitable for bundling",
                "        w - waiting to be aggregated",
                "        d - default port", "", "",
                f"Number of channel-groups in use: {len(groups)}",
                f"Number of aggregators:           {len(groups)}", "",
                "Group  Port-channel  Protocol    Ports",
                "------+-------------+-----------+-----------------------------------------------"]
        for g, members in sorted(groups.items()):
            po = f"Port-channel{g}"
            up = po in self.ifs and self._status(po)[1] == "up"
            layer = "R" if po in self.ifs and "no switchport" in self.ifs[po]["lines"] else "S"
            mode = members[0][1]
            proto = ("LACP" if mode in ("active", "passive")
                     else "PAgP" if mode in ("auto", "desirable") else "-")
            ports = "   ".join(f"{_p_short(n)}({'D' if self.ifs[n]['shut'] else 'P'})"
                               for n, _ in members)
            po_flags = f"Po{g}({layer}{'U' if up else 'D'})"
            rows.append(f"{g:<7}{po_flags:<14}{proto:<12}{ports}")
        return rows

    def _vtp(self):
        def val(head, default):
            v = self._last_val(self.globals, "vtp", head)
            return v[-1] if v else default
        mode = _p_match(val("mode", "server"), ["client", "off", "server", "transparent"])[0]
        mode = (mode or "server").title()
        return ["VTP Version capable             : 1 to 3",
                f"VTP version running             : {val('version', '1')}",
                f"VTP Domain Name                 : {val('domain', '')}",
                "VTP Pruning Mode                : Disabled",
                "VTP Traps Generation            : Disabled",
                f"Device ID                       : {self._mac()}",
                "Configuration last modified by 0.0.0.0 at 3-1-93 00:00:00", "",
                "Feature VLAN:", "--------------",
                f"VTP Operating Mode                : {mode}",
                "Maximum VLANs supported locally   : 255",
                f"Number of existing VLANs          : {len(self.vlans)}",
                f"Configuration Revision            : "
                f"{0 if mode in ('Transparent', 'Off') else self.vtp_rev}"]

    def _mac_table(self, low):
        static = [] if low and "dynamic".startswith(low[0]) else [
            f" All    {m}    STATIC      CPU" for m in (
                "0100.0ccc.cccc", "0100.0ccc.cccd", "0180.c200.0000", "0180.c200.0001",
                "0180.c200.0002", "ffff.ffff.ffff")]
        return (["          Mac Address Table", "-------------------------------------------", "",
                 "Vlan    Mac Address       Type        Ports",
                 "----    -----------       --------    -----"] + static
                + [f"Total Mac Addresses for this criterion: {len(static)}"])

    def _ospf_rid(self, lines):
        v = self._last_val(lines, "router-id")
        if v:
            return v[0]
        ups = [(n, i["ip"][0]) for n, i in self.ifs.items()
               if i["ip"] and self._status(n)[1] == "up"]
        pick = [ip for n, ip in ups if n.startswith("Loopback")] or [ip for _, ip in ups]
        return max(pick, key=lambda a: int(ipaddress.IPv4Address(a))) if pick else "0.0.0.0"

    def _ip_protocols(self):
        rows = []
        for header, lines in self.sections.items():
            t = header.split()
            if t[0] != "router" or len(t) < 2:
                continue
            proto = t[1].lower()
            rows += [f'Routing Protocol is "{" ".join(t[1:])}"',
                     "  Outgoing update filter list for all interfaces is not set",
                     "  Incoming update filter list for all interfaces is not set"]
            if proto in ("ospf", "eigrp"):
                rows.append(f"  Router ID {self._ospf_rid(lines)}")
            rows.append("  Routing for Networks:")
            rows += ["    " + l.split(None, 1)[1] for l in lines
                     if l.startswith("network ") and len(l.split()) > 1]
            passive = [l.split(None, 1)[1] for l in lines if l.startswith("passive-interface ")]
            if passive:
                rows.append("  Passive Interface(s):")
                rows += ["    " + (_p_ifname(p) or p) for p in passive]
            dist = {"ospf": 110, "eigrp": 90, "rip": 120, "bgp": 20}.get(proto, 1)
            rows += ["  Routing Information Sources:",
                     "    Gateway         Distance      Last Update",
                     f"  Distance: (default is {dist})", ""]
        if self.kind == "switch" and not rows:
            return []
        return ["*** IP Routing is NSF aware ***", ""] + rows

    def _ospf_neighbors(self):
        if not any(h.startswith("router ospf") for h in self.sections):
            return []
        return ["", "Neighbor ID     Pri   State           Dead Time   Address         Interface"]

    def _standby_brief(self):
        rows = []
        for name in self._if_order():
            groups = {}
            for l in self.ifs[name]["lines"]:
                t = l.split()
                if t[0] != "standby" or len(t) < 3:
                    continue
                gid, t = (t[1], t[2:]) if t[1].isdigit() else ("0", t[1:])
                g = groups.setdefault(gid, {"ip": None, "pri": "100", "pre": False})
                w = t[0].lower()
                if w == "ip" and len(t) > 1:
                    g["ip"] = t[1]
                elif len(t) > 1 and "priority".startswith(w):
                    g["pri"] = t[1]
                elif "preempt".startswith(w):
                    g["pre"] = True
            for gid, g in groups.items():
                if not g["ip"]:
                    continue
                active = self._status(name) == ("up", "up")
                rows.append(f"{_p_short(name):<12}{gid:<5}{g['pri']:<4}{'P' if g['pre'] else ' '} "
                            f"{'Active' if active else 'Init':<8}{'local' if active else 'unknown':<16}"
                            f"{'unknown':<16}{g['ip']}")
        if not rows:
            return []
        return ["                     P indicates configured to preempt.",
                "                     |",
                "Interface   Grp  Pri P State   Active          Standby         Virtual IP"] + rows

    def _nat_static(self):
        out = []
        for l in self.globals:
            t = l.split()
            if [w.lower() for w in t[:5]] == ["ip", "nat", "inside", "source", "static"] \
                    and len(t) == 7:
                out.append((t[5], t[6]))
        return out

    def _nat_translations(self):
        static = self._nat_static()
        if not static:
            return []
        return (["Pro Inside global      Inside local       Outside local      Outside global"]
                + [f"--- {glob:<18} {loc:<18} {'---':<18} ---" for loc, glob in static])

    def _nat_statistics(self):
        def sides(word):
            return [f"  {n}" for n in self._if_order() if f"ip nat {word}" in self.ifs[n]["lines"]]
        n = len(self._nat_static())
        return [f"Total active translations: {n} ({n} static, 0 dynamic; 0 extended)",
                "Outside interfaces:"] + sides("outside") + ["Inside interfaces:"] + sides(
            "inside") + ["Hits: 0  Misses: 0"]

    def _ip_ssh(self):
        ver = "2.0" if "ip ssh version 2" in self.globals else "1.99"
        if self.rsa_bits:
            return [f"SSH Enabled - version {ver}",
                    "Authentication timeout: 120 secs; Authentication retries: 3"]
        return [f"SSH Disabled - version {ver}",
                "%Please create RSA keys to enable SSH (and of atleast 768 bits for SSH v2).",
                "Authentication timeout: 120 secs; Authentication retries: 3"]


class PracticeTransport:
    """Connects a Session to a PracticeDevice inside the program, the way
    SerialTransport connects it to a console cable. Nothing goes over a network."""
    eol = "\r"

    def __init__(self, device, port):
        self.device = device
        self.port = self.label = port.split(" (")[0]     # "Practice router"
        self._out = queue.Queue()
        self._open = False

    def open(self):
        self._open = True

    def read(self) -> bytes:
        try:
            return self._out.get(timeout=0.1)
        except queue.Empty:
            return b""

    def write(self, data: bytes):
        if not self._open:
            raise OSError("The practice device is closed.")
        out = self.device.feed(data)
        if out:
            self._out.put(out)

    def push(self, text: str):
        """Show device output that isn't an answer to typing (after a reset)."""
        self._out.put(text.encode("latin-1"))

    def send_break(self):
        pass

    def close(self):
        self._open = False

PRACTICE_INTRO = ("No switch or router? Practice here. The practice router and switch are "
                  "simple imitations of Cisco devices that run inside this program. They know "
                  "the common commands, so a real device will say more. Pick the devices "
                  "closest to your lab's.")
# The pairs of devices the practice switch and router can imitate (Practice tab).
PRACTICE_MODELS = {"ios": "Catalyst 2960 + ISR 2911 (IOS 15)",
                   "xe": "Catalyst 9200 + Catalyst 8200 (IOS XE)"}
# The ports the practice steps use: as typed, and as the show commands list them.
PRACTICE_STEP_PORTS = {
    "ios": {"rport": "g0/1", "rshow": "G0/1", "sport": "f0/1", "sshow": "Fa0/1"},
    "xe": {"rport": "g0/0/1", "rshow": "G0/0/1", "sport": "g1/0/1", "sshow": "Gi1/0/1"},
}
PRACTICE_DONE = ("Done! Click Reset practice device to start over. Like a lab device after a "
                 "power cycle, it forgets everything because nothing was saved.")
PRACTICE_CHECK = chr(0x2713)        # check mark
# kind: [(title, what to do, device events that show it's done, in order)]
_P_MISTAKE = ("(misspelled on purpose). The device marks the error with ^, and a [Hint] "
              "line explains it.")
_P_SAVE = ("Type end, then write memory. The program stops it before it reaches the device. "
           "Saving is blocked here, so every device comes back clean.")
PRACTICE_STEPS = {
    "router": [
        ("Connect", "Pick Router, click Start practice, then press Enter. The device "
                    "answers with Router>.", ("prompt",)),
        ("Privileged mode", "Type enable. The prompt changes to Router#.", ("exec",)),
        ("Configuration mode", "Type configure terminal (conf t for short). The prompt "
                               "becomes Router(config)#.", ("config",)),
        ("Name the device", "Type hostname R1. The prompt changes right away.",
         ("hostname",)),
        ("Set up an interface", "Type interface {rport}, then ip address 192.168.1.1 "
                                "255.255.255.0, then no shutdown. Watch for the message that "
                                "the interface changed state to up.", ("if_up",)),
        ("Check your work", "Type end, then show ip interface brief. {rshow} shows your "
                            "address and up.", ("show_ip_brief",)),
        ("Make a mistake", "Type conf t, then hostnme R2 " + _P_MISTAKE, ("invalid",)),
        ("Try to save", _P_SAVE, ("blocked",)),
        ("Use a template", "Open Quick config, pick Basics: device setup, fill it in, click "
                           "Build commands, then Send to device. Then type show "
                           "running-config to see what it sent.", ("template", "show_run")),
    ],
    "switch": [
        ("Connect", "Pick Switch, click Start practice, then press Enter. The device "
                    "answers with Switch>.", ("prompt",)),
        ("Privileged mode", "Type enable. The prompt changes to Switch#.", ("exec",)),
        ("Configuration mode", "Type configure terminal (conf t for short). The prompt "
                               "becomes Switch(config)#.", ("config",)),
        ("Name the device", "Type hostname S1. The prompt changes right away.",
         ("hostname",)),
        ("Create a VLAN", "Type vlan 10, then name Sales. The prompt shows (config-vlan) "
                          "while you're in the VLAN.", ("vlan_named",)),
        ("Put a port in the VLAN", "Type interface {sport}, then switchport mode access, "
                                   "then switchport access vlan 10.", ("port_vlan",)),
        ("Check your work", "Type end, then show vlan brief. VLAN 10 Sales lists {sshow}.",
         ("show_vlan",)),
        ("Make a mistake", "Type conf t, then hostnme S2 " + _P_MISTAKE, ("invalid",)),
        ("Try to save", _P_SAVE, ("blocked",)),
        ("Use a template", "Open Quick config, pick Basics: switch management IP (SVI), fill "
                           "it in, click Build commands, then Send to device. Then type show "
                           "ip interface brief to see the VLAN interface with its address.",
         ("template", "show_ip_brief")),
    ],
}


# ===========================================================================
# Settings and command log
# ===========================================================================

_DEFAULT_SALT = "cisco-lab-console"    # only for a settings file with no salt in it
_OLD_ROUNDS = 120_000                   # files written before 2.17 have no "rounds"
PW_ROUNDS = 600_000
PW_MIN_LEN = 8


def _hash_pw(pw: str, salt: str, rounds: int = PW_ROUNDS) -> str:
    return hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), rounds).hex()


# Everything the program keeps beside itself starts with "slatewise", so the files
# sort and read together. Before 2.17 they had other names; carried_over() moves them.
SETTINGS_FILE = "slatewise_settings.json"
PREFS_FILE = "slatewise_prefs.json"       # this PC's last port, window and text size
LOGS_DIR = "slatewise_logs"
SETUPS_DIR = "slatewise_setups"
_OLD_NAMES = {SETTINGS_FILE: "lab_console_settings.json", PREFS_FILE: "lab_console_prefs.json",
              LOGS_DIR: "logs", SETUPS_DIR: "setups"}


def _ours(old: Path, name: str) -> bool:
    """True if an old file or folder is clearly the program's own. The old folder
    names (logs, setups) are common words: a folder of other files is left alone."""
    if name in (SETTINGS_FILE, PREFS_FILE):
        return old.is_file()
    if not old.is_dir():
        return False
    pattern = r"\d{4}-\d{2}-\d{2}\.log" if name == LOGS_DIR else r".+\.txt"
    try:
        return all(f.is_file() and re.fullmatch(pattern, f.name) for f in old.iterdir())
    except OSError:
        return False


def carried_over(folder: Path, name: str) -> Path:
    """folder/name. A file or folder still under its pre-2.17 name is renamed; if it
    can't be (a read-only folder), it's used where it is."""
    new, old = folder / name, folder / _OLD_NAMES[name]
    if not new.exists() and _ours(old, name):
        try:
            old.rename(new)
        except OSError:
            return old
    return new

# Names Windows treats as devices, even with .txt added (on Windows 10): a setup
# saved as COM3 would go straight out of the console port, unchecked.
_WIN_DEVICE_RE = re.compile(r"con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3]",
                            re.I)


def program_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent


def settings_dir() -> Path:
    """The instructor's settings file is read only from beside the program, even
    when that folder is read-only, as it may be on a locked-down student computer.
    A file anywhere else wouldn't do: any student could make one with their own
    password."""
    return program_dir()


def app_dir() -> Path:
    """A folder the program can write to: beside the program, or ~/Slatewise if
    that folder is read-only. A ~/CiscoLabConsole folder from before the rename
    becomes ~/Slatewise."""
    base = program_dir()
    try:
        probe = base / ".slatewise_write_test"
        probe.write_text("ok")
        probe.unlink()
        return base
    except OSError:
        home = Path.home() / "Slatewise"
        old = Path.home() / "CiscoLabConsole"
        if old.is_dir() and not home.exists():
            try:
                old.rename(home)
            except OSError:
                return old
        home.mkdir(exist_ok=True)
        return home


class Settings:
    def __init__(self, folder: Path):
        self.path = carried_over(folder, SETTINGS_FILE)
        self.data = {"salt": _DEFAULT_SALT, "instructor_hash": "",
                     "instructor_timeout_min": 30, "rounds": _OLD_ROUNDS}
        try:
            loaded = json.loads(self.path.read_text())
        except (OSError, ValueError):
            loaded = None
        if isinstance(loaded, dict):            # keep only values of the right kind
            for key, default in self.data.items():
                if type(loaded.get(key)) is type(default):
                    self.data[key] = loaded[key]
        if not 1 <= self.data["instructor_timeout_min"] <= 24 * 60:
            self.data["instructor_timeout_min"] = 30
        if not 1000 <= self.data["rounds"] <= 10_000_000:
            self.data["rounds"] = _OLD_ROUNDS

    def check_password(self, pw: str) -> bool:
        stored = self.data["instructor_hash"]
        if not stored:
            return False
        got = _hash_pw(pw, self.data["salt"], self.data["rounds"])
        return hmac.compare_digest(stored.encode(), got.encode())

    def has_password(self) -> bool:
        return bool(self.data.get("instructor_hash"))

    def set_password(self, pw: str):
        salt = os.urandom(16).hex()
        self.data.update(salt=salt, rounds=PW_ROUNDS, instructor_hash=_hash_pw(pw, salt))
        self.path.write_text(json.dumps(self.data, indent=2))


# Words followed by a password, key or community string, with the shortest
# abbreviation to match (IOS accepts "enable sec", "pass", "snmp-server comm").
_SECRET_WORDS = (("secret", 3), ("password", 3), ("key-string", 5), ("community", 3),
                 ("authentication-key", 16), ("message-digest-key", 16),
                 ("pre-shared-key", 5))


def _secret_start(w: list[str]) -> int | None:
    """Index of the first word of the line that is part of a secret, or None."""
    for i, tok in enumerate(w):
        if any(_abbr(tok, word, n) for word, n in _SECRET_WORDS):
            return i + 1
        nxt = w[i + 1] if i + 1 < len(w) else ""
        if tok == "key" and (
                # crypto isakmp key, tacacs-server key, radius-server key
                (i and any(_abbr(w[i - 1], x, 6) for x in
                           ("isakmp", "tacacs-server", "radius-server")))
                # key <string> in a server's mode; not key chain or key <number>
                or (i == 0 and nxt and not nxt.isdigit() and not _abbr(nxt, "chain", 2))):
            return i + 1
        # standby/vrrp/glbp <group> authentication [text|md5 key-string] <string>
        if (_abbr(tok, "authentication", 4) and w[0] in ("vrrp", "glbp")
                or _abbr(tok, "authentication", 4) and _abbr(w[0], "standby", 4)):
            return i + 1
    if len(w) > 2 and _abbr(w[0], "snmp-server", 6):
        if _abbr(w[1], "user", 2) and "auth" in w:      # SNMPv3 passphrases
            return w.index("auth") + 1
        if _abbr(w[1], "host", 2):                      # the community string
            return 3
    return None


def mask_secrets(line: str) -> str:
    """The line with every password, key and community string replaced by ****.
    It errs toward hiding too much (a passive-interface typed as "pass g0/1" is
    masked too). Anything after a '|' is a show filter, not a secret."""
    words = line.split()
    low = [w.lower() for w in words]
    cut = low.index("|") if "|" in low else len(low)
    start = _secret_start(low[:cut])
    if start is None or start >= cut:
        return line
    return " ".join(words[:start] + ["****"] + words[cut:])


# Log entries that hold a command line: logged as sent (sanitize_line), with
# secrets masked.
_COMMAND_ACTIONS = ("SENT", "BLOCKED", "OVERRIDE", "CANCELED", "REJECTED")


class CommandLog:
    def __init__(self, folder: Path):
        self.folder = carried_over(folder, LOGS_DIR)
        self.error = ""                 # why the last write failed, if it did

    @property
    def path(self) -> Path:
        return self.folder / f"{datetime.now():%Y-%m-%d}.log"

    def write(self, device: str, action: str, text: str, note: str = "") -> bool:
        if action in _COMMAND_ACTIONS:
            text = mask_secrets(sanitize_line(text))
        if note:
            text += f"  [{note}]"
        try:
            self.folder.mkdir(exist_ok=True)
            with open(self.path, "a", encoding="utf-8") as f:
                f.write(f"{datetime.now():%H:%M:%S}  {device:<18} {action:<9} {text}\n")
        except OSError as exc:
            self.error = str(exc)
            return False
        self.error = ""
        return True


# ===========================================================================
# Quick-config templates
# ===========================================================================

def _req(value: str, label: str) -> str:
    value = (value or "").strip()
    if not value:
        raise ValueError(f"{label} is required.")
    return value


def _word(value: str, label: str, required: bool = True) -> str:
    value = (value or "").strip()
    if not value:
        if required:
            raise ValueError(f"{label} is required.")
        return ""
    if " " in value or "?" in value:
        raise ValueError(f"{label} can't contain spaces or '?' (IOS reads '?' as a "
                         "help request).")
    return value


def _ip(value: str, label: str) -> str:
    value = _req(value, label)
    try:
        return str(ipaddress.IPv4Address(value))
    except ValueError:
        raise ValueError(f"{label} '{value}' is not a valid IPv4 address.") from None


def _mask(value: str, label: str = "Subnet mask") -> str:
    value = _req(value, label).lstrip("/")
    err = ValueError(f"{label} '{value}' is not valid. Use dotted form "
                     "(255.255.255.0) or a prefix length (/24).")
    if value.isdigit():
        if not 0 <= int(value) <= 32:
            raise err
        return str(ipaddress.IPv4Network(f"0.0.0.0/{value}").netmask)
    try:
        n = int(ipaddress.IPv4Address(value))
    except ValueError:
        raise err from None
    inv = ~n & 0xFFFFFFFF
    if inv & (inv + 1):
        raise ValueError(f"{label} '{value}' is not a contiguous mask. "
                         "(Wildcard masks like 0.0.0.255 belong in ACLs and OSPF.)")
    return value


def _vlan_id(value: str, label: str = "VLAN ID", allow_default: bool = False) -> int:
    value = _req(value, label)
    if not value.isdigit() or not 1 <= int(value) <= 4094:
        raise ValueError(f"{label} must be a number from 1 to 4094.")
    vid = int(value)
    if 1002 <= vid <= 1005:
        raise ValueError(f"VLANs 1002-1005 are reserved; pick another {label}.")
    if vid == 1 and not allow_default:
        raise ValueError("VLAN 1 is the default VLAN and already exists.")
    return vid


def _vty(value: str) -> str:
    value = _req(value, "VTY lines")
    if not re.fullmatch(r"\d{1,2}( \d{1,2})?", value):
        raise ValueError("VTY lines should look like '0 4' or '0 15'.")
    return value


def _host_ip(ip: str, mask: str, label: str = "IP address"):
    net = ipaddress.IPv4Network(f"{ip}/{mask}", strict=False)
    if net.prefixlen < 31 and ip in (str(net.network_address), str(net.broadcast_address)):
        raise ValueError(f"{label} {ip} is the network or broadcast address of {net}. "
                         "Use a host address.")
    return net


def _network(addr: str, mask: str, label: str = "Network"):
    net = ipaddress.IPv4Network(f"{addr}/{mask}", strict=False)
    if str(net.network_address) != addr:
        raise ValueError(f"{label} {addr} {mask} has host bits set. The network address "
                         f"is {net.network_address}.")
    return net


def _split_list(text: str):
    return [p.strip() for p in re.split(r"[,;\n]+", text or "") if p.strip()]


def _vlan_list(text: str, label: str, required: bool = True) -> str:
    t = (text or "").replace(" ", "")
    if not t:
        if required:
            raise ValueError(f"{label} is required.")
        return ""
    if not re.fullmatch(r"\d+(-\d+)?(,\d+(-\d+)?)*", t):
        raise ValueError(f"{label} should look like 10,20,30-40.")
    for part in t.split(","):
        ends = [int(n) for n in part.split("-")]
        if not all(1 <= n <= 4094 for n in ends):
            raise ValueError(f"{label}: VLANs go from 1 to 4094 ('{part}').")
        if len(ends) == 2 and ends[0] > ends[1]:
            raise ValueError(f"{label}: write the range '{part}' low to high.")
    return t


def _ip_entries(text: str, label: str):
    """Parse '10=192.168.10.1/24, 20=192.168.20.1/24' (or '10 192.168.10.1 255.255.255.0')
    into [(vlan, ip, mask)], checking each is a host address in its own subnet."""
    entries, nets = [], []
    for item in _split_list(text):
        m = re.fullmatch(r"(\d+)\s*(?:=|:|\s)\s*([\d.]+)\s*(?:/\s*(\d+)|\s+([\d.]+))", item)
        if not m:
            raise ValueError(f"{label}: '{item}' should look like 10=192.168.10.1/24.")
        vid = _vlan_id(m.group(1), "VLAN", allow_default=True)
        if any(vid == e[0] for e in entries):
            raise ValueError(f"{label}: VLAN {vid} is listed twice.")
        ip = _ip(m.group(2), f"VLAN {vid} address")
        mask = _mask(m.group(3) or m.group(4))
        net = _host_ip(ip, mask, f"VLAN {vid} address")
        for other_vid, other in nets:
            if net.overlaps(other):
                raise ValueError(f"VLAN {vid} ({net}) overlaps VLAN {other_vid} ({other}). "
                                 "Each VLAN needs its own subnet.")
        nets.append((vid, net))
        entries.append((vid, ip, mask))
    if not entries:
        raise ValueError(f"{label} is required.")
    return entries


def _intf_cmd(ports: str) -> str:
    return ("interface range " if ("-" in ports or "," in ports) else "interface ") + ports


def build_basic(v):
    host = _word(v["hostname"], "Hostname")   # the device decides what else is legal
    lines = ["configure terminal", f"hostname {host}"]
    minlen = _number(v.get("minlen", ""), "Minimum password length", 1, 16, required=False)
    if minlen:
        # before any password, as IOS refuses shorter ones from then on
        lines.append(f"security passwords min-length {minlen}")
        for key, label in (("enable_secret", "Enable secret"),
                           ("console_pw", "Console password"),
                           ("admin_pw", "Admin password"), ("password", "SSH password")):
            pw = (v.get(key) or "").strip()
            used = {"admin_pw": "admin_user", "password": "user"}.get(key)
            if pw and (not used or (v.get(used) or "").strip()) and len(pw) < int(minlen):
                raise ValueError(f"{label} is shorter than the minimum password length "
                                 f"({minlen}).")
    if v["no_lookup"]:
        lines.append("no ip domain lookup")
    secret = _word(v["enable_secret"], "Enable secret", required=False)
    if secret:
        lines.append(f"enable secret {secret}")
    if v["encrypt"]:
        lines.append("service password-encryption")
    motd = v["motd"].strip()
    if motd:
        if "?" in motd:
            raise ValueError("Remove '?' from the banner (IOS reads it as a help request).")
        delim = next((c for c in "#$%&*~@" if c not in motd), None)
        if not delim:
            raise ValueError("The banner uses every delimiter character; simplify it.")
        lines.append(f"banner motd {delim}{motd}{delim}")
    con_pw = _word(v["console_pw"], "Console password", required=False)
    if con_pw or v["logsync"]:
        lines.append("line console 0")
        if con_pw:
            lines += [f"password {con_pw}", "login"]
        if v["logsync"]:
            lines.append("logging synchronous")
        lines.append("exit")
    admin = _word(v.get("admin_user", ""), "Admin username", required=False)
    if admin:
        lines.append(f"username {admin} privilege 15 secret "
                     f"{_word(v['admin_pw'], 'Admin password')}")
    if v.get("vty_local"):
        if not admin:
            raise ValueError("Add an admin username for the VTY lines to log in with.")
        lines += [f"line vty {_vty(v['vty'])}", "login local", "exit"]
    return lines + ["end"]


def build_interface(v):
    intf = _req(v["interface"], "Interface")
    ip, mask = _ip(v["ip"], "IP address"), _mask(v["mask"])
    _host_ip(ip, mask)
    lines = ["configure terminal"]
    tag = v["dot1q"].strip()
    if tag:
        if intf.lower().startswith("vl"):
            raise ValueError("802.1Q tagging is for a router port (router-on-a-stick), "
                             "not an SVI like interface vlan 10.")
        vid = _vlan_id(tag, "802.1Q VLAN", True)
        parent = intf.split(".")[0]
        if "." not in intf:
            intf = f"{intf}.{vid}"        # g0/0/1 + VLAN 10 -> subinterface g0/0/1.10
        if v["no_shut"]:                  # the subinterfaces only work if the port is up
            lines += [f"interface {parent}", "no shutdown", "exit"]
        else:
            lines.insert(0, f"! The physical interface ({parent}) must also be up "
                            "('no shutdown').")
    lines.append(f"interface {intf}")
    if tag:
        lines.append(f"encapsulation dot1Q {vid}")
    elif v.get("no_switchport"):
        lines.append("no switchport")       # Layer 3 switch: make it a routed port
    desc = v["description"].strip()
    if desc:
        lines.append(f"description {sanitize_line(desc)}")
    lines.append(f"ip address {ip} {mask}")
    if v["no_shut"]:
        lines.append("no shutdown")
    lines.append("end")
    return lines


def _route_via(text: str) -> str:
    """'10.0.0.2', 'g0/0/0', or 'g0/0/0 10.0.0.2': an exit interface and then the next
    hop, the usual form on Ethernet."""
    toks = _req(text, "Next hop").split()
    addr = None
    try:
        addr = str(ipaddress.IPv4Address(toks[-1]))
        toks = toks[:-1]
    except ValueError:
        pass
    intf = " ".join(toks)
    if re.search(r"\d+\.\d+\.\d+\.\d+|:", intf):
        raise ValueError("Next hop should be an IP address, an exit interface, or an exit "
                         "interface and then an IP address.")
    return " ".join(x for x in (intf, addr) if x)


def build_route(v):
    dest, mask = _ip(v["network"], "Destination network"), _mask(v["mask"])
    _network(dest, mask, "Destination network")
    cmd = f"ip route {dest} {mask} {_route_via(v['next_hop'])}"
    ad = v["distance"].strip()
    if ad:
        if not ad.isdigit() or not 1 <= int(ad) <= 255:
            raise ValueError("Administrative distance must be 1-255.")
        cmd += f" {ad}"
    return ["configure terminal", cmd, "end"]


def build_vlan(v):
    vid = _vlan_id(v["vlan_id"])
    name = _word(v["name"], "VLAN name", required=False)
    lines = ["configure terminal", f"vlan {vid}"]
    if name:
        lines.append(f"name {name}")
    lines.append("exit")
    ports = v["ports"].strip()
    if ports:
        lines += [_intf_cmd(ports), "switchport mode access"]
        if v["nonegotiate"]:
            lines.append("switchport nonegotiate")
        lines.append(f"switchport access vlan {vid}")
        if v["portfast"]:
            lines.append("spanning-tree portfast")
        if v["no_shut"]:
            lines.append("no shutdown")
    return lines + ["end"]


def build_trunk(v):
    ports = _req(v["ports"], "Interface(s)")
    lines = ["configure terminal", _intf_cmd(ports)]
    if v["encap"]:
        lines.append("switchport trunk encapsulation dot1q")
    lines.append("switchport mode trunk")
    if v["nonegotiate"]:
        lines.append("switchport nonegotiate")   # after 'mode trunk', or IOS refuses it
    native = v["native"].strip()
    if native:
        lines.append(f"switchport trunk native vlan {_vlan_id(native, 'Native VLAN', True)}")
    allowed = _vlan_list(v["allowed"], "Allowed VLANs", required=False)
    if allowed:
        lines.append(f"switchport trunk allowed vlan {allowed}")
    if v["no_shut"]:
        lines.append("no shutdown")
    return lines + ["end"]


def _key_note(bits: str) -> list:
    """IOS XE 17.11 and later turn the SSH server off with a key under 2048 bits
    (Cisco field notice FN72511); earlier releases only warn."""
    return (["! IOS XE 17.11 and later turn SSH off with a key under 2048 bits. Use 2048 "
             "there."] if int(bits) < 2048 else [])


def build_ssh(v):
    domain = _word(v["domain"], "Domain name")  # e.g. lab.local; IOS takes any word
    user = _word(v["user"], "Username", required=False)
    bits = v["modulus"].strip()
    if bits not in ("1024", "2048", "4096"):
        raise ValueError("Key size must be 1024, 2048 or 4096.")
    lines = ["! The hostname must already be set (not Router or Switch)."] + _key_note(bits)
    if not user:
        lines.append("! No username here: login local uses the admin user from Device setup.")
    lines += ["configure terminal", f"ip domain name {domain}"]
    if user:
        lines.append(f"username {user} secret {_word(v['password'], 'Password')}")
    return lines + [f"crypto key generate rsa general-keys modulus {bits}",
                    "ip ssh version 2", f"line vty {_vty(v['vty'])}",
                    "transport input ssh", "login local", "exit", "end"]


def _body(lines):
    """The commands between 'configure terminal' and 'end' (notes dropped)."""
    return [l for l in lines if l not in ("configure terminal", "end") and not l.startswith("!")]


def build_morning(v):
    user = v["user"].strip()
    bits = v.get("modulus") or "2048"         # setups saved before 2.22 have no key size
    # The user is the admin user (privilege 15), as in Device setup.
    lines = ["configure terminal"] + _body(build_basic(
        dict(v, admin_user=user, admin_pw=v["password"], vty_local=False)))
    notes = []
    if user:
        ssh = build_ssh({"domain": v["domain"], "user": "", "password": "",
                         "modulus": bits, "vty": v["vty"]})
        notes, lines = _key_note(bits), lines + _body(ssh)
    if v["ip"].strip():
        lines += _body(build_mgmt_svi({"vlan": v["vlan"], "ip": v["ip"], "mask": v["mask"],
                                       "gateway": v["gateway"]}))
    return notes + lines + ["end"]


def build_mgmt_svi(v):
    vid = _vlan_id(v["vlan"], "Management VLAN", allow_default=True)
    ip, mask = _ip(v["ip"], "IP address"), _mask(v["mask"])
    net = _host_ip(ip, mask)
    lines = ["configure terminal"]
    if vid != 1:
        lines += [f"vlan {vid}", "name MANAGEMENT", "exit"]
    lines += [f"interface vlan {vid}", f"ip address {ip} {mask}", "no shutdown", "exit"]
    gw = v["gateway"].strip()
    if gw:
        gw = _ip(gw, "Default gateway")
        if ipaddress.IPv4Address(gw) not in net or gw == ip:
            raise ValueError(f"The default gateway {gw} must be another address in {net}.")
        lines.append(f"ip default-gateway {gw}")
    return lines + ["end"]


def build_vlans(v):
    items = _split_list(v["vlans"])
    if not items:
        raise ValueError("Enter at least one VLAN, like 10 USERS.")
    lines = ["configure terminal"]
    for item in items:
        parts = item.split()
        vid = _vlan_id(parts[0])
        if len(parts) > 2:
            raise ValueError(f"VLAN names can't contain spaces ('{item}'). Use an underscore.")
        lines.append(f"vlan {vid}")
        if len(parts) == 2:
            lines.append(f"name {_word(parts[1], 'VLAN name')}")
        lines.append("exit")
    return lines + ["end"]


def build_voice(v):
    ports = _req(v["ports"], "Port(s)")
    data = _vlan_id(v["data_vlan"], "Data VLAN", allow_default=True)
    voice = _vlan_id(v["voice_vlan"], "Voice VLAN")
    if data == voice:
        raise ValueError("The data and voice VLANs must be different.")
    lines = ["configure terminal"]
    if v["create"]:
        for vid, name in ((data, v["data_name"]), (voice, v["voice_name"])):
            if vid == 1:
                continue
            lines.append(f"vlan {vid}")
            name = _word(name, "VLAN name", required=False)
            if name:
                lines.append(f"name {name}")
            lines.append("exit")
    lines += [_intf_cmd(ports), "switchport mode access"]
    if v["nonegotiate"]:
        lines.append("switchport nonegotiate")
    lines += [f"switchport access vlan {data}", f"switchport voice vlan {voice}"]
    if v["portfast"]:
        lines.append("spanning-tree portfast")
    if v["no_shut"]:
        lines.append("no shutdown")
    return lines + ["end"]


DTP_MODES = ["Trunk, DTP off (mode trunk + nonegotiate)",
             "Trunk, DTP on (mode trunk)",
             "Dynamic desirable (asks the other end to trunk)",
             "Dynamic auto (trunks only if asked; Cisco default)",
             "Access, DTP off (mode access + nonegotiate)"]


def build_dtp(v):
    ports = _req(v["ports"], "Port(s)")
    body = {
        DTP_MODES[0]: ["switchport mode trunk", "switchport nonegotiate"],
        DTP_MODES[1]: ["switchport mode trunk", "no switchport nonegotiate"],
        # nonegotiate has to go before a dynamic mode, or IOS rejects the mode
        DTP_MODES[2]: ["no switchport nonegotiate", "switchport mode dynamic desirable"],
        DTP_MODES[3]: ["no switchport nonegotiate", "switchport mode dynamic auto"],
        DTP_MODES[4]: ["switchport mode access", "switchport nonegotiate"],
    }.get(v["mode"])
    if body is None:
        raise ValueError("Pick a DTP mode.")
    lines = ["configure terminal", _intf_cmd(ports)]
    if v["encap"] and v["mode"].startswith("Trunk"):
        lines.append("switchport trunk encapsulation dot1q")
    return lines + body + ["end"]


def build_reset(v):
    ports = _req(v["ports"], "Port(s)")
    if v["full"]:
        return ["configure terminal", "default " + _intf_cmd(ports), "end"]
    return ["configure terminal", _intf_cmd(ports), "no switchport nonegotiate",
            "no switchport trunk allowed vlan", "no switchport trunk native vlan",
            "switchport mode dynamic auto", "end"]


EC_MODES = {"LACP active (starts the negotiation)": "active",
            "LACP passive (waits for an active side)": "passive",
            "PAgP desirable (Cisco only, starts it)": "desirable",
            "PAgP auto (Cisco only, waits)": "auto",
            "Static, no negotiation (on)": "on"}


def build_etherchannel(v):
    ports = _req(v["ports"], "Member ports")
    grp = v["group"].strip()
    if not grp.isdigit() or not 1 <= int(grp) <= 255:
        raise ValueError("Channel-group number must be a number, usually 1-48.")
    mode = EC_MODES.get(v["mode"])
    if not mode:
        raise ValueError("Pick a negotiation mode.")
    trunk_cfg = []
    if v["trunk"]:
        if v["encap"]:
            trunk_cfg.append("switchport trunk encapsulation dot1q")
        trunk_cfg.append("switchport mode trunk")
        if v["nonegotiate"]:
            trunk_cfg.append("switchport nonegotiate")
        native = v["native"].strip()
        if native:
            trunk_cfg.append(f"switchport trunk native vlan "
                             f"{_vlan_id(native, 'Native VLAN', True)}")
        allowed = _vlan_list(v["allowed"], "Allowed VLANs", required=False)
        if allowed:
            trunk_cfg.append(f"switchport trunk allowed vlan {allowed}")
    # Members are shut while they're set up, configured to match, then bundled.
    lines = ["configure terminal", _intf_cmd(ports), "shutdown"] + trunk_cfg + [
        f"channel-group {grp} mode {mode}", "no shutdown", "exit"]
    if trunk_cfg:
        lines += [f"interface port-channel {grp}"] + trunk_cfg + ["exit"]
    return lines + ["end"]


def build_roas(v):
    port = _req(v["port"], "Router port")
    if "." in port:
        raise ValueError("Enter the physical port (g0/0/1); the subinterfaces are built "
                         "for you.")
    entries = _ip_entries(v["subifs"], "VLANs and gateway addresses")
    native = v["native"].strip()
    native_vid = _vlan_id(native, "Native VLAN", True) if native else None
    lines = ["configure terminal", f"interface {port}", "no shutdown", "exit"]
    for vid, ip, mask in entries:
        enc = f"encapsulation dot1Q {vid}" + (" native" if vid == native_vid else "")
        lines += [f"interface {port}.{vid}", enc, f"ip address {ip} {mask}", "exit"]
    if native_vid and native_vid not in [vid for vid, _, _ in entries]:
        # A native VLAN without a gateway of its own: its subinterface has no address.
        lines += [f"interface {port}.{native_vid}", f"encapsulation dot1Q {native_vid} native",
                  "exit"]
    return lines + ["end"]


def build_l3_svis(v):
    entries = _ip_entries(v["svis"], "VLANs and gateway addresses")
    lines = ["configure terminal"]
    if v["ip_routing"]:
        lines.append("ip routing")
    if v["create"]:
        for vid, _, _ in entries:
            if vid != 1:
                lines += [f"vlan {vid}", "exit"]
    for vid, ip, mask in entries:
        lines += [f"interface vlan {vid}", f"ip address {ip} {mask}", "no shutdown", "exit"]
    return lines + ["end"]


def build_default_route(v):
    return build_route({"network": "0.0.0.0", "mask": "0.0.0.0",
                        "next_hop": v["next_hop"], "distance": ""})


def build_floating_route(v):
    ad = v["distance"].strip()
    if not ad.isdigit() or not 2 <= int(ad) <= 254:
        raise ValueError("A floating static route needs an administrative distance higher "
                         "than the main route's (static 1, EIGRP 90, OSPF 110, RIP 120) "
                         "and below 255.")
    return build_route(v)


def build_hsrp(v):
    intf = _req(v["interface"], "Interface")
    group = _number(v["group"], "Group number", 0, 4095 if v["v2"] else 255)
    vip = _ip(v["vip"], "Virtual IP")
    prio = _number(v["priority"], "Priority", 1, 255, required=False)
    lines = ["configure terminal", f"interface {intf}"]
    if v["v2"]:
        lines.append("standby version 2")
    lines.append(f"standby {group} ip {vip}")
    if prio:
        lines.append(f"standby {group} priority {prio}")
    if v["preempt"]:
        lines.append(f"standby {group} preempt")
    return lines + ["end"]


# ---------------------------------------------------------------- NTP and syslog
def build_ntp(v):
    lines = ["configure terminal"]
    if v["master"]:
        stratum = _number(v["stratum"], "Stratum", 1, 15, required=False)
        lines.append(f"ntp master {stratum}".strip())
    server = v["server"].strip()
    if server:
        lines.append(f"ntp server {_ip(server, 'NTP server')}")
    if len(lines) == 1:
        raise ValueError("Enter an NTP server, make this router the time source, or both.")
    if v["timestamps"]:
        lines.append("service timestamps log datetime msec")
    return lines + ["end"]


LOG_LEVELS = ["0 emergencies", "1 alerts", "2 critical", "3 errors", "4 warnings",
              "5 notifications", "6 informational", "7 debugging"]


def build_syslog(v):
    server = _ip(v["server"], "Syslog server")
    if v["level"] not in LOG_LEVELS:
        raise ValueError("Pick a severity level.")
    lines = ["configure terminal"]
    if v["timestamps"]:
        lines.append("service timestamps log datetime msec")
    lines += [f"logging host {server}", f"logging trap {v['level'].split()[1]}"]
    return lines + ["end"]


# ---------------------------------------------------------------- IPv6
def _ipv6_plain(addr, text: str, label: str):
    """Python takes some addresses IOS doesn't here: a %zone, :: and multicast."""
    if "%" in text:
        raise ValueError(f"{label} '{text}' has a zone (%...), which IOS doesn't take. "
                         "Leave it out.")
    if addr.is_unspecified:
        raise ValueError(f"{label} can't be :: (the unspecified address).")
    if addr.is_multicast:
        raise ValueError(f"{label} can't be a multicast address (ff00::/8).")
    return addr


def _ipv6_interface(value: str, label: str):
    value = _req(value, label)
    if "/" not in value:
        raise ValueError(f"{label} needs a prefix length, for example /64.")
    try:
        iface = ipaddress.IPv6Interface(value)
    except ValueError:
        raise ValueError(f"{label} '{value}' should look like 2001:db8:acad:1::1/64.") from None
    _ipv6_plain(iface.ip, value, label)
    return iface


def build_ipv6_interface(v):
    intf = _req(v["interface"], "Interface")
    addr = _ipv6_interface(v["address"], "IPv6 address")
    lines = ["configure terminal"]
    if v["unicast_routing"]:
        lines.append("ipv6 unicast-routing")
    lines.append(f"interface {intf}")
    if v["eui64"]:
        lines.append(f"ipv6 address {addr.network} eui-64")
    else:
        lines.append(f"ipv6 address {addr}")
    ll = v["link_local"].strip()
    if ll:
        try:
            lla = ipaddress.IPv6Address(ll)
        except ValueError:
            raise ValueError(f"Link-local address '{ll}' should look like fe80::1 "
                             "(no prefix length).") from None
        _ipv6_plain(lla, ll, "Link-local address")
        if not lla.is_link_local:
            raise ValueError("A link-local address starts with fe80, for example fe80::1.")
        lines.append(f"ipv6 address {lla} link-local")
    if v["no_shut"]:
        lines.append("no shutdown")
    return lines + ["end"]


def _ipv6_next_hop(text: str) -> str:
    """'2001:db8::2', 'g0/0/1', or 'g0/0/1 fe80::2' (a link-local next hop needs the
    exit interface)."""
    toks = _req(text, "Next hop").split()
    addr = None
    if ":" in toks[-1]:
        try:
            addr = ipaddress.IPv6Address(toks[-1])
        except ValueError:
            raise ValueError(f"Next hop '{toks[-1]}' is not a valid IPv6 address.") from None
        _ipv6_plain(addr, toks[-1], "Next hop")
        toks = toks[:-1]
    intf = " ".join(toks)
    if ":" in intf or re.fullmatch(r"[\d.]+", intf):
        raise ValueError("Next hop should be an IPv6 address, an exit interface, or an exit "
                         "interface and then an address.")
    if addr and addr.is_link_local and not intf:
        raise ValueError("A link-local next hop needs the exit interface in front of it, "
                         "for example g0/0/1 fe80::2.")
    return " ".join(x for x in (intf, str(addr) if addr else "") if x)


def build_ipv6_route(v):
    prefix = _req(v["prefix"], "Destination prefix")
    if "/" not in prefix:
        raise ValueError("Destination prefix needs a prefix length, for example /64 "
                         "(::/0 for a default route).")
    try:
        net = ipaddress.IPv6Network(prefix, strict=False)
    except ValueError:
        raise ValueError(f"Destination prefix '{prefix}' should look like "
                         "2001:db8:acad:2::/64.") from None
    if net.network_address != ipaddress.IPv6Interface(prefix).ip:
        raise ValueError(f"{prefix} has host bits set. The prefix is {net}.")
    cmd = f"ipv6 route {net} {_ipv6_next_hop(v['next_hop'])}"
    ad = _number(v["distance"], "Administrative distance", 1, 255, required=False)
    if ad:
        cmd += f" {ad}"
    lines = ["configure terminal"]
    if v["unicast_routing"]:
        lines.append("ipv6 unicast-routing")
    return lines + [cmd, "end"]


def build_dhcp_pool(v):
    name = _word(v["pool"], "Pool name")
    addr, mask = _ip(v["network"], "Network"), _mask(v["mask"])
    net = _network(addr, mask)
    gw = _ip(v["gateway"], "Default router")
    if ipaddress.IPv4Address(gw) not in net:
        raise ValueError(f"The default router {gw} isn't in {net}.")
    _host_ip(gw, mask, "Default router")
    lines = ["configure terminal"]
    excl = v["exclude"].strip()
    if excl:
        parts = [x for x in re.split(r"\s*-\s*|\s+", excl) if x]
        if len(parts) not in (1, 2):
            raise ValueError("Excluded addresses should look like 192.168.10.1-192.168.10.10.")
        addrs = [_ip(x, "Excluded address") for x in parts]
        if any(ipaddress.IPv4Address(a) not in net for a in addrs):
            raise ValueError(f"The excluded addresses must be inside {net}.")
        if len(addrs) == 2 and ipaddress.IPv4Address(addrs[0]) > ipaddress.IPv4Address(addrs[1]):
            raise ValueError("Put the lower excluded address first.")
        lines.append("ip dhcp excluded-address " + " ".join(addrs))
    lines += [f"ip dhcp pool {name}", f"network {addr} {mask}", f"default-router {gw}"]
    dns = [_ip(x, "DNS server") for x in re.split(r"[,;\s]+", v["dns"].strip()) if x]
    if dns:
        lines.append("dns-server " + " ".join(dns))
    domain = _word(v["domain"], "Domain name", required=False)
    if domain:
        lines.append(f"domain-name {domain}")
    return lines + ["exit", "end"]


def build_dhcp_relay(v):
    intf = _req(v["interface"], "Interface")
    servers = [_ip(x, "DHCP server") for x in re.split(r"[,;\s]+", v["servers"].strip()) if x]
    if not servers:
        raise ValueError("Enter the DHCP server's IP address.")
    return (["configure terminal", f"interface {intf}"]
            + [f"ip helper-address {x}" for x in servers] + ["end"])


PS_VIOLATIONS = ["shutdown (err-disables the port; the default)",
                 "restrict (drops and logs)",
                 "protect (drops silently)"]


def build_port_security(v):
    ports = _req(v["ports"], "Port(s)")
    mx = v["max"].strip() or "1"
    if not mx.isdigit() or int(mx) < 1:
        raise ValueError("Maximum MAC addresses must be a number like 1 or 2.")
    violation = v["violation"].split()[0] if v["violation"] else ""
    if violation not in ("shutdown", "restrict", "protect"):
        raise ValueError("Pick a violation mode.")
    lines = ["configure terminal", _intf_cmd(ports), "switchport mode access"]
    if v["nonegotiate"]:
        lines.append("switchport nonegotiate")
    lines += ["switchport port-security", f"switchport port-security maximum {mx}",
              f"switchport port-security violation {violation}"]
    if v["sticky"]:
        lines.append("switchport port-security mac-address sticky")
    lines.append("exit")
    if v["recovery"]:
        lines += ["errdisable recovery cause psecure-violation",
                  "errdisable recovery interval 300"]
    return lines + ["end"]


def build_unused_ports(v):
    ports = _req(v["ports"], "Unused port(s)")
    lines = ["configure terminal"]
    vid = v["vlan"].strip()
    if vid:
        vid = _vlan_id(vid, "VLAN")
        lines += [f"vlan {vid}", "exit"]        # creates it if needed; never renames it
    lines += [_intf_cmd(ports), "switchport mode access"]
    if v["nonegotiate"]:
        lines.append("switchport nonegotiate")
    if vid:
        lines.append(f"switchport access vlan {vid}")
    desc = v["description"].strip()
    if desc:
        lines.append(f"description {sanitize_line(desc)}")
    if v["shut"]:
        lines.append("shutdown")
    return lines + ["exit", "end"]


def build_vlan_hopping(v):
    trunks, unused = v["trunks"].strip(), v["unused"].strip()
    if not trunks and not unused:
        raise ValueError("Enter trunk ports, unused ports, or both.")
    lines = ["configure terminal"]
    native = None
    if trunks:
        native = _vlan_id(v["native"], "Unused native VLAN")
        lines += [f"vlan {native}", "name NATIVE_UNUSED", "exit"]
    if unused:
        hole = _vlan_id(v["blackhole"], "Black-hole VLAN")
        if hole == native:
            raise ValueError("Use different VLANs for the native VLAN and the black-hole VLAN.")
        lines.append(f"vlan {hole}")
        name = _word(v.get("hole_name", ""), "Black-hole VLAN name", required=False)
        if name:                                # blank keeps an existing VLAN's name
            lines.append(f"name {name}")
        lines.append("exit")
    if trunks:
        lines.append(_intf_cmd(trunks))
        if v["encap"]:
            lines.append("switchport trunk encapsulation dot1q")
        lines += ["switchport mode trunk", "switchport nonegotiate",
                  f"switchport trunk native vlan {native}", "exit"]
    if unused:
        lines += [_intf_cmd(unused), "switchport mode access", "switchport nonegotiate",
                  f"switchport access vlan {hole}"]
        desc = v.get("description", "").strip()
        if desc:
            lines.append(f"description {sanitize_line(desc)}")
        if v["shut_unused"]:
            lines.append("shutdown")
        lines.append("exit")
    if v["tag_native"]:
        lines.append("vlan dot1q tag native")
    return lines + ["end"]


def build_dhcp_snooping(v):
    vlans = _vlan_list(v["vlans"], "VLANs")
    trusted = _req(v["trusted"], "Trusted port(s)")
    # Trust the uplink first so DHCP keeps working the moment snooping turns on.
    lines = ["configure terminal", _intf_cmd(trusted), "ip dhcp snooping trust", "exit"]
    limited = v["limit_ports"].strip()
    if limited:
        rate = v["rate"].strip() or "10"
        if not rate.isdigit() or not 1 <= int(rate) <= 2048:
            raise ValueError("The rate limit is packets per second, for example 10.")
        lines += [_intf_cmd(limited), f"ip dhcp snooping limit rate {rate}", "exit"]
    lines += ["ip dhcp snooping", f"ip dhcp snooping vlan {vlans}"]
    if v["no_opt82"]:
        lines.append("no ip dhcp snooping information option")
    return lines + ["end"]


def build_dai(v):
    vlans = _vlan_list(v["vlans"], "VLANs")
    trusted = _req(v["trusted"], "Trusted port(s)")
    lines = ["configure terminal", _intf_cmd(trusted), "ip arp inspection trust", "exit",
             f"ip arp inspection vlan {vlans}"]
    if v["validate"]:
        lines.append("ip arp inspection validate src-mac dst-mac ip")
    return lines + ["end"]


def build_portfast(v):
    ports = v["ports"].strip()
    lines = ["configure terminal"]
    if v["global_pf"]:
        lines.append("spanning-tree portfast default")
    if v["global_bg"]:
        lines.append("spanning-tree portfast bpduguard default")
    body = (["spanning-tree portfast"] if v["portfast"] else []) + (
        ["spanning-tree bpduguard enable"] if v["bpduguard"] else [])
    if ports and body:
        lines += [_intf_cmd(ports)] + body + ["exit"]
    if len(lines) == 1:
        raise ValueError("Pick ports with PortFast or BPDU Guard, a global option, or both.")
    return lines + ["end"]


STP_ROLES = ["Root bridge (root primary)",
             "Backup root bridge (root secondary)",
             "Use the priority below",
             "Leave the priority as it is"]


def build_stp_root(v):
    role = v["role"]
    vlans = _vlan_list(v["vlans"], "VLANs", required=role != STP_ROLES[3])
    lines = ["configure terminal"]
    if v["rapid"]:
        lines.append("spanning-tree mode rapid-pvst")
    if role == STP_ROLES[0]:
        lines.append(f"spanning-tree vlan {vlans} root primary")
    elif role == STP_ROLES[1]:
        lines.append(f"spanning-tree vlan {vlans} root secondary")
    elif role == STP_ROLES[2]:
        prio = _number(v["priority"], "Priority", 0, 61440)
        if int(prio) % 4096:
            raise ValueError("Priority goes in steps of 4096: 0, 4096, 8192 ... 61440.")
        lines.append(f"spanning-tree vlan {vlans} priority {prio}")
    elif role != STP_ROLES[3]:
        raise ValueError("Pick what this switch should be.")
    if len(lines) == 1:
        raise ValueError("Pick Rapid PVST+, a root or priority setting, or both.")
    return lines + ["end"]


# ---------------------------------------------------------------- OSPF
def _wildcard(mask: str) -> str:
    return str(ipaddress.IPv4Address(~int(ipaddress.IPv4Address(mask)) & 0xFFFFFFFF))


def _number(value: str, label: str, lo: int, hi: int, required: bool = True) -> str:
    value = (value or "").strip()
    if not value:
        if required:
            raise ValueError(f"{label} is required.")
        return ""
    if not value.isdigit() or not lo <= int(value) <= hi:
        raise ValueError(f"{label} must be a number from {lo} to {hi}.")
    return value


def _area(value: str) -> str:
    value = (value or "0").strip()
    if value.isdigit() or re.fullmatch(r"\d+\.\d+\.\d+\.\d+", value):
        return value
    raise ValueError(f"OSPF area '{value}' should be a number like 0 or 1.")


def _ospf_networks(text: str, default_area: str):
    out = []
    for item in _split_list(text):
        m = re.fullmatch(r"([\d.]+)\s*(?:/\s*(\d+)|\s+([\d.]+))?(?:\s+area\s+(\S+))?", item, re.I)
        if not m:
            raise ValueError(f"OSPF network '{item}' should look like 192.168.10.0/24 "
                             "or 10.0.0.0/30 area 1.")
        addr = _ip(m.group(1), "OSPF network")
        if m.group(2):
            wild = _wildcard(_mask(m.group(2)))
        elif m.group(3):
            wild = _ip(m.group(3), "Wildcard")
        else:
            wild = "0.0.0.0"                       # one interface address
        out.append(f"network {addr} {wild} area {_area(m.group(4) or default_area)}")
    if not out:
        raise ValueError("Enter at least one network, like 192.168.10.0/24.")
    return out


PASSIVE_MODES = ["The listed interfaces are passive",
                 "All interfaces passive except the listed ones"]


def _ospf_interfaces(text: str, default_area: str):
    """'g0/0/1' or 'g0/0/1 area 1' -> [(interface, area)]."""
    out = []
    for item in _split_list(text):
        m = re.fullmatch(r"(.+?)(?:\s+area\s+(.+))?", item, re.I)
        name = m.group(1).strip()
        if re.fullmatch(r"[\d./\s]+", name):
            raise ValueError(f"'{item}' looks like an address. Put it under Networks; "
                             "Interfaces takes names like g0/0/1.")
        out.append((name, _area(m.group(2) or default_area)))
    return out


def build_ospf(v):
    pid = _number(v["pid"], "Process ID", 1, 65535)
    lines = ["configure terminal", f"router ospf {pid}"]
    rid = v["router_id"].strip()
    if rid:
        lines.append(f"router-id {_ip(rid, 'Router ID')}")
    ref = _number(v["refbw"], "Reference bandwidth", 1, 4294967, required=False)
    if ref:
        lines.append(f"auto-cost reference-bandwidth {ref}")
    intfs = _ospf_interfaces(v.get("interfaces", ""), v["area"])
    if not v["networks"].strip() and not intfs:
        raise ValueError("Enter at least one network or interface, like 192.168.10.0/24 "
                         "or g0/0/1.")
    if v["networks"].strip():
        lines += _ospf_networks(v["networks"], v["area"])
    passive = _split_list(v["passive"])
    if v["passive_mode"] == PASSIVE_MODES[1]:
        lines.append("passive-interface default")
        lines += [f"no passive-interface {i}" for i in passive]
    else:
        lines += [f"passive-interface {i}" for i in passive]
    if v["originate"]:
        lines.append("default-information originate" + (" always" if v["always"] else ""))
    lines.append("exit")
    for name, area in intfs:                    # Cisco's third way: OSPF on the interface
        lines += [_intf_cmd(name), f"ip ospf {pid} area {area}", "exit"]
    return lines + ["end"]


def build_loopback_rid(v):
    num = _number(v["loopback"], "Loopback number", 0, 2147483647)
    ip = _ip(v["ip"], "Loopback address")
    lines = ["configure terminal", f"interface loopback {num}",
             f"ip address {ip} 255.255.255.255", "exit"]
    if v["set_rid"] or v["advertise"]:
        pid = _number(v["pid"], "Process ID", 1, 65535)
        lines.append(f"router ospf {pid}")
        if v["set_rid"]:
            lines.append(f"router-id {ip}")
        if v["advertise"]:
            lines.append(f"network {ip} 0.0.0.0 area {_area(v['area'])}")
        lines.append("exit")
    return lines + ["end"]


def build_ospf_intf(v):
    values = []
    hello = dead = None
    for key, cmd, lo, hi, label in (
            ("priority", "ip ospf priority", 0, 255, "Priority"),
            ("cost", "ip ospf cost", 1, 65535, "Cost"),
            ("bandwidth", "bandwidth", 1, 100000000, "Bandwidth (kbps)"),
            ("hello", "ip ospf hello-interval", 1, 65535, "Hello interval"),
            ("dead", "ip ospf dead-interval", 1, 65535, "Dead interval")):
        val = _number(v[key], label, lo, hi, required=False)
        if val:
            values.append(f"{cmd} {val}")
            if key == "hello":
                hello = int(val)
            if key == "dead":
                dead = int(val)
    if hello and dead and dead <= hello:
        raise ValueError("The dead interval must be longer than the hello interval "
                         "(usually 4 times as long).")
    ref = _number(v["refbw"], "Reference bandwidth", 1, 4294967, required=False)
    intf = v["interface"].strip()
    lines = ["configure terminal"]
    # With only the reference bandwidth filled in, the interface part is skipped.
    if intf or values or not ref:
        intf = _req(intf, "Interface")
        body = (["ip ospf network point-to-point"] if v["p2p"] else []) + values
        if body:
            lines += [f"interface {intf}"] + body + ["exit"]
    if ref:
        pid = _number(v["pid"], "OSPF process ID", 1, 65535)
        lines += [f"router ospf {pid}", f"auto-cost reference-bandwidth {ref}", "exit"]
    if len(lines) == 1:
        raise ValueError("Fill in at least one setting.")
    return lines + ["end"]


def build_ospf_default(v):
    pid = _number(v["pid"], "Process ID", 1, 65535)
    lines = ["configure terminal"]
    if v["next_hop"].strip():
        lines += _body(build_default_route({"next_hop": v["next_hop"]}))
    lines += [f"router ospf {pid}",
              "default-information originate" + (" always" if v["always"] else ""), "exit"]
    return lines + ["end"]


# ---------------------------------------------------------------- ACLs
def _acl_addr(item: str, label: str) -> str:
    """'any', 'host 10.1.1.5', '10.1.1.5', '192.168.10.0/24' or '192.168.10.0 0.0.0.255'."""
    t = item.strip().lower()
    if t == "any":
        return "any"
    parts = t.split()
    if parts[0] == "host" and len(parts) == 2:
        return f"host {_ip(parts[1], label)}"
    if len(parts) == 1 and "/" in parts[0]:
        addr, pfx = parts[0].split("/", 1)
        addr, mask = _ip(addr, label), _mask(pfx)
        if mask == "255.255.255.255":
            return f"host {addr}"
        _network(addr, mask, label)
        return f"{addr} {_wildcard(mask)}"
    if len(parts) == 1:
        return f"host {_ip(parts[0], label)}"
    if len(parts) == 2:
        return f"{_ip(parts[0], label)} {_ip(parts[1], 'Wildcard')}"
    raise ValueError(f"{label} '{item}' should be any, a host address, or a network "
                     "like 192.168.10.0/24.")


def _acl_entries(text: str, label: str):
    """One entry per line, written as on the console ('permit 192.168.10.0 0.0.0.255',
    'deny tcp any host 10.1.1.10 eq 80'). A /prefix is turned into a wildcard."""
    out = []
    for raw in re.split(r"[;\n]+", text or ""):
        entry = sanitize_line(raw).strip()
        if not entry:
            continue
        words = entry.split()
        if words[0].lower() not in ("permit", "deny", "remark"):
            raise ValueError(f"{label}: '{entry}' should start with permit, deny or remark.")
        if words[0].lower() != "remark":
            conv = []
            for w in words:
                m = re.fullmatch(r"(\d+\.\d+\.\d+\.\d+)/(\d+)", w)
                conv += _acl_addr(w, "ACL address").split() if m else [w]
            if len(conv) < 2:
                raise ValueError(f"{label}: '{entry}' needs an address (any, host "
                                 "A.B.C.D or A.B.C.D/24).")
            entry = " ".join(conv)
        out.append(entry)
    if not any(e.split()[0].lower() != "remark" for e in out):
        raise ValueError(f"{label}: add at least one permit or deny entry.")
    return out


def _acl_name(value: str, kind: str) -> str:
    name = _word(value, "ACL name or number")
    if name.isdigit():
        n = int(name)
        ok = (1 <= n <= 99 or 1300 <= n <= 1999) if kind == "standard" else (
            100 <= n <= 199 or 2000 <= n <= 2699)
        if not ok:
            raise ValueError("Standard ACL numbers are 1-99 or 1300-1999." if kind == "standard"
                             else "Extended ACL numbers are 100-199 or 2000-2699.")
    return name


def _build_acl(v, kind):
    name = _acl_name(v["name"], kind)
    lines = ["! Every ACL ends with an invisible 'deny any'.", "configure terminal",
             f"ip access-list {kind} {name}"] + _acl_entries(v["entries"], "Entries") + ["exit"]
    intf = v["interface"].strip()
    if intf:
        lines += [f"interface {intf}", f"ip access-group {name} {v['direction']}", "exit"]
    return lines + ["end"]


def build_std_acl(v):
    return _build_acl(v, "standard")


def build_ext_acl(v):
    return _build_acl(v, "extended")


def build_vty_acl(v):
    name = _acl_name(v["name"], "standard")
    allowed = [_acl_addr(x, "Allowed address") for x in _split_list(v["allowed"])]
    if not allowed:
        raise ValueError("Enter at least one allowed host or network.")
    lines = ["configure terminal", f"ip access-list standard {name}"]
    lines += [f"permit {a}" for a in allowed] + ["exit", f"line vty {_vty(v['vty'])}",
                                                 f"access-class {name} in"]
    if v["ssh_only"]:
        lines.append("transport input ssh")
    timeout = _number(v["timeout"], "Idle timeout", 0, 35791, required=False)
    if timeout:
        lines.append(f"exec-timeout {timeout} 0")
    return lines + ["exit", "end"]


# ---------------------------------------------------------------- NAT / PAT
def _nat_interfaces(v):
    inside = _split_list(v["inside"])
    if not inside:
        raise ValueError("Enter at least one inside interface.")
    outside = _req(v["outside"], "Outside interface")
    lines = []
    for i in inside:
        lines += [f"interface {i}", "ip nat inside", "exit"]
    return lines + [f"interface {outside}", "ip nat outside", "exit"]


def _nat_acl(v):
    name = _acl_name(v["acl"], "standard")
    nets = [_acl_addr(x, "Inside network") for x in _split_list(v["networks"])]
    if not nets:
        raise ValueError("Enter the inside network(s) to translate, like 192.168.10.0/24.")
    return name, [f"ip access-list standard {name}"] + [f"permit {n}" for n in nets] + ["exit"]


def build_static_nat(v):
    lines = ["configure terminal"] + _nat_interfaces(v)
    pairs = _split_list(v["pairs"])
    if not pairs:
        raise ValueError("Enter at least one inside=outside address pair.")
    for item in pairs:
        m = re.fullmatch(r"([\d.]+)\s*(?:=|->|\s)\s*([\d.]+)", item)
        if not m:
            raise ValueError(f"'{item}' should look like 192.168.10.10=203.0.113.10.")
        lines.append(f"ip nat inside source static {_ip(m.group(1), 'Inside address')} "
                     f"{_ip(m.group(2), 'Outside address')}")
    return lines + ["end"]


def build_pat_interface(v):
    name, acl = _nat_acl(v)
    outside = _req(v["outside"], "Outside interface")
    return (["configure terminal"] + _nat_interfaces(v) + acl
            + [f"ip nat inside source list {name} interface {outside} overload", "end"])


def _build_nat_pool(v, overload):
    name, acl = _nat_acl(v)
    pool = _word(v["pool"], "Pool name")
    start, end = _ip(v["start"], "First address"), _ip(v["end"], "Last address")
    mask = _mask(v["mask"])
    if ipaddress.IPv4Address(start) > ipaddress.IPv4Address(end):
        raise ValueError("The first pool address must come before the last.")
    if ipaddress.IPv4Address(end) not in ipaddress.IPv4Network(f"{start}/{mask}", strict=False):
        raise ValueError(f"{start} and {end} aren't in the same {mask} subnet.")
    return (["configure terminal"] + _nat_interfaces(v) + acl
            + [f"ip nat pool {pool} {start} {end} netmask {mask}",
               f"ip nat inside source list {name} pool {pool}" + (" overload" if overload else ""),
               "end"])


def build_dynamic_nat(v):
    return _build_nat_pool(v, overload=False)


def build_pat_pool(v):
    return _build_nat_pool(v, overload=True)


# name: (description, fields, checkboxes, builder)
#   field:    (key, label, default) or (key, label, default, [choices])
#   checkbox: (key, label, default)
_DTP_OFF = ("nonegotiate", "Turn off DTP (switchport nonegotiate)", True)
_ENCAP = ("encap", "Set encapsulation dot1q (only switches that require it, such as the "
                   "3560/3750; others reject it)", False)
_UP = ("no_shut", "Bring it up (no shutdown)", True)
TEMPLATES = {
    "Basics: morning baseline (setup + SSH + management IP)": (
        "Everything a freshly booted device needs each morning: hostname, passwords, "
        "banner, SSH and, on a switch, a management IP. Leave the username blank to skip "
        "SSH, or the switch IP blank on a router. Save your own version with 'Save "
        "setup...' to replay tomorrow.",
        [("hostname", "Hostname", "S1"),
         ("minlen", "Minimum password length (optional)", ""),
         ("enable_secret", "Enable secret", "cisco"),
         ("console_pw", "Console password", "cisco"),
         ("motd", "Banner MOTD", "Authorized access only"),
         ("domain", "Domain name", "lab.local"),
         ("user", "Admin username (SSH, privilege 15)", "admin"),
         ("password", "Admin password", "cisco"),
         ("modulus", "RSA key size", "2048", ["1024", "2048", "4096"]),
         ("vty", "VTY lines", "0 15"),
         ("vlan", "Management VLAN", "99"), ("ip", "Switch IP (blank on routers)", "192.168.99.11"),
         ("mask", "Mask or /prefix", "/24"), ("gateway", "Default gateway", "192.168.99.1")],
        [("no_lookup", "Turn off DNS lookup (no ip domain lookup)", True),
         ("encrypt", "Encrypt passwords (service password-encryption)", True),
         ("logsync", "logging synchronous on the console", True)],
        build_morning),
    "Basics: device setup": (
        "Hostname, passwords, banner and console line, and optionally a minimum password "
        "length and a local admin user that the VTY lines log in with. Set up SSH with the "
        "SSH template next.",
        [("hostname", "Hostname", "R1"),
         ("minlen", "Minimum password length (optional)", ""),
         ("enable_secret", "Enable secret", "cisco"),
         ("console_pw", "Console password", "cisco"),
         ("motd", "Banner MOTD", "Authorized access only"),
         ("admin_user", "Admin username (optional)", ""),
         ("admin_pw", "Admin password", "cisco"), ("vty", "VTY lines", "0 15")],
        [("no_lookup", "Turn off DNS lookup (no ip domain lookup)", True),
         ("encrypt", "Encrypt passwords (service password-encryption)", True),
         ("logsync", "logging synchronous on the console", True),
         ("vty_local", "VTY lines use the admin user (login local)", False)],
        build_basic),
    "Basics: switch management IP (SVI)": (
        "Layer 2 switch: an IP address on a management VLAN, plus the default gateway "
        "it uses to reach other networks. The SVI only comes up once a port in that "
        "VLAN (or a trunk carrying it) is up.",
        [("vlan", "Management VLAN", "99"), ("ip", "Switch IP address", "192.168.99.11"),
         ("mask", "Mask or /prefix", "/24"), ("gateway", "Default gateway", "192.168.99.1")],
        [], build_mgmt_svi),
    "Basics: SSH remote access": (
        "RSA keys, SSH-only VTY lines (Telnet off) and a local user. Leave the username "
        "blank if Device setup already made the admin user. Set the hostname first. Test "
        "it from a neighboring device: ssh -l admin <its IP>",
        [("domain", "Domain name", "lab.local"),
         ("user", "Username (blank: use Device setup's admin user)", "admin"),
         ("password", "Password", "cisco"),
         ("modulus", "RSA key size", "2048", ["1024", "2048", "4096"]),
         ("vty", "VTY lines", "0 15")],
        [], build_ssh),
    "Management: NTP time sync": (
        "Keeps the device clock in step with an NTP server, so log messages from "
        "different devices line up. One router can be the lab's time source with "
        "'ntp master'.",
        [("server", "NTP server IP (blank on the time source)", "192.168.1.1"),
         ("stratum", "Stratum when it's the time source (1-15)", "3")],
        [("master", "This router is the lab's time source (ntp master)", False),
         ("timestamps", "Show the date and time on log messages", True)],
        build_ntp),
    "Management: syslog server": (
        "Sends the device's log messages to a syslog server. Lower numbers are more "
        "severe (0 emergencies ... 7 debugging); the level you pick and everything more "
        "severe is sent.",
        [("server", "Syslog server IP", "192.168.1.50"),
         ("level", "Send this level and more severe", "6 informational", LOG_LEVELS)],
        [("timestamps", "Show the date and time on log messages", True)],
        build_syslog),
    "VLANs: create and name VLANs": (
        "One or more VLANs, separated by commas: an ID, then a name with no spaces.",
        [("vlans", "VLANs", "10 USERS, 20 VOICE, 99 MANAGEMENT")],
        [], build_vlans),
    "VLANs: assign access ports": (
        "Create a VLAN and put ports in it. Ranges work: f0/1-5 or g1/0/1-4,g1/0/10.",
        [("vlan_id", "VLAN ID", "10"), ("name", "VLAN name", "USERS"),
         ("ports", "Access port(s)", "f0/1-5")],
        [_DTP_OFF, ("portfast", "Enable PortFast (end-device ports only)", True),
         ("no_shut", "Bring ports up (no shutdown)", True)],
        build_vlan),
    "VLANs: voice VLAN on access ports": (
        "A PC and an IP phone on one port: the PC's traffic on the data VLAN, the "
        "phone's tagged on the voice VLAN.",
        [("ports", "Access port(s)", "f0/1-10"), ("data_vlan", "Data VLAN", "10"),
         ("data_name", "Data VLAN name", "USERS"), ("voice_vlan", "Voice VLAN", "20"),
         ("voice_name", "Voice VLAN name", "VOICE")],
        [("create", "Create the VLANs too", True), _DTP_OFF,
         ("portfast", "Enable PortFast", True), ("no_shut", "Bring ports up", True)],
        build_voice),
    "Trunks: trunk port": (
        "802.1Q trunk between switches or to a router-on-a-stick.",
        [("ports", "Interface(s)", "g0/1"), ("allowed", "Allowed VLANs (blank = all)", ""),
         ("native", "Native VLAN (optional)", "")],
        [_ENCAP,
         ("nonegotiate", "Turn off DTP (switchport nonegotiate). The other end must "
                         "also be a static trunk, not dynamic auto/desirable", True),
         _UP],
        build_trunk),
    "Trunks: DTP mode (on, off, dynamic)": (
        "How the port uses DTP, the protocol switches use to agree on trunking. "
        "Two dynamic auto ends never form a trunk; desirable + auto or desirable does.",
        [("ports", "Interface(s)", "g0/1"), ("mode", "Mode", DTP_MODES[0], DTP_MODES)],
        [_ENCAP], build_dtp),
    "Trunks: reset port to defaults": (
        "Undo trunk settings. A full reset (default interface) wipes everything on "
        "the port. Unchecked, only trunk settings go back to the Cisco defaults: "
        "dynamic auto, all VLANs allowed, native VLAN 1.",
        [("ports", "Interface(s)", "g0/1")],
        [("full", "Full reset (default interface)", True)], build_reset),
    "Trunks: EtherChannel": (
        "Bundle links between two switches. Both ends need the same settings and a "
        "compatible mode: active/passive or active/active (LACP), desirable/auto "
        "(PAgP), or on/on.",
        [("ports", "Member ports", "g0/1-2"), ("group", "Channel-group number", "1"),
         ("mode", "Negotiation", next(iter(EC_MODES)), list(EC_MODES)),
         ("allowed", "Allowed VLANs (blank = all)", ""), ("native", "Native VLAN (optional)", "")],
        [("trunk", "Make it a trunk", True), _DTP_OFF, _ENCAP],
        build_etherchannel),
    "Routing: interface IP address": (
        "IPv4 address on a router port or an SVI (interface vlan 10). For "
        "router-on-a-stick, enter the physical port (g0/0/1) and a VLAN; the "
        "subinterface (g0/0/1.10) is built for you.",
        [("interface", "Interface", "g0/0"), ("ip", "IP address", "192.168.1.1"),
         ("mask", "Mask or /prefix", "/24"), ("description", "Description", ""),
         ("dot1q", "802.1Q VLAN (router-on-a-stick)", "")],
        [_UP, ("no_switchport", "Layer 3 switch port: make it a routed port "
                                "(no switchport)", False)],
        build_interface),
    "Routing: inter-VLAN, router-on-a-stick": (
        "One subinterface per VLAN on a router port. Each address becomes that VLAN's "
        "default gateway. The switch port facing the router must be a trunk. A native VLAN "
        "that isn't listed gets its own subinterface with no address.",
        [("port", "Router port", "g0/0/1"),
         ("subifs", "VLAN=gateway/prefix, ...", "10=192.168.10.1/24, 20=192.168.20.1/24"),
         ("native", "Native VLAN (optional)", "")],
        [], build_roas),
    "Routing: inter-VLAN, Layer 3 switch SVIs": (
        "One SVI per VLAN on a multilayer switch, with ip routing on. Each address "
        "becomes that VLAN's default gateway. A 2960 needs 'sdm prefer lanbase-routing' "
        "and a reload first.",
        [("svis", "VLAN=gateway/prefix, ...", "10=192.168.10.1/24, 20=192.168.20.1/24")],
        [("ip_routing", "Turn on routing (ip routing)", True),
         ("create", "Create the VLANs too", True)],
        build_l3_svis),
    "Routing: static route": (
        "A route to one remote network through a next-hop address, an exit interface, or "
        "both.",
        [("network", "Destination network", "192.168.20.0"), ("mask", "Mask or /prefix", "/24"),
         ("next_hop", "Next hop IP, exit interface, or both", "10.0.0.2"),
         ("distance", "Admin distance (optional)", "")],
        [], build_route),
    "Routing: default route": (
        "Gateway of last resort: traffic for any network not in the routing table goes "
        "here (ip route 0.0.0.0 0.0.0.0).",
        [("next_hop", "Next hop IP, exit interface, or both", "10.0.0.2")],
        [], build_default_route),
    "Routing: floating static route": (
        "A backup route that only takes over when the main one disappears. Give it a "
        "higher administrative distance than the main route (static 1, EIGRP 90, "
        "OSPF 110).",
        [("network", "Destination network", "0.0.0.0"), ("mask", "Mask or /prefix", "0.0.0.0"),
         ("next_hop", "Backup next hop, exit interface, or both", "10.0.1.2"),
         ("distance", "Admin distance (2-254)", "5")],
        [], build_floating_route),
    "Routing: HSRP gateway redundancy": (
        "Two routers share a virtual IP that hosts use as their default gateway. Run it "
        "on both routers with the same group and virtual IP, and give the one that "
        "should be active the higher priority. Each interface needs its own IP in that "
        "subnet first.",
        [("interface", "Interface (LAN side)", "g0/0/1"), ("group", "Group number", "1"),
         ("vip", "Virtual IP (the hosts' gateway)", "192.168.1.254"),
         ("priority", "Priority (optional, 1-255; set 150 on the router that should "
                      "be Active)", "")],
        [("v2", "HSRP version 2 (standby version 2)", True),
         ("preempt", "Take over again when this router comes back (preempt)", True)],
        build_hsrp),
    "IPv6: interface address": (
        "IPv6 address on a router port or an SVI. Tick EUI-64 to enter just the /64 "
        "prefix and let the device fill in the rest from its MAC address. The link-local "
        "address (fe80::) is optional; the device makes one anyway.",
        [("interface", "Interface", "g0/0/0"),
         ("address", "IPv6 address/prefix", "2001:db8:acad:1::1/64"),
         ("link_local", "Link-local address (optional)", "")],
        [("unicast_routing", "Route IPv6 between interfaces (ipv6 unicast-routing)", True),
         ("eui64", "EUI-64: build the host part from the MAC address", False), _UP],
        build_ipv6_interface),
    "IPv6: static or default route": (
        "A route to a remote IPv6 network. Use ::/0 for a default route. A link-local "
        "next hop (fe80::) needs the exit interface in front of it: g0/0/1 fe80::2.",
        [("prefix", "Destination prefix (::/0 = default)", "2001:db8:acad:2::/64"),
         ("next_hop", "Next hop IPv6, exit interface, or both", "2001:db8:acad:3::2"),
         ("distance", "Admin distance (optional)", "")],
        [("unicast_routing", "Turn on IPv6 routing (ipv6 unicast-routing)", True)],
        build_ipv6_route),
    "DHCP: server pool": (
        "A DHCP pool on a router or switch. Exclude the gateway and any other static "
        "addresses so they aren't handed out.",
        [("pool", "Pool name", "VLAN10"), ("network", "Network", "192.168.10.0"),
         ("mask", "Mask or /prefix", "/24"), ("gateway", "Default router", "192.168.10.1"),
         ("exclude", "Excluded addresses (from-to)", "192.168.10.1-192.168.10.10"),
         ("dns", "DNS server(s) (optional)", "8.8.8.8"), ("domain", "Domain name (optional)", "")],
        [], build_dhcp_pool),
    "DHCP: relay (ip helper-address)": (
        "Forward DHCP requests to a server on another network. Configure it on the "
        "interface the clients use as their gateway, such as g0/0/1.10 or vlan 10.",
        [("interface", "Client-facing interface", "g0/0/1.10"),
         ("servers", "DHCP server IP(s)", "192.168.50.10")],
        [], build_dhcp_relay),
    "Security: port security": (
        "Limit which and how many MAC addresses can use an access port.",
        [("ports", "Access port(s)", "f0/1-10"), ("max", "Maximum MAC addresses", "2"),
         ("violation", "On violation", PS_VIOLATIONS[0], PS_VIOLATIONS)],
        [("sticky", "Learn addresses automatically (sticky)", True), _DTP_OFF,
         ("recovery", "Bring err-disabled ports back after 5 minutes", False)],
        build_port_security),
    "Security: secure unused ports": (
        "Lock down switch ports nothing is plugged into: access mode, DTP off, an "
        "optional parking VLAN and a description, then shut down. A VLAN that already "
        "exists keeps its name.",
        [("ports", "Unused port(s)", "f0/11-24"), ("vlan", "Park them in VLAN (optional)", ""),
         ("description", "Description (optional)", "UNUSED")],
        [_DTP_OFF, ("shut", "Shut them down", True)],
        build_unused_ports),
    "Security: VLAN hopping mitigation": (
        "Trunks: DTP off and an unused native VLAN (it must match on both ends). "
        "Unused ports: access mode, DTP off, parked in a black-hole VLAN and shut down.",
        [("trunks", "Trunk port(s)", "g0/1"), ("native", "Unused native VLAN", "999"),
         ("unused", "Unused port(s)", "f0/11-24"), ("blackhole", "Black-hole VLAN", "666"),
         ("hole_name", "Black-hole VLAN name (optional, such as BLACKHOLE)", ""),
         ("description", "Unused-port description (optional)", "UNUSED")],
        [("shut_unused", "Shut down the unused ports", True), _ENCAP,
         ("tag_native", "Also tag native VLAN traffic (vlan dot1q tag native)", False)],
        build_vlan_hopping),
    "Security: DHCP snooping": (
        "Only trusted ports (toward the real DHCP server) may send DHCP offers. Other "
        "ports can be rate-limited.",
        [("vlans", "VLANs", "10,20"), ("trusted", "Trusted port(s) (uplinks)", "g0/1"),
         ("limit_ports", "Rate-limited port(s) (optional)", "f0/1-10"),
         ("rate", "Packets per second", "10")],
        [("no_opt82", "Don't insert option 82 (needed when a Cisco router is the "
                      "DHCP server)", True)],
        build_dhcp_snooping),
    "Security: Dynamic ARP Inspection": (
        "Drops ARP replies that don't match DHCP snooping's bindings, so set up DHCP "
        "snooping first. Hosts with static addresses need an ARP ACL or they'll be "
        "blocked.",
        [("vlans", "VLANs", "10,20"), ("trusted", "Trusted port(s) (uplinks)", "g0/1")],
        [("validate", "Also check MAC and IP fields (validate src-mac dst-mac ip)", True)],
        build_dai),
    "OSPF: single-area setup": (
        "Router ID, where OSPF runs, passive interfaces and default route propagation in "
        "one go. Networks: one per line or comma-separated; a /prefix is turned into a "
        "wildcard, an address on its own (10.0.0.1) matches just that interface (wildcard "
        "0.0.0.0), and 'area N' overrides the area below. Interfaces: each one gets 'ip "
        "ospf <process> area <area>' instead of a network statement. Fill in either box or "
        "both.",
        [("pid", "Process ID", "1"), ("router_id", "Router ID (optional, such as 1.1.1.1)", ""),
         ("networks", "Networks", "192.168.10.0/24\n10.0.0.1", "multiline"),
         ("interfaces", "Interfaces (optional, such as g0/0/0, g0/0/1)", ""),
         ("area", "Area", "0"), ("passive", "Passive interface(s) (optional)", ""),
         ("passive_mode", "Passive mode", PASSIVE_MODES[0], PASSIVE_MODES),
         ("refbw", "Reference bandwidth, Mbps (optional)", "")],
        [("originate", "Advertise this router's default route (default-information "
                       "originate)", False),
         ("always", "...even when it has no default route itself (always)", False)],
        build_ospf),
    "OSPF: loopback as router ID": (
        "A loopback never goes down, which makes it a stable router ID. Without a "
        "router-id command, OSPF uses the highest loopback address when the process "
        "starts. If OSPF is already running, a new ID needs 'clear ip ospf process'. For a "
        "loopback that isn't the router ID, use Routing: interface IP address instead.",
        [("loopback", "Loopback number", "0"), ("ip", "Loopback address", "1.1.1.1"),
         ("pid", "OSPF process ID", "1"), ("area", "Area", "0")],
        [("set_rid", "Also set it explicitly (router-id)", True),
         ("advertise", "Advertise the loopback in OSPF", False)],
        build_loopback_rid),
    "OSPF: interface settings (cost, priority, timers)": (
        "Per-interface OSPF tuning. Leave a box blank to skip it. Priority 0 never "
        "becomes DR/BDR; lower cost is preferred; bandwidth changes the calculated cost, "
        "not the real speed; hello and dead timers must match on both neighbors. "
        "Reference bandwidth applies to the whole router: cost = reference bandwidth / "
        "interface bandwidth. Use the same value on every OSPF router. To set only the "
        "reference bandwidth, leave Interface blank.",
        [("interface", "Interface", "g0/0/0"), ("priority", "Priority (0-255)", ""),
         ("cost", "Cost (1-65535)", ""), ("bandwidth", "Bandwidth (kbps)", ""),
         ("hello", "Hello interval (sec)", ""), ("dead", "Dead interval (sec)", ""),
         ("pid", "OSPF process ID", "1"),
         ("refbw", "Reference bandwidth, Mbps (whole router)", "")],
        [("p2p", "Point-to-point network type (no DR/BDR election)", False)],
        build_ospf_intf),
    "OSPF: default route propagation": (
        "Give this router a default route and have OSPF hand it to the other routers. "
        "Leave the next hop blank if a default route already exists.",
        [("pid", "OSPF process ID", "1"), ("next_hop", "Default route next hop (optional)",
                                           "203.0.113.1")],
        [("always", "Advertise it even without a default route (always)", False)],
        build_ospf_default),
    "ACL: standard ACL": (
        "Filters by source address only, so put it close to the destination. One "
        "entry per line, as on the console; /24-style prefixes are converted for you.",
        [("name", "Name or number", "BLOCK_GUESTS"),
         ("entries", "Entries", "remark Guests can't reach this LAN\ndeny 192.168.30.0/24\n"
                                "permit any", "multiline"),
         ("interface", "Apply to interface (optional)", "g0/0/1"),
         ("direction", "Direction", "out", ["in", "out"])],
        [], build_std_acl),
    "ACL: extended ACL": (
        "Filters by protocol, source, destination and port, so put it close to the "
        "source. One entry per line, as on the console; /24-style prefixes are "
        "converted for you.",
        [("name", "Name or number", "WEB_ONLY"),
         ("entries", "Entries", "permit tcp 192.168.10.0/24 host 192.168.50.10 eq 80\n"
                                "permit tcp 192.168.10.0/24 host 192.168.50.10 eq 443\n"
                                "deny ip 192.168.10.0/24 192.168.50.0/24\npermit ip any any",
          "multiline"),
         ("interface", "Apply to interface (optional)", "g0/0/1"),
         ("direction", "Direction", "in", ["in", "out"])],
        [], build_ext_acl),
    "ACL: secure the VTY lines": (
        "Only the listed hosts or networks may open an SSH session to this device. "
        "The console isn't affected, so you can't lock yourself out completely.",
        [("name", "ACL name", "VTY_ACCESS"),
         ("allowed", "Allowed hosts/networks", "192.168.99.0/24, 10.1.1.5"),
         ("vty", "VTY lines", "0 15"), ("timeout", "Idle timeout, minutes (optional)", "10")],
        [("ssh_only", "SSH only (transport input ssh)", True)],
        build_vty_acl),
    "NAT: static NAT": (
        "A fixed one-to-one mapping, typically so an inside server can be reached from "
        "outside. Pairs: inside=outside, comma-separated.",
        [("inside", "Inside interface(s)", "g0/0/1"), ("outside", "Outside interface", "g0/0/0"),
         ("pairs", "inside=outside address pairs", "192.168.10.10=203.0.113.10")],
        [], build_static_nat),
    "NAT: dynamic NAT (address pool)": (
        "Inside hosts borrow a public address from a pool, one address per host, so the "
        "pool can run out.",
        [("inside", "Inside interface(s)", "g0/0/1"), ("outside", "Outside interface", "g0/0/0"),
         ("networks", "Inside network(s) to translate", "192.168.10.0/24"),
         ("acl", "ACL name", "NAT_INSIDE"), ("pool", "Pool name", "PUBLIC"),
         ("start", "First pool address", "203.0.113.10"),
         ("end", "Last pool address", "203.0.113.20"), ("mask", "Pool mask or /prefix", "/24")],
        [], build_dynamic_nat),
    "NAT: PAT on the outside interface (overload)": (
        "Every inside host shares the outside interface's single address, told apart by "
        "port numbers. This is what a home router does.",
        [("inside", "Inside interface(s)", "g0/0/1"), ("outside", "Outside interface", "g0/0/0"),
         ("networks", "Inside network(s) to translate", "192.168.10.0/24"),
         ("acl", "ACL name", "NAT_INSIDE")],
        [], build_pat_interface),
    "NAT: PAT with an address pool (overload)": (
        "Inside hosts share the addresses in a pool, told apart by port numbers. For PAT "
        "to one public address that isn't the interface's, use the same first and last "
        "address.",
        [("inside", "Inside interface(s)", "g0/0/1"), ("outside", "Outside interface", "g0/0/0"),
         ("networks", "Inside network(s) to translate", "192.168.10.0/24"),
         ("acl", "ACL name", "NAT_INSIDE"), ("pool", "Pool name", "PUBLIC"),
         ("start", "First pool address", "203.0.113.10"),
         ("end", "Last pool address", "203.0.113.11"), ("mask", "Pool mask or /prefix", "/24")],
        [], build_pat_pool),
    "Spanning tree: root bridge and Rapid PVST+": (
        "Pick which switch is the root bridge for each VLAN, and turn on Rapid PVST+ for "
        "faster convergence. The lowest priority wins; a tie goes to the lowest MAC "
        "address.",
        [("vlans", "VLANs", "1"), ("role", "This switch", STP_ROLES[0], STP_ROLES),
         ("priority", "Priority (for 'Use the priority below')", "4096")],
        [("rapid", "Use Rapid PVST+ (spanning-tree mode rapid-pvst)", True)],
        build_stp_root),
    "Spanning tree: PortFast and BPDU Guard": (
        "PortFast lets end-device ports skip the STP wait; BPDU Guard shuts a PortFast "
        "port if a switch is plugged into it. Never use PortFast on links to switches.",
        [("ports", "Access port(s) (optional)", "f0/1-10")],
        [("portfast", "PortFast on these ports", True),
         ("bpduguard", "BPDU Guard on these ports", True),
         ("global_pf", "Global: PortFast on every access port (portfast default)", False),
         ("global_bg", "Global: BPDU Guard on every PortFast port (bpduguard default)", False)],
        build_portfast),
}

# name: (show commands to check the result, what to look at if it doesn't work)
TEMPLATE_HELP = {
    "Basics: morning baseline (setup + SSH + management IP)": (
        ["show running-config", "show ip interface brief", "show ip ssh"],
        "Run it first on a freshly booted device. If key generation complains, the "
        "hostname or domain name didn't take; check the lines above it. IOS XE 17.11 and "
        "later need a 2048-bit key for SSH."),
    "Basics: device setup": (
        ["show running-config"],
        "Passwords are case-sensitive. 'service password-encryption' only hides the "
        "plain passwords in show run; the enable secret is always hashed."),
    "Basics: switch management IP (SVI)": (
        ["show ip interface brief", "show vlan brief"],
        "SVI down? The VLAN must exist and a port in it (or a trunk carrying it) must be "
        "up. Can reach the local subnet but nothing else? Check the default gateway."),
    "Basics: SSH remote access": (
        ["show ip ssh", "show crypto key mypubkey rsa", "show users"],
        "'Please define a hostname' means set the hostname first. If SSH is refused, check "
        "'transport input ssh', 'login local' and the username. Test from another device: "
        "ssh -l admin <IP>. On IOS XE 17.11 and later, SSH stays off with a key under 2048 "
        "bits."),
    "Management: NTP time sync": (
        ["show ntp associations", "show ntp status", "show clock"],
        "Syncing can take several minutes; the server in use is marked with * in show "
        "ntp associations. Set the time source's own clock first with 'clock set' in "
        "privileged exec mode."),
    "Management: syslog server": (
        ["show logging"],
        "The top of show logging lists the server and the level (Trap logging). Nothing "
        "arriving? Ping the server, and check that a syslog program is running on it."),
    "VLANs: create and name VLANs": (
        ["show vlan brief"],
        "A VTP client switch can't create VLANs. VLANs live in vlan.dat and survive a "
        "power cycle, so delete vlan.dat at the end of the day."),
    "VLANs: assign access ports": (
        ["show vlan brief", "show interfaces status"],
        "A port missing from show vlan brief is probably a trunk (show interfaces trunk). "
        "'Access VLAN does not exist' means the switch created the VLAN for you."),
    "VLANs: voice VLAN on access ports": (
        ["show interfaces switchport", "show vlan brief"],
        "The phone learns the voice VLAN through CDP or LLDP; the PC behind it stays on "
        "the data VLAN."),
    "Trunks: trunk port": (
        ["show interfaces trunk", "show interfaces switchport"],
        "Not trunking? With DTP off, the far end must be a static trunk too. A native "
        "VLAN mismatch means both ends need the same native VLAN. A VLAN missing from "
        "'allowed and active' isn't created on this switch or isn't allowed."),
    "Trunks: DTP mode (on, off, dynamic)": (
        ["show interfaces switchport", "show dtp interface"],
        "Compare 'Administrative Mode' and 'Operational Mode'. Auto + auto stays access."),
    "Trunks: reset port to defaults": (
        ["show interfaces switchport", "show interfaces status"],
        "A full reset also removes the description, port security and PortFast."),
    "Trunks: EtherChannel": (
        ["show etherchannel summary", "show interfaces trunk"],
        "SU and (P) mean up and bundled; (s), (I) or (D) mean a member didn't join. "
        "Members must match speed, duplex, VLANs and mode, and the two ends need "
        "compatible modes."),
    "Routing: interface IP address": (
        ["show ip interface brief"],
        "'administratively down' needs 'no shutdown'. 'up/down' points at the cable or the "
        "far end. '... overlaps with ...' means another interface already uses that subnet."),
    "Routing: inter-VLAN, router-on-a-stick": (
        ["show ip interface brief", "show ip route"],
        "Hosts need the subinterface address as their default gateway. The switch port "
        "toward the router must be a trunk that allows these VLANs. Ping each gateway "
        "from a host first."),
    "Routing: inter-VLAN, Layer 3 switch SVIs": (
        ["show ip interface brief", "show ip route"],
        "No connected routes? 'ip routing' is off or unsupported (a 2960 needs 'sdm prefer "
        "lanbase-routing' and a reload). An SVI stays down until a port in its VLAN is up."),
    "Routing: static route": (
        ["show ip route", "show ip route static"],
        "A static route only appears if its next hop is reachable or its exit interface "
        "is up. The far router also needs a route back. On Ethernet, give the exit "
        "interface and the next hop together (g0/0/0 10.0.0.2): an exit interface alone "
        "relies on proxy ARP."),
    "Routing: default route": (
        ["show ip route"],
        "Look for 'Gateway of last resort' near the top of show ip route."),
    "Routing: floating static route": (
        ["show ip route", "show ip route static"],
        "The backup route stays out of the table while the main route exists; that's "
        "normal. Test it by shutting down the main link."),
    "IPv6: interface address": (
        ["show ipv6 interface brief", "show ipv6 route"],
        "Each IPv6 interface also gets a link-local address (fe80::). No IPv6 routes? "
        "Turn on 'ipv6 unicast-routing'. A 2960 may need an IPv6-capable SDM template "
        "('show sdm prefer') and a reload before it takes IPv6 addresses."),
    "IPv6: static or default route": (
        ["show ipv6 route", "show ipv6 route static"],
        "A static route only appears if its next hop is reachable or its exit interface "
        "is up. The far router also needs a route back. A link-local next hop needs its "
        "exit interface: g0/0/1 fe80::2."),
    "Routing: HSRP gateway redundancy": (
        ["show standby brief", "show standby"],
        "One router should show Active and the other Standby. Both Active? They can't "
        "hear each other: check the group number, the subnet and the link. Without "
        "preempt, a higher-priority router doesn't take over again after it comes back."),
    "DHCP: server pool": (
        ["show ip dhcp pool", "show ip dhcp binding"],
        "Clients get nothing? The pool network must match the client subnet, and a server "
        "on another network needs a relay (ip helper-address). 'show ip dhcp conflict' "
        "lists addresses already in use."),
    "DHCP: relay (ip helper-address)": (
        ["show running-config | section interface", "show ip interface brief"],
        "The helper goes on the clients' gateway interface. The server needs a pool for "
        "the clients' subnet and a route back to it."),
    "Security: port security": (
        ["show port-security", "show port-security address",
         "show interfaces status err-disabled"],
        "In shutdown mode a violation err-disables the port: remove the cause, then "
        "'shutdown' and 'no shutdown'. Sticky addresses are lost at the next reload."),
    "Security: secure unused ports": (
        ["show interfaces status", "show vlan brief"],
        "Shut-down ports show as disabled in show interfaces status. To use one again, put "
        "it in the right VLAN and enter 'no shutdown'."),
    "Security: VLAN hopping mitigation": (
        ["show interfaces trunk", "show vlan brief", "show interfaces status"],
        "Change the native VLAN on both ends of every trunk, or CDP reports a native VLAN "
        "mismatch."),
    "Security: DHCP snooping": (
        ["show ip dhcp snooping", "show ip dhcp snooping binding"],
        "Clients stopped getting addresses? The port toward the DHCP server or relay must "
        "be trusted. With a Cisco router as the server, keep the option 82 box ticked."),
    "Security: Dynamic ARP Inspection": (
        ["show ip arp inspection", "show ip dhcp snooping binding"],
        "Hosts lost connectivity? Each needs a DHCP snooping binding and the uplinks must "
        "be trusted. Hosts with static addresses need an ARP ACL."),
    "OSPF: single-area setup": (
        ["show ip ospf neighbor", "show ip route ospf", "show ip protocols"],
        "No neighbors? Both ends need the same area, subnet, hello/dead timers and network "
        "type, and the link can't be passive. Routes missing? The network statements or "
        "'ip ospf ... area' lines must cover the interfaces (show ip ospf interface brief)."),
    "OSPF: loopback as router ID": (
        ["show ip ospf", "show ip interface brief"],
        "The first line of show ip ospf gives the router ID. A changed ID only applies "
        "after 'clear ip ospf process' (answer yes) or a reload. Each router needs a "
        "different ID."),
    "OSPF: interface settings (cost, priority, timers)": (
        ["show ip ospf interface brief", "show ip ospf interface", "show ip ospf neighbor"],
        "Neighbors dropped after a timer change? Hello and dead must match on both ends, "
        "and so must the network type. A new priority only matters at the next DR/BDR "
        "election. Every fast link shows cost 1? Set a reference bandwidth such as 10000 "
        "on every router."),
    "OSPF: default route propagation": (
        ["show ip route", "show ip ospf database"],
        "Without 'always', the router only advertises a default while it has one itself. "
        "The other routers show it as O*E2 in show ip route."),
    "ACL: standard ACL": (
        ["show access-lists", "show ip interface"],
        "Everything blocked? Every ACL ends with an implicit deny any, so add a permit. "
        "Counters in show access-lists show which entries are matching. Check the "
        "interface and direction."),
    "ACL: extended ACL": (
        ["show access-lists", "show ip interface"],
        "'in' filters traffic arriving on the interface, 'out' traffic leaving it. "
        "Entries are checked top to bottom and the first match wins, so put specific "
        "entries first."),
    "ACL: secure the VTY lines": (
        ["show access-lists", "show running-config | section line vty", "show users"],
        "Test SSH from an allowed host and a blocked one. The console still works if a "
        "mistake locks out remote access."),
    "NAT: static NAT": (
        ["show ip nat translations", "show ip nat statistics"],
        "Nothing translating? 'ip nat inside' and 'ip nat outside' must be on the right "
        "interfaces, and the outside router needs a route back to the public address."),
    "NAT: dynamic NAT (address pool)": (
        ["show ip nat translations", "show ip nat statistics"],
        "New hosts can't get out once the pool runs out; PAT fixes that. The ACL must "
        "permit the inside hosts. 'clear ip nat translation *' clears old entries."),
    "NAT: PAT on the outside interface (overload)": (
        ["show ip nat translations", "show ip nat statistics"],
        "Traffic must come in an 'ip nat inside' interface and leave the 'ip nat outside' "
        "one, and the ACL must permit the inside hosts. Translations only appear while "
        "traffic flows; ping out and check again."),
    "NAT: PAT with an address pool (overload)": (
        ["show ip nat translations", "show ip nat statistics"],
        "Check the pool addresses are routed back to this router by the outside network, "
        "and the ACL permits the inside hosts."),
    "Spanning tree: root bridge and Rapid PVST+": (
        ["show spanning-tree root", "show spanning-tree summary"],
        "Look for 'This bridge is the root' under each VLAN in show spanning-tree. 'root "
        "primary' only picks a priority low enough to win right now; a switch added "
        "later with a lower one still takes over."),
    "Spanning tree: PortFast and BPDU Guard": (
        ["show spanning-tree summary", "show interfaces status err-disabled"],
        "A port err-disabled by BPDU Guard had a switch plugged into it. Remove it, then "
        "'shutdown' and 'no shutdown'."),
}

QUICK_SHOWS = [("show run", "show running-config"),
               ("ip int brief", "show ip interface brief"),
               ("vlan brief", "show vlan brief"),
               ("int trunk", "show interfaces trunk"),
               ("ip route", "show ip route"),
               ("no paging", "terminal length 0")]

MORE_SHOWS = ["show interfaces status", "show interfaces switchport",
              "show etherchannel summary", "show spanning-tree", "show spanning-tree summary",
              "show mac address-table", "show cdp neighbors",
              "show port-security", "show port-security address",
              "show interfaces status err-disabled", "show errdisable recovery",
              "show ip dhcp snooping", "show ip dhcp snooping binding",
              "show ip arp inspection", "show ip dhcp pool", "show ip dhcp binding",
              "show spanning-tree root", "show ipv6 interface brief", "show ipv6 route",
              "show standby brief", "show ntp associations", "show logging",
              "show ip route static", "show ip protocols", "show ip ospf neighbor",
              "show ip ospf interface brief", "show ip route ospf", "show ip ospf",
              "show access-lists", "show ip nat translations", "show ip nat statistics",
              "show ip ssh", "show users",
              "show version", "show startup-config"]

RULES_TEXT = """\
BLOCKED FOR STUDENTS
- Saving: write, write memory, copy running-config startup-config, and their
  abbreviations, such as wr, copy r s and do wr. Also answering "yes" when a
  reload asks to save, and option [2] at the end of the setup dialog.
- Erasing: write erase, erase startup-config / nvram: / flash:
- Flash changes: delete, rename, format, squeeze, mkdir, rmdir, fsck, and any
  copy into flash, NVRAM or startup-config. The one exception is the VLAN
  database: students may run 'delete vlan.dat' (or delete flash:vlan.dat; it's
  the same file) and confirm it. Every other file stays blocked.
- Loading a config file: copy tftp: running-config (or from flash:, http: or
  any other place), configure replace, configure network. The file's commands
  can't be checked.
- Boot settings: any boot or system command, config-register, switch <n>
  priority / renumber, ROMMON confreg and ROMMON variables.
- Hidden automation: alias, macro, kron, event manager, archive, file prompt,
  tclsh.
- Output to files: | redirect, | append, | tee
- Software and licenses: install, upgrade, software, request, license.
- no service password-recovery
- prompt: the console reads the normal prompt to track the device, so the
  prompt can't be changed.

ASKS FIRST
- reload: clears all unsaved work.
- vtp: every VTP setting lives in vlan.dat and survives a power cycle.
- sdm prefer: stored for the next boot.
- transport input telnet / all: Telnet sends passwords in clear text. Use
  transport input ssh.
- ip ssh version 1: use version 2.
- Commands that write files to flash (logging file flash:, captures, key
  exports) and remote-write services (SNMP rw or write views, NETCONF,
  RESTCONF, ip scp server enable, ip http server).

GOOD TO KNOW
- VLANs are kept in flash:vlan.dat and survive a power cycle even without
  saving. Students can clear them with 'delete vlan.dat', or use the Instructor
  tab (Delete VLAN database), before the switches power down. VLANs stay active
  until then, and any VLAN change made afterward recreates the file.
- Pasted lines, '?' help and Tab completion are all checked. The device always
  runs exactly the text that was checked. Banner text is checked too, so a banner
  line that starts with a blocked word, such as write, copy or delete, is
  refused; reword it.
- Only plain characters are sent: curly quotes and dashes become straight
  ones, accented letters lose the accent (é becomes e), and other symbols
  are dropped.
- Every sent and blocked command is logged in slatewise_logs\\<date>.log.
- Saved setups ('Save setup...' on the Quick config tab) are stored on this
  PC, never on the device. Replay one each morning; every line is still
  checked.
- Logs and saved setups on this PC are in:
  {folder}
- [Hint] lines in the terminal explain common device messages. Turn them off
  with the Hints box in the status bar.
- Test SSH from another lab device: ssh -l USERNAME IP-ADDRESS

DEVICE-SIDE BACKSTOPS (instructor)
This app is a guardrail: a student using another terminal program can still
save. To make a save harmless on the device itself:
- Routers: config-register 0x2142 makes the router ignore startup-config at
  every boot (set it back to 0x2102 to undo).
- Catalyst 9200/9300: system ignore startupconfig switch all
- Catalyst 2960 (no config register): boot config-file
  flash:/nonexistent/config.text points the startup-config at a folder that
  doesn't exist, so saving fails. vlan.dat is still used.
"""


# ===========================================================================
# GUI
# ===========================================================================

BAUD_RATES = ["9600", "19200", "38400", "57600", "115200"]
TEXT_SIZES = (8, 24)                      # smallest and largest terminal text
ZOOM_KEYSYMS = ("equal", "plus", "minus", "0", "kp_add", "kp_subtract")
# Display settings (the Display window), saved per PC in slatewise_prefs.json.
THEME_NAMES = [("light", "Light (Windows colors)"), ("dark", "Dark"),
               ("contrast", "High contrast (larger text)")]
LAYOUT_NAMES = [("right", "Panel on the right"), ("left", "Panel on the left"),
                ("terminal", "Terminal only (F6 shows the panel)")]
UI_FONTS = ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont", "TkCaptionFont")
_TERM_COLORS = ("#0f141a", "#d7dee7", "#2d4f73",          # background, text, selection
                ("#ff8b80", "#f2c55c", "#7fd1a8", "#93c5fd"))  # guard, note, info, hint
# Mode colors: (badge background, badge text, strip background, strip text). Light keeps
# the system look on Windows; its bg/surface/hover colors are used where ttk's clam is
# the system theme.
PALETTES = {
    "light": dict(bg="#eceff3", surface="#f8fafc", hover="#e2e8f0", field="#ffffff",
                  fg="#111827", muted="#6b7280", soft="#4b5563", body="#374151",
                  success="#15803d", border="#8a8f98", accent="#2563eb", accent_fg="#ffffff",
                  select="#bfdbfe", disabled="#9ca3af", thumb=("#c7ccd4", "#9ca3af"),
                  term=_TERM_COLORS,
                  student=("#166534", "#ffffff", "#fef9c3", "#713f12"),
                  instructor=("#b45309", "#ffffff", "#ffedd5", "#7c2d12")),
    "dark": dict(bg="#1b2028", surface="#242b35", hover="#303947", field="#2a323d",
                 fg="#e5e7eb", muted="#9aa4b2", soft="#b4bcc8", body="#d1d5db",
                 success="#4ade80", border="#3a4350", accent="#3b82f6", accent_fg="#ffffff",
                 select="#2d4f73", disabled="#5b6573", thumb=("#3f4856", "#566173"),
                 term=_TERM_COLORS,
                 student=("#14532d", "#dcfce7", "#16351f", "#bbf7d0"),
                 instructor=("#9a3412", "#ffedd5", "#3a2414", "#fed7aa")),
    "contrast": dict(bg="#000000", surface="#000000", hover="#1f1f1f", field="#000000",
                     fg="#ffffff", muted="#ffff66", soft="#ffff66", body="#ffffff",
                     success="#00ff7f", border="#ffffff", accent="#ffff00",
                     accent_fg="#000000", select="#1d4ed8", disabled="#8a8a8a",
                     thumb=("#ffffff", "#ffff00"),
                     term=("#000000", "#ffffff", "#1d4ed8",
                           ("#ff6b6b", "#ffff00", "#00ff7f", "#00ffff")),
                     student=("#00ff7f", "#000000", "#000000", "#ffffff"),
                     instructor=("#ffaa00", "#000000", "#000000", "#ffffff")),
}


class _Scrollable(ttk.Frame if tk else object):    # importable without Tkinter
    """A frame whose contents scroll vertically when they don't fit, and stay put when
    they do."""
    def __init__(self, parent, bg):
        super().__init__(parent)
        self.canvas = tk.Canvas(self, highlightthickness=0, borderwidth=0, background=bg)
        sb = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
        self.inner = ttk.Frame(self.canvas)
        self._win = self.canvas.create_window((0, 0), window=self.inner, anchor="nw")
        self.canvas.configure(yscrollcommand=sb.set)
        self.canvas.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        self.inner.bind("<Configure>", lambda e: self._fit())
        self.canvas.bind("<Configure>", self._resized)
        self.canvas.bind("<Enter>", self._grab_wheel)
        self.canvas.bind("<Leave>", self._release_wheel)

    def _resized(self, e):
        self.canvas.itemconfigure(self._win, width=e.width)
        self._fit()

    def fits(self) -> bool:
        return self.inner.winfo_reqheight() <= self.canvas.winfo_height()

    def _fit(self):
        """Never let the scroll area be shorter than the view: a canvas scrolls short
        contents up and down, leaving a gap above them."""
        height = max(self.inner.winfo_reqheight(), self.canvas.winfo_height())
        self.canvas.configure(scrollregion=(0, 0, self.canvas.winfo_width(), height))
        if self.fits():
            self.canvas.yview_moveto(0)

    def scroll(self, units: int):
        if not self.fits():
            self.canvas.yview_scroll(units, "units")

    def _grab_wheel(self, _e):
        self.canvas.bind_all("<MouseWheel>", lambda e: self.scroll(
            int(-e.delta / 120) or (-1 if e.delta > 0 else 1)))
        self.canvas.bind_all("<Button-4>", lambda e: self.scroll(-1))
        self.canvas.bind_all("<Button-5>", lambda e: self.scroll(1))

    def _release_wheel(self, _e):
        for seq in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
            self.canvas.unbind_all(seq)

    def to_top(self):
        self.canvas.yview_moveto(0)


class _TextValue:
    """Lets a multi-line Text box stand in for a StringVar in the template form."""
    def __init__(self, box):
        self.box = box

    def get(self) -> str:
        return self.box.get("1.0", "end-1c")


class LabConsoleApp:
    def __init__(self, root):
        self.root = root
        _set_icon(root)
        folder = self.folder = app_dir()
        self.settings = Settings(settings_dir())
        self.log_book = CommandLog(folder)
        self._log_warned = False
        self.session = None
        self.device_label = self.port_name = ""
        self.instructor = False
        self._relock_job = None
        self.ui_queue = queue.Queue()
        self.history, self.hist_idx = [], None
        self.noted = set()
        self.batch_stop = threading.Event()
        self.batch_thread = None
        self._masked = False
        self._hint_buf = ""
        self._hint_last = {}
        self._hint_queue = []
        self.practice_devices = {k: PracticeDevice(k) for k in ("router", "switch")}
        self._build_ui()
        self._refresh_ports()
        self._apply_prefs()
        self._set_instructor(False, quiet=True)
        self.root.report_callback_exception = self._on_tk_error
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        self._poll_job = self.root.after(40, self._poll)

    # -- layout --------------------------------------------------------------
    def _build_ui(self):
        r = self.root
        r.title(f"{APP_NAME} {VERSION}")
        r.geometry("1300x820")
        r.minsize(1000, 640)
        style = ttk.Style()
        if sys.platform != "win32" and "clam" in style.theme_names():
            style.theme_use("clam")
        self._native_theme = style.theme_use()
        self._font_sizes = {n: tkfont.nametofont(n).cget("size") for n in UI_FONTS}
        self.theme, self.layout, self._panel_hidden = "light", "right", False
        self._display_win = None
        self._wrap = 440                # wrap width for text in the side panel
        self.ttk_bg = style.lookup("TFrame", "background") or "#f0f0f0"
        families = set(tkfont.families())
        mono = next((f for f in ("Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Menlo",
                                 "Courier New") if f in families), "Courier")
        self.mono = tkfont.Font(family=mono, size=10)
        self.bold = tkfont.nametofont("TkDefaultFont").copy()
        self.bold.configure(weight="bold")

        self._build_top_bar(r)
        self._build_status_bar(r)       # packed before the panes, so a short window can't hide it

        panes = ttk.PanedWindow(r, orient="horizontal")
        panes.pack(fill="both", expand=True, padx=8, pady=(0, 2))
        self._panes = panes
        self.term_side, self.panel = ttk.Frame(panes), ttk.Frame(panes)
        self._build_terminal(self.term_side)

        nb = ttk.Notebook(self.panel)
        nb.pack(fill="both", expand=True)
        self.nb = nb
        nb.add(self._build_quick_config(nb), text="Quick config")
        nb.add(self._build_practice_tab(nb), text="Practice")
        nb.add(self._build_rules_tab(nb), text="Lab rules")
        self.instr_tab = self._build_instructor_tab(nb)
        nb.add(self.instr_tab, text="Instructor")
        self.panel.bind("<Configure>", self._panel_resized)

        self._apply_theme("light")
        self._apply_layout("right")
        r.bind("<F6>", lambda e: self._toggle_panel())
        self._term_write(f"{APP_NAME} {VERSION}\n"
                         "Plug in the console cable, pick its port, click Connect, then "
                         "press Enter to wake the device. No device? Open the Practice "
                         "tab.\n\n", "info")

    def _build_top_bar(self, r):
        """Connection settings on one row, then the mode strip: the mode, what it means
        and the instructor button."""
        bar = ttk.Frame(r, padding=(8, 6, 8, 4))
        bar.pack(fill="x")
        ttk.Label(bar, text="Console port").pack(side="left")
        self.port_var = tk.StringVar()
        self.port_box = ttk.Combobox(bar, textvariable=self.port_var, width=34,
                                     postcommand=self._refresh_ports)
        self.port_box.pack(side="left", padx=(6, 4))
        ttk.Button(bar, text="Refresh", width=8, command=self._refresh_ports).pack(side="left")
        ttk.Label(bar, text="Baud").pack(side="left", padx=(12, 4))
        self.baud_var = tk.StringVar(value="9600")
        ttk.Combobox(bar, textvariable=self.baud_var, width=8, state="readonly",
                     values=BAUD_RATES).pack(side="left")
        self.connect_btn = ttk.Button(bar, text="Connect", width=12, style="Accent.TButton",
                                      command=self._toggle_connect)
        self.connect_btn.pack(side="left", padx=(10, 0))
        self.panel_btn = ttk.Button(bar, text="Show panel (F6)", command=self._toggle_panel)
        # shown only while the side panel is hidden (see _apply_layout)
        self.conn_hint = ttk.Label(bar, style="Muted.TLabel", justify="left",
                                   text="Cisco consoles default to 9600 baud. A USB console "
                                        "cable shows up here once its driver is installed.")
        self.conn_hint.pack(side="left", fill="x", expand=True, padx=(12, 0))
        self.conn_hint.bind("<Configure>", lambda e: self.conn_hint.configure(
            wraplength=max(e.width, 200)))

        self.strip = tk.Frame(r)
        self.strip.pack(fill="x", padx=8, pady=(0, 6))
        self.mode_label = tk.Label(self.strip, font=self.bold, padx=12, pady=3)
        self.mode_label.pack(side="left", fill="y")
        self.instr_btn = ttk.Button(self.strip, text="Instructor...",
                                    command=self._toggle_instructor)
        self.instr_btn.pack(side="right", padx=4, pady=2)
        self.banner = tk.Label(self.strip, anchor="w", justify="left", padx=10, pady=3)
        self.banner.pack(side="left", fill="both", expand=True)
        self.banner.bind("<Configure>", lambda e: self.banner.configure(
            wraplength=max(e.width - 20, 200)))

    def _build_terminal(self, left):
        """Quick show buttons, the terminal, the command line and the key buttons."""
        r = self.root
        # The bottom rows go first, so a short window shrinks the terminal instead.
        keys = ttk.Frame(left)
        keys.pack(side="bottom", fill="x", pady=(4, 0))
        for label, key in [("Enter", "enter"), ("Space (--More--)", "space"),
                           ("Ctrl-C", "ctrl_c"), ("Ctrl-Z (end)", "ctrl_z"),
                           ("Ctrl-Shift-6", "ctrl_shift_6"), ("Break", "break")]:
            ttk.Button(keys, text=label, command=lambda k=key: self._send_key(k)).pack(
                side="left", padx=(0, 4))
        ttk.Label(keys, text="? help  ·  Tab completes  ·  Up/Down history",
                  style="Muted.TLabel").pack(side="right")

        cmd = ttk.Frame(left)
        cmd.pack(side="bottom", fill="x", pady=(6, 0))
        ttk.Label(cmd, text="Command").pack(side="left")
        self.entry = tk.Entry(cmd, font=self.mono)
        self.entry.pack(side="left", fill="x", expand=True, padx=8, ipady=4)
        self.send_btn = ttk.Button(cmd, text="Send", style="Accent.TButton",
                                   command=self._send_entry)
        self.send_btn.pack(side="left")
        self.entry.bind("<Return>", self._send_entry)
        self.entry.bind("<KP_Enter>", self._send_entry)
        self.entry.bind("<Up>", lambda e: self._history(-1))
        self.entry.bind("<Down>", lambda e: self._history(1))
        self.entry.bind("<Tab>", self._tab_complete)
        self.entry.bind("<KeyPress-question>", self._context_help)
        self.entry.bind("<Escape>", lambda e: self.entry.delete(0, "end"))

        # The quick show buttons hide with the side panel (F6), leaving a plain terminal;
        # Save output is also on the terminal's right-click menu.
        shows = self.shows = ttk.Frame(left)
        shows.pack(fill="x", pady=(0, 4))
        for label, cmd_text in QUICK_SHOWS:
            ttk.Button(shows, text=label, command=lambda c=cmd_text: self._submit(c)).pack(
                side="left", padx=(0, 3))
        self.more_var = tk.StringVar(value="More show commands")
        more = ttk.Combobox(shows, textvariable=self.more_var, values=MORE_SHOWS,
                            state="readonly", width=24, height=len(MORE_SHOWS))
        more.pack(side="left", padx=(4, 0))

        def pick(_):
            cmd = self.more_var.get()
            self.more_var.set("More show commands")
            self._submit(cmd)
        more.bind("<<ComboboxSelected>>", pick)
        ttk.Button(shows, text="Save output...", command=self._save_output).pack(side="right")

        termf = ttk.Frame(left)
        termf.pack(fill="both", expand=True)
        self.term = tk.Text(termf, font=self.mono, wrap="char", relief="flat", padx=10, pady=8,
                            state="disabled", cursor="xterm", highlightthickness=0)
        sb = ttk.Scrollbar(termf, orient="vertical", command=self.term.yview)
        self.term.configure(yscrollcommand=sb.set)
        self.term.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        self.term.tag_configure("guard", wrap="word",
                                font=(self.mono.actual("family"), 10, "bold"))
        for tag in ("note", "info", "hint"):
            self.term.tag_configure(tag, wrap="word")
        self.term.bind("<Key>", self._term_key)
        self.term_menu = tk.Menu(r, tearoff=False)
        self.term_menu.add_command(label="Copy", command=self._term_copy)
        self.term_menu.add_command(label="Paste", command=self._term_paste)
        self.term_menu.add_command(label="Select all",
                                   command=lambda: self.term.tag_add("sel", "1.0", "end-1c"))
        self.term_menu.add_separator()
        self.term_menu.add_command(label="Save output...", command=self._save_output)
        self.term.bind("<Button-3>", self._term_menu_show)
        for seq, step in (("<Control-equal>", 1), ("<Control-plus>", 1), ("<Control-KP_Add>", 1),
                          ("<Control-minus>", -1), ("<Control-KP_Subtract>", -1),
                          ("<Control-0>", 0)):
            r.bind(seq, lambda e, s=step: self._zoom(s))
        self.term.bind("<Button-1>", lambda e: self.root.after(1, self._focus_entry_if_no_sel))

    def _build_status_bar(self, r):
        status = ttk.Frame(r, padding=(10, 0, 10, 4))
        status.pack(side="bottom", fill="x")
        self.status_conn = ttk.Label(status, text="Not connected")
        self.status_conn.pack(side="left")
        self.status_prompt = ttk.Label(status, text="", style="Soft.TLabel")
        self.status_prompt.pack(side="left", padx=20)
        ttk.Button(status, text="Display...", command=self._open_display).pack(side="right")
        self.status_log = ttk.Label(status, text="", style="Muted.TLabel")
        self.status_log.pack(side="right", padx=12)
        self.hints_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(status, text="Hints", variable=self.hints_var).pack(side="right")

    # -- display: theme and layout -------------------------------------------------
    def _apply_theme(self, name: str):
        """Recolor the whole window. "light" keeps the system look; the others use
        ttk's clam theme, which takes colors on every platform."""
        name = name if name in PALETTES else "light"
        self.theme = name
        p = PALETTES[name]
        style = ttk.Style()
        native = name == "light" and self._native_theme != "clam"
        style.theme_use(self._native_theme if native else "clam")
        for font_name, size in self._font_sizes.items():
            bigger = size + 2 if size > 0 else size - 2     # negative sizes are pixels
            tkfont.nametofont(font_name).configure(size=bigger if name == "contrast" else size)
        self.bold.configure(size=tkfont.nametofont("TkDefaultFont").cget("size"))
        if not native:
            style.configure(".", background=p["bg"], foreground=p["fg"],
                            fieldbackground=p["field"], bordercolor=p["border"],
                            lightcolor=p["surface"], darkcolor=p["surface"],
                            troughcolor=p["bg"], focuscolor=p["accent"],
                            selectbackground=p["select"], selectforeground=p["fg"],
                            insertcolor=p["fg"], arrowcolor=p["fg"])
            # clam lights up any widget under the mouse unless told otherwise
            style.map(".", background=[("disabled", p["bg"]), ("active", p["hover"])])
            style.configure("TButton", background=p["surface"], padding=(10, 3))
            style.map("TButton", background=[("disabled", p["bg"]), ("active", p["hover"])],
                      foreground=[("disabled", p["disabled"])])
            style.configure("Accent.TButton", background=p["accent"], foreground=p["accent_fg"])
            style.map("Accent.TButton", background=[("disabled", p["bg"]),
                                                    ("active", p["accent"])])
            style.map("TCombobox", fieldbackground=[("readonly", p["field"])],
                      foreground=[("readonly", p["fg"])],
                      selectbackground=[("readonly", p["field"])],
                      selectforeground=[("readonly", p["fg"])])
            style.configure("TNotebook", background=p["bg"], bordercolor=p["border"])
            style.configure("TNotebook.Tab", background=p["bg"], foreground=p["muted"],
                            padding=(12, 4), bordercolor=p["border"])
            style.map("TNotebook.Tab", background=[("selected", p["surface"])],
                      foreground=[("selected", p["fg"]), ("disabled", p["disabled"])])
            for widget in ("TCheckbutton", "TRadiobutton"):
                style.configure(widget, background=p["bg"], foreground=p["fg"],
                                indicatorbackground=p["field"], indicatorforeground=p["fg"])
                style.map(widget, background=[("active", p["bg"])])
            thumb, thumb_hover = p["thumb"]
            style.configure("Vertical.TScrollbar", background=thumb, troughcolor=p["bg"],
                            bordercolor=p["bg"], lightcolor=thumb, darkcolor=thumb,
                            arrowcolor=p["fg"], gripsize=0)
            style.map("Vertical.TScrollbar",
                      background=[("pressed", thumb_hover), ("active", thumb_hover)],
                      lightcolor=[("pressed", thumb_hover), ("active", thumb_hover)],
                      darkcolor=[("pressed", thumb_hover), ("active", thumb_hover)])
        for style_name, key in (("Muted.TLabel", "muted"), ("Soft.TLabel", "soft"),
                                ("Body.TLabel", "body"), ("Success.TLabel", "success")):
            style.configure(style_name, foreground=p[key])
        self.ttk_bg = style.lookup("TFrame", "background") or "#f0f0f0"
        self.root.configure(bg=self.ttk_bg)
        bg, fg, sel, (guard, note, info, hint) = p["term"]
        self.term.configure(bg=bg, fg=fg, insertbackground=fg, selectbackground=sel)
        for tag, color in (("guard", guard), ("note", note), ("info", info), ("hint", hint)):
            self.term.tag_configure(tag, foreground=color)
        self._paint(self.root)
        self._paint_mode()

    def _paint(self, widget):
        """Color the plain Tk widgets under widget (ttk ones follow the style)."""
        p = PALETTES[self.theme]
        todo = [widget]
        while todo:
            w = todo.pop()
            todo.extend(w.winfo_children())
            cls = w.winfo_class()
            if w is self.term:
                continue
            if cls in ("Entry", "Text"):
                read_only = w is getattr(self, "rules_text", None)
                w.configure(bg=self.ttk_bg if read_only else p["field"], fg=p["fg"],
                            insertbackground=p["fg"], relief="flat",
                            highlightthickness=0 if read_only else 1,
                            highlightbackground=p["border"], highlightcolor=p["accent"],
                            selectbackground=p["select"])
            elif cls in ("Checkbutton", "Radiobutton"):
                w.configure(bg=self.ttk_bg, fg=p["fg"], selectcolor=p["field"],
                            activebackground=self.ttk_bg, activeforeground=p["fg"])
            elif cls in ("Canvas", "Toplevel"):
                w.configure(bg=self.ttk_bg)
            elif cls == "TCombobox":
                try:                     # the drop-down list is a plain Tk listbox
                    pop = w.tk.call("ttk::combobox::PopdownWindow", w)
                    w.tk.call(f"{pop}.f.l", "configure", "-background", p["field"],
                              "-foreground", p["fg"], "-selectbackground", p["select"],
                              "-selectforeground", p["fg"])
                except tk.TclError:
                    pass
        self.term_menu.configure(bg=p["field"], fg=p["fg"], activebackground=p["select"],
                                 activeforeground=p["fg"])

    def _panel_resized(self, e):
        """Let the side panel's text use its width instead of a fixed 440 pixels."""
        wrap = max(e.width - 70, 260)
        if abs(wrap - self._wrap) < 12:
            return
        self._wrap = wrap
        for w in (self.tpl_desc, self.cmds_label, self.batch_status, self.practice_intro):
            w.configure(wraplength=wrap)
        for w in self.tpl_help.winfo_children():
            if str(w.cget("text")).startswith("If it doesn't work"):
                w.configure(wraplength=wrap)
        self._practice_show()

    def _paint_mode(self):
        badge_bg, badge_fg, strip_bg, strip_fg = PALETTES[self.theme][
            "instructor" if self.instructor else "student"]
        self.mode_label.config(bg=badge_bg, fg=badge_fg)
        self.banner.config(bg=strip_bg, fg=strip_fg)
        self.strip.config(bg=strip_bg)

    def _apply_layout(self, name: str):
        """Put the side panel right or left of the terminal, or hide it."""
        name = name if name in dict(LAYOUT_NAMES) else "right"
        self.layout = name
        self._panel_hidden = name == "terminal"
        self._place_panel()

    def _toggle_panel(self):
        self._panel_hidden = not self._panel_hidden
        self._place_panel()

    def _place_panel(self):
        panes = self._panes
        for pane in panes.panes():
            panes.forget(pane)
        left = self.layout == "left"
        if self._panel_hidden:
            panes.add(self.term_side, weight=1)
        elif left:
            panes.add(self.panel, weight=2)
            panes.add(self.term_side, weight=3)
        else:
            panes.add(self.term_side, weight=3)
            panes.add(self.panel, weight=2)
        if self._panel_hidden:
            self.shows.pack_forget()
        else:
            self.shows.pack(fill="x", pady=(0, 4), before=self.term.master)
        self.nb.pack_configure(padx=(0, 6) if left else (6, 0))
        if self.layout == "terminal" or self._panel_hidden:
            self.panel_btn.configure(text="Hide panel (F6)" if not self._panel_hidden
                                     else "Show panel (F6)")
            self.panel_btn.pack(side="right")
        else:
            self.panel_btn.pack_forget()

        def sash():
            width = max(self._panes.winfo_width(), 1000)
            term = max(min(int(width * 0.64), width - 420), width // 2)
            if len(self._panes.panes()) == 2:
                self._panes.sashpos(0, width - term if left else term)
        self.root.after(200, sash)

    def _open_display(self):
        """The Display window: theme, layout and terminal text size, saved on this PC."""
        if self._display_win and self._display_win.winfo_exists():
            self._display_win.lift()
            return
        w = self._display_win = tk.Toplevel(self.root)
        w.title("Display")
        w.transient(self.root)
        w.resizable(False, False)
        w.bind("<Escape>", lambda e: w.destroy())
        f = ttk.Frame(w, padding=16)
        f.pack(fill="both", expand=True)
        theme_var = tk.StringVar(value=self.theme)
        layout_var = tk.StringVar(value=self.layout)

        def changed():
            if theme_var.get() != self.theme:
                self._apply_theme(theme_var.get())
            if layout_var.get() != self.layout:
                self._apply_layout(layout_var.get())
            self._save_prefs()
        row = 0
        for title, var, names in (("Theme", theme_var, THEME_NAMES),
                                  ("Layout", layout_var, LAYOUT_NAMES)):
            ttk.Label(f, text=title, font=self.bold).grid(row=row, column=0, sticky="w",
                                                          pady=(0 if row == 0 else 10, 2))
            row += 1
            for value, label in names:
                ttk.Radiobutton(f, text=label, value=value, variable=var,
                                command=changed).grid(row=row, column=0, sticky="w",
                                                      padx=(12, 0))
                row += 1
        ttk.Label(f, text="Terminal text size", font=self.bold).grid(
            row=row, column=0, sticky="w", pady=(10, 2))
        size_row = ttk.Frame(f)
        size_row.grid(row=row + 1, column=0, sticky="w", padx=(12, 0))
        size_label = ttk.Label(size_row, width=6, anchor="center")

        def zoom(step=None):
            if step is not None:
                self._zoom(step)
                self._save_prefs()
            size_label.configure(text=f"{int(self.mono.cget('size'))} pt")
        ttk.Button(size_row, text="A-", width=4, command=lambda: zoom(-1)).pack(side="left")
        size_label.pack(side="left", padx=6)
        ttk.Button(size_row, text="A+", width=4, command=lambda: zoom(1)).pack(side="left")
        zoom(None)
        ttk.Label(f, text="Saved on this PC only.", style="Muted.TLabel").grid(
            row=row + 2, column=0, sticky="w", pady=(12, 0))
        row += 3
        if sys.platform == "win32":
            ttk.Label(f, text="Desktop shortcut", font=self.bold).grid(
                row=row, column=0, sticky="w", pady=(10, 2))
            ttk.Button(f, text="Add Slatewise to the desktop",
                       command=self._desktop_shortcut).grid(row=row + 1, column=0, sticky="w",
                                                            padx=(12, 0))
            row += 2
        ttk.Button(f, text="Close", command=w.destroy).grid(row=row, column=0, sticky="e",
                                                            pady=(10, 0))
        self._paint(w)

    def _desktop_shortcut(self):
        parent = self._display_win if self._display_win else self.root
        try:
            make_shortcut(program_file(), icon_file(self.folder))
        except Exception as exc:
            messagebox.showerror("Desktop shortcut", f"Couldn't add the shortcut:\n{exc}",
                                 parent=parent)
            return
        messagebox.showinfo("Desktop shortcut", "Added a Slatewise shortcut to the desktop.",
                            parent=parent)

    def _build_quick_config(self, parent):
        f = ttk.Frame(parent, padding=10)
        row = ttk.Frame(f)
        row.pack(fill="x")
        ttk.Label(row, text="Template").pack(side="left")
        self.tpl_var = tk.StringVar(value=next(iter(TEMPLATES)))
        box = ttk.Combobox(row, textvariable=self.tpl_var, values=list(TEMPLATES),
                           state="readonly", width=44, height=min(len(TEMPLATES), 30))
        box.pack(side="left", padx=8)
        box.bind("<<ComboboxSelected>>", self._show_template)
        # Top half: the form (scrolls if it's long). Bottom half: the commands box and
        # its buttons, always visible. The divider between them can be dragged.
        split = ttk.PanedWindow(f, orient="vertical")
        split.pack(fill="both", expand=True, pady=(6, 0))
        self.form_area = _Scrollable(split, self.ttk_bg)
        bottom = ttk.Frame(split)
        split.add(self.form_area, weight=3)
        split.add(bottom, weight=2)
        top = self.form_area.inner
        self.tpl_desc = ttk.Label(top, wraplength=self._wrap, style="Soft.TLabel",
                                  justify="left")
        self.tpl_desc.pack(fill="x", pady=(0, 6))
        self.form = ttk.Frame(top)
        self.form.pack(fill="x", padx=(0, 6))
        ttk.Button(top, text="Build commands", command=self._build_commands).pack(
            anchor="w", pady=(8, 4))
        self.tpl_help = ttk.Frame(top)
        self.tpl_help.pack(fill="x", pady=(0, 6))

        self.cmds_label = ttk.Label(bottom, text="Commands. Edit or paste your own; every "
                                                 "line is checked before it is sent.",
                                    wraplength=self._wrap, justify="left")
        self.cmds_label.pack(fill="x", pady=(6, 0))
        self.batch_status = ttk.Label(bottom, text="", wraplength=self._wrap, justify="left",
                                      style="Body.TLabel")
        self.batch_status.pack(side="bottom", fill="x", pady=(6, 0))
        setups = ttk.Frame(bottom)
        setups.pack(side="bottom", fill="x", pady=(6, 0))
        ttk.Button(setups, text="Save setup...", command=self._save_setup).pack(side="left")
        self.setup_var = tk.StringVar(value="Load a saved setup")
        self.setup_box = ttk.Combobox(setups, textvariable=self.setup_var, state="readonly",
                                      width=32, postcommand=self._refresh_setups)
        self.setup_box.pack(side="left", padx=6)
        self.setup_box.bind("<<ComboboxSelected>>", self._load_setup)
        btns = ttk.Frame(bottom)
        btns.pack(side="bottom", fill="x", pady=(8, 0))
        self.batch_btn = ttk.Button(btns, text="Send to device", command=self._send_preview)
        self.batch_btn.pack(side="left")
        self.stop_btn = ttk.Button(btns, text="Stop", command=self.batch_stop.set, state="disabled")
        self.stop_btn.pack(side="left", padx=6)
        ttk.Button(btns, text="Clear", command=lambda: self.preview.delete("1.0", "end")).pack(
            side="left")
        pf = ttk.Frame(bottom)
        pf.pack(fill="both", expand=True, pady=(4, 0))
        self.preview = tk.Text(pf, height=6, font=self.mono, wrap="none", undo=True,
                               padx=6, pady=4)
        psb = ttk.Scrollbar(pf, orient="vertical", command=self.preview.yview)
        self.preview.configure(yscrollcommand=psb.set)
        self.preview.pack(side="left", fill="both", expand=True)
        psb.pack(side="right", fill="y")
        self.root.after(350, lambda: split.sashpos(0, int(max(split.winfo_height(), 500) * 0.56)))
        self._show_template()
        return f

    def _build_practice_tab(self, parent):
        f = ttk.Frame(parent, padding=10)
        self.practice_intro = ttk.Label(f, text=PRACTICE_INTRO, wraplength=self._wrap,
                                        justify="left")
        self.practice_intro.pack(fill="x")
        row = ttk.Frame(f)
        row.pack(fill="x", pady=(8, 0))
        ttk.Label(row, text="Practice on a").pack(side="left")
        self.practice_kind = tk.StringVar(value="router")
        for kind in ("router", "switch"):
            ttk.Radiobutton(row, text=kind.title(), value=kind, variable=self.practice_kind,
                            command=self._practice_show).pack(side="left", padx=(8, 0))
        row = ttk.Frame(f)
        row.pack(fill="x", pady=(6, 0))
        ttk.Label(row, text="Devices").pack(side="left")
        self.practice_model = tk.StringVar(value=PRACTICE_MODELS["ios"])
        self.practice_model_box = ttk.Combobox(
            row, textvariable=self.practice_model, values=list(PRACTICE_MODELS.values()),
            state="readonly", width=38)
        self.practice_model_box.pack(side="left", padx=(8, 0))
        self.practice_model_box.bind("<<ComboboxSelected>>",
                                     lambda e: self._practice_set_model(self._practice_model()))
        btns = ttk.Frame(f)
        btns.pack(fill="x", pady=(8, 4))
        ttk.Button(btns, text="Start practice", command=self._practice_start).pack(side="left")
        ttk.Button(btns, text="Reset practice device", command=self._practice_reset).pack(
            side="left", padx=6)
        area = _Scrollable(f, self.ttk_bg)
        area.pack(fill="both", expand=True, pady=(6, 0))
        self.practice_box = area.inner
        self.practice_done = {"router": 0, "switch": 0}    # steps ticked off, per device
        self._practice_marks = {"router": 0, "switch": 0}  # events already used up
        self._practice_shown = None
        self._practice_show()
        self._practice_job = self.root.after(300, self._practice_poll)
        return f

    def _practice_show(self):
        """Draw the steps for the chosen device: done ones ticked, the next one bold."""
        kind = self.practice_kind.get()
        done = self.practice_done[kind]
        self._practice_shown = (kind, done)
        for w in self.practice_box.winfo_children():
            w.destroy()
        steps = PRACTICE_STEPS[kind]
        ports = PRACTICE_STEP_PORTS[self._practice_model()]
        for n, (title, text, _) in enumerate(steps):
            text = text.format(**ports)
            row = ttk.Frame(self.practice_box)
            row.pack(fill="x", pady=(0, 8))
            ttk.Label(row, text=PRACTICE_CHECK if n < done else "", width=2,
                      style="Success.TLabel").grid(row=0, column=0, sticky="nw")
            head = ttk.Label(row, text=f"{n + 1}. {title}")
            if n == done:
                head.configure(font=self.bold)
            head.grid(row=0, column=1, sticky="w")
            ttk.Label(row, text=text, wraplength=self._wrap - 40, justify="left",
                      style="Body.TLabel" if n == done else "Muted.TLabel").grid(
                row=1, column=1, sticky="w")
        if done == len(steps):
            ttk.Label(self.practice_box, text=PRACTICE_DONE, wraplength=self._wrap - 20,
                      justify="left",
                      font=self.bold).pack(fill="x", pady=(4, 0))

    def _practice_poll(self):
        """Tick off steps as the practice device's events show them done."""
        kind = self.practice_kind.get()
        events = self.practice_devices[kind].events
        steps = PRACTICE_STEPS[kind]
        done, mark = self.practice_done[kind], self._practice_marks[kind]
        while done < len(steps):
            need, i = list(steps[done][2]), mark
            while need and i < len(events):
                if events[i] == need[0]:
                    need.pop(0)
                i += 1
            if need:
                break
            done, mark = done + 1, i
        self.practice_done[kind], self._practice_marks[kind] = done, mark
        if self._practice_shown != (kind, done):
            self._practice_show()
        self._practice_job = self.root.after(300, self._practice_poll)

    def _practice_model(self) -> str:
        """The chosen PRACTICE_MODELS key."""
        label = self.practice_model.get()
        return next((k for k, v in PRACTICE_MODELS.items() if v == label), "ios")

    def _practice_set_model(self, model: str):
        """Make both practice devices imitate another pair. Like Reset, this starts them
        over."""
        self.practice_model.set(PRACTICE_MODELS[model])
        for kind, dev in self.practice_devices.items():
            if dev.model == model:
                continue
            dev.model = model
            text = dev.power_cycle()
            if self._practice_device() is dev:
                self.session.transport.push(text)
            self.practice_done[kind] = self._practice_marks[kind] = 0
        self._practice_show()

    def _practice_device(self):
        """The practice device this window is connected to, or None."""
        s = self.session
        return s.transport.device if s and isinstance(s.transport, PracticeTransport) else None

    def _practice_note(self, event: str):
        dev = self._practice_device()
        if dev:
            dev.note(event)

    def _practice_start(self):
        kind = self.practice_kind.get()
        if self._practice_device() is self.practice_devices[kind]:
            self.entry.focus_set()
            return
        if self.session:
            self._disconnect("Disconnected.")
        self.port_var.set(next(p for p, k in PRACTICE_PORTS.items() if k == kind))
        self._toggle_connect()

    def _practice_reset(self):
        kind = self.practice_kind.get()
        dev = self.practice_devices[kind]
        text = dev.power_cycle()
        if self._practice_device() is dev:
            self.session.transport.push(text)
        self.practice_done[kind] = self._practice_marks[kind] = 0
        self._practice_show()

    def _build_rules_tab(self, parent):
        f = ttk.Frame(parent, padding=10)
        t = self.rules_text = tk.Text(f, wrap="word", padx=6, pady=4,
                                      font=tkfont.nametofont("TkDefaultFont"))
        t.insert("1.0", RULES_TEXT.replace("{folder}", str(self.folder)))
        for head in ("BLOCKED FOR STUDENTS", "ASKS FIRST", "GOOD TO KNOW",
                     "DEVICE-SIDE BACKSTOPS (instructor)"):
            idx = t.search(head, "1.0", "end")
            if idx:
                t.tag_add("head", idx, f"{idx}+{len(head)}c")
        t.tag_configure("head", font=self.bold, spacing1=6)
        t.configure(state="disabled")
        t.pack(fill="both", expand=True)
        return f

    def _build_instructor_tab(self, parent):
        f = ttk.Frame(parent, padding=12)
        ttk.Label(f, text="Instructor tools", font=self.bold).pack(anchor="w")
        ttk.Label(f, wraplength=430, justify="left", style="Soft.TLabel",
                  text="These act on the connected device. In instructor mode, blocked "
                       "commands typed in the Command box ask for confirmation instead of "
                       "being refused.").pack(fill="x", pady=(2, 10))
        self.instr_buttons = []
        items = [
            ("Erase startup-config",
             "write erase: the device boots with no configuration.", self._macro_erase),
            ("Delete VLAN database",
             "delete flash:vlan.dat: VLANs and VTP settings clear at the next power-up. "
             "Do this last thing in the day, after VLAN labs.", self._macro_vlan),
            ("Show boot settings",
             "Configuration register and boot variables.",
             lambda: self._run_macro(["show version | include register", "show boot"])),
            ("Show stored files", "dir flash: and dir nvram:",
             lambda: self._run_macro(["dir flash:", "dir nvram:"])),
            ("Change instructor password", f"Stored hashed in {SETTINGS_FILE}. "
             "Copy that file to other computers to share the password.",
             self._change_password),
            ("Open log folder", "Every sent and blocked command, by day.", self._open_logs),
        ]
        for label, desc, cmd in items:
            row = ttk.Frame(f)
            row.pack(fill="x", pady=4)
            b = ttk.Button(row, text=label, width=26, command=cmd)
            b.pack(side="left")
            ttk.Label(row, text=desc, wraplength=260, justify="left",
                      style="Soft.TLabel").pack(side="left", padx=10)
            self.instr_buttons.append(b)
        return f

    # -- template form -------------------------------------------------------
    def _show_template(self, *_):
        for w in self.form.winfo_children():
            w.destroy()
        name = self.tpl_var.get()
        desc, fields, checks, _ = TEMPLATES[name]
        self.tpl_desc.config(text=desc)
        for w in self.tpl_help.winfo_children():
            w.destroy()
        verify, tip = TEMPLATE_HELP.get(name, ([], ""))
        if verify:
            ttk.Label(self.tpl_help, text="Check it:").grid(row=0, column=0, sticky="nw",
                                                           pady=2, padx=(0, 6))
            for i, cmd in enumerate(verify):
                ttk.Button(self.tpl_help, text=cmd, command=lambda c=cmd: self._submit(c)).grid(
                    row=i // 2, column=1 + i % 2, sticky="w", padx=2, pady=1)
        if tip:
            ttk.Label(self.tpl_help, text="If it doesn't work: " + tip, wraplength=self._wrap,
                      justify="left", style="Muted.TLabel").grid(
                row=(len(verify) + 1) // 2 + 1, column=0, columnspan=3, sticky="w", pady=(4, 0))
        self.form_vars = {}
        for i, field in enumerate(fields):
            key, label, default = field[:3]
            ttk.Label(self.form, text=label).grid(row=i, column=0, sticky="w", pady=2,
                                                 padx=(0, 10))
            var = tk.StringVar(value=default)
            if len(field) > 3 and field[3] == "multiline":
                box = tk.Text(self.form, height=4, width=30, font=self.mono, wrap="none",
                              undo=True)
                box.insert("1.0", default)
                box.grid(row=i, column=1, sticky="ew", pady=2)
                var = _TextValue(box)
            elif len(field) > 3:               # a fixed list of choices
                ttk.Combobox(self.form, textvariable=var, values=field[3],
                             state="readonly").grid(row=i, column=1, sticky="ew", pady=2)
            else:
                ttk.Entry(self.form, textvariable=var).grid(row=i, column=1, sticky="ew",
                                                            pady=2)
            self.form_vars[key] = var
        for j, (key, label, default) in enumerate(checks):
            var = tk.BooleanVar(value=default)
            tk.Checkbutton(self.form, text=label, variable=var, wraplength=420,
                           justify="left", anchor="w", bg=self.ttk_bg,
                           activebackground=self.ttk_bg, highlightthickness=0).grid(
                row=len(fields) + j, column=0, columnspan=2, sticky="w", pady=1)
            self.form_vars[key] = var
        self.form.columnconfigure(1, weight=1)
        self._paint(self.form)
        self.form_area.to_top()

    def _build_commands(self):
        builder = TEMPLATES[self.tpl_var.get()][3]
        try:
            lines = builder({k: v.get() for k, v in self.form_vars.items()})
        except ValueError as exc:
            messagebox.showerror("Check the form", str(exc), parent=self.root)
            return
        self.preview.delete("1.0", "end")
        self.preview.insert("1.0", "\n".join(lines) + "\n")
        self.batch_status.config(text="")

    # -- saved setups --------------------------------------------------------
    def _setups_dir(self) -> Path:
        d = carried_over(self.folder, SETUPS_DIR)
        d.mkdir(exist_ok=True)
        return d

    def _refresh_setups(self):
        try:
            names = sorted(p.stem for p in self._setups_dir().glob("*.txt"))
        except OSError:
            names = []
        self.setup_box["values"] = names or ["(no saved setups yet)"]

    def _save_setup(self):
        text = self.preview.get("1.0", "end").strip()
        if not text:
            messagebox.showinfo("Save setup", "Build or paste some commands first.",
                                parent=self.root)
            return
        name = simpledialog.askstring("Save setup", "Name for this setup "
                                      "(for example: SW1 morning):", parent=self.root)
        if not name:
            return
        safe = re.sub(r"[^\w\- ]", "", name).strip()[:60]
        if not safe:
            messagebox.showerror("Save setup", "Use letters and numbers in the name.",
                                 parent=self.root)
            return
        if _WIN_DEVICE_RE.fullmatch(safe):
            messagebox.showerror("Save setup", f"Windows keeps the name '{safe}' for a "
                                 "device. Pick another name.", parent=self.root)
            return
        path = self._setups_dir() / f"{safe}.txt"
        if path.exists() and not messagebox.askyesno(
                "Save setup", f"Replace the saved setup '{safe}'?", parent=self.root):
            return
        try:
            path.write_text(text + "\n", encoding="utf-8")
        except OSError as exc:
            messagebox.showerror("Save setup", f"Couldn't save it:\n{exc}", parent=self.root)
            return
        self.batch_status.config(text=f"Saved as '{safe}' on this PC. Load it from the list "
                                      "next time and press Send to device.")

    def _load_setup(self, _event=None):
        name = self.setup_var.get()
        self.setup_var.set("Load a saved setup")
        path = self._setups_dir() / f"{name}.txt"
        if not path.is_file():
            return
        self.preview.delete("1.0", "end")
        self.preview.insert("1.0", path.read_text(encoding="utf-8", errors="replace"))
        self.batch_status.config(text=f"Loaded '{name}'. Check it, then press Send to device.")

    # -- connection ----------------------------------------------------------
    def _refresh_ports(self):
        practice = list(PRACTICE_PORTS)
        try:
            from serial.tools import list_ports
        except ImportError:
            self.port_box["values"] = practice
            if self.port_var.get() not in PRACTICE_PORTS:
                self.port_var.set("")
                self.port_box.set("(pip install pyserial)")
            return
        values = []
        for p in sorted(list_ports.comports(), key=lambda p: p.device):
            d = p.description if p.description and p.description != "n/a" else ""
            values.append(f"{p.device} - {d}" if d else p.device)
        self.port_box["values"] = values + practice
        current = self.port_var.get()
        # Pick a real port by default, never the practice devices.
        if values and (not current or current not in values + practice):
            self.port_var.set(values[0])

    def _toggle_connect(self):
        if self.session:
            self._disconnect("Disconnected.")
            return
        port = self.port_var.get().split(" - ")[0].strip()
        if port in PRACTICE_PORTS:
            msg = ""
        elif port.startswith("(pip"):
            msg = PYSERIAL_MISSING + "\nthen restart this program."
        elif not port or port.startswith("("):
            msg = ("Pick a console port. If none are listed, check the cable and its USB "
                   "driver, then click Refresh.")
        elif "://" in port and not ALLOW_URL_PORTS:
            msg = URL_PORT_REFUSED
        else:
            msg = ""
        if msg:
            messagebox.showerror("Connect", msg, parent=self.root)
            return
        if port in PRACTICE_PORTS:
            transport = PracticeTransport(self.practice_devices[PRACTICE_PORTS[port]], port)
        else:
            transport = SerialTransport(port, int(self.baud_var.get()))
        self.connect_btn.configure(state="disabled", text="Connecting...")
        threading.Thread(target=self._open_transport, args=(transport,), daemon=True).start()

    def _open_transport(self, transport):
        try:
            transport.open()
            self.ui_queue.put(("opened", transport))
        except Exception as exc:
            self.ui_queue.put(("open_failed", str(exc) or exc.__class__.__name__))

    def _on_opened(self, transport):
        self.session = Session(transport,
                               on_data=lambda t: self.ui_queue.put(("data", t)),
                               on_closed=lambda r: self.ui_queue.put(("closed", r)))
        self.device_label, self.port_name = transport.label, transport.port
        self.noted.clear()
        self.connect_btn.configure(state="normal", text="Disconnect")
        self.log("CONNECT", transport.label)
        self._save_prefs()
        # Ctrl-C first: if the device was left sitting at a question (such as the
        # setup dialog's "[2] save" menu), a stray Enter must not answer it.
        try:
            self.session.write(b"\x03")
        except OSError:
            pass
        self._term_write(f"\n[Connected to {transport.label}. Press Enter to wake the "
                         "device.]\n", "info")
        self.entry.focus_set()

    def _disconnect(self, message: str):
        self.batch_stop.set()
        if self.session:
            self.log("DISCONNECT", self.device_label)
            self.session.close()
        self.session = None
        self.connect_btn.configure(state="normal", text="Connect")
        self._term_write(f"\n[{message}]\n", "info")
        if self.instructor:
            self._set_instructor(False, quiet=True)

    # -- queue pump ----------------------------------------------------------
    def _poll(self):
        try:
            for _ in range(500):
                msg = self.ui_queue.get_nowait()
                kind = msg[0]
                if kind == "data":
                    self._render(msg[1])
                    self._scan_hints(msg[1])
                elif kind == "opened":
                    self._on_opened(msg[1])
                elif kind == "open_failed":
                    self.connect_btn.configure(state="normal", text="Connect")
                    self.log("CONNFAIL", msg[1].splitlines()[0])
                    messagebox.showerror("Connect", f"Could not connect:\n\n{msg[1]}",
                                         parent=self.root)
                elif kind == "closed":
                    if self.session:
                        self.session = None
                        self._disconnect(f"Connection lost: {msg[1]}")
                elif kind == "batch_progress":
                    _, i, total, line = msg
                    self.log("SENT", line)
                    self.batch_status.config(text=f"Sending {i + 1} of {total}: {line}")
                elif kind == "batch_done":
                    self._on_batch_done(*msg[1:])
        except queue.Empty:
            pass
        self._update_status()
        self._poll_job = self.root.after(40, self._poll)

    def _scan_hints(self, text: str):
        if not self.hints_var.get():
            return
        self._hint_buf = apply_marks(self._hint_buf, text)[-2000:]
        found = hints_for(self._hint_buf)
        if not found:
            return
        self._hint_buf = ""              # don't match the same message twice
        now = time.monotonic()
        for key, hint in found:
            if now - self._hint_last.get(key, -1e9) > 180:   # once every 3 minutes
                self._hint_last[key] = now
                self._hint_queue.append((key, hint))

    def _flush_hints(self):
        """Show queued hints once the device is back at its prompt (a DNS lookup hint
        right away, since the device sits there until Ctrl-Shift-6)."""
        s = self.session
        if not self._hint_queue or not s:
            return
        if not s.at_prompt() and not any(k == "dns" for k, _ in self._hint_queue):
            return
        for _, hint in self._hint_queue:
            self._term_notice(f"[Hint] {hint}", "hint")
        self._hint_queue.clear()

    def _update_status(self):
        self._flush_hints()
        s = self.session
        if s:
            self.status_conn.config(text=f"Connected: {self.device_label}")
            p = s.prompt()
            self.status_prompt.config(text=f"Device prompt: {p}" if p else "")
            masked = bool(PASSWORD_PROMPT_RE.search(s.last_line()))
        else:
            self.status_conn.config(text="Not connected")
            self.status_prompt.config(text="")
            masked = False
        if bool(s) == bool(self.conn_hint.winfo_manager()):   # the port tip, only while disconnected
            if s:
                self.conn_hint.pack_forget()
            else:
                self.conn_hint.pack(side="left", fill="x", expand=True, padx=(12, 0))
        if masked != self._masked:
            self._masked = masked
            self.entry.configure(show="*" if masked else "")
        book = self.log_book                    # the path rolls over at midnight
        log_text = f"Log: {book.folder.name}\\{book.path.name}"
        if self.status_log.cget("text") != log_text:
            self.status_log.config(text=log_text)
        title = f"{APP_NAME} {VERSION}"
        if s:
            name = self._device_name()
            title += f" · {name} on {self.port_name}" if name else f" · {self.port_name}"
        if self.root.title() != title:
            self.root.title(title)

    def _device_name(self) -> str:
        """The device's hostname from its prompt (R1 for 'R1(config)#'), or ''."""
        m = re.match(r"[\w.\-]+", self.session.prompt()) if self.session else None
        return m.group(0) if m else ""

    # -- terminal display ----------------------------------------------------
    def _render(self, text: str):
        t = self.term
        t.configure(state="normal")
        for piece in re.split(r"([\b\r])", text):
            if piece == "\b":
                prev = t.get("end-2c")
                if prev and prev != "\n":
                    t.delete("end-2c")
            elif piece == "\r":
                t.delete("end-1c linestart", "end-1c")
            elif piece:
                t.insert("end", piece)
                # A device at the wrong speed can send one endless line, and Tk slows
                # down on those: start a new line every 4000 characters.
                line, col = map(int, t.index("end-1c").split("."))
                while col > 4000:
                    t.insert(f"{line}.4000", "\n")
                    line, col = line + 1, col - 4000
        lines = int(t.index("end-1c").split(".")[0])
        if lines > 6000:
            t.delete("1.0", f"{lines - 5000}.0")
        t.see("end")
        t.configure(state="disabled")

    def _term_write(self, text: str, tag: str = ""):
        t = self.term
        t.configure(state="normal")
        t.insert("end", text, tag)
        t.see("end")
        t.configure(state="disabled")

    def _term_notice(self, text: str, tag: str):
        """Show a lab message, then repeat the device prompt so the screen still
        lines up with what the device shows."""
        t = self.term
        if t.get("end-2c") not in ("\n", ""):
            self._term_write("\n")
        self._term_write(text.rstrip() + "\n", tag)
        if self.session:
            self._term_write(self.session.last_line(strip=False))

    def _term_menu_show(self, event):
        self.term_menu.entryconfigure(
            "Copy", state="normal" if self.term.tag_ranges("sel") else "disabled")
        try:
            self.term_menu.tk_popup(event.x_root, event.y_root)
        finally:
            self.term_menu.grab_release()
        return "break"

    def _term_copy(self):
        if self.term.tag_ranges("sel"):
            self.root.clipboard_clear()
            self.root.clipboard_append(self.term.get("sel.first", "sel.last"))

    def _term_paste(self):
        """Paste into the Command box, not straight to the device: the text is checked
        when it's sent, like anything typed there."""
        try:
            text = self.root.clipboard_get()
        except tk.TclError:
            return
        self.entry.focus_set()
        self.entry.insert("insert", text)

    def _term_key(self, event):
        if event.state & 0x4 and event.keysym.lower() == "v":
            self._term_paste()                # Ctrl+V on the terminal goes to the Command box
            return "break"
        if event.state & 0x4 and event.keysym.lower() in ("c", "a") + ZOOM_KEYSYMS:
            return None                       # let copy, select-all and text size work
        self.entry.focus_set()
        if event.char and event.char.isprintable():
            self.entry.insert("end", event.char)
        return "break"

    def _save_output(self):
        """Save what the terminal shows to a text file on this PC, for handing in show
        output. Nothing is sent to the device."""
        text = self.term.get("1.0", "end-1c")
        if not text.strip():
            messagebox.showinfo("Save output", "There's no output to save yet.",
                                parent=self.root)
            return
        docs = Path.home() / "Documents"
        path = filedialog.asksaveasfilename(
            parent=self.root, title="Save output", defaultextension=".txt",
            filetypes=[("Text files", "*.txt"), ("All files", "*.*")],
            initialdir=str(docs if docs.is_dir() else Path.home()),
            initialfile=f"{self._device_name() or 'console'} {datetime.now():%Y-%m-%d %H%M}.txt")
        if not path:
            return
        try:
            Path(path).write_text(text.rstrip() + "\n", encoding="utf-8")
        except OSError as exc:
            messagebox.showerror("Save output", f"Couldn't save it:\n{exc}", parent=self.root)
            return
        self._term_notice(f"[Output saved to {path}]", "info")

    def _focus_entry_if_no_sel(self):
        if not self.term.tag_ranges("sel"):
            self.entry.focus_set()

    # -- sending -------------------------------------------------------------
    def _need_session(self) -> bool:
        if not self.session:
            messagebox.showinfo("Not connected", "Connect to a device first.",
                                parent=self.root)
            return False
        if self.batch_thread and self.batch_thread.is_alive():
            messagebox.showinfo("Busy", "Commands are still being sent. Wait for them to "
                                "finish or press Stop.", parent=self.root)
            return False
        return True

    def _send_entry(self, event=None):
        text = self.entry.get()
        if self._submit(text, from_entry=True):
            self.entry.delete(0, "end")
        return "break"

    def _submit(self, text: str, from_entry: bool = False) -> bool:
        if not self._need_session():
            return False
        lines = re.split(r"\r\n|\r|\n", text)
        if len(lines) > 1:                     # pasted several lines
            return self._start_batch(lines, source="entry")
        if self._masked:                       # answering Password:
            # Still refuse anything that would be a blocked command, in case the
            # "Password:" line wasn't really a password prompt.
            if check_command(text, self.session.context()).level == BLOCK \
                    and not self.instructor:
                self.log("BLOCKED", "<hidden>", "text at a password prompt")
                messagebox.showwarning("Blocked by lab safeguards",
                                       "That text would run as a blocked command if the "
                                       "device isn't really asking for a password, so it "
                                       "wasn't sent.", parent=self.root)
                return False
            self.session.send_line(text)
            self.log("SENT", "<password>")
            return True
        if not self._guard_ok([text], single=True):
            return False
        self.session.send_line(text)
        self.log("SENT", text if text.strip() else "(Enter)")
        if from_entry and text.strip() and (not self.history or self.history[-1] != text):
            self.history.append(text)
        self.hist_idx = None
        return True

    def _send_key(self, key: str):
        if not self._need_session():
            return
        if key == "enter":
            self._submit("")
        elif key == "break":
            try:
                self.session.send_break()
                self.log("SENT", "<break>")
            except Exception as exc:
                messagebox.showerror("Break", f"Could not send a break: {exc}",
                                     parent=self.root)
        else:
            if key == "space" and check_reply(self.session.last_line(), " "):
                if not self._guard_ok([" "], single=True, reply_only=True):
                    return
            self.session.write({"space": b" ", "ctrl_c": b"\x03", "ctrl_z": b"\x1a",
                                "ctrl_shift_6": b"\x1e"}[key])
        self.entry.focus_set()

    def _context_help(self, event):
        s = self.session
        if not s or self._masked or not s.help_prompt():
            return None                        # type a literal '?'
        s.send_partial(self.entry.get().split("\n")[0], b"?")
        return "break"

    def _tab_complete(self, event):
        s = self.session
        prompt = s.help_prompt() if s else ""
        if not prompt or self._masked:
            return "break"
        s.send_partial(self.entry.get().split("\n")[0], b"\t")

        def finish():
            if self.session is s:
                last = s.last_line(strip=False)
                if last.startswith(prompt):
                    self.entry.delete(0, "end")
                    self.entry.insert(0, last[len(prompt):])
                    self.entry.icursor("end")
        self.root.after(300, finish)
        return "break"

    def _history(self, step: int):
        if not self.history:
            return "break"
        if self.hist_idx is None:
            self.hist_idx = len(self.history)
        self.hist_idx = max(0, min(len(self.history), self.hist_idx + step))
        self.entry.delete(0, "end")
        if self.hist_idx < len(self.history):
            self.entry.insert(0, self.history[self.hist_idx])
        return "break"

    # -- guard dialogs -------------------------------------------------------
    def _guard_ok(self, lines, single: bool = False, reply_only: bool = False) -> bool:
        s = self.session
        ctx = s.context() if s else "ios"
        verdicts = []
        if single and s:
            v = check_reply(s.last_line(), lines[0])
            if v:
                verdicts.append(v)
        if not reply_only:
            for line in lines_to_check(lines, s.banner_delim if s else None):
                v = check_command(line, ctx)
                if v.level:
                    verdicts.append(v)
        blocks = [v for v in verdicts if v.level == BLOCK]
        warns = [v for v in verdicts if v.level == WARN]
        notes = [v for v in verdicts if v.level == NOTE]
        if blocks:
            for v in blocks:
                self.log("BLOCKED", v.line, v.rule)
            summary = "\n".join(f"  {v.line}" for v in blocks[:8])
            if not self.instructor:
                self._practice_note("blocked")
                first = blocks[0]
                self._term_notice(f'[Lab safeguard] Blocked "{first.line}": {first.title}.',
                                  "guard")
                more = f"\n\nAlso blocked:\n{summary}" if len(blocks) > 1 else ""
                messagebox.showwarning("Blocked by lab safeguards",
                                       f"{first.title}\n\n{first.detail}\n\nCommand: "
                                       f"{first.line}{more}", parent=self.root)
                return False
            if not messagebox.askyesno(
                    "Instructor override",
                    f"These commands are blocked for students:\n\n{summary}\n\n"
                    f"{blocks[0].title}.\n\nSend anyway?", icon="warning", parent=self.root):
                return False
            for v in blocks:
                self.log("OVERRIDE", v.line)
        if warns:
            body = "\n\n".join(f"{v.detail}\n  Command: {v.line}"
                               for v in {v.rule: v for v in warns}.values())
            if not messagebox.askyesno(warns[0].title, body, parent=self.root):
                self.log("CANCELED", warns[0].line)
                return False
        for v in notes:
            if v.rule not in self.noted:
                self.noted.add(v.rule)
                self._term_notice(f"[Lab note] {v.title}: {v.detail}", "note")
        return True

    # -- batches -------------------------------------------------------------
    def _send_preview(self):
        text = self.preview.get("1.0", "end")
        self._start_batch(text.splitlines(), source="preview")

    def _start_batch(self, raw_lines, source: str, macro: bool = False) -> bool:
        if not self._need_session():
            return False
        lines = [l.rstrip() for l in raw_lines]
        lines = [l for l in lines if l.strip() and not l.strip().startswith("!")]
        if not lines:
            return False
        if not macro and not self._guard_ok(lines):
            return False
        self.batch_stop.clear()
        self.batch_btn.configure(state="disabled")
        self.send_btn.configure(state="disabled")
        self.stop_btn.configure(state="normal")
        session = self.session

        def worker():
            def progress(i, line):
                self.ui_queue.put(("batch_progress", i, len(lines), line))
            sent, status = session.run_batch(lines, self.batch_stop, progress,
                                             auto_confirm=macro)
            self.ui_queue.put(("batch_done", sent, status, lines, source,
                               list(session.batch_errors)))

        self.batch_thread = threading.Thread(target=worker, daemon=True)
        self.batch_thread.start()
        return True

    def _on_batch_done(self, sent, status, lines, source, errors=()):
        if source == "preview" and status == "done":
            self._practice_note("template")
        self.batch_btn.configure(state="normal")
        self.send_btn.configure(state="normal")
        self.stop_btn.configure(state="disabled")
        total, remaining = len(lines), lines[sent:]
        last = lines[sent - 1] if sent else ""
        where = f"Paused after \"{last}\"" if sent else "Nothing sent"
        text = {
            "done": f"Sent {total} line{'s' if total != 1 else ''}.",
            "question": f"{where}: the device is asking a question. Answer it in the "
                        "Command box, then press Send to device to continue.",
            "timeout": f"{where}: no response from the device.",
            "stopped": f"Stopped after {sent} of {total} lines.",
            "closed": "The connection closed.",
        }[status]
        if remaining and source == "preview" and status in ("question", "timeout", "stopped"):
            self.preview.delete("1.0", "end")
            self.preview.insert("1.0", "\n".join(remaining) + "\n")
            text += f" The {len(remaining)} remaining line(s) are left in the box."
        if errors:
            for line, err in errors:
                self.log("REJECTED", line, err)
            listing = "\n".join(f'  "{line}": {err}' for line, err in errors[:6])
            more = f"\n  ...and {len(errors) - 6} more" if len(errors) > 6 else ""
            text += (f"\n\nThe device rejected {len(errors)} line(s). This IOS version may "
                     f"use different syntax, or there's a typo:\n{listing}{more}")
            self._term_notice(f"[Lab note] The device rejected {len(errors)} line(s) of that "
                              "batch; see the Quick config tab.", "note")
        self.batch_status.config(text=text)
        self.entry.focus_set()

    # -- instructor ----------------------------------------------------------
    def _toggle_instructor(self):
        if self.instructor:
            self._set_instructor(False)
            return
        if not self.settings.has_password():
            messagebox.showinfo(
                "Instructor mode",
                "Instructor mode isn't set up on this computer.\n\nOn your own computer, "
                "start the program with --set-instructor-password, then copy "
                f"{SETTINGS_FILE} next to the program here.", parent=self.root)
            return
        pw = simpledialog.askstring("Instructor mode", "Instructor password:", show="*",
                                    parent=self.root)
        if pw is None:
            return
        if not self.settings.check_password(pw):
            self.log("AUTH", "wrong instructor password")
            messagebox.showerror("Instructor mode", "Incorrect password.", parent=self.root)
            return
        self.log("AUTH", "instructor mode on")
        self._set_instructor(True)

    def _set_instructor(self, on: bool, quiet: bool = False):
        minutes = int(self.settings.data["instructor_timeout_min"])
        self.instructor = on
        if self._relock_job:
            self.root.after_cancel(self._relock_job)
            self._relock_job = None
        if on:
            self._relock_job = self.root.after(minutes * 60_000,
                                               lambda: self._set_instructor(False))
            self.mode_label.config(text="INSTRUCTOR MODE")
            self.instr_btn.config(text="Lock")
            self.banner.config(text=f"Instructor mode: blocked commands ask for confirmation "
                                    f"instead of being refused. Relocks in {minutes} minutes "
                                    "or on disconnect.")
            self.nb.tab(self.instr_tab, state="normal")
        else:
            self.mode_label.config(text="STUDENT MODE")
            self.instr_btn.config(text="Instructor...")
            self.banner.config(text="Lab safeguards are on: saving the configuration, erasing "
                                    "or changing flash, and changing boot settings are blocked. "
                                    "Devices return to a clean state when powered off.")
            if self.nb.select() == str(self.instr_tab):
                self.nb.select(0)
            self.nb.tab(self.instr_tab, state="disabled")
            if not quiet:
                self.log("AUTH", "instructor mode off")
        self._paint_mode()

    def _run_macro(self, lines):
        if self.instructor:
            self._start_batch(lines, source="macro", macro=True)

    def _macro_erase(self):
        if self.instructor and self.session and messagebox.askyesno(
                "Erase startup-config",
                f"Erase the startup-config on {self.device_label}?\n\nThe device will boot "
                "with no configuration. The running configuration stays until it reloads.",
                icon="warning", parent=self.root):
            self.log("OVERRIDE", "write erase")
            self._run_macro(["write erase"])

    def _macro_vlan(self):
        if self.instructor and self.session and messagebox.askyesno(
                "Delete VLAN database",
                f"Delete flash:vlan.dat on {self.device_label}?\n\nVLANs and VTP settings "
                "stay active until the switch powers off, then come back as defaults. Any "
                "VLAN change made after this recreates the file, so do it last.",
                icon="warning", parent=self.root):
            self.log("OVERRIDE", "delete /force flash:vlan.dat")
            self._run_macro(["delete /force flash:vlan.dat"])

    def _change_password(self):
        if self.instructor and set_password_dialog(self.root, self.settings):
            self.log("AUTH", "instructor password changed")

    def _open_logs(self):
        folder = self.log_book.folder
        folder.mkdir(exist_ok=True)
        try:
            if sys.platform == "win32":
                os.startfile(folder)  # noqa: S606
            elif sys.platform == "darwin":
                subprocess.Popen(["open", str(folder)])
            else:
                subprocess.Popen(["xdg-open", str(folder)])
        except Exception:
            messagebox.showinfo("Log folder", str(folder), parent=self.root)

    # -- misc ----------------------------------------------------------------
    def log(self, action: str, text: str, note: str = ""):
        if (not self.log_book.write(self.device_label or "-", action, text, note)
                and not self._log_warned):
            self._log_warned = True
            self._term_notice(f"[Lab note] The command log can't be saved: "
                              f"{self.log_book.error}", "note")

    def _on_tk_error(self, exc, val, tb):
        messagebox.showerror("Unexpected error",
                             "".join(traceback.format_exception(exc, val, tb))[-1500:],
                             parent=self.root)

    # -- per-PC preferences --------------------------------------------------
    def _prefs_path(self) -> Path:
        return carried_over(self.folder, PREFS_FILE)

    def _apply_prefs(self):
        """Restore this PC's last console port, baud rate, window size and text size."""
        try:
            p = json.loads(self._prefs_path().read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return
        if not isinstance(p, dict):
            return
        port = str(p.get("port", ""))
        listed = next((v for v in self.port_box.cget("values") or ()
                       if v.split(" - ")[0] == port), None)
        if listed or ("://" in port and ALLOW_URL_PORTS):
            self.port_var.set(listed or port)
        if str(p.get("baud")) in BAUD_RATES:
            self.baud_var.set(str(p["baud"]))
        if isinstance(p.get("text_size"), int):
            self._set_text_size(p["text_size"])
        if p.get("theme") in PALETTES and p["theme"] != self.theme:
            self._apply_theme(p["theme"])
        if p.get("layout") in dict(LAYOUT_NAMES) and p["layout"] != self.layout:
            self._apply_layout(p["layout"])
        if p.get("practice_model") in PRACTICE_MODELS:
            self._practice_set_model(p["practice_model"])
        m = re.fullmatch(r"(\d+)x(\d+)", str(p.get("window", "")))
        if m:
            self.root.geometry(f"{min(int(m.group(1)), self.root.winfo_screenwidth())}x"
                               f"{min(int(m.group(2)), self.root.winfo_screenheight())}")
        if p.get("maximized"):
            try:
                self.root.state("zoomed")
            except tk.TclError:
                pass

    def _save_prefs(self):
        r = self.root
        zoomed = r.state() == "zoomed"
        p = {"port": self.port_var.get().split(" - ")[0].strip(), "baud": self.baud_var.get(),
             "text_size": int(self.mono.cget("size")), "maximized": zoomed,
             "theme": self.theme, "layout": self.layout,
             "practice_model": self._practice_model()}
        if not zoomed:
            p["window"] = f"{r.winfo_width()}x{r.winfo_height()}"
        try:
            self._prefs_path().write_text(json.dumps(p, indent=2), encoding="utf-8")
        except OSError:
            pass

    def _set_text_size(self, size: int):
        size = max(TEXT_SIZES[0], min(TEXT_SIZES[1], int(size)))
        self.mono.configure(size=size)
        self.term.tag_configure("guard", font=(self.mono.actual("family"), size, "bold"))

    def _zoom(self, step: int):
        """Ctrl+plus / Ctrl+minus change the terminal's text size; Ctrl+0 resets it."""
        self._set_text_size(10 if step == 0 else self.mono.cget("size") + step)
        return "break"

    def _on_close(self):
        self._save_prefs()
        for job in (getattr(self, "_poll_job", None), getattr(self, "_practice_job", None),
                    self._relock_job):
            if job:
                try:
                    self.root.after_cancel(job)
                except Exception:
                    pass
        if self.session:
            self.batch_stop.set()
            self.session.close()
        self.root.destroy()


def set_password_dialog(root, settings: Settings) -> bool:
    """Ask for (current and) new instructor password and save it."""
    if settings.has_password():
        cur = simpledialog.askstring("Instructor password", "Current instructor password:",
                                     show="*", parent=root)
        if cur is None:
            return False
        if not settings.check_password(cur):
            messagebox.showerror("Instructor password", "Incorrect password.", parent=root)
            return False
    a = simpledialog.askstring("Instructor password",
                               f"New instructor password (at least {PW_MIN_LEN} characters):",
                               show="*", parent=root)
    if a is None:
        return False
    if len(a) < PW_MIN_LEN:
        messagebox.showerror("Instructor password", f"Use at least {PW_MIN_LEN} characters.",
                             parent=root)
        return False
    b = simpledialog.askstring("Instructor password", "Type it again:", show="*", parent=root)
    if a != b:
        messagebox.showerror("Instructor password", "The passwords didn't match.", parent=root)
        return False
    try:
        settings.set_password(a)
    except OSError as exc:
        messagebox.showerror(
            "Instructor password",
            f"Couldn't save {SETTINGS_FILE} beside the program:\n{exc}\n\nIf this account "
            "can't change that folder, copy the program to a folder you can change, set "
            f"the password there, then copy {SETTINGS_FILE} beside the program.",
            parent=root)
        return False
    messagebox.showinfo("Instructor password",
                        f"Saved in {settings.path}.\n\nCopy that file next to the program on "
                        "each student computer to use the same password there.", parent=root)
    return True


# The Slatewise icon (a slate with a >_ prompt), as PNG files. The window and
# taskbar use them, and a desktop shortcut gets them as slatewise.ico.
_ICON_PNGS = {
    16: (
        "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACX0lEQVR42o2TTUjlZRTGf+d937/3f6/e8d6N"
        "RPRpFEFiLsJFrdrXfpYxK4M2EYQQRbtEJJiImJXBtJABhyRSIggEIxmpqbRdkB99oqJX9Nr9Oue08F5Ga4QO"
        "PJv35bzneZ8fR4AAWHV4dESccQ+SUITAxTIg4mLeMezb2uZP60AQgMrjI69BmlajyP+oGGjgOlnb2rgu1eHR"
        "ETc2YhDKuSuCAH5JrwB+/LdENYDOWIoiz9c78MwjQadeGYiq4Jf0u0OWhLdunuj6lsWBPHshqSKFhG/vZ0zM"
        "luiokhf60LMR/xmPCAf7DQqp5W11Sb27jioeilwZKPLL9m9UBsuYGf4vMyL0zoQuAUIQWu02Dz84xPL8Da5d"
        "fZnDwxpRIEWIAdI5XQi0WHngOXdeyvPc92r18PPmDjPvvE55oMTK2g/EGBHpZtC10G6eupsGhKXeF1BzBvtL"
        "zC18webO73y9MEuz2Wbqw4+pViuoaRf6xQr32AaOT055+onHeP/dN1he/Y75xa+4Ui6RgtGX7s81AUgQms0m"
        "Tw0/ydInH7By5y4Tk++RgjP7qvFQtUEhc26vZUwvFskC6PkHcHck4O68Pf0Rtz77klIxJ6XIzOcd+gsZIvBn"
        "TShmTqt50YG7I1kW+eOvPeY2f6UyWAZ3zJ0fdzLMzwLsi1DIoGE9tuKJYKt4QNvt2KofaB4Tp7U9vAs7CKRu"
        "eOZC3QzTdkTAzb+JjcPd3bwyVEPCi26aaacTTDW4nclUg3ZlqsHdAkjLzd882l7/VHrrPPjos2MhMe4mCVwu"
        "2SUXETXVtaPt9e+B8A8ZqCEoAnL6RAAAAABJRU5ErkJggg=="
    ),
    32: (
        "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAE5UlEQVR42sWXa4hVVRTHf2vvc8+5jxnvZE/S"
        "lLKYD6ZYRFZEBVJQkOWHqTQlRHpYJPSw15dABiN6QH6IMIo+iBFFDzBCIjR64YMSNU0zbbKispx778x4H+fs"
        "vfpwruOIM2N2b3Rgw+Yczl7/9d9rrf9acNzTY/nPn+NtyIi9BdzESTMmu1BuF5iNpwgKiPw7Y6ogIFQU2WxM"
        "/Fb//l0/AQbwIwD0WHjbFafMXGKseR5julBtr+MiqHMDovpYf9/2V47alGHjUy9ebGzmdVUP3idICj1loCXL"
        "6RmKIhKICcAlS4+CEIDTz7vkXGf9XoQcqiqCdb69BFgDqhw9NYa4u/Tj7r4AILFugTFBQV2ciEhQjyEXgjVC"
        "Oy7Cq1KtQxhgUE3EZiL1ugjoDVKS9CpQtUakfES5a07IU7dlGawqRlo1Dp054bl3a6xe36CrIOJRVeRKgCAN"
        "VukERCWN95rLUKpmGKp5jEjL3jsMNecRaTQZVUHpPAagGWneQyGrrPsmwyd78oi0JxAUQ3XI0REp3gtijtkM"
        "RsvcXAiZIN1ba/Be0RbS0ggQQrV+fOGhWRDGKh+AUq4M4pzDGtMCA4xZVsx4dyci3HDtFRQ7O6gMDhEE7a/U"
        "owKw1lAZHGTBrTfy1ivPsvbllUzvnsZf/eW2gxjjCpQwE7J3fx+HSxVmTe/mnVef4+brr+HQXyWsMUiL2TEu"
        "AO+VQj7Hhi+3csvih9i55wfOPmMir73wNA/fu5BSZQDvW0/R8WPAe4oTOti1dz/zljzCRxu+IBuFrHj0Plb1"
        "PoZXpREnLTNhAbJdZy8SY6aluaYmCHPYTIT3nmw24ki1ygfrNxIEAd3TpnLFpTOYNb2b9Ru/ohEnmJNkiIjg"
        "kgZJo4aIqIgYVe2rl39/46S55ZyjkM9xpFrjtbXv018eoFqvM+fqy5l0zpk04rglFszJkAfW8sehw1x31WV8"
        "uGYVF0yZRBI77lney+7vD9CRT5kaRf7/URsTjNM/4Jynv1xh8R1z6X38ASZ0FDhw8FeWPrGSL7Zs47RikYGq"
        "EmVO7ADqSepdmPmXALSZjiuWL2XZkvlYY/h8yzbuf/IZDv7yG2dO7KLWcDwxt87saY5afMxjI1BtCCs/iNj3"
        "uyEbniIAaw2l8gDLltzBQ3ffiQJr3/uI5b0vEccJXcVO6rEjF8LcSxMuOsdRi2VYup2HfE55d2uGb3825KNT"
        "BKCqRFHItp17+Hrnd6z7+DNeXL2GQj5HPhuRJA5rYKgO972eY+YUR5ykDKQCBqUh4dPdAR3ZFNApAfBeyeez"
        "bN62k5sWPkgcJ0zoKDS988OCFQaw46Bh6wF7osoJFCI9aSAGo7TnwyxkowhUicIM3uuoqpkPlUI0utQ5L2NM"
        "AMdeBM3twMj21yWNJp+pK6qMmevjSe3wL81CNOKLIgwOA1B0kyDz0o7YkNSPUK38ic1EtDwfiODiBkl9CBGT"
        "Upu6s2mYhokXzpjsE9kLEjWJMKq+zXOJoTkNKeDV013+afsBCz22enhDOeo665AxwVzUC6qJiNFm3W7LQtUj"
        "YsUGBvUPl/u2r4cea2GXQo+tlzZszU444xDGXCs2yIqIad8yRqw1oFW8W176cceqEaPZcbrgi1Nmni/WzFd0"
        "Nl6LY4XwKbSDIFIRdItPkjcrP+/aN3I45f8ez/8G6/E+xEwSQqwAAAAASUVORK5CYII="
    ),
    48: (
        "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"
        "rkJggg=="
    ),
    256: (
        "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"
        "/wDtimNJn5GyqwAAAABJRU5ErkJggg=="
    ),
}


def _set_icon(root):
    """Give the window, its dialogs and its taskbar button the Slatewise icon."""
    try:
        root.slatewise_icons = [tk.PhotoImage(master=root, data=_ICON_PNGS[n])
                                for n in (256, 48, 32, 16)]
        root.iconphoto(True, *root.slatewise_icons)
    except tk.TclError:
        pass                            # a Tk without PNG support keeps its own icon


def icon_file(folder: Path) -> Path:
    """Write slatewise.ico into folder: a Windows shortcut takes its icon from a file."""
    pngs = [(n, base64.b64decode(_ICON_PNGS[n])) for n in sorted(_ICON_PNGS)]
    offset, entries, blobs = 6 + 16 * len(pngs), b"", b""
    for n, data in pngs:
        entries += struct.pack("<BBBBHHII", n % 256, n % 256, 0, 0, 1, 32, len(data), offset)
        offset += len(data)
        blobs += data
    path = folder / "slatewise.ico"
    path.write_bytes(struct.pack("<HHH", 0, 1, len(pngs)) + entries + blobs)
    return path


def program_file() -> Path:
    """The file a shortcut opens: this script, or the .exe when built as one."""
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve()
    return Path(__file__).resolve()


def make_shortcut(target: Path, icon: Path, folder: Path | None = None) -> Path:
    """Make Slatewise.lnk in folder (the desktop by default) that opens target, with
    the given icon. Windows only: it uses Windows' own shortcut maker (IShellLink)
    and raises OSError if Windows refuses."""
    import ctypes
    from ctypes import POINTER, byref, c_int, c_void_p, c_wchar_p

    class GUID(ctypes.Structure):
        _fields_ = [("a", ctypes.c_ulong), ("b", ctypes.c_ushort), ("c", ctypes.c_ushort),
                    ("d", ctypes.c_ubyte * 8)]

    ole32 = ctypes.OleDLL("ole32")

    def guid(text):
        g = GUID()
        ole32.CLSIDFromString(c_wchar_p(text), byref(g))
        return g

    def method(obj, index, restype, *argtypes):
        vtable = ctypes.cast(obj, POINTER(POINTER(c_void_p))).contents
        return ctypes.WINFUNCTYPE(restype, c_void_p, *argtypes)(vtable[index])

    if folder is None:
        buf = ctypes.create_unicode_buffer(260)
        ctypes.OleDLL("shell32").SHGetFolderPathW(None, 0x10, None, 0, buf)   # the desktop
        folder = Path(buf.value)
    path = folder / "Slatewise.lnk"
    try:
        ole32.CoInitialize(None)
    except OSError:
        pass                            # COM is already set up on this thread
    link, saver = c_void_p(), c_void_p()
    ole32.CoCreateInstance(byref(guid("{00021401-0000-0000-C000-000000000046}")), None, 1,
                           byref(guid("{000214F9-0000-0000-C000-000000000046}")), byref(link))
    try:
        method(link, 20, ctypes.HRESULT, c_wchar_p)(link, str(target))           # SetPath
        method(link, 9, ctypes.HRESULT, c_wchar_p)(link, str(target.parent))     # SetWorkingDirectory
        method(link, 7, ctypes.HRESULT, c_wchar_p)(link, "Slatewise lab console")  # SetDescription
        method(link, 17, ctypes.HRESULT, c_wchar_p, c_int)(link, str(icon), 0)   # SetIconLocation
        method(link, 0, ctypes.HRESULT, c_void_p, c_void_p)(                     # QueryInterface
            link, byref(guid("{0000010B-0000-0000-C000-000000000046}")), byref(saver))
        try:
            method(saver, 6, ctypes.HRESULT, c_wchar_p, c_int)(saver, str(path), 1)  # Save
        finally:
            method(saver, 2, ctypes.c_ulong)(saver)                              # Release
    finally:
        method(link, 2, ctypes.c_ulong)(link)
    return path


def _startup_error(text: str):
    """Say why the program can't start. Run as .pyw there's no console for a print,
    so Windows also shows it in a message box."""
    if sys.stderr:
        print(text, file=sys.stderr)
    if sys.platform == "win32":
        try:
            import ctypes
            ctypes.windll.user32.MessageBoxW(None, text, APP_NAME, 0x10)
        except Exception:
            pass
    sys.exit(1)


def main():
    if tk is None:
        _startup_error("Tkinter is not available. On Windows, reinstall Python from python.org "
                       "with 'tcl/tk and IDLE' checked. On Debian/Ubuntu: sudo apt install "
                       "python3-tk")
    if sys.platform == "win32":
        import ctypes
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(1)
        except Exception:
            pass
        try:        # its own taskbar button and icon, not Python's
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
                "ParakalixStudios.Slatewise")
        except Exception:
            pass
    try:
        root = tk.Tk()
    except tk.TclError as exc:
        _startup_error(f"{APP_NAME} couldn't open its window:\n\n{exc}")
    if "--set-instructor-password" in sys.argv[1:]:
        root.withdraw()
        _set_icon(root)
        set_password_dialog(root, Settings(settings_dir()))
        root.destroy()
        return
    try:
        LabConsoleApp(root)
    except Exception:
        root.destroy()
        _startup_error(f"{APP_NAME} couldn't start:\n\n" + traceback.format_exc()[-1500:])
    root.mainloop()


if __name__ == "__main__":
    main()
