#!/usr/bin/env python3
"""router-ui — management page for the FredOS router (services/router-ui.nix).

Deliberately narrow: this process never generates Nix. It reads and writes two
TOML files (ports.toml, devices.toml) through the Forgejo API, then asks a
systemd unit to deploy that exact commit. Everything else is read-only.

Two views of the config exist and they are not the same thing:
  * deployed — /etc/router/*.toml, baked into the running system generation
  * git      — what Forgejo has on main, i.e. what the next apply will deploy
The UI edits git and shows drift against deployed.

No auth here: nginx + Authelia is the gate, and we bind loopback only.
"""

import base64
import json
import os
import re
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import tomlkit

# --- config (all injected by the systemd unit) -------------------------------

PORT = int(os.environ.get("ROUTER_UI_PORT", "8086"))
STATE = os.environ.get("ROUTER_UI_STATE", "/var/lib/router-ui")
ETC = os.environ.get("ROUTER_UI_ETC", "/etc/router")
FORGEJO = os.environ.get("FORGEJO_API", "https://forg.gregersen.it/api/v1")
REPO = os.environ.get("FORGEJO_REPO", "rope/nixos")
BRANCH = os.environ.get("FORGEJO_BRANCH", "main")
TOKEN_FILE = os.environ.get("FORGEJO_TOKEN_FILE", "/var/secrets/forgejo-router-token")
WAN_IF = os.environ.get("WAN_IF", "eno1")
LAN_IF = os.environ.get("LAN_IF", "eth0")
LAN_PREFIX = os.environ.get("LAN_PREFIX", "10.0.0.")
ROUTER_IP = os.environ.get("ROUTER_IP", "10.0.0.1")
POOL = (int(os.environ.get("POOL_START", "100")), int(os.environ.get("POOL_END", "250")))
LEASES = os.environ.get("DNSMASQ_LEASES", "/var/lib/dnsmasq/dnsmasq.leases")
OUI_FILE = os.environ.get("OUI_FILE", "")
SPEEDTEST_LOG = os.path.join(STATE, "speedtest.jsonl")
LAST_REV = os.path.join(STATE, "last-rev")
TRAFFIC_FILE = os.path.join(STATE, "traffic.json")

# Absolute, injected by the unit. sudo matches on argv, so the systemctl path
# here must be byte-identical to the one in the sudoers rule.
SUDO = os.environ.get("SUDO_BIN", "/run/wrappers/bin/sudo")
SYSTEMCTL = os.environ.get("SYSTEMCTL_BIN", "systemctl")
JOURNALCTL = os.environ.get("JOURNALCTL_BIN", "journalctl")
IP = os.environ.get("IP_BIN", "ip")
VNSTAT = os.environ.get("VNSTAT_BIN", "vnstat")
NFT = os.environ.get("NFT_BIN", "nft")

# Forwards that must always survive an edit — losing either locks us out.
REQUIRED_FORWARDS = [("tcp", "22"), ("tcp", "443")]

MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
PORT_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
REV_RE = re.compile(r"^[0-9a-f]{7,40}$")


class Refused(Exception):
    """A validation failure we want to show the user verbatim."""


# --- small helpers -----------------------------------------------------------


def run(*args, timeout=15):
    try:
        return subprocess.run(
            args, capture_output=True, text=True, timeout=timeout
        ).stdout
    except (subprocess.SubprocessError, OSError):
        return ""


def token():
    try:
        with open(TOKEN_FILE) as fh:
            return fh.read().strip()
    except OSError as exc:
        raise Refused(
            f"no Forgejo token at {TOKEN_FILE} — create one with write:repository "
            f"on {REPO} and write it there (see services/router-ui.nix)"
        ) from exc


