#!/usr/bin/env python3
"""Validate CoreSystemConfig.json — the gate every builder in tools/ runs first.

THE POINT OF THIS FILE: before CoreSystemConfig.json existed, an IP lived in six build
scripts and a token in three, so "the door panel can't reach identity" could mean a
typo in any one of them, discovered only after a 40-minute ISO build and a reboot.
Centralising the values removes the duplication; this script removes the rest — it
fails the build at second zero for anything it can prove wrong on paper.

Deliberately stdlib-only and dependency-free (no jsonschema): the whole point is that
a fresh checkout can validate a config before anything is installed, on a machine
where `pip install` may not even be available yet.

Exit codes:  0 = valid (warnings may still print)   1 = errors found   2 = unusable file
"""

from __future__ import annotations

import ipaddress
import json
import re
import sys
from pathlib import Path
from typing import Any

HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display", "steam-tv-box"}
TIERS = {"auto", "gpu", "cpu"}
ARCHITECTURES = {"amd64", "arm64"}
# Which GPU is in a steam-tv-box. Not detected: installing the NVIDIA driver on an
# AMD box breaks it, and the build has no way to see hardware it is not running on.
GPU_VENDORS = {"amd", "intel", "nvidia"}

# Tokens the validator insists on, and the enable-flag that makes each one required.
# A token for a disabled service is not an error — you shouldn't have to invent a
# transit token to build a door panel.
TOKEN_REQUIREMENTS = [
    ("identity_token", "identity", "identity, the door panel and the kitchen display"),
    ("pantry_vision_token", "pantry_vision", "pantry-vision and the kitchen display"),
    ("transit_token", "transit", "transit"),
]

MIN_TOKEN_LEN = 32

# Substrings that mean "nobody actually generated this". Matched anywhere in the value,
# case-insensitively, so padding a placeholder out to 32 characters doesn't sneak past.
PLACEHOLDER_MARKERS = (
    "changeme", "change-me", "your-", "yourtoken", "placeholder", "example",
    "xxxxx", "secret", "password", "todo", "fixme", "insert",
)


def _looks_like_placeholder(value: str) -> bool:
    lowered = value.lower()
    if any(marker in lowered for marker in PLACEHOLDER_MARKERS):
        return True
    # A "token" made of one repeated character (aaaa..., 0000...) is padding, not entropy.
    return len(set(value)) <= 2 and len(value) > 4


class Report:
    def __init__(self) -> None:
        self.errors: list[str] = []
        self.warnings: list[str] = []

    def error(self, where: str, msg: str) -> None:
        self.errors.append(f"{where}: {msg}")

    def warn(self, where: str, msg: str) -> None:
        self.warnings.append(f"{where}: {msg}")


def _get(cfg: dict, path: str, default: Any = None) -> Any:
    node = cfg
    for part in path.split("."):
        if not isinstance(node, dict) or part not in node:
            return default
        node = node[part]
    return node


def _require(cfg: dict, path: str, kind, rep: Report, where: str | None = None) -> Any:
    """Fetch a required key, reporting a precise path rather than a KeyError."""
    value = _get(cfg, path, None)
    label = where or path
    if value is None:
        rep.error(label, "missing (required)")
        return None
    if kind is not None and not isinstance(value, kind):
        rep.error(label, f"must be {getattr(kind, '__name__', kind)}, got {type(value).__name__}")
        return None
    return value


def validate_household(cfg: dict, rep: Report) -> None:
    tz = _require(cfg, "household.timezone", str, rep)
    if tz:
        # Checked against the zoneinfo database actually present, not a regex — a
        # plausible-looking but wrong timezone ("Europe/Wien") silently gives every
        # image the wrong clock, and every timestamped log with it.
        if not Path(f"/usr/share/zoneinfo/{tz}").exists():
            rep.warn("household.timezone",
                     f"'{tz}' isn't in this machine's /usr/share/zoneinfo — verify it exists on the target")
    _require(cfg, "household.locale", str, rep)
    layout = _require(cfg, "household.keyboard_layout", str, rep)
    if layout and not re.match(r"^[a-z]{2,6}$", layout):
        rep.error("household.keyboard_layout", f"'{layout}' doesn't look like an xkb layout (e.g. 'de', 'us')")
    _require(cfg, "household.debian_release", str, rep)


