"""Self-check for router-ui.py: TOML round-trips and the refusal rules.

Run it against the same python the service uses:

    nix shell .#nixosConfigurations.FredOS-Mediaserver.pkgs.python3Packages.tomlkit \
      -c python3 scripts/router-ui-check.py

The TOML round-trip cases are the ones that matter: a save must leave an
unedited file byte-identical, and must never eat the parked/commented forwards
in ports.toml.
"""
import difflib
import importlib.util
import os
import sys
import tempfile

import tomlkit

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.join(HERE, "..")

spec = importlib.util.spec_from_file_location("routerui", os.path.join(HERE, "router-ui.py"))
r = importlib.util.module_from_spec(spec)
spec.loader.exec_module(r)


def refuses(fn, *a):
    try:
        fn(*a)
    except r.Refused as e:
        return str(e)
    raise AssertionError(f"{fn.__name__} accepted what it should have refused")


def same(before, after, what):
    assert before == after, what + ":\n" + "".join(
        difflib.unified_diff(before.splitlines(1), after.splitlines(1), "before", "after"))


# --- ports.toml round-trip ---------------------------------------------------
src = open(os.path.join(REPO, "ports.toml")).read()
doc = tomlkit.parse(src)
items = r.forwards_from(doc)
assert len(items) == 4, items
assert items[3]["port"] == "25565-25600", items[3]

# Saving without editing anything must not touch a single byte.
r.check_forwards(items)
r.edit_aot(doc, "forward", items)
same(src, tomlkit.dumps(doc), "no-op forward save rewrote the file")

# Add one, drop the Pelican range; every comment block must survive.
doc2 = tomlkit.parse(src)
items2 = r.forwards_from(doc2)
items2.pop(3)
items2.append({"_i": None, "name": "Test", "port": "9999", "protocol": "udp",
               "dest": "10.0.0.5"})
r.check_forwards(items2)
r.edit_aot(doc2, "forward", items2)
out = tomlkit.dumps(doc2)
assert "7DTD voice/dynamic" in out, "parked blocks were lost"
assert "WoW Classic realmd" in out, "parked blocks were lost"
assert "Pelican game-server allocation range" in out, "inline comment was lost"
assert "Pelican game servers" not in out.replace("# Pelican", ""), "deletion did not happen"
assert 'name = "Test"' in out and "port = 9999" in out, out[-300:]
back = tomlkit.parse(out)["forward"]
assert len(back) == 4 and back[3]["protocol"] == "udp" and back[3]["dest"] == "10.0.0.5"
assert [f["name"] for f in back[:3]] == ["HTTP", "HTTPS", "SSH"]

# --- forward validation ------------------------------------------------------
mk = lambda: r.forwards_from(tomlkit.parse(src))  # noqa: E731 - fresh fixture per case
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "SSH"]))
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "HTTPS"]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "70000",
                                         "protocol": "tcp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "9-8",
                                         "protocol": "tcp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "80",
                                         "protocol": "sctp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "", "port": "80",
                                         "protocol": "tcp", "dest": ""}]))
# A range that covers 22 and 443 satisfies the keep-me-reachable rule.
r.check_forwards([{"_i": None, "name": "wide", "port": "20-500", "protocol": "both",
                   "dest": ""}])

# --- device validation -------------------------------------------------------
def dev(**kw):
    base = {"_i": None, "mac": "aa:bb:cc:dd:ee:01", "name": "thing", "ip": "",
            "blocked": False, "note": ""}
    return {**base, **kw}


me, my_ip = "aa:bb:cc:dd:ee:99", "10.0.0.161"

print(refuses(r.check_devices, [dev(ip="10.0.0.161")], me, my_ip))   # inside the pool
print(refuses(r.check_devices, [dev(ip="10.0.0.1")], me, my_ip))     # the router itself
print(refuses(r.check_devices, [dev(ip="192.168.1.5")], me, my_ip))  # off-LAN
print(refuses(r.check_devices, [dev(mac="nope")], me, my_ip))
print(refuses(r.check_devices, [dev(name="bad name")], me, my_ip))
print(refuses(r.check_devices, [dev(), dev()], me, my_ip))           # duplicate MAC
print(refuses(r.check_devices, [dev(ip="10.0.0.20"),
                                dev(mac="aa:bb:cc:dd:ee:02", ip="10.0.0.20")], me, my_ip))
