#!/usr/bin/env python3
"""Matter/Thread commissioning pre-flight check.

Why this exists
---------------
A survey of 47 Home Assistant threads where Matter or Thread commissioning
failed found that, of the 24 with a confirmed root cause, 15 -- 62.5% -- were
in the network layer rather than in Home Assistant, the device, or its
firmware. Every one of those 15 was invisible from inside Home Assistant. The
users who eventually solved them did so after hours or days, usually because a
stranger on a forum happened to know where to look.

Each check below reproduces one of those root causes. The thread that
documented it is named, so any finding can be traced back to a real failure
rather than to someone's idea of a best practice.

What this does NOT do
---------------------
It makes no changes. It opens no outbound connection. It reads no credentials.
It writes nothing except the report you asked for, to the path you named. Every
check is a read of a file under /proc, a read of the routing table, or a local
socket operation that never leaves the machine.

It also does not tell you your setup is fine. A check that cannot run reports
CANNOT_DETERMINE, never PASS -- "we could not look" and "we looked and found
nothing wrong" are different answers and are never merged.

Usage
-----
    python3 matter_preflight.py                 # report to stdout
    python3 matter_preflight.py --json          # machine-readable
    python3 matter_preflight.py --self-test     # prove the logic, no host reads
"""

from __future__ import annotations

import argparse
import json
import os
import platform
import re
import socket
import subprocess
import sys
from dataclasses import dataclass, field, asdict

VERSION = "0.1.0"

PASS = "PASS"
FAIL = "FAIL"
UNKNOWN = "CANNOT_DETERMINE"
INFO = "INFO"


@dataclass
class Finding:
    check: str
    state: str
    summary: str
    detail: str = ""
    fix: str = ""
    evidence: str = ""
    source: str = ""


@dataclass
class Report:
    version: str = VERSION
    environment: dict = field(default_factory=dict)
    findings: list = field(default_factory=list)

    def add(self, f: Finding) -> None:
        self.findings.append(f)

    def counts(self) -> dict:
        out = {PASS: 0, FAIL: 0, UNKNOWN: 0, INFO: 0}
        for f in self.findings:
            out[f.state] = out.get(f.state, 0) + 1
        return out


# --------------------------------------------------------------------------
# read helpers -- every one of these is a read, and every one can fail cleanly
# --------------------------------------------------------------------------

def read_sysctl(path: str):
    """Read one /proc/sys value. Returns None when it does not exist.

    A missing key is meaningful here, not an error: check 4 exists precisely
    because a kernel that lacks accept_ra_rt_info_max_plen silently ignores the
    route the border router advertises.
    """
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read().strip()
    except (FileNotFoundError, PermissionError, OSError):
        return None


# The only commands this tool may ever execute. Each one reads state and
# changes nothing. The guard test asserts that subprocess is called from
# exactly one place -- run() below -- and that run() enforces this list before
# executing, because a static scan cannot see what a variable argv holds.
ALLOWED_COMMANDS = frozenset({"ip"})


def run(cmd: list):
    """Run a read-only command. Returns (ok, output).

    Refuses anything not on ALLOWED_COMMANDS. This is enforced here rather
    than at the call sites so that adding a call site cannot widen what the
    tool is able to execute.
    """
    if not cmd or cmd[0] not in ALLOWED_COMMANDS:
        return False, "refused: %r is not on the read-only allowlist" % (cmd[0] if cmd else None)
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        return p.returncode == 0, (p.stdout or "") + (p.stderr or "")
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
        return False, "%s: %s" % (type(exc).__name__, exc)


def detect_environment() -> dict:
    env = {
        "system": platform.system(),
        "release": platform.release(),
        "python": platform.python_version(),
        "is_root": (os.geteuid() == 0) if hasattr(os, "geteuid") else None,
    }
    env["in_container"] = os.path.exists("/.dockerenv") or _cgroup_mentions("docker")
    env["haos"] = os.path.exists("/etc/os-release") and _file_mentions(
        "/etc/os-release", "Home Assistant OS"
    )
    env["proxmox_guest"] = _dmi_mentions("QEMU") or _dmi_mentions("KVM")
    env["synology"] = os.path.exists("/etc/synoinfo.conf")
    return env