def validate_network(cfg: dict, rep: Report) -> None:
    prefix = _require(cfg, "network.subnet_prefix", str, rep)
    if not prefix:
        return
    if prefix.endswith("."):
        rep.error("network.subnet_prefix", f"'{prefix}' must not end with a dot")
        return
    try:
        network = ipaddress.ip_network(f"{prefix}.0/24", strict=True)
    except ValueError as exc:
        rep.error("network.subnet_prefix", f"'{prefix}' is not a valid /24 prefix ({exc})")
        return

    if not network.is_private:
        rep.warn("network.subnet_prefix",
                 f"{network} is not a private range — this stack is designed to be LAN-only "
                 "(docs/network-integration.md §1)")
    # 192.168.0.x and 192.168.1.x are what nearly every café, hotel and consumer router
    # uses, so a WireGuard split tunnel routing this VLAN would collide with them —
    # docs/network-integration.md §2.1 spells this out.
    if prefix in ("192.168.0", "192.168.1"):
        rep.warn("network.subnet_prefix",
                 f"'{prefix}' collides with typical café/hotel LANs; a WireGuard split tunnel "
                 "routing it will break connectivity on those networks (network-integration.md §2.1)")

    gw = _get(cfg, "network.gateway_last_octet")
    if not isinstance(gw, int) or not 1 <= gw <= 254:
        rep.error("network.gateway_last_octet", f"must be an int 1-254, got {gw!r}")

    dns = _get(cfg, "network.dns_servers")
    if not isinstance(dns, list) or not dns:
        rep.error("network.dns_servers", "must be a non-empty list of IP addresses")
    else:
        for entry in dns:
            try:
                ipaddress.ip_address(entry)
            except ValueError:
                rep.error("network.dns_servers", f"'{entry}' is not a valid IP address")

    ssid = _get(cfg, "network.wifi.ssid", "")
    psk = _get(cfg, "network.wifi.psk", "")
    if ssid and not psk:
        rep.error("network.wifi.psk", "an SSID is set but the PSK is empty")
    if psk and not ssid:
        rep.error("network.wifi.ssid", "a PSK is set but the SSID is empty")
    if psk and len(psk) < 8:
        rep.error("network.wifi.psk", "WPA2 pre-shared keys are at least 8 characters")


def validate_hosts(cfg: dict, rep: Report) -> None:
    """The twinning check. The container host and the LLM host derive each other's
    addresses from these two numbers, so the one thing that must hold is that they are
    two distinct, valid, same-subnet addresses — get that right and no builder can
    produce a mismatched pair.
    """
    octets: dict[str, int] = {}
    for host in ("container_host", "llm_host"):
        hostname = _require(cfg, f"{host}.hostname", str, rep)
        if hostname and not HOSTNAME_RE.match(hostname):
            rep.error(f"{host}.hostname",
                      f"'{hostname}' is not a valid DNS label (lowercase letters, digits, hyphens; "
                      "no leading/trailing hyphen)")
        octet = _get(cfg, f"{host}.ip_last_octet")
        if not isinstance(octet, int) or not 1 <= octet <= 254:
            rep.error(f"{host}.ip_last_octet", f"must be an int 1-254, got {octet!r}")
        else:
            octets[host] = octet
        disk = _require(cfg, f"{host}.install_disk", str, rep)
        if disk and not disk.startswith("/dev/"):
            rep.error(f"{host}.install_disk", f"'{disk}' should be a device path like /dev/sda or /dev/nvme0n1")
        _require(cfg, f"{host}.admin_username", str, rep)

    if len(octets) == 2:
        if octets["container_host"] == octets["llm_host"]:
            rep.error("llm_host.ip_last_octet",
                      f"the container host and LLM host both want .{octets['llm_host']} — "
                      "they are two separate machines and need two addresses")
        gw = _get(cfg, "network.gateway_last_octet")
        for host, octet in octets.items():
            if octet == gw:
                rep.error(f"{host}.ip_last_octet", f".{octet} is the gateway address")

    tier = _get(cfg, "llm_host.tier")
    if tier not in TIERS:
        rep.error("llm_host.tier", f"must be one of {sorted(TIERS)}, got {tier!r}")
    for key in ("text_model_gpu", "text_model_cpu", "vision_model"):
        _require(cfg, f"llm_host.{key}", str, rep)
    for key in ("max_loaded_models", "num_parallel"):
        value = _get(cfg, f"llm_host.{key}")
        if not isinstance(value, int) or value < 1:
            rep.error(f"llm_host.{key}", f"must be a positive int, got {value!r}")
    keep_alive = _get(cfg, "llm_host.keep_alive")
    if not isinstance(keep_alive, str) or not re.match(r"^\d+[smh]$|^-1$", str(keep_alive)):
        rep.error("llm_host.keep_alive", f"must look like '30m', '2h', '600s' or '-1', got {keep_alive!r}")