print(refuses(r.check_devices, [dev(mac=me, blocked=True)], me, my_ip))
print(refuses(r.check_devices, [dev(ip="10.0.0.30", blocked=True)], me, "10.0.0.30"))

ok = [dev(ip="10.0.0.39", name="camera-bedroom")]
r.check_devices(ok, me, my_ip)
assert ok[0]["ip"] == "10.0.0.39" and "blocked" not in ok[0] and "note" not in ok[0]
cleared = [dev(ip="")]
r.check_devices(cleared, me, my_ip)
assert "ip" not in cleared[0], "an empty reservation should drop the key entirely"

# --- devices.toml round-trip -------------------------------------------------
dsrc = open(os.path.join(REPO, "devices.toml")).read()
ddoc = tomlkit.parse(dsrc)
ditems = r.devices_from(ddoc)
assert len(ditems) == 1 and ditems[0]["ip"] == "10.0.0.39"
r.check_devices(ditems, me, my_ip)
r.edit_aot(ddoc, "device", ditems)
same(dsrc, tomlkit.dumps(ddoc), "no-op device save rewrote the file")

ditems = r.devices_from(ddoc)
ditems[0]["ip"] = ""
ditems[0]["blocked"] = True
r.check_devices(ditems, me, my_ip)
r.edit_aot(ddoc, "device", ditems)
entry = tomlkit.dumps(ddoc).split("[[device]]")[1]
assert "ip = " not in entry, entry
assert "blocked = true" in entry, entry

# --- accounting: deltas, and counter resets on ruleset reload ----------------
state = tempfile.mkdtemp()
r.TRAFFIC_FILE = os.path.join(state, "traffic.json")
r.LEASES = os.path.join(state, "leases")
open(r.LEASES, "w").write("999 aa:bb:cc:dd:ee:01 10.0.0.50 phone *\n")

samples = []
r.nft_counters = lambda: samples[-1]  # noqa: E731 - stand in for the live sets


def tick(up, down):
    samples.append({"10.0.0.50": {"up": up, "down": down}})
    return r.accounting_tick()["totals"]["aa:bb:cc:dd:ee:01"]


t = tick(100, 200)
assert (t["up"], t["down"]) == (100, 200), t
t = tick(150, 700)                       # normal growth -> delta added
assert (t["up"], t["down"]) == (150, 700), t
t = tick(10, 5)                          # counters reset -> whole sample counts
assert (t["up"], t["down"]) == (160, 705), t
t = tick(10, 5)                          # unchanged -> nothing added
assert (t["up"], t["down"]) == (160, 705), t
assert t["ip"] == "10.0.0.50"

seen = t["last_seen"]
t = tick(10, 5)                          # idle tick must not refresh last_seen
assert t["last_seen"] == seen, (t["last_seen"], seen)

# an address with no lease is keyed by IP so it still shows up
samples.append({"10.0.0.77": {"up": 5, "down": 5}})
assert "10.0.0.77" in r.accounting_tick()["totals"]

# --- speedtest parsing (bytes/s -> Mbps, ns -> ms) ---------------------------
sample = ('{"timestamp":"2026-08-15 12:00:55","servers":[{"name":"Preston",'
          '"latency":18796260,"dl_speed":63607890.17,"ul_speed":8839605.08}]}\n')
tmp = os.path.join(tempfile.mkdtemp(), "st.jsonl")
open(tmp, "w").write(sample + "not json at all\n" + sample)
r.SPEEDTEST_LOG = tmp
got = r.speedtests()
assert len(got) == 2, got
assert got[0] == {"ts": "2026-08-15 12:00:55", "down": 508.9, "up": 70.7,
                  "ping": 18.8, "server": "Preston"}, got[0]

print("\nall checks passed", file=sys.stderr)