def _file_mentions(path: str, needle: str) -> bool:
    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            return needle.lower() in fh.read().lower()
    except OSError:
        return False


def _cgroup_mentions(needle: str) -> bool:
    return _file_mentions("/proc/1/cgroup", needle)


def _dmi_mentions(needle: str) -> bool:
    for p in ("/sys/class/dmi/id/sys_vendor", "/sys/class/dmi/id/product_name"):
        if _file_mentions(p, needle):
            return True
    return False


def runs_border_router_here() -> bool:
    """Does a Thread border router run on this host?

    OpenThread creates a `wpan*` interface when it owns the radio. Its presence
    is the cheapest reliable signal that this machine is the border router
    rather than merely a client of one -- and that distinction changes the
    verdict of check_forwarding_accept_ra completely.
    """
    try:
        return any(n.startswith("wpan") for n in os.listdir("/sys/class/net"))
    except OSError:
        return False


def primary_interfaces() -> list:
    """Interfaces that are up and not loopback."""
    names = []
    try:
        for name in sorted(os.listdir("/sys/class/net")):
            if name == "lo":
                continue
            state = read_sysctl("/sys/class/net/%s/operstate" % name)
            if state == "up":
                names.append(name)
    except OSError:
        pass
    return names


# --------------------------------------------------------------------------
# checks
# --------------------------------------------------------------------------

def check_ipv6_globally_enabled(rep: Report) -> None:
    """Matter is IPv6-only on the local link. If IPv6 is off, nothing works.

    Root cause seen in the wild: a Proxmox host shipped
    net.ipv6.conf.all.disable_ipv6=1 in /etc/sysctl.d/99-proxmox.conf. The VM
    running Home Assistant inherited it. Commissioning reached PASE and then
    failed with "address unreachable" -- a symptom that points nowhere near the
    actual cause.
    """
    src = "community.home-assistant.io/t/1013449 (2026-06-10)"
    val = read_sysctl("/proc/sys/net/ipv6/conf/all/disable_ipv6")
    if val is None:
        rep.add(Finding("ipv6_enabled", UNKNOWN,
                        "Could not read whether IPv6 is enabled.",
                        detail="/proc/sys/net/ipv6/conf/all/disable_ipv6 is not readable here.",
                        fix="Run this on the host that runs Home Assistant, not inside an "
                            "unprivileged container.",
                        source=src))
        return
    if val == "1":
        rep.add(Finding("ipv6_enabled", FAIL,
                        "IPv6 is disabled on this host. Matter cannot work.",
                        detail="net.ipv6.conf.all.disable_ipv6 = 1. Matter and Thread use "
                               "IPv6 on the local link; there is no IPv4 fallback.",
                        fix="Find which file sets it: grep -r disable_ipv6 /etc/sysctl.conf "
                            "/etc/sysctl.d/ . On Proxmox this is often "
                            "/etc/sysctl.d/99-proxmox.conf. Change it to 0 and reboot. "
                            "Note this is a HOST setting -- changing it inside the VM is "
                            "not enough if the host disables it.",
                        evidence="disable_ipv6=1", source=src))
    else:
        rep.add(Finding("ipv6_enabled", PASS, "IPv6 is enabled.",
                        evidence="disable_ipv6=%s" % val, source=src))