def forgejo(method, path, payload=None):
    req = urllib.request.Request(
        f"{FORGEJO}/{path}",
        method=method,
        data=json.dumps(payload).encode() if payload is not None else None,
        headers={
            "Authorization": f"token {token()}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read() or b"{}")
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode(errors="replace")[:400]
        raise Refused(f"Forgejo {exc.code}: {detail}") from exc
    except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
        raise Refused(f"Forgejo unreachable: {exc}") from exc


def git_file(name):
    """Return (tomlkit document, blob sha) for a file on the tracked branch."""
    meta = forgejo("GET", f"repos/{REPO}/contents/{name}?ref={BRANCH}")
    text = base64.b64decode(meta["content"]).decode()
    return tomlkit.parse(text), meta["sha"]


def git_commit(name, doc, sha, message):
    body = {
        "content": base64.b64encode(tomlkit.dumps(doc).encode()).decode(),
        "sha": sha,
        "branch": BRANCH,
        "message": message,
    }
    return forgejo("PUT", f"repos/{REPO}/contents/{name}", body)["commit"]["sha"]


def deployed(name):
    """The copy baked into the running generation, or None if unreadable."""
    try:
        with open(os.path.join(ETC, name)) as fh:
            return tomlkit.parse(fh.read())
    except (OSError, ValueError):
        return None


def edit_aot(doc, key, items):
    """Apply the UI's list back onto an array-of-tables, in place.

    In place, not wholesale replacement: ports.toml carries a lot of
    commented-out forwards, and tomlkit only keeps those if the surrounding
    tables survive. Each item carries `_i`, its index in the original array,
    or None when it's new.

    Assignments are skipped when the value is already what we'd write. tomlkit
    moves a key to the end of its table when you re-add it, and the last table
    in a file owns every trailing comment — so a blind rewrite drags keys down
    past unrelated comment blocks. Writing only genuine changes keeps an
    untouched entry byte-identical.
    """
    aot = doc[key]
    original = len(aot)  # fixed up front: appends must not shift the delete set
    keep = set()
    added = []
    for item in items:
        fields = {k: v for k, v in item.items() if not k.startswith("_")}
        idx = item.get("_i")
        if idx is None:
            added.append(fields)
            continue
        if not isinstance(idx, int) or not 0 <= idx < original:
            raise Refused(f"stale index {idx} — reload the page and retry")
        keep.add(idx)
        table = aot[idx]
        for stale in [k for k in table.keys() if k not in fields]:
            del table[stale]
        for k, v in fields.items():
            if k not in table or table[k] != v:
                table[k] = v
    for idx in sorted(set(range(original)) - keep, reverse=True):
        aot.pop(idx)
    for fields in added:
        table = tomlkit.table()
        for k, v in fields.items():
            table[k] = v
        aot.append(table)


# --- LAN state ---------------------------------------------------------------


_oui_cache = {}


def vendor(mac):
    if not OUI_FILE:
        return ""
    if not _oui_cache:
        try:
            with open(OUI_FILE, errors="replace") as fh:
                for line in fh:
                    if line.startswith("#") or " " not in line:
                        continue
                    prefix, _, name = line.strip().partition(" ")
                    if len(prefix) == 6:
                        _oui_cache[prefix.upper()] = name
        except OSError:
            _oui_cache["_"] = ""
    return _oui_cache.get(mac.replace(":", "")[:6].upper(), "")


def leases():
    out = {}
    try:
        with open(LEASES) as fh:
            for line in fh:
                parts = line.split()
                if len(parts) >= 4:
                    expiry, mac, ip, name = parts[0], parts[1].lower(), parts[2], parts[3]
                    out[mac] = {"ip": ip, "lease_name": "" if name == "*" else name,
                                "expires": int(expiry)}
    except OSError:
        pass
    return out


def neighbours():
    """{mac: ARP state}. REACHABLE/DELAY/PROBE mean traffic in the last few
    seconds; STALE only means we spoke to it at some point since boot, which
    is why it doesn't count as online on its own."""
    states = {}
    for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
        parts = line.split()
        if len(parts) >= 5 and parts[1] == "lladdr":
            states[parts[2].lower()] = parts[-1]
    return states


LIVE_STATES = ("REACHABLE", "DELAY", "PROBE")


def nft_counters():
    """{lan_ip: {"up": bytes, "down": bytes}} from the accounting table."""
    out = {}
    for direction in ("up", "down"):
        try:
            blob = json.loads(run(NFT, "-j", "list", "set", "ip", "accounting", direction))
        except ValueError:
            continue
        for node in blob.get("nftables", []):
            if not isinstance(node, dict):
                continue
            for elem in node.get("set", {}).get("elem", []):
                e = elem.get("elem", elem) if isinstance(elem, dict) else None
                if not isinstance(e, dict):
                    continue
                ip, counter = e.get("val"), e.get("counter") or {}
                if isinstance(ip, str) and "bytes" in counter:
                    out.setdefault(ip, {"up": 0, "down": 0})[direction] = counter["bytes"]
    return out


def load_traffic():
    try:
        with open(TRAFFIC_FILE) as fh:
            data = json.load(fh)
    except (OSError, ValueError):
        data = {}
    data.setdefault("totals", {})
    data.setdefault("snapshot", {})
    data.setdefault("since", int(time.time()))
    return data


def accounting_tick():
    """Fold the live nft counters into per-device totals that survive.

    Run as root from router-accounting.service. The nft counters restart at
    zero every time the ruleset reloads, so a sample below the previous one is
    read as a reset and counted in full rather than as a negative delta.

    Totals are keyed by MAC where a DHCP lease gives us one, so a device
    keeps its history across an address change.
    """
    data = load_traffic()
    by_ip = {lease["ip"]: mac for mac, lease in leases().items()}
    now = int(time.time())
    current = nft_counters()

    for ip, counts in current.items():
        key = by_ip.get(ip, ip)
        was = data["snapshot"].get(ip, {})
        entry = data["totals"].setdefault(key, {"up": 0, "down": 0, "last_seen": 0})
        moved = False
        for direction in ("up", "down"):
            sample, previous = counts.get(direction, 0), was.get(direction, 0)
            delta = sample - previous if sample >= previous else sample
            if delta > 0:
                entry[direction] += delta
                moved = True
        if moved:
            entry["last_seen"] = now
        entry["ip"] = ip

    data["snapshot"] = current
    tmp = TRAFFIC_FILE + ".tmp"
    with open(tmp, "w") as fh:
        json.dump(data, fh)
    os.replace(tmp, TRAFFIC_FILE)
    os.chmod(TRAFFIC_FILE, 0o644)  # the web app runs as router-ui and only reads
    return data


def caller_ip(handler):
    fwd = handler.headers.get("X-Forwarded-For", "")
    return fwd.split(",")[0].strip() or handler.client_address[0]


def mac_of(ip):
    for mac, lease in leases().items():
        if lease["ip"] == ip:
            return mac
    for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
        parts = line.split()
        if len(parts) >= 5 and parts[0] == ip and parts[1] == "lladdr":
            return parts[2].lower()
    return ""


# --- validation --------------------------------------------------------------


def check_devices(items, self_mac, self_ip):
    seen_mac, seen_ip = set(), set()
    for d in items:
        mac = str(d.get("mac", "")).strip().lower()
        name = str(d.get("name", "")).strip()
        if not MAC_RE.match(mac):
            raise Refused(f"'{mac}' is not a MAC address (aa:bb:cc:dd:ee:ff)")
        if not re.match(r"^[A-Za-z0-9_-]{1,63}$", name):
            raise Refused(f"'{name}' is not a usable DHCP name (letters, digits, - and _)")
        if mac in seen_mac:
            raise Refused(f"{mac} listed twice")
        seen_mac.add(mac)
        d["mac"], d["name"] = mac, name

        if d.get("blocked") and (mac == self_mac or d.get("ip") == self_ip):
            raise Refused("that's the device you're connected from — refusing to block it")

        # Drop defaults rather than writing them out: router.nix reads both
        # with `or` fallbacks, and absent keys keep the file (and its diffs)
        # small.
        if d.get("blocked"):
            d["blocked"] = True
        else:
            d.pop("blocked", None)
        if not str(d.get("note", "")).strip():
            d.pop("note", None)
        else:
            d["note"] = str(d["note"]).strip()

        ip = str(d.get("ip", "")).strip()
        if not ip:
            d.pop("ip", None)
            continue
        if not ip.startswith(LAN_PREFIX):
            raise Refused(f"{ip} is outside the LAN ({LAN_PREFIX}0/24)")
        host = ip[len(LAN_PREFIX):]
        if not host.isdigit() or not 1 <= int(host) <= 254:
            raise Refused(f"{ip} is not a valid LAN address")
        if ip == ROUTER_IP:
            raise Refused(f"{ip} is the router itself")
        if POOL[0] <= int(host) <= POOL[1]:
            raise Refused(
                f"{ip} sits inside the DHCP pool {LAN_PREFIX}{POOL[0]}-{POOL[1]} — "
                f"reservations must live outside it"
            )
        if ip in seen_ip:
            raise Refused(f"{ip} reserved twice")
        seen_ip.add(ip)
        d["ip"] = ip


def check_forwards(items):
    covered = set()
    for f in items:
        name = str(f.get("name", "")).strip()
        proto = str(f.get("protocol", "")).strip()
        if not name:
            raise Refused("every forward needs a name")
        if proto not in ("tcp", "udp", "both"):
            raise Refused(f"'{proto}' is not tcp, udp or both")
        # Accept either key so re-validating an already-normalised entry works.
        spec = str(f.get("port") or f.get("ports") or "").strip()
        if not PORT_RE.match(spec):
            raise Refused(f"'{spec}' is not a port or range (443, or 26901-26902)")
        bounds = [int(p) for p in spec.split("-")]
        if any(not 1 <= p <= 65535 for p in bounds):
            raise Refused(f"{spec} is out of range")
        if len(bounds) == 2 and bounds[0] >= bounds[1]:
            raise Refused(f"{spec} is backwards")
        # Normalise onto the two keys router.nix understands.
        f.pop("port", None)
        f.pop("ports", None)
        f["name"] = name
        f["protocol"] = proto
        if len(bounds) == 2:
            f["ports"] = spec
        else:
            f["port"] = bounds[0]
        dest = str(f.get("dest", "")).strip()
        if dest:
            f["dest"] = dest
        else:
            f.pop("dest", None)
        for p in range(bounds[0], bounds[-1] + 1):
            for pr in (("tcp", "udp") if proto == "both" else (proto,)):
                covered.add((pr, str(p)))
    for need in REQUIRED_FORWARDS:
        if need not in covered:
            raise Refused(
                f"refusing to drop the {need[0]}/{need[1]} forward — "
                f"that's how you reach this box from outside"
            )


# --- API payloads ------------------------------------------------------------


def forwards_from(doc):
    out = []
    for i, f in enumerate(doc.get("forward", [])):
        out.append({
            "_i": i,
            "name": str(f.get("name", "")),
            "port": str(f["ports"]) if "ports" in f else str(f.get("port", "")),
            "protocol": str(f.get("protocol", "tcp")),
            "dest": str(f.get("dest", "")),
        })
    return out


def devices_from(doc):
    out = []
    for i, d in enumerate(doc.get("device", [])):
        out.append({
            "_i": i,
            "mac": str(d.get("mac", "")).lower(),
            "name": str(d.get("name", "")),
            "ip": str(d.get("ip", "")),
            "blocked": bool(d.get("blocked", False)),
            "note": str(d.get("note", "")),
        })
    return out


def device_view():
    """Registry from git, joined with what's actually on the wire."""
    doc, sha = git_file("devices.toml")
    known = devices_from(doc)
    lease = leases()
    states = neighbours()
    totals = load_traffic()["totals"]
    now = int(time.time())

    def enrich(d, seen):
        d["lease_ip"] = seen.get("ip", "")
        d["hostname"] = seen.get("lease_name", "")
        d["vendor"] = vendor(d["mac"])
        # Counters are keyed by MAC once a lease exists, by raw IP before that.
        stats = totals.get(d["mac"]) or totals.get(d["lease_ip"]) or {}
        d["up"] = stats.get("up", 0)
        d["down"] = stats.get("down", 0)
        d["last_seen"] = stats.get("last_seen", 0)
        # ARP says "right now"; the accounting tick only resolves to a minute,
        # so treat a recent tick as live too for devices that idle quietly.
        d["online"] = bool(states.get(d["mac"]) in LIVE_STATES
                           or (d["last_seen"] and now - d["last_seen"] < 180))
        return d

    by_mac = {d["mac"]: d for d in known}
    for d in known:
        enrich(d, lease.get(d["mac"], {}))
        d["known"] = True

    unknown = []
    for mac, seen in sorted(lease.items(), key=lambda kv: kv[1]["ip"]):
        if mac in by_mac:
            continue
        unknown.append(enrich({
            "_i": None, "mac": mac, "name": seen["lease_name"] or mac.replace(":", ""),
            "ip": "", "blocked": False, "note": "", "known": False,
        }, seen))
    return {"sha": sha, "devices": known + unknown,
            "since": load_traffic().get("since", 0)}


def wan_stats():
    rx = tx = 0
    try:
        with open("/proc/net/dev") as fh:
            for line in fh:
                iface, _, rest = line.partition(":")
                if iface.strip() == WAN_IF:
                    cols = rest.split()
                    rx, tx = int(cols[0]), int(cols[8])
    except (OSError, ValueError, IndexError):
        pass
    return {"rx": rx, "tx": tx, "t": time.time()}


def overview():
    addr = run(IP, "-4", "-o", "addr", "show", WAN_IF).split()
    wan_ip = addr[3].split("/")[0] if len(addr) > 3 else "down"
    try:
        with open("/proc/uptime") as fh:
            up = int(float(fh.read().split()[0]))
    except (OSError, ValueError):
        up = 0
    try:
        with open(LAST_REV) as fh:
            rev = fh.read().strip()
    except OSError:
        rev = ""

    drift = {}
    for name in ("ports.toml", "devices.toml"):
        local = deployed(name)
        try:
            remote, _ = git_file(name)
            drift[name] = local is not None and tomlkit.dumps(local) != tomlkit.dumps(remote)
        except Refused:
            drift[name] = None
    return {
        "wan_ip": wan_ip,
        "uptime": up,
        "leases": len(leases()),
        "last_rev": rev,
        "drift": drift,
        "wan_if": WAN_IF,
    }


def pending():
    """Commits on the branch that this UI has not deployed."""
    try:
        with open(LAST_REV) as fh:
            last = fh.read().strip()
    except OSError:
        last = ""
    commits = forgejo("GET", f"repos/{REPO}/commits?sha={BRANCH}&limit=20&stat=false")
    out = []
    for c in commits:
        if c["sha"] == last:
            break
        out.append({
            "sha": c["sha"],
            "message": c["commit"]["message"].splitlines()[0],
            "date": c["commit"]["committer"]["date"],
        })
    head = commits[0]["sha"] if commits else ""
    return {"head": head, "last_rev": last, "unapplied": out}


def speedtests(limit=30):
    out = []
    try:
        with open(SPEEDTEST_LOG) as fh:
            for line in fh:
                try:
                    r = json.loads(line)
                except ValueError:
                    continue
                srv = (r.get("servers") or [{}])[0]
                out.append({
                    "ts": r.get("timestamp", ""),
                    "down": round(srv.get("dl_speed", 0) * 8 / 1e6, 1),
                    "up": round(srv.get("ul_speed", 0) * 8 / 1e6, 1),
                    "ping": round(srv.get("latency", 0) / 1e6, 1),
                    "server": srv.get("name", ""),
                })
    except OSError:
        pass
    return out[-limit:]


def device_traffic():
    """Per-device totals, newest names first. Reads the deployed devices.toml
    rather than git so the traffic tab needs no Forgejo round-trip."""
    data = load_traffic()
    names = {}
    doc = deployed("devices.toml")
    if doc:
        for d in doc.get("device", []):
            names[str(d.get("mac", "")).lower()] = str(d.get("name", ""))
    lease = leases()
    rows = [
        {
            "key": key,
            "name": names.get(key) or lease.get(key, {}).get("lease_name") or "",
            "ip": t.get("ip", ""),
            "up": t.get("up", 0),
            "down": t.get("down", 0),
            "last_seen": t.get("last_seen", 0),
        }
        for key, t in data["totals"].items()
    ]
    rows.sort(key=lambda r: r["up"] + r["down"], reverse=True)
    return {"since": data.get("since", 0), "devices": rows}


def traffic():
    per_device = device_traffic()
    raw = run(VNSTAT, "--json", "-i", WAN_IF, timeout=20)
    try:
        iface = json.loads(raw)["interfaces"][0]["traffic"]
    except (ValueError, KeyError, IndexError):
        return {"day": [], "month": [], "hour": [], **per_device}

    def series(key):
        return [
            {
                "label": "{:04d}-{:02d}-{:02d}".format(
                    e["date"]["year"], e["date"].get("month", 1), e["date"].get("day", 1)
                ) + (" {:02d}:00".format(e["time"]["hour"]) if "time" in e else ""),
                "rx": e["rx"],
                "tx": e["tx"],
            }
            for e in iface.get(key, [])
        ]

    return {"hour": series("hour")[-24:], "day": series("day")[-30:],
            "month": series("month")[-12:], **per_device}


# --- actions -----------------------------------------------------------------


def start_unit(unit):
    subprocess.run(
        [SUDO, "-n", SYSTEMCTL, "start", "--no-block", unit],
        check=True, capture_output=True, timeout=20,
    )


def save(name, key, items, message, sha, validate):
    doc, current = git_file(name)
    if sha and sha != current:
        raise Refused(f"{name} changed in git since you loaded it — reload and redo")
    validate(items)
    edit_aot(doc, key, items)
    rev = git_commit(name, doc, current, message)
    return {"ok": True, "rev": rev}


def apply(rev, confirmed):
    if not REV_RE.match(rev or ""):
        raise Refused("not a commit id")
    state = pending()
    extra = [c for c in state["unapplied"] if c["sha"] != rev]
    if extra and not confirmed:
        raise Refused(
            "CONFIRM:main carries commits this page didn't make; applying deploys "
            "those too:\n" + "\n".join(f"  {c['sha'][:8]} {c['message']}" for c in extra)
        )
    start_unit(f"router-apply@{rev}.service")
    return {"ok": True, "rev": rev}


# --- HTTP --------------------------------------------------------------------


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):  # journal already timestamps
        print(fmt % args, flush=True)

    def _send(self, code, body, ctype="application/json", extra=None):
        blob = body if isinstance(body, bytes) else body.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(blob)))
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(blob)

    def _json(self, obj, code=200):
        self._send(code, json.dumps(obj))

    def _body(self):
        length = int(self.headers.get("Content-Length", "0"))
        return json.loads(self.rfile.read(length) or b"{}")

    def do_GET(self):
        path, _, query = self.path.partition("?")
        args = dict(p.split("=", 1) for p in query.split("&") if "=" in p)
        try:
            if path == "/":
                return self._send(200, PAGE, "text/html; charset=utf-8")
            if path == "/api/overview":
                return self._json(overview())
            if path == "/api/pending":
                return self._json(pending())
            if path == "/api/devices":
                return self._json(device_view())
            if path == "/api/ports":
                doc, sha = git_file("ports.toml")
                return self._json({"sha": sha, "forwards": forwards_from(doc)})
            if path == "/api/live":
                return self._json(wan_stats())
            if path == "/api/traffic":
                return self._json(traffic())
            if path == "/api/speedtest":
                return self._json(speedtests())
            if path == "/api/apply/log":
                return self._stream_log(args.get("rev", ""))
        except Refused as exc:
            return self._json({"error": str(exc)}, 400)
        except Exception as exc:  # noqa: BLE001 - surface, don't 500 silently
            return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
        self._json({"error": "not found"}, 404)

    def do_POST(self):
        try:
            body = self._body()
            if self.path == "/api/devices":
                self_ip = caller_ip(self)
                return self._json(save(
                    "devices.toml", "device", body.get("devices", []),
                    "devices.toml: update from router UI", body.get("sha"),
                    lambda items: check_devices(items, mac_of(self_ip), self_ip),
                ))
            if self.path == "/api/ports":
                return self._json(save(
                    "ports.toml", "forward", body.get("forwards", []),
                    "ports.toml: update from router UI", body.get("sha"),
                    check_forwards,
                ))
            if self.path == "/api/apply":
                return self._json(apply(body.get("rev", ""), body.get("confirm", False)))
            if self.path == "/api/speedtest/run":
                start_unit("router-speedtest.service")
                return self._json({"ok": True})
        except Refused as exc:
            return self._json({"error": str(exc)}, 400)
        except Exception as exc:  # noqa: BLE001
            return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
        self._json({"error": "not found"}, 404)

    def _stream_log(self, rev):
        if not REV_RE.match(rev or ""):
            return self._json({"error": "not a commit id"}, 400)
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("X-Accel-Buffering", "no")  # nginx must not buffer SSE
        self.send_header("Connection", "close")
        self.end_headers()
        proc = subprocess.Popen(
            [JOURNALCTL, "-u", f"router-apply@{rev}.service",
             "-f", "-n", "200", "-o", "cat", "--since", "-30min"],
            stdout=subprocess.PIPE, text=True,
        )
        try:
            for line in proc.stdout:
                self.wfile.write(f"data: {line.rstrip()}\n\n".encode())
                self.wfile.flush()
        except (BrokenPipeError, ConnectionResetError, OSError):
            pass
        finally:
            proc.terminate()