def validate_ports(cfg: dict, rep: Report) -> None:
    """Port uniqueness is the single highest-value check here: a duplicate produces a
    container that silently fails to bind, or two services fighting over one port with
    whichever won last boot answering. Both are miserable to diagnose from the symptom.
    """
    ports = _get(cfg, "ports")
    if not isinstance(ports, dict):
        rep.error("ports", "missing or not an object")
        return

    seen: dict[int, list[str]] = {}
    for name, value in ports.items():
        if name.startswith("_"):
            continue
        if not isinstance(value, int) or not 1 <= value <= 65535:
            rep.error(f"ports.{name}", f"must be an int 1-65535, got {value!r}")
            continue
        if value < 1024:
            rep.warn(f"ports.{name}", f"{value} is a privileged port (<1024)")
        seen.setdefault(value, []).append(name)

    for value, names in sorted(seen.items()):
        if len(names) > 1:
            rep.error("ports", f"port {value} is claimed by {len(names)} services: {', '.join(sorted(names))}")


def validate_secrets(cfg: dict, rep: Report) -> None:
    enable = _get(cfg, "container_host.enable", {}) or {}
    for token_name, flag, used_by in TOKEN_REQUIREMENTS:
        value = _get(cfg, f"secrets.{token_name}", "") or ""
        if not enable.get(flag):
            if value:
                rep.warn(f"secrets.{token_name}",
                         f"set, but container_host.enable.{flag} is false — it won't be used")
            continue
        if not value:
            rep.error(f"secrets.{token_name}",
                      f"required because container_host.enable.{flag} is true (used by {used_by}). "
                      "Generate one with: openssl rand -hex 32")
        # Placeholder BEFORE length: a long placeholder ("changeme-changeme-changeme-...")
        # would otherwise sail past the length check, and a short one would be reported
        # as merely too short — which invites someone to pad it rather than generate one.
        elif _looks_like_placeholder(value):
            rep.error(f"secrets.{token_name}",
                      f"'{value[:24]}' is a placeholder, not a generated secret. "
                      "Generate one with: openssl rand -hex 32")
        elif len(value) < MIN_TOKEN_LEN:
            rep.error(f"secrets.{token_name}",
                      f"only {len(value)} characters; use at least {MIN_TOKEN_LEN} "
                      "(openssl rand -hex 32)")

    mqtt_user = _get(cfg, "secrets.mqtt_username", "") or ""
    mqtt_pass = _get(cfg, "secrets.mqtt_password", "") or ""
    if mqtt_user and not mqtt_pass:
        rep.error("secrets.mqtt_password", "an MQTT username is set but the password is empty")
    if not mqtt_user:
        rep.warn("secrets.mqtt_username",
                 "empty — every kiosk will connect to Mosquitto anonymously. Fine while "
                 "allow_anonymous is on; revisit before that changes")

    # OPNsense: a LIST of firewalls now, each with its own key pair keyed by name.
    # Half-configured is the failure worth catching: an address with no credentials (or
    # credentials with no address) looks configured and produces a network digest that
    # silently reports nothing — which is indistinguishable from a quiet network.
    firewalls = _get(cfg, "opnsense", []) or []
    if isinstance(firewalls, dict):
        # The single-object shape this key used to have. Accepted so an existing config
        # keeps validating, and reported once so it gets migrated rather than lingering.
        rep.warn("opnsense",
                 "is a single object — it is a list of firewalls now, so more than one can "
                 "be watched. Wrap it in [ ] and give it a `name`; the old shape still works "
                 "for one firewall but nothing new reads it")
        firewalls = [firewalls]
    keys = _get(cfg, "secrets.opnsense_keys", {}) or {}
    seen_names: set[str] = set()
    for index, fw in enumerate(firewalls):
        where = f"opnsense[{index}]"
        if not isinstance(fw, dict):
            rep.error(where, "must be an object")
            continue
        name = str(fw.get("name") or "").strip()
        if not name:
            rep.error(f"{where}.name", "every firewall needs a name — it is what tells two "
                                      "of them apart in the digest and in the health table")
        elif name in seen_names:
            rep.error(f"{where}.name", f"'{name}' is used twice; names must be unique")
        else:
            seen_names.add(name)
        url = str(fw.get("base_url") or "").strip()
        pair = keys.get(name, {}) if isinstance(keys, dict) else {}
        api_key = str((pair or {}).get("api_key") or "").strip()
        api_secret = str((pair or {}).get("api_secret") or "").strip()
        if url and not (api_key and api_secret):
            rep.error(f"secrets.opnsense_keys.{name or index}",
                      f"{where}.base_url is set, so this firewall needs both api_key and "
                      "api_secret. OPNsense mints them: System -> Access -> Users -> API keys; "
                      "give that user only the 'Services: Intrusion Detection' privilege")
        if (api_key or api_secret) and not url:
            rep.warn(f"{where}.base_url",
                     "empty, but an API key is set for it — nothing will use that key until "
                     "this points at the firewall (e.g. https://opnsense.home.lan)")

    # CheckMK, same half-configured check and the same reasoning.
    cmk_url = str(_get(cfg, "checkmk.base_url", "") or "").strip()
    cmk_user = str(_get(cfg, "secrets.checkmk_username", "") or "").strip()
    cmk_secret = str(_get(cfg, "secrets.checkmk_secret", "") or "").strip()
    if cmk_url and not (cmk_user and cmk_secret):
        rep.error("secrets.checkmk_username",
                  "checkmk.base_url is set, so both checkmk_username and checkmk_secret are "
                  "needed. Use a Guest-role automation user and its AUTOMATION SECRET, not a "
                  "login password — the read-only guarantee comes from that role")
    if cmk_url and not str(_get(cfg, "checkmk.site", "") or "").strip():
        rep.error("checkmk.site", "required — it is the path segment in every CheckMK API URL")
    if (cmk_user or cmk_secret) and not cmk_url:
        rep.warn("checkmk.base_url", "empty, but CheckMK credentials are set — nothing polls it yet")

    # The workshop assistant's own token, on the same rule as every other service token.
    if _get(cfg, "container_host.enable.workshop", False) and not (
            _get(cfg, "secrets.workshop_token", "") or ""):
        rep.error("secrets.workshop_token",
                  "required because container_host.enable.workshop is true. "
                  "Generate one with: openssl rand -hex 32")

    # Gitea is optional even with workshop on — the assistant simply refuses repo
    # creation without it — but half a config is worth a word.
    gitea_url = str(_get(cfg, "secrets.gitea_url", "") or "").strip()
    gitea_token = str(_get(cfg, "secrets.gitea_token", "") or "").strip()
    if bool(gitea_url) != bool(gitea_token):
        rep.warn("secrets.gitea_url",
                 "gitea_url and gitea_token only work as a pair — repo creation stays "
                 "refused until both are set")
    if gitea_token and not str(_get(cfg, "secrets.gitea_owner", "") or "").strip():
        rep.warn("secrets.gitea_owner",
                 "empty, so repos are created under the token's OWN account. Prefer a "
                 "dedicated 'workshop-bot' user scoped to one organisation — the blast "
                 "radius of a leaked env file should be one org, not everything you own")

    # KEYCLOAK AND FREEIPA ARE ONE DECISION. Keycloak federates FreeIPA as its user
    # store; it is not a place people are created. Configuring one without the other
    # would mean a second, parallel set of household accounts — the exact outcome a
    # directory exists to prevent — so either alone is an error, not a warning.
    issuer = str(_get(cfg, "identity_provider.issuer_url", "") or "").strip()
    ipa_server = str(_get(cfg, "freeipa.server", "") or "").strip()

    if issuer and not ipa_server:
        rep.error("freeipa.server",
                  "identity_provider.issuer_url is set, but Keycloak is never deployed without "
                  "FreeIPA in this project — it federates the directory rather than being its "
                  "own user store. Configure both, or neither")
    if ipa_server and not issuer:
        rep.error("identity_provider.issuer_url",
                  "freeipa.server is set, but FreeIPA here exists to be federated by Keycloak — "
                  "the two are one decision. Configure both, or neither")

    if issuer:
        if not issuer.startswith(("http://", "https://")):
            rep.error("identity_provider.issuer_url", f"{issuer!r} is not a URL")
        elif issuer.startswith("http://"):
            rep.warn("identity_provider.issuer_url",
                     "is plain HTTP — an OIDC issuer over HTTP means tokens in cleartext on "
                     "the LAN")

    if ipa_server:
        for field in ("domain", "base_dn", "bind_dn"):
            if not str(_get(cfg, f"freeipa.{field}", "") or "").strip():
                rep.error(f"freeipa.{field}", "required once freeipa.server is set")
        if not (_get(cfg, "secrets.freeipa_bind_password", "") or ""):
            rep.error("secrets.freeipa_bind_password",
                      "required once freeipa.server is set. Use a dedicated service account "
                      "with READ-ONLY access — the mirror never writes to the directory")
        if not str(_get(cfg, "freeipa.household_group", "") or "").strip():
            rep.error("freeipa.household_group",
                      "required — without it every service account in the directory becomes "
                      "a household member")

        # The USR_HA_<parameter> convention. A WARNING, not an error: an existing
        # directory may already have its own naming, and renaming groups in FreeIPA is
        # not something a config file gets to force. The prefix is what makes "which
        # groups does the smart home read?" answerable with one filter.
        for field, expected in (("household_group", "USR_HA_household"),
                                ("chore_exempt_group", "USR_HA_chore_exempt"),
                                ("admin_group", "USR_HA_admins")):
            value = str(_get(cfg, f"freeipa.{field}", "") or "").strip()
            if value and not value.startswith("USR_HA_"):
                rep.warn(f"freeipa.{field}",
                         f"{value!r} does not follow the USR_HA_<parameter> convention "
                         f"(expected {expected!r}). It will still work — this is about being "
                         "able to audit which groups the smart home reads")

    if issuer or ipa_server:
        rep.warn("identity_provider",
                 "NEITHER SSO NOR THE DIRECTORY MIRROR IS IMPLEMENTED YET — these blocks record "
                 "the decision so the proxy config and identity can be pointed at them later. "
                 "They change no behaviour today")

    if not (_get(cfg, "secrets.ha_token", "") or ""):
        rep.warn("secrets.ha_token",
                 "empty — identity's /register and /presence can't reach Home Assistant until "
                 "this is a real Long-Lived Access Token. It cannot be generated ahead of time; "
                 "fill it in and re-run the builder once HA is up")

    key = _get(cfg, "secrets.ssh_authorized_key", "") or ""
    if key and not re.match(r"^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+) \S+", key):
        rep.error("secrets.ssh_authorized_key",
                  "doesn't look like an OpenSSH public key (should start 'ssh-ed25519 AAAA...')")
    if key and "PRIVATE KEY" in key:
        rep.error("secrets.ssh_authorized_key", "this is a PRIVATE key — put the .pub here instead")
    if not key:
        rep.warn("secrets.ssh_authorized_key",
                 "empty — the built images will have no way in over SSH. Fine for a kiosk you "
                 "only ever touch physically, painful for a headless host")


