#!/usr/bin/env python3
"""
Behavioral tests for netbox/dc-util-hosts-import.py. OFFLINE -- the NB client is a fake
injected at the get_nb() seam. lib-net.sh / lib-hosts.sh ARE sourced for real (the repo
is present), so the derived addresses are exercised against the true carve -- a drift
guard: if lib-hosts moves tailscale-01 off .7 or lib-net moves a plane base, these fail.

Each assertion has a failing DIRECTION (the 2026-07-30 lesson: a suite that stays green
when an assertion is deleted was decoration). Run via run-tests.sh.
"""
import importlib.util
import io
import os
import sys
from contextlib import redirect_stdout, redirect_stderr

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
from fake_netbox import FakeNB  # noqa: E402

# import the dashed-filename module by path
_spec = importlib.util.spec_from_file_location("util_hosts_import",
                                               os.path.join(REPO, "netbox", "dc-util-hosts-import.py"))
mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mod)

_p, _f = 0, 0


def ok():
    global _p
    _p += 1


def bad(msg):
    global _f
    _f += 1
    print(f"  FAIL: {msg}")


def dc_ranges(*bases):
    """Utility ranges .4-.49 for each /22 base given (e.g. '10.12.8')."""
    return [{"start_address": f"{b}.4/22", "end_address": f"{b}.49/22"} for b in bases]


def run(argv, fake, env_over=None):
    """Drive main() with argv/env against `fake`. Returns (exit_code, stdout, stderr)."""
    env = {"NETBOX_URL": "http://10.10.1.10:8000", "NETBOX_TOKEN": "nbt_x.y"}
    if env_over:
        env.update(env_over)
    old_env = {k: os.environ.get(k) for k in ("NETBOX_URL", "NETBOX_TOKEN", "UTIL_SITE", "UTIL_HOST")}
    old_argv = sys.argv
    old_get = mod.get_nb
    for k in ("UTIL_SITE", "UTIL_HOST"):
        os.environ.pop(k, None)
    os.environ.update({k: v for k, v in env.items() if v is not None})
    sys.argv = ["dc-util-hosts-import.py"] + argv
    mod.get_nb = lambda base, token: fake
    out, err = io.StringIO(), io.StringIO()
    code = 0
    try:
        with redirect_stdout(out), redirect_stderr(err):
            rc = mod.main()
            code = rc if rc is not None else 0
    except SystemExit as e:
        code = e.code if isinstance(e.code, int) else 1
    finally:
        sys.argv = old_argv
        mod.get_nb = old_get
        for k, v in old_env.items():
            if v is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = v
    return code, out.getvalue(), err.getvalue()


# 1. DERIVATION pinned: dc0 tailscale-01 -> 10.12.8.7 + 10.12.4.7 (dry run, 0 writes)
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"))
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01"], fake)
if code == 0 and "10.12.8.7/22" in out and "10.12.4.7/22" in out and not fake.creates:
    ok()
else:
    bad(f"dry-run derivation dc0 tailscale-01 (code={code}, creates={fake.creates}, out={out!r})")

# 2. COMMIT writes both planes with dns_name + reads back
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"))
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
addrs = {p["address"] for _, p in fake.creates}
dns = {p.get("dns_name") for _, p in fake.creates}
if code == 0 and addrs == {"10.12.8.7/22", "10.12.4.7/22"} and dns == {"vr1-dc0-tailscale-01"}:
    ok()
else:
    bad(f"commit should create both planes with dns_name (creates={fake.creates})")

# 3. dc1 maas-01 -> .6 on 10.12.68 / 10.12.64
fake = FakeNB(ip_ranges=dc_ranges("10.12.68", "10.12.64"))
code, out, err = run(["--site", "vr1-dc1", "--host", "vr1-dc1-maas-01", "--commit"], fake)
addrs = {p["address"] for _, p in fake.creates}
if code == 0 and addrs == {"10.12.68.6/22", "10.12.64.6/22"}:
    ok()
else:
    bad(f"dc1 maas-01 should derive .6 on both planes (creates={fake.creates})")