PAGE = ("""<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Router</title>
<style>
:root{--bg:#12141a;--card:#1b1e27;--line:#2b3040;--fg:#e7e9f0;--dim:#9aa1b5;
      --ok:#4ade80;--bad:#f87171;--warn:#fbbf24;--accent:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
header{display:flex;gap:4px;padding:12px 16px;border-bottom:1px solid var(--line);
       flex-wrap:wrap;align-items:center;position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:15px;margin:0 16px 0 0;font-weight:600}
button{background:var(--card);color:var(--fg);border:1px solid var(--line);
       border-radius:6px;padding:6px 12px;cursor:pointer;font:inherit}
button:hover{border-color:var(--accent)}
button.on{background:var(--accent);color:#06101f;border-color:var(--accent)}
button.go{background:var(--ok);color:#06210f;border-color:var(--ok);font-weight:600}
button.danger{border-color:var(--bad);color:var(--bad)}
main{padding:16px;max-width:1100px}
section{display:none}section.on{display:block}
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
      padding:12px 16px;min-width:150px}
.card .k{color:var(--dim);font-size:12px}.card .v{font-size:22px;font-weight:600}
table{border-collapse:collapse;width:100%;background:var(--card);
      border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--dim);font-weight:500}tr:last-child td{border-bottom:none}
input[type=text]{background:#0e1016;color:var(--fg);border:1px solid var(--line);
      border-radius:5px;padding:4px 7px;font:inherit;width:100%;min-width:70px}
select{background:#0e1016;color:var(--fg);border:1px solid var(--line);
      border-radius:5px;padding:4px;font:inherit}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--dim)}
.dot.up{background:var(--ok)}
.msg{padding:10px 12px;border-radius:8px;margin:12px 0;white-space:pre-wrap;
     border:1px solid var(--line);background:var(--card);display:none;font-size:13px}
.msg.bad{border-color:var(--bad);color:var(--bad)}
.msg.ok{border-color:var(--ok);color:var(--ok)}
.bar{display:flex;align-items:flex-end;gap:2px;height:70px;margin-top:8px;
     border-bottom:1px solid var(--line)}
.bar i{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px}
.bar i.up{background:var(--warn)}
.bar .empty{color:var(--dim);font-size:13px;font-style:normal;align-self:center;
     background:none;flex:1;text-align:center;min-height:0}
.scroll{overflow-x:auto}
pre{background:#0e1016;border:1px solid var(--line);border-radius:8px;padding:12px;
    max-height:340px;overflow:auto;font-size:12px;white-space:pre-wrap}
.dim{color:var(--dim)}.right{text-align:right}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0}
</style>
<header>
  <h1>Router</h1>
  <button data-tab="overview" class="on">Overview</button>
  <button data-tab="devices">Devices</button>
  <button data-tab="ports">Ports</button>
  <button data-tab="traffic">Traffic</button>
  <button data-tab="speed">Speedtest</button>
</header>
<main>
<div id="msg" class="msg"></div>

<section id="overview" class="on">
  <div class="cards" id="ovcards"></div>
  <div class="row">
    <button class="go" id="applybtn">Apply pending changes</button>
    <span class="dim" id="pendinfo"></span>
  </div>
  <pre id="applylog" style="display:none"></pre>
</section>

<section id="devices">
  <div class="row"><button class="go" id="devsave">Save to git</button>
    <span class="dim">Saving commits devices.toml. Nothing reaches the router until you Apply.</span></div>
  <div class="scroll">
  <table><thead><tr><th></th><th>Name</th><th>Last seen</th><th>MAC</th><th>Vendor</th>
    <th>Lease</th><th class="right">Down</th><th class="right">Up</th>
    <th>Reserved IP</th><th>Block</th><th>Note</th><th></th></tr></thead>
    <tbody id="devrows"></tbody></table>
  </div>
  <p class="dim">Rows marked <em>unregistered</em> are live DHCP leases that
    aren't in devices.toml. They're only written to git once you give one a
    reserved IP, a note, or tick Block — otherwise Save leaves them alone.</p>
</section>

<section id="ports">
  <div class="row"><button class="go" id="portsave">Save to git</button>
    <button id="portadd">Add forward</button></div>
  <table><thead><tr><th>Name</th><th>Port / range</th><th>Protocol</th>
    <th>Destination</th><th></th></tr></thead>
    <tbody id="portrows"></tbody></table>
</section>

<section id="traffic">
  <div class="cards" id="livecards"></div>
  <h3>Last 24 hours</h3><div class="bar" id="th"></div>
  <h3>Last 30 days</h3><div class="bar" id="td"></div>
  <h3>Monthly</h3><div class="bar" id="tm"></div>
  <p class="dim">Blue = download, amber = upload. Hover a bar for exact figures.</p>
  <h3>Per device</h3>
  <div class="scroll">
  <table><thead><tr><th>Device</th><th>Address</th><th class="right">Down</th>
    <th class="right">Up</th><th class="right">Total</th><th>Last seen</th></tr></thead>
    <tbody id="dtrows"></tbody></table>
  </div>
  <p class="dim" id="dtsince"></p>
</section>

<section id="speed">
  <div class="row"><button class="go" id="strun">Run speedtest now</button>
    <span class="dim">Takes ~30s, then refresh.</span></div>
  <div class="cards" id="stcards"></div>
  <table><thead><tr><th>When</th><th class="right">Down</th><th class="right">Up</th>
    <th class="right">Ping</th><th>Server</th></tr></thead>
    <tbody id="strows"></tbody></table>
</section>
</main>
<script>
const $ = s => document.querySelector(s);
const el = (t, p = {}) => Object.assign(document.createElement(t), p);
let devSha = '', portSha = '', devs = [], ports = [], head = '';

function say(text, bad) {
  const m = $('#msg'); m.textContent = text;
  m.className = 'msg ' + (bad ? 'bad' : 'ok'); m.style.display = text ? 'block' : 'none';
}
async function api(path, opts) {
  const r = await fetch(path, opts);
  const j = await r.json().catch(() => ({ error: r.statusText }));
  if (!r.ok) throw new Error(j.error || r.statusText);
  return j;
}
const post = (p, b) => api(p, {
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b)
});

document.querySelectorAll('header button').forEach(b => b.onclick = () => {
  document.querySelectorAll('header button').forEach(x => x.classList.remove('on'));
  document.querySelectorAll('section').forEach(x => x.classList.remove('on'));
  b.classList.add('on'); $('#' + b.dataset.tab).classList.add('on'); say(''); load(b.dataset.tab);
});

function dur(s) {
  const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600);
  return d ? d + 'd ' + h + 'h' : h + 'h ' + Math.floor(s % 3600 / 60) + 'm';
}
function bytes(b) {
  const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
  let i = 0;
  while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
  return (i ? b.toFixed(1) : b) + ' ' + u[i];
}
function ago(ts) {
  if (!ts) return 'never';
  const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
  if (s < 180) return 'just now';
  if (s < 3600) return Math.floor(s / 60) + ' min ago';
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}

async function load(tab) {
  try {
    if (tab === 'overview') {
      const o = await api('/api/overview'), p = await api('/api/pending');
      head = p.head;
      const drift = Object.entries(o.drift).filter(([, v]) => v).map(([k]) => k);
      $('#ovcards').innerHTML = '';
      [['WAN address', o.wan_ip], ['Uptime', dur(o.uptime)],
       ['DHCP leases', o.leases], ['Deployed', o.last_rev ? o.last_rev.slice(0, 8) : 'unknown'],
       ['Config drift', drift.length ? drift.join(', ') : 'none']
      ].forEach(([k, v]) => {
        const c = el('div', { className: 'card' });
        c.append(el('div', { className: 'k', textContent: k }),
                 el('div', { className: 'v', textContent: v }));
        $('#ovcards').append(c);
      });
      $('#pendinfo').textContent = p.unapplied.length
        ? p.unapplied.length + ' commit(s) not yet deployed: '
          + p.unapplied.map(c => c.sha.slice(0, 8) + ' ' + c.message).join(' · ')
        : 'Nothing to deploy — git matches the last apply.';
    }
    if (tab === 'devices') {
      const d = await api('/api/devices'); devSha = d.sha; devs = d.devices; drawDevs();
    }
    if (tab === 'ports') {
      const p = await api('/api/ports'); portSha = p.sha; ports = p.forwards; drawPorts();
    }
    if (tab === 'traffic') { live(); drawTraffic(await api('/api/traffic')); }
    if (tab === 'speed') drawSpeed(await api('/api/speedtest'));
  } catch (e) { say(e.message, true); }
}

function bind(obj, key) {
  const i = el('input', { type: 'text', value: obj[key] ?? '' });
  i.oninput = () => obj[key] = i.value.trim();
  return i;
}
// A lease that isn't in devices.toml stays out of git until you actually
// give it something to remember — mirrors what the Save button sends.
const registered = d => d.known || d.ip || d.blocked || (d.note || '').trim();

function drawDevs() {
  const body = $('#devrows'); body.innerHTML = '';
  devs.forEach((d, n) => {
    const tr = el('tr');
    const dot = el('span', {
      className: 'dot' + (d.online ? ' up' : ''),
      title: d.online ? 'active now' : 'last seen ' + ago(d.last_seen)
    });
    const blocked = el('input', { type: 'checkbox', checked: d.blocked });
    blocked.onchange = () => { d.blocked = blocked.checked; drawDevs(); };
    const cells = [dot, bind(d, 'name'), d.online ? 'active now' : ago(d.last_seen),
                   d.mac, d.vendor || '—', d.lease_ip || '—',
                   bytes(d.down || 0), bytes(d.up || 0),
                   bind(d, 'ip'), blocked, bind(d, 'note')];
    cells.forEach((c, i) => {
      const td = el('td', { className: i === 6 || i === 7 ? 'right' : '' });
      td.append(c.nodeType ? c : document.createTextNode(c));
      tr.append(td);
    });
    const td = el('td');
    if (d.known) {
      const b = el('button', { className: 'danger', textContent: 'Forget' });
      b.onclick = () => { devs.splice(n, 1); drawDevs(); };
      td.append(b);
    } else {
      td.append(el('span', {
        className: 'dim',
        textContent: registered(d) ? 'will be added' : 'unregistered'
      }));
    }
    tr.append(td); body.append(tr);
  });
}
function drawDeviceTraffic(t) {
  const body = $('#dtrows'); body.innerHTML = '';
  (t.devices || []).forEach(d => {
    const tr = el('tr');
    [d.name || d.key, d.ip || d.key, bytes(d.down), bytes(d.up),
     bytes(d.down + d.up), ago(d.last_seen)].forEach((v, i) => {
      tr.append(el('td', { textContent: v, className: i >= 2 && i <= 4 ? 'right' : '' }));
    });
    body.append(tr);
  });
  $('#dtsince').textContent = (t.devices || []).length
    ? 'Counting since ' + new Date((t.since || 0) * 1000).toLocaleString()
      + '. Totals survive rebuilds; the underlying nftables counters do not.'
    : 'No per-device data yet — the accounting tick runs every minute.';
}
function drawPorts() {
  const body = $('#portrows'); body.innerHTML = '';
  ports.forEach((f, n) => {
    const tr = el('tr');
    const proto = el('select');
    ['tcp', 'udp', 'both'].forEach(p => proto.append(el('option', { value: p, textContent: p, selected: f.protocol === p })));
    proto.onchange = () => f.protocol = proto.value;
    [bind(f, 'name'), bind(f, 'port'), proto, bind(f, 'dest')].forEach(c => {
      const td = el('td'); td.append(c); tr.append(td);
    });
    const td = el('td'), b = el('button', { className: 'danger', textContent: 'Remove' });
    b.onclick = () => { ports.splice(n, 1); drawPorts(); };
    td.append(b); tr.append(td); body.append(tr);
  });
}
function bars(node, rows, empty) {
  node.innerHTML = '';
  if (!rows.length) {
    node.append(el('i', { className: 'empty', textContent: empty }));
    return;
  }
  const max = Math.max(1, ...rows.map(r => Math.max(r.rx, r.tx)));
  rows.forEach(r => {
    [['rx', 'down'], ['tx', 'up']].forEach(([k, label]) => {
      const i = el('i', { title: r.label + '  ' + label + ' ' + bytes(r[k]) });
      if (label === 'up') i.className = 'up';
      i.style.height = Math.max(2, r[k] / max * 100) + '%';
      node.append(i);
    });
  });
}
function drawTraffic(t) {
  const wait = 'vnstat is still collecting — it writes every 5 minutes';
  bars($('#th'), t.hour, wait);
  bars($('#td'), t.day, wait);
  bars($('#tm'), t.month, wait);
  drawDeviceTraffic(t);
}

let prev = null, timer = null;
async function live() {
  clearInterval(timer);
  const tick = async () => {
    if (!$('#traffic').classList.contains('on')) return;
    const s = await api('/api/live');
    if (prev) {
      const dt = Math.max(0.5, s.t - prev.t);
      const mbps = b => ((b) * 8 / dt / 1e6).toFixed(1) + ' Mbps';
      $('#livecards').innerHTML = '';
      [['Down now', mbps(s.rx - prev.rx)], ['Up now', mbps(s.tx - prev.tx)]]
        .forEach(([k, v]) => {
          const c = el('div', { className: 'card' });
          c.append(el('div', { className: 'k', textContent: k }),
                   el('div', { className: 'v', textContent: v }));
          $('#livecards').append(c);
        });
    }
    prev = s;
  };
  await tick(); timer = setInterval(tick, 2000);
}
function drawSpeed(rows) {
  const last = rows[rows.length - 1];
  $('#stcards').innerHTML = '';
  if (last) [['Download', last.down + ' Mbps'], ['Upload', last.up + ' Mbps'],
             ['Ping', last.ping + ' ms'], ['When', last.ts]].forEach(([k, v]) => {
    const c = el('div', { className: 'card' });
    c.append(el('div', { className: 'k', textContent: k }),
             el('div', { className: 'v', textContent: v }));
    $('#stcards').append(c);
  });
  const body = $('#strows'); body.innerHTML = '';
  rows.slice().reverse().forEach(r => {
    const tr = el('tr');
    [r.ts, r.down, r.up, r.ping, r.server].forEach((v, i) => {
      tr.append(el('td', { textContent: v, className: i > 0 && i < 4 ? 'right' : '' }));
    });
    body.append(tr);
  });
}

$('#devsave').onclick = async () => {
  try {
    const payload = devs.filter(registered);
    const added = payload.filter(d => !d.known).length;
    const r = await post('/api/devices', { sha: devSha, devices: payload });
    head = r.rev;
    say('Committed ' + r.rev.slice(0, 8)
        + (added ? ' (' + added + ' newly registered)' : '')
        + '. Go to Overview and Apply.');
    load('devices');
  } catch (e) { say(e.message, true); }
};
$('#portsave').onclick = async () => {
  try {
    const r = await post('/api/ports', { sha: portSha, forwards: ports });
    head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
    load('ports');
  } catch (e) { say(e.message, true); }
};
$('#portadd').onclick = () => {
  ports.push({ _i: null, name: '', port: '', protocol: 'tcp', dest: '' }); drawPorts();
};
$('#strun').onclick = async () => {
  try { await post('/api/speedtest/run', {}); say('Speedtest started — refresh in ~30s.'); }
  catch (e) { say(e.message, true); }
};

$('#applybtn').onclick = async () => {
  if (!head) { say('Nothing to apply.', true); return; }
  await doApply(false);
};
async function doApply(confirm) {
  try {
    await post('/api/apply', { rev: head, confirm });
    say('Applying ' + head.slice(0, 8) + ' — test, health check, then switch.');
    tail(head);
  } catch (e) {
    if (e.message.startsWith('CONFIRM:')) {
      if (window.confirm(e.message.slice(8) + '\\n\\nApply anyway?')) return doApply(true);
      return say('Cancelled.', true);
    }
    say(e.message, true);
  }
}
function tail(rev) {
  const pre = $('#applylog'); pre.style.display = 'block'; pre.textContent = '';
  const src = new EventSource('/api/apply/log?rev=' + rev);
  src.onmessage = e => { pre.textContent += e.data + '\\n'; pre.scrollTop = pre.scrollHeight; };
  src.onerror = () => src.close();
}

load('overview');
</script>
""").encode()


if __name__ == "__main__":
    os.makedirs(STATE, exist_ok=True)
    if len(sys.argv) > 1 and sys.argv[1] == "tick":
        # router-accounting.service, as root: reading nft sets needs NET_ADMIN.
        accounting_tick()
    else:
        ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