def validate_proxy(cfg: dict, rep: Report) -> None:
    proxy = _get(cfg, "proxy") or {}
    if not proxy.get("enabled"):
        return
    hostname = (proxy.get("hostname") or "").strip()
    if not hostname:
        rep.error("proxy.hostname", "required when the proxy is enabled — it's what the "
                                    "certificate is issued for")
    elif not re.match(r"^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?$", hostname):
        rep.error("proxy.hostname", f"'{hostname}' is not a valid hostname or IP")
    elif hostname == "home.example.lan":
        rep.warn("proxy.hostname", "still the template's placeholder — set a name that "
                                   "actually resolves to the container host")

    mode = proxy.get("tls", "internal")
    if mode not in {"internal", "custom"}:
        rep.error("proxy.tls", f"must be 'internal' or 'custom', got {mode!r}")
    elif mode == "custom":
        for key in ("cert_file", "key_file"):
            if not (proxy.get(key) or "").strip():
                rep.error(f"proxy.{key}", "required when proxy.tls is 'custom'")
    else:
        rep.warn("proxy.tls",
                 "'internal' means Caddy runs its own CA — browsers warn until you install "
                 "its root (tools/export-proxy-ca.sh). A kiosk cannot dismiss that warning, "
                 "which is why the kiosk images still use plain HTTP; see proxy/README.md")