# 4. IDEMPOTENT: both addresses already present -> 0 creates, EXISTS
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"),
              ip_addresses=[{"address": "10.12.8.7/22", "dns_name": "vr1-dc0-tailscale-01"},
                            {"address": "10.12.4.7/22", "dns_name": "vr1-dc0-tailscale-01"}])
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
if code == 0 and not fake.creates and "EXISTS" in out:
    ok()
else:
    bad(f"idempotent re-run must create nothing (creates={fake.creates})")

# 5. MISSING utility range -> die (exit 2), nothing written
fake = FakeNB(ip_ranges=[])
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
if code == 2 and not fake.creates and "no apex ip-range covers" in err:
    ok()
else:
    bad(f"missing range must die before any write (code={code}, creates={fake.creates}, err={err!r})")

# 6. Address OUTSIDE any range (only a VIP range seeded) -> die
fake = FakeNB(ip_ranges=[{"start_address": "10.12.8.50/22", "end_address": "10.12.8.99/22"},
                         {"start_address": "10.12.4.50/22", "end_address": "10.12.4.99/22"}])
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
if code == 2 and not fake.creates:
    ok()
else:
    bad(f"address outside every range must die (code={code}, creates={fake.creates})")

# 7. Non-sandbox host + --commit without --yes-write-upstream -> REFUSE
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"))
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake,
                     env_over={"NETBOX_URL": "https://netbox.baldurkeep.com"})
if code == 2 and "REFUSING to --commit" in err and not fake.creates:
    ok()
else:
    bad(f"non-sandbox commit must refuse without --yes-write-upstream (code={code}, err={err!r})")

# 8. ROLE node (suffix not whitelisted) -> die, no lib-hosts guess
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"))
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-control-01", "--commit"], fake)
if code == 2 and not fake.creates:
    ok()
else:
    bad(f"a role node must be rejected (code={code}, creates={fake.creates})")

# 9. CROSS-SITE: dc0 host under --site vr1-dc1 -> die
fake = FakeNB(ip_ranges=dc_ranges("10.12.68", "10.12.64"))
code, out, err = run(["--site", "vr1-dc1", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
if code == 2 and not fake.creates:
    ok()
else:
    bad(f"a dc0 host under --site vr1-dc1 must die (code={code}, creates={fake.creates})")

# 10. dns_name COLLISION: same dns_name on a non-planned address -> die
fake = FakeNB(ip_ranges=dc_ranges("10.12.8", "10.12.4"),
              ip_addresses=[{"address": "10.12.8.99/22", "dns_name": "vr1-dc0-tailscale-01"}])
code, out, err = run(["--site", "vr1-dc0", "--host", "vr1-dc0-tailscale-01", "--commit"], fake)
if code == 2 and not fake.creates and "stale/rename conflict" in err:
    ok()
else:
    bad(f"dns_name on a non-planned address must die (code={code}, creates={fake.creates}, err={err!r})")

# 11. UNIT: lib_hosts_octet on a host absent from HOST_OCTET -> die (the clean-zero guard)
try:
    out, err = io.StringIO(), io.StringIO()
    with redirect_stdout(out), redirect_stderr(err):
        mod.lib_hosts_octet("vr1-dc0", "vr1-dc0-nonesuch-99")
    bad("lib_hosts_octet on a missing host should die, did not")
except SystemExit as e:
    ok() if e.code == 2 else bad(f"lib_hosts_octet missing-host exit != 2 ({e.code})")

# 12. UNIT: derivation of octet .5/.6/.7 from the REAL lib-hosts (drift guard)
try:
    got = {mod.lib_hosts_octet("vr1-dc0", h) for h in
           ("vr1-dc0-juju-01", "vr1-dc0-maas-01", "vr1-dc0-tailscale-01")}
    ok() if got == {5, 6, 7} else bad(f"lib-hosts octets drifted: {got} != {{5,6,7}}")
except SystemExit as e:
    bad(f"lib_hosts_octet raised on a real host ({e.code})")

print(f"test_logic: {_p} passed, {_f} failed")
sys.exit(1 if _f else 0)