def check_docker_ipv6(rep: Report, env: dict) -> None:
    """Home Assistant in Docker gets no IPv6 unless it is switched on.

    Root cause seen in the wild: commissioning stalled on a Ugreen NAS. Thread,
    the radio and the device were all fine. The container had no IPv6.
    """
    src = "community.home-assistant.io/t/1006775 (2026-04-24), t/953420 (2025-11-20)"
    if not env.get("in_container"):
        rep.add(Finding("docker_ipv6", INFO,
                        "Not running inside a container; container IPv6 does not apply.",
                        source=src))
        return
    v6 = [i for i in primary_interfaces() if _has_global_v6(i)]
    if not primary_interfaces():
        rep.add(Finding("docker_ipv6", UNKNOWN,
                        "Running in a container but no interfaces could be listed.",
                        source=src))
    elif v6:
        rep.add(Finding("docker_ipv6", PASS,
                        "Container has a routable IPv6 address.",
                        evidence="interfaces with global IPv6: %s" % ", ".join(v6),
                        source=src))
    else:
        rep.add(Finding("docker_ipv6", FAIL,
                        "This container has no routable IPv6 address.",
                        detail="Matter commissioning will stall. The Thread network, the "
                               "radio and the device can all be healthy and it will still "
                               "fail here.",
                        fix="On Home Assistant OS/Supervised: "
                            "ha docker options --enable-ipv6=true  then restart. "
                            "On plain Docker: enable IPv6 in the daemon config, or run the "
                            "container with host networking.",
                        source=src))


def _has_global_v6(iface: str) -> bool:
    ok, out = run(["ip", "-6", "addr", "show", "dev", iface])
    if not ok:
        return False
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("inet6") and "scope global" in line:
            return True
    return False


def check_forwarding_accept_ra(rep: Report) -> None:
    """When forwarding is on, the kernel ignores router advertisements
    unless accept_ra is 2. Border routers announce their route via RA.

    This combination is what silently breaks hosts that also run Docker,
    because Docker turns forwarding on.
    """
    src = "OpenThread border router routing; seen alongside t/953420"
    fwd = read_sysctl("/proc/sys/net/ipv6/conf/all/forwarding")
    if fwd is None:
        rep.add(Finding("forwarding_accept_ra", UNKNOWN,
                        "Could not read IPv6 forwarding state.", source=src))
        return
    if fwd != "1":
        # An earlier version returned PASS here, and that was a false
        # guarantee. Two documented cases had forwarding=0 as the ROOT CAUSE
        # on hosts running OpenThread themselves -- the thread title of one is
        # literally "SOLVED - It was IPv6 forwarding". This check would have
        # told both of those users their host was fine.
        #
        # When a border router runs here, packets have to be forwarded between
        # the Thread mesh and the LAN, so forwarding=0 breaks it outright.
        # When no border router runs here, forwarding is genuinely irrelevant
        # -- but we cannot always tell, and "we could not tell" is not a pass.
        if runs_border_router_here():
            rep.add(Finding("forwarding_accept_ra", FAIL,
                            "A Thread border router runs on this host, but IPv6 "
                            "forwarding is off.",
                            detail="This host owns the Thread radio, so it has to forward "
                                   "packets between the mesh and your LAN. With "
                                   "forwarding=0 it cannot, and the symptom appears "
                                   "somewhere else entirely -- usually as a device that "
                                   "joins Thread and is then unreachable.",
                            fix="sysctl -w net.ipv6.conf.all.forwarding=1 and persist it in "
                                "/etc/sysctl.d/. Note that a container running OTBR cannot "
                                "set this itself even with host networking; it has to be set "
                                "on the host OS.",
                            evidence="forwarding=0, wpan interface present",
                            source=src + ", t/953420 (2025-11-20)"))
        else:
            rep.add(Finding("forwarding_accept_ra", UNKNOWN,
                            "IPv6 forwarding is off, and we cannot tell from here whether "
                            "that matters.",
                            detail="Forwarding only has to be on when a Thread border router "
                                   "runs on this same host. No wpan interface was found, "
                                   "which usually means the border router is elsewhere -- "
                                   "but a containerised border router may hide it.",
                            fix="If your border router runs on this machine (OpenThread "
                                "add-on, otbr container, a USB radio plugged in here), then "
                                "forwarding=0 is your fault line: set "
                                "net.ipv6.conf.all.forwarding=1 on the HOST. If your border "
                                "router is a HomePod, Apple TV, Nest, eero or similar, this "
                                "check does not apply to you.",
                            evidence="forwarding=0, no wpan interface found",
                            source=src + ", t/953420 (2025-11-20)"))
        return

    bad = []
    for iface in primary_interfaces():
        ra = read_sysctl("/proc/sys/net/ipv6/conf/%s/accept_ra" % iface)
        if ra is not None and ra != "2":
            bad.append("%s=%s" % (iface, ra))
    if bad:
        rep.add(Finding("forwarding_accept_ra", FAIL,
                        "IPv6 forwarding is on, but some interfaces will ignore router "
                        "advertisements.",
                        detail="With forwarding=1 the kernel only honours RAs when "
                               "accept_ra=2. Your Thread border router advertises its route "
                               "by RA, so that route never gets installed.",
                        fix="For each interface listed: "
                            "sysctl -w net.ipv6.conf.<iface>.accept_ra=2 . Make it permanent "
                            "in /etc/sysctl.d/ . Docker turns forwarding on, so this "
                            "combination is common on hosts that also run containers.",
                        evidence="forwarding=1; accept_ra: %s" % ", ".join(bad), source=src))
    else:
        rep.add(Finding("forwarding_accept_ra", PASS,
                        "IPv6 forwarding is on and accept_ra is set correctly.",
                        evidence="forwarding=1", source=src))