# An HA area_id: lowercase, digits, underscores. Home Assistant slugifies area names
# into exactly this shape, and it is also what identity's floorplan rooms store in
# `ha_area_id`. Validating it here is what keeps ONE room vocabulary across the
# project instead of "Kitchen" in one file and "kitchen" in another — see
# docs/rooms-and-endpoints.md.
AREA_ID_RE = re.compile(r"^[a-z0-9_]+$")


def _check_room(value, where: str, rep) -> str:
    """Returns the room, or "" — and reports why it isn't usable if it isn't.

    Missing is a WARNING, not an error: a household that hasn't decided its room names
    yet must still be able to build an image. What it loses is automatic area
    assignment in HA, which is a nuisance to fix by hand, not a broken device.
    """
    if value is None or value == "":
        rep.warn(where, "no room set — HA won't file this device in an area by itself, "
                        "and every per-room feature (voice 'in here', the floorplan, "
                        "room-scoped assistants) will treat it as unplaced")
        return ""
    if not isinstance(value, str) or not AREA_ID_RE.match(value):
        rep.error(where, f"{value!r} is not an HA area_id — lowercase letters, digits and "
                         "underscores only, e.g. 'living_room'")
        return ""
    return value


def validate_kiosks(cfg: dict, rep: Report) -> None:
    kiosks = _get(cfg, "kiosks")
    if kiosks is None:
        rep.warn("kiosks", "no kiosks defined — only the core pair will be buildable")
        return
    if not isinstance(kiosks, list):
        rep.error("kiosks", "must be a list")
        return

    hostnames: dict[str, int] = {}
    # hostname -> room, so the summary below can say what ended up where.
    rooms: dict[str, str] = {}
    for index, kiosk in enumerate(kiosks):
        where = f"kiosks[{index}]"
        if not isinstance(kiosk, dict):
            rep.error(where, "must be an object")
            continue
        ktype = kiosk.get("type")
        if ktype not in KIOSK_TYPES:
            rep.error(f"{where}.type", f"must be one of {sorted(KIOSK_TYPES)}, got {ktype!r}")
        hostname = kiosk.get("hostname")
        if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
            rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
        else:
            if hostname in hostnames:
                rep.error(f"{where}.hostname",
                          f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
                          "hostnames identify these devices on the network and in HA, so they must be unique")
            hostnames[hostname] = index
        if not kiosk.get("friendly_name"):
            rep.error(f"{where}.friendly_name", "missing — this is the name shown on the HA device")
        rooms[hostname if isinstance(hostname, str) else where] = _check_room(
            kiosk.get("room"), f"{where}.room", rep
        )
        if not kiosk.get("kiosk_username"):
            rep.error(f"{where}.kiosk_username", "missing")

        # A per-kiosk Debian release, overriding household.debian_release. Exists for
        # the steam-tv-box, whose Mesa version is the one thing on that machine that
        # cannot be fixed later by editing a config file — see
        # tools/build-steam-tv-box-iso.sh. Allowed on any kiosk; only checked for shape
        # here, because whether a given release exists is a question for the mirror.
        release = kiosk.get("debian_release")
        if release is not None and (not isinstance(release, str) or not release.strip()):
            rep.error(f"{where}.debian_release",
                      f"must be a Debian suite name like 'bookworm' or 'trixie', got {release!r}")

        # Discord as a fourth app on a touch panel. Only that image knows what to do
        # with it: on any other type the flag would be silently ignored, which is worse
        # than being told, because the panel boots looking fine and simply lacks the
        # app somebody configured.
        discord = kiosk.get("enable_discord")
        if discord is not None:
            if not isinstance(discord, bool):
                rep.error(f"{where}.enable_discord", f"must be true or false, got {discord!r}")
            elif discord and ktype != "touch-panel":
                rep.error(f"{where}.enable_discord",
                          f"only a touch-panel can run Discord, and this is a {ktype!r} — "
                          "no other image installs the Flatpak or has a workspace for it")

        if ktype == "steam-tv-box":
            vendor = kiosk.get("gpu_vendor")
            if vendor not in GPU_VENDORS:
                rep.error(f"{where}.gpu_vendor",
                          f"a steam-tv-box must say which GPU it has — one of "
                          f"{sorted(GPU_VENDORS)}, got {vendor!r}. This is not detected "
                          "because installing the NVIDIA driver on an AMD box breaks it.")
            if str(kiosk.get("debian_release") or "").strip() == "bookworm":
                rep.warn(f"{where}.debian_release",
                         "bookworm's Mesa is 22.3, which is old for a machine whose job is "
                         "running games locally — 'trixie' is the intended release for this type")
            if not kiosk.get("enable_installer"):
                rep.warn(f"{where}.enable_installer",
                         "false means this image boots live, so a Steam library would live in "
                         "RAM and vanish on reboot — a real gaming box wants an installed system")

    # Audio endpoints share the hostname namespace with kiosks: they are all devices on
    # one network, and two of anything answering to the same name is the same problem
    # regardless of what kind of device they are.
    for index, endpoint in enumerate(_get(cfg, "audio_endpoints", []) or []):
        where = f"audio_endpoints[{index}]"
        if not isinstance(endpoint, dict):
            rep.error(where, "must be an object")
            continue
        hostname = endpoint.get("hostname")
        if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
            rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
        elif hostname in hostnames:
            rep.error(f"{where}.hostname",
                      f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
                      "kiosks and audio endpoints share one hostname namespace")
        else:
            hostnames[hostname] = index
        if endpoint.get("arch") not in ARCHITECTURES:
            rep.error(f"{where}.arch",
                      f"must be one of {sorted(ARCHITECTURES)}, got {endpoint.get('arch')!r}")
        if not endpoint.get("friendly_name"):
            rep.error(f"{where}.friendly_name", "missing")
        rooms[hostname if isinstance(hostname, str) else where] = _check_room(
            endpoint.get("room"), f"{where}.room", rep
        )

    # Cross-check: a kiosk that talks to a disabled service will build fine and then
    # fail at runtime with a connection error, which is exactly the class of "works on
    # paper" mistake this file exists to catch.
    enable = _get(cfg, "container_host.enable", {}) or {}
    needs = {
        "door-panel": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
        "kitchen-display": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
    }
    for index, kiosk in enumerate(kiosks):
        if not isinstance(kiosk, dict):
            continue
        for flag, label in needs.get(kiosk.get("type", ""), []):
            if not enable.get(flag):
                rep.error(f"kiosks[{index}]",
                          f"a {kiosk.get('type')} needs {label}, but container_host.enable.{flag} is false")