def check_rt_info_max_plen(rep: Report) -> None:
    """Some kernels silently ignore the more-specific route a border router
    advertises, because they lack this knob or it is 0.

    Root cause seen in the wild: a Synology NAS on kernel 4.4 did not support
    accept_ra_rt_info_max_plen at all. The Apple TV border router was correctly
    advertising the Thread mesh route via RIO; the kernel dropped it.
    """
    src = "community.home-assistant.io/t/1011565 (2026-05-25)"
    ifaces = primary_interfaces()
    if not ifaces:
        rep.add(Finding("rt_info_max_plen", UNKNOWN,
                        "No interfaces could be listed.", source=src))
        return
    missing, zero, ok_ifaces = [], [], []
    for iface in ifaces:
        v = read_sysctl("/proc/sys/net/ipv6/conf/%s/accept_ra_rt_info_max_plen" % iface)
        if v is None:
            missing.append(iface)
        elif v == "0":
            zero.append("%s=0" % iface)
        else:
            ok_ifaces.append("%s=%s" % (iface, v))

    if missing and not ok_ifaces:
        rep.add(Finding("rt_info_max_plen", FAIL,
                        "This kernel does not support accept_ra_rt_info_max_plen.",
                        detail="The border router advertises the Thread mesh route as a "
                               "Route Information Option. A kernel without this knob "
                               "silently discards it, so Thread devices stay unreachable "
                               "even though everything else looks correct.",
                        fix="This is a kernel limitation, not a setting. Seen on Synology "
                            "DSM with kernel 4.4. Either run Home Assistant on a host with "
                            "a newer kernel, or add a static route for the Thread mesh "
                            "prefix by hand.",
                        evidence="absent on: %s" % ", ".join(missing), source=src))
    elif zero:
        rep.add(Finding("rt_info_max_plen", FAIL,
                        "accept_ra_rt_info_max_plen is 0, so mesh routes are ignored.",
                        detail="0 means the kernel accepts no Route Information Options at "
                               "all. The Thread mesh prefix is advertised this way.",
                        fix="sysctl -w net.ipv6.conf.<iface>.accept_ra_rt_info_max_plen=64 "
                            "and persist it in /etc/sysctl.d/ .",
                        evidence=", ".join(zero), source=src))
    else:
        rep.add(Finding("rt_info_max_plen", PASS,
                        "Kernel accepts the route information the border router advertises.",
                        evidence=", ".join(ok_ifaces), source=src))


def check_thread_route(rep: Report) -> None:
    """A Thread mesh lives on a ULA prefix. If no route to it exists, the
    devices are simply unreachable.

    Root cause seen in the wild: fc00::/7 was not routable, and separately, a
    border router kept advertising a stale OMR prefix after a reset.
    """
    src = "community.home-assistant.io/t/990934 (2026-02-26), t/1019472"
    ok, out = run(["ip", "-6", "route"])
    if not ok:
        rep.add(Finding("thread_route", UNKNOWN,
                        "Could not read the IPv6 routing table.",
                        detail=out.strip()[:200],
                        fix="Install iproute2, or run this on the host rather than inside a "
                            "minimal container.",
                        source=src))
        return
    ulas = [l.strip() for l in out.splitlines()
            if re.match(r"^(f[cd][0-9a-f]{2}:)", l.strip(), re.I)]
    if not ulas:
        rep.add(Finding("thread_route", FAIL,
                        "No route to any unique-local (Thread mesh) prefix.",
                        detail="Thread devices sit on a ULA prefix in fc00::/7. Without a "
                               "route, they are unreachable from here no matter how healthy "
                               "the mesh is.",
                        fix="Confirm the border router is running and advertising. If more "
                            "than one border router has ever been set up, a stale prefix "
                            "may still be advertised -- check for more than one mesh prefix "
                            "and reset the one you no longer use.",
                        source=src))
    elif len(ulas) > 1:
        rep.add(Finding("thread_route", FAIL,
                        "More than one Thread mesh prefix is routed here.",
                        detail="This usually means an old border router is still "
                               "advertising a prefix it no longer serves. Traffic can be "
                               "sent down the dead one.",
                        fix="Identify which prefix belongs to the border router you "
                            "actually use, and factory-reset or disable the others.",
                        evidence="; ".join(ulas[:4]), source=src))
    else:
        rep.add(Finding("thread_route", PASS,
                        "Exactly one Thread mesh prefix is routed.",
                        evidence=ulas[0], source=src))


def check_mdns_interface(rep: Report) -> None:
    """Matter discovery is mDNS over IPv6 multicast. On a host with more than
    one interface, the responder often binds to the wrong one.

    Root cause seen in the wild: "mDNSPlatformSendUDP got error 99 (Cannot
    assign requested address) sending packet to ff02::fb". The auto-default
    picks the interface with the default route, which on a multi-homed host is
    rarely the one the border router is on.
    """
    src = "community.home-assistant.io/t/1010466 (2026-05-15), t/977888"
    ifaces = primary_interfaces()
    if not ifaces:
        rep.add(Finding("mdns_interface", UNKNOWN,
                        "No interfaces could be listed.", source=src))
        return
    if len(ifaces) > 1:
        # Two interfaces is a documented cause AND a documented fix: one case
        # was solved by turning wlan0 off, another by deliberately ADDING an
        # IPv6-only NIC onto the border router's VLAN. From here we cannot
        # tell which situation this is, so this is reported as something to
        # check rather than as a fault.
        rep.add(Finding("mdns_interface", UNKNOWN,
                        "This host has more than one active interface, which is worth "
                        "checking either way.",
                        detail="Matter discovery uses link-local multicast to ff02::fb. "
                               "The responder binds to one interface, chosen by default "
                               "from the default route -- which on a multi-homed host is "
                               "often not the one your devices are on. The visible symptom "
                               "is 'error 99 (Cannot assign requested address)'.",
                        fix="Pin the responder to the interface your Matter devices are on. "
                            "For the Matter Server add-on this is the 'Primary network "
                            "interface' option. Confirm the chosen interface is the one "
                            "carrying the Thread mesh prefix.",
                        evidence="active interfaces: %s" % ", ".join(ifaces), source=src))
    else:
        rep.add(Finding("mdns_interface", PASS,
                        "Single active interface, so there is nothing for mDNS to pick "
                        "wrongly.",
                        evidence=ifaces[0], source=src))


def check_multicast_socket(rep: Report) -> None:
    """Can this host actually join the mDNS multicast group?

    This is a local socket operation. Nothing is transmitted off the machine.
    """
    src = "local capability check"
    try:
        s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("", 0))
        s.close()
        rep.add(Finding("multicast_socket", PASS,
                        "An IPv6 UDP socket can be opened here.", source=src))
    except OSError as exc:
        rep.add(Finding("multicast_socket", FAIL,
                        "Could not open an IPv6 UDP socket.",
                        detail="%s: %s" % (type(exc).__name__, exc),
                        fix="IPv6 is unavailable to processes on this host. See the IPv6 "
                            "check above.",
                        source=src))


# --------------------------------------------------------------------------
# rendering
# --------------------------------------------------------------------------

ORDER = {FAIL: 0, UNKNOWN: 1, PASS: 2, INFO: 3}