# A light entity_id. Deliberately narrower than a generic entity_id check: the dock's
# dials call light.turn_on, and a switch or a scene in this list is a service call that
# fails at the moment somebody turns a knob, which is the worst time to find out.
LIGHT_ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")

# Bare 6-digit hex, no leading '#' — the format ~/Dotfiles/colors.conf uses, so the
# theme's colours can be pasted between the two files without reformatting.
HEX_COLOR_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

# What stream-dock/apply-leds.sh knows how to do after rewriting leds.toml. Kept in
# step with that script's own case statement.
APPLY_STRATEGIES = {"none", "signal", "restart-plugin", "restart-opendeck"}


def validate_stream_dock(cfg: dict, rep: Report) -> None:
    """The desk-side Stream Dock. Off by default; only checked when enabled.

    Nothing here builds an image, so an invalid block cannot break a build — but it
    can generate bindings that fail silently on the dock, hours after the mistake,
    with no error anywhere except a lamp that does not change colour. That is what
    this function is for.
    """
    dock = cfg.get("stream_dock")
    if dock is None:
        return
    if not isinstance(dock, dict):
        rep.error("stream_dock", "must be an object")
        return
    if not dock.get("enabled"):
        return

    _check_room(dock.get("room"), "stream_dock.room", rep)

    lights = dock.get("lights")
    if not isinstance(lights, list) or not lights:
        rep.error("stream_dock.lights",
                  "an enabled dock must name at least one light entity — a dial cannot "
                  "target an area, only entities")
    else:
        seen: set[str] = set()
        for index, entity in enumerate(lights):
            where = f"stream_dock.lights[{index}]"
            if not isinstance(entity, str) or not LIGHT_ENTITY_RE.match(entity or ""):
                rep.error(where, f"{entity!r} is not a light entity_id, e.g. "
                                 "'light.living_room_lamp'")
            elif entity in seen:
                rep.warn(where, f"'{entity}' is listed twice — harmless, but every dial "
                                "turn will address it twice")
            else:
                seen.add(entity)
            if isinstance(entity, str) and _looks_like_placeholder(entity):
                rep.warn(where, f"'{entity}' looks like the template's example entity — "
                                "check it against Developer Tools -> States")

    for key, low, high, default in (("rgb_step", 1, 64, 8),
                                    ("brightness_step_pct", 1, 50, 5),
                                    ("tick_bucket_ms", 0, 2000, 120)):
        value = dock.get(key, default)
        if not isinstance(value, (int, float)) or isinstance(value, bool) or not low <= value <= high:
            rep.error(f"stream_dock.{key}", f"must be a number between {low} and {high}, got {value!r}")

    # The dock authenticates to HA with the same long-lived token as everything else,
    # and the plugin needs an ADMIN one — it drives HA's execute-script command, which
    # is admin-only. Worth saying out loud: this token ends up in the plugin's settings
    # on a desktop machine, which is a different exposure than a token living on the
    # container host, and it is why the dock is not a good reason to create a token
    # with more rights than the household already has.
    if not (cfg.get("secrets", {}) or {}).get("ha_token"):
        rep.warn("secrets.ha_token",
                 "empty, and stream_dock is enabled — the Home Assistant plugin needs a "
                 "long-lived token with ADMIN rights before a single dial does anything")

    leds = dock.get("knob_leds", {}) or {}
    if not isinstance(leds, dict):
        rep.error("stream_dock.knob_leds", "must be an object")
        return
    if not leds.get("enabled"):
        return
    brightness = leds.get("brightness", 100)
    if not isinstance(brightness, int) or isinstance(brightness, bool) or not 0 <= brightness <= 100:
        rep.error("stream_dock.knob_leds.brightness", f"must be 0-100, got {brightness!r}")
    floor = leds.get("min_channel_led", 0)
    if not isinstance(floor, int) or isinstance(floor, bool) or not 0 <= floor <= 255:
        rep.error("stream_dock.knob_leds.min_channel_led", f"must be 0-255, got {floor!r}")
    poll = leds.get("poll_seconds", 2.0)
    if not isinstance(poll, (int, float)) or isinstance(poll, bool) or not 0.2 <= poll <= 60:
        rep.error("stream_dock.knob_leds.poll_seconds", f"must be 0.2-60, got {poll!r}")
    idle = leds.get("idle_colors", []) or []
    if not isinstance(idle, list):
        rep.error("stream_dock.knob_leds.idle_colors",
                  "must be a list of bare 6-digit hex colours, or [] to leave the rings alone")
    else:
        for index, colour in enumerate(idle):
            if not isinstance(colour, str) or not HEX_COLOR_RE.match(colour or ""):
                rep.error(f"stream_dock.knob_leds.idle_colors[{index}]",
                          f"must be a bare 6-digit hex colour with no '#', the same format as "
                          f"~/Dotfiles/colors.conf, got {colour!r}")
    cycle = leds.get("idle_cycle_seconds", 3.0)
    if not isinstance(cycle, (int, float)) or isinstance(cycle, bool) or not 0 <= cycle <= 3600:
        rep.error("stream_dock.knob_leds.idle_cycle_seconds", f"must be 0-3600, got {cycle!r}")
    elif cycle and len(idle) > 1 and leds.get("apply_command"):
        rep.warn("stream_dock.knob_leds.idle_cycle_seconds",
                 f"a chase step every {cycle}s means running apply_command that often, all "
                 "the time, whether or not anybody is at the desk — set it to 0 to hold one "
                 "colour if that command is a plugin restart rather than a cheap poke")

    if not leds.get("layer_gate_command"):
        rep.warn("stream_dock.knob_leds.layer_gate_command",
                 "empty — the rings will follow the lamps on every layer, including the "
                 "ones where the dials do something else entirely")

    strategy = leds.get("apply_strategy", "none")
    if strategy not in APPLY_STRATEGIES:
        rep.error("stream_dock.knob_leds.apply_strategy",
                  f"must be one of {sorted(APPLY_STRATEGIES)}, got {strategy!r}")
    interval = leds.get("apply_min_interval_seconds", 2.0)
    if not isinstance(interval, (int, float)) or isinstance(interval, bool) or not 0 <= interval <= 60:
        rep.error("stream_dock.knob_leds.apply_min_interval_seconds",
                  f"must be 0-60, got {interval!r}")
    elif strategy in ("restart-plugin", "restart-opendeck") and interval < 1:
        rep.warn("stream_dock.knob_leds.apply_min_interval_seconds",
                 f"{interval}s with the {strategy!r} strategy means re-initialising the "
                 "device that often while a dial is being turned — 2s or more is the "
                 "point of this setting")
    if not isinstance(leds.get("plugin_process_pattern", "akp05"), str):
        rep.error("stream_dock.knob_leds.plugin_process_pattern", "must be a string")

    if strategy == "none" and not leds.get("apply_command"):
        rep.warn("stream_dock.knob_leds.apply_strategy",
                 "'none' with no apply_command — the sync service will keep leds.toml "
                 "correct, but the device plugin only reads that file when it starts, so "
                 "the rings will not follow the lamp until something makes it re-read. "
                 "Run stream-dock/apply-leds.sh --probe with the dock plugged in; it "
                 "checks first whether 'none' is already enough (stream-dock/README.md §5)")