def render(rep: Report) -> str:
    c = rep.counts()
    lines = []
    lines.append("Matter/Thread commissioning pre-flight  v%s" % rep.version)
    lines.append("=" * 62)
    e = rep.environment
    lines.append("host: %s %s | python %s | root: %s" % (
        e.get("system"), e.get("release"), e.get("python"), e.get("is_root")))
    flags = [k for k in ("in_container", "haos", "proxmox_guest", "synology") if e.get(k)]
    lines.append("detected: %s" % (", ".join(flags) if flags else "bare host"))
    lines.append("")
    lines.append("%d problem(s), %d could not be checked, %d passed" % (
        c.get(FAIL, 0), c.get(UNKNOWN, 0), c.get(PASS, 0)))
    lines.append("")
    for f in sorted(rep.findings, key=lambda x: (ORDER.get(x.state, 9), x.check)):
        if f.state == INFO:
            continue
        lines.append("[%s] %s" % (f.state, f.summary))
        if f.evidence:
            lines.append("    measured : %s" % f.evidence)
        if f.detail:
            lines.append("    why      : %s" % _wrap(f.detail))
        if f.fix:
            lines.append("    to fix   : %s" % _wrap(f.fix))
        if f.source:
            lines.append("    seen in  : %s" % f.source)
        lines.append("")
    lines.append("-" * 62)
    lines.append("Nothing was changed. Nothing left this machine. A check that could")
    lines.append("not run is reported as CANNOT_DETERMINE, never as a pass.")
    return "\n".join(lines)


def _wrap(text: str, width: int = 62, indent: str = " " * 15) -> str:
    words, line, out = text.split(), "", []
    for w in words:
        if len(line) + len(w) + 1 > width:
            out.append(line)
            line = w
        else:
            line = (line + " " + w).strip()
    if line:
        out.append(line)
    return ("\n" + indent).join(out)


# --------------------------------------------------------------------------
# self-test: prove the logic without touching a real host
# --------------------------------------------------------------------------

def self_test() -> int:
    """Each case fixes the inputs and asserts the verdict.

    This is how the predicates are frozen before any live scan, so a passing
    live run means something.
    """
    import types
    mod = sys.modules[__name__]
    cases, failures = [], []

    def with_sysctl(mapping, ifaces, route_out=("", False)):
        def fake_read(path):
            return mapping.get(path)
        def fake_ifaces():
            return ifaces
        def fake_run(cmd):
            if cmd[:3] == ["ip", "-6", "route"]:
                return route_out[1], route_out[0]
            return False, ""
        return fake_read, fake_ifaces, fake_run

    def run_case(name, mapping, ifaces, check, expect, route_out=("", False)):
        orig = (mod.read_sysctl, mod.primary_interfaces, mod.run)
        mod.read_sysctl, mod.primary_interfaces, mod.run = with_sysctl(mapping, ifaces, route_out)
        try:
            r = Report()
            check(r)
            got = r.findings[0].state
        finally:
            mod.read_sysctl, mod.primary_interfaces, mod.run = orig
        ok = got == expect
        cases.append((name, expect, got, ok))
        if not ok:
            failures.append(name)

    A = "/proc/sys/net/ipv6/conf/all/"
    run_case("IPv6 disabled -> FAIL", {A + "disable_ipv6": "1"}, ["eth0"],
             check_ipv6_globally_enabled, FAIL)
    run_case("IPv6 enabled -> PASS", {A + "disable_ipv6": "0"}, ["eth0"],
             check_ipv6_globally_enabled, PASS)
    run_case("IPv6 unreadable -> CANNOT_DETERMINE", {}, ["eth0"],
             check_ipv6_globally_enabled, UNKNOWN)

    run_case("forwarding on + accept_ra=1 -> FAIL",
             {A + "forwarding": "1", "/proc/sys/net/ipv6/conf/eth0/accept_ra": "1"},
             ["eth0"], check_forwarding_accept_ra, FAIL)
    run_case("forwarding on + accept_ra=2 -> PASS",
             {A + "forwarding": "1", "/proc/sys/net/ipv6/conf/eth0/accept_ra": "2"},
             ["eth0"], check_forwarding_accept_ra, PASS)
    # forwarding=0 must never be a pass. Which of the two non-pass answers it
    # is depends on whether a border router runs here.
    import types as _t
    _orig_br = mod.runs_border_router_here
    mod.runs_border_router_here = lambda: True
    run_case("forwarding off + BR on this host -> FAIL", {A + "forwarding": "0"}, ["eth0"],
             check_forwarding_accept_ra, FAIL)
    mod.runs_border_router_here = lambda: False
    run_case("forwarding off + BR elsewhere -> CANNOT_DETERMINE",
             {A + "forwarding": "0"}, ["eth0"], check_forwarding_accept_ra, UNKNOWN)
    mod.runs_border_router_here = _orig_br

    P = "/proc/sys/net/ipv6/conf/eth0/accept_ra_rt_info_max_plen"
    run_case("rt_info knob absent (Synology 4.4) -> FAIL", {}, ["eth0"],
             check_rt_info_max_plen, FAIL)
    run_case("rt_info = 0 -> FAIL", {P: "0"}, ["eth0"], check_rt_info_max_plen, FAIL)
    run_case("rt_info = 64 -> PASS", {P: "64"}, ["eth0"], check_rt_info_max_plen, PASS)

    run_case("no ULA route -> FAIL", {}, ["eth0"], check_thread_route, FAIL,
             route_out=("fe80::/64 dev eth0 proto kernel\ndefault via 192.168.1.1\n", True))
    run_case("one ULA route -> PASS", {}, ["eth0"], check_thread_route, PASS,
             route_out=("fd11:2233:4455::/64 dev eth0 proto ra\n", True))
    run_case("two ULA routes (stale prefix) -> FAIL", {}, ["eth0"], check_thread_route, FAIL,
             route_out=("fd11:2233:4455::/64 dev eth0\nfdaa:bbbb:cccc::/64 dev eth0\n", True))
    run_case("routing table unreadable -> CANNOT_DETERMINE", {}, ["eth0"],
             check_thread_route, UNKNOWN, route_out=("no such tool", False))

    run_case("two interfaces -> CANNOT_DETERMINE", {}, ["eth0", "wlan0"], check_mdns_interface, UNKNOWN)
    run_case("one interface -> PASS", {}, ["eth0"], check_mdns_interface, PASS)
    run_case("no interfaces -> CANNOT_DETERMINE", {}, [], check_mdns_interface, UNKNOWN)

    width = max(len(c[0]) for c in cases)
    for name, expect, got, ok in cases:
        print("  %s  %s  expected %-18s got %s" % (
            "ok  " if ok else "FAIL", name.ljust(width), expect, got))
    print()
    print("  %d/%d cases passed" % (len(cases) - len(failures), len(cases)))
    if failures:
        print("  failing: %s" % ", ".join(failures))
        return 1
    print("  Every check reports CANNOT_DETERMINE rather than PASS when its input")
    print("  is unavailable. That property is what makes a live PASS meaningful.")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--self-test", action="store_true",
                    help="run synthetic fixtures; reads nothing from this host")
    args = ap.parse_args()

    if args.self_test:
        return self_test()

    rep = Report(environment=detect_environment())
    if rep.environment["system"] != "Linux":
        print("This checks Linux hosts running Home Assistant. Detected: %s."
              % rep.environment["system"], file=sys.stderr)
        print("Run --self-test to verify the logic anywhere.", file=sys.stderr)
        return 2

    check_ipv6_globally_enabled(rep)
    check_docker_ipv6(rep, rep.environment)
    check_forwarding_accept_ra(rep)
    check_rt_info_max_plen(rep)
    check_thread_route(rep)
    check_mdns_interface(rep)
    check_multicast_socket(rep)

    if args.json:
        print(json.dumps({"version": rep.version, "environment": rep.environment,
                          "findings": [asdict(f) for f in rep.findings],
                          "counts": rep.counts()}, indent=2))
    else:
        print(render(rep))
    return 1 if rep.counts().get(FAIL, 0) else 0


if __name__ == "__main__":
    sys.exit(main())