def validate(cfg: dict) -> Report:
    rep = Report()
    validate_network(cfg, rep)
    validate_household(cfg, rep)
    validate_hosts(cfg, rep)
    validate_ports(cfg, rep)
    validate_secrets(cfg, rep)
    validate_kiosks(cfg, rep)
    validate_proxy(cfg, rep)
    validate_stream_dock(cfg, rep)
    return rep


def main(argv: list[str]) -> int:
    path = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent / "CoreSystemConfig.json"

    if not path.exists():
        template = path.parent / "CoreSystemConfig.json.template"
        print(f"error: {path} not found.", file=sys.stderr)
        if template.exists():
            print(f"       Copy the template and fill it in:\n"
                  f"         cp {template.name} {path.name}\n"
                  f"         $EDITOR {path.name}", file=sys.stderr)
        return 2

    try:
        cfg = json.loads(path.read_text())
    except json.JSONDecodeError as exc:
        # Line and column, because a trailing comma in a 200-line JSON file is otherwise
        # a genuinely annoying thing to find.
        print(f"error: {path} is not valid JSON — line {exc.lineno}, column {exc.colno}: {exc.msg}",
              file=sys.stderr)
        return 2

    rep = validate(cfg)

    for warning in rep.warnings:
        print(f"  warn   {warning}")
    for error in rep.errors:
        print(f"  ERROR  {error}", file=sys.stderr)

    if rep.errors:
        print(f"\n{len(rep.errors)} error(s), {len(rep.warnings)} warning(s) — {path.name} is not usable yet.",
              file=sys.stderr)
        return 1

    print(f"\n{path.name} is valid ({len(rep.warnings)} warning(s)).")
    return 0


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