#!/usr/bin/env python3
"""EtherGold field configuration — schema, validation, env render, apply.

Canonical UI/API model is field-config.json. Runtime still uses
/etc/ethergold/{role.env,ethergold.env,edge.env}. See FIELD_CONFIG_MATRIX.md.
"""
from __future__ import annotations

import json
import os
import re
import shutil
import sys
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any

# atlas_copper (ETHERGOLD_ROLE=topbox, inject=1) is withdrawn — not reliable.
# Field TopBox is listen-only: POG422 tap, never drive the bus.
PROFILES = (
    "listen_ohd",
    "listen_ohd_server",
    "wifi_bank_bridge",
    "atlas_wifi_star",
)
LISTEN_PROFILES = ("listen_ohd", "listen_ohd_server")
WITHDRAWN_PROFILES = ("atlas_copper",)
HS_PATHS = ("uart", "pi")
# Rev E J19: BCM10 OIS_OUT, BCM11 M-S_OUT. Idle = driven low (never high-Z).
HS_OUT_IDLE_LOW_BCMS = (10, 11)
_HS_PATH_ALIASES = {
    "uart": "uart",
    "uart_hs": "uart",
    "hat": "uart",
    "xr": "uart",
    "pi": "pi",
    "pi_hs": "pi",
    "gpio": "pi",
    "j19": "pi",
}
DEFAULT_AUTH_USER = "SGVI"
DEFAULT_AUTH_PASSWORD = "Southland4111Gaming"
_LEGACY_DEFAULT_PASSWORDS = ("SouthLand4111Gaming",)

TID_RE = re.compile(r"^[0-9A-Fa-f]{4}$")
_TID_PLACEHOLDERS = {"", "-", "--", "---", "—", "–", "N/A", "NONE", "NULL"}


def sanitize_field_tid(raw: Any) -> str:
    """Normalize UI/JSON TID. Placeholders and em-dashes become empty."""
    text = str(raw or "").strip().upper()
    if text in _TID_PLACEHOLDERS or (text and set(text) <= set("-—–")):
        return ""
    return text


DEFAULT_ETC = Path(os.environ.get("ETHERGOLD_ETC", "/etc/ethergold"))
DEFAULT_VAR = Path(os.environ.get("ETHERGOLD_VAR", "/var/lib/ethergold"))


@dataclass
class FieldConfig:
    profile: str = "listen_ohd"
    hs_path: str = "uart"  # uart = HAT XR HS; pi = GPIO J19. Independent of profile.
    asset_id: str = ""
    bank: int = 0
    node_role: str = ""  # side_a|side_b|master_hub|slave_spoke|""
    tid: str = ""  # 4-hex: listen filter (GAMESTATS) and/or star spoke tunnel key
    uplink: str = "wlan0"
    game_if: str = "eth1"
    wifi_ssid: str = "POGCOM"
    wifi_psk: str = ""
    ois_wifi_plane: bool = True  # field target: OIS off VPN
    display_dyzineframe: bool | None = None  # None = profile default
    bank_position: int = 0  # operator bank seat 1..x; not FIXTURE_POS
    enrollment_location: str = ""
    enrollment_path: str = ""
    log_upload_enabled: bool = True
    notes: str = ""
    pog_cc_theme: int = 1  # Heltec attract 1–5 (Slots/Numbers/Rainbow/Fire/Quiet)
    pog_cc_volume: int = 5  # Heltec DS1881 0–62

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> "FieldConfig":
        data = dict(data or {})
        aliases = {
            "ASSET_ID": "asset_id",
            "asset": "asset_id",
            "FIELD_BANK": "bank",
            "FIELD_PROFILE": "profile",
            "FIELD_TID": "tid",
            "BANK_POSITION": "bank_position",
            "POG422_HS_PATH": "hs_path",
            "hs_mode": "hs_path",
        }
        for src, dest in aliases.items():
            if dest not in data and src in data:
                data[dest] = data[src]
        known = {f.name for f in cls.__dataclass_fields__.values()}  # type: ignore[attr-defined]
        filtered = {k: v for k, v in data.items() if k in known}
        if "bank" in filtered:
            try:
                filtered["bank"] = int(filtered["bank"])
            except (TypeError, ValueError):
                filtered["bank"] = 0
        if "tid" in filtered and filtered["tid"] is not None:
            filtered["tid"] = sanitize_field_tid(filtered["tid"])
        if "asset_id" in filtered and filtered["asset_id"] is not None:
            filtered["asset_id"] = str(filtered["asset_id"]).strip()
        if "enrollment_location" in filtered and filtered["enrollment_location"] is not None:
            filtered["enrollment_location"] = str(filtered["enrollment_location"]).strip()
        if "enrollment_path" in filtered and filtered["enrollment_path"] is not None:
            text = str(filtered["enrollment_path"]).strip()
            if text and not text.startswith("/"):
                text = "/" + text
            if text and not text.endswith("/"):
                text += "/"
            filtered["enrollment_path"] = text
        if "log_upload_enabled" in filtered:
            raw = filtered["log_upload_enabled"]
            if isinstance(raw, str):
                filtered["log_upload_enabled"] = raw.strip().lower() in (
                    "1",
                    "true",
                    "yes",
                    "on",
                )
            else:
                filtered["log_upload_enabled"] = bool(raw)
        if "bank_position" in filtered:
            try:
                filtered["bank_position"] = int(filtered["bank_position"])
            except (TypeError, ValueError):
                filtered["bank_position"] = 0
        if "pog_cc_theme" in filtered:
            try:
                filtered["pog_cc_theme"] = int(filtered["pog_cc_theme"])
            except (TypeError, ValueError):
                filtered["pog_cc_theme"] = 1
        if "pog_cc_volume" in filtered:
            try:
                filtered["pog_cc_volume"] = int(filtered["pog_cc_volume"])
            except (TypeError, ValueError):
                filtered["pog_cc_volume"] = 5
        if "hs_path" in filtered:
            filtered["hs_path"] = normalize_hs_path(filtered["hs_path"])
        return cls(**filtered)


def normalize_hs_path(raw: Any) -> str:
    text = str(raw or "uart").strip().lower().replace("-", "_").replace(" ", "_")
    return _HS_PATH_ALIASES.get(text, text)


def etc_dir(root: Path | None = None) -> Path:
    return Path(root) if root is not None else DEFAULT_ETC


def var_dir(root: Path | None = None) -> Path:
    return Path(root) if root is not None else DEFAULT_VAR


def field_config_path(etc: Path | None = None) -> Path:
    return etc_dir(etc) / "field-config.json"


def auth_path(etc: Path | None = None) -> Path:
    return etc_dir(etc) / "field-ui.auth"


def _parse_env_file(path: Path) -> dict[str, str]:
    parsed: dict[str, str] = {}
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            raw = line.strip()
            if not raw or raw.startswith("#") or "=" not in raw:
                continue
            key, _, val = raw.partition("=")
            parsed[key.strip()] = val.strip()
    except OSError:
        return {}
    return parsed


def _merge_env_identity(cfg: FieldConfig, etc: Path | None) -> FieldConfig:
    """Fill blank identity from applied ethergold.env so /config is not empty."""
    env = _parse_env_file(etc_dir(etc) / "ethergold.env")
    if not env:
        return cfg
    data = cfg.to_dict()
    if not (data.get("asset_id") or "").strip():
        data["asset_id"] = env.get("ASSET_ID") or ""
    if not data.get("bank"):
        try:
            data["bank"] = int(env.get("FIELD_BANK") or 0)
        except (TypeError, ValueError):
            pass
    if not field_config_path(etc).is_file():
        prof = env.get("FIELD_PROFILE") or ""
        if prof in PROFILES:
            data["profile"] = prof
        if env.get("FIELD_TID"):
            data["tid"] = env["FIELD_TID"]
        hs = env.get("POG422_HS_PATH") or ""
        if hs:
            data["hs_path"] = normalize_hs_path(hs)
    if not data.get("bank_position"):
        try:
            data["bank_position"] = int(env.get("BANK_POSITION") or 0)
        except (TypeError, ValueError):
            pass
    return FieldConfig.from_dict(data)


def load_field_config(etc: Path | None = None) -> FieldConfig:
    path = field_config_path(etc)
    cfg = FieldConfig()
    if path.is_file():
        try:
            with path.open(encoding="utf-8") as handle:
                cfg = FieldConfig.from_dict(json.load(handle))
        except (OSError, ValueError, json.JSONDecodeError):
            cfg = FieldConfig()
    return _merge_env_identity(cfg, etc)


def save_field_config(cfg: FieldConfig, etc: Path | None = None) -> Path:
    path = field_config_path(etc)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".json.tmp")
    with tmp.open("w", encoding="utf-8") as handle:
        json.dump(cfg.to_dict(), handle, indent=2, sort_keys=True)
        handle.write("\n")
    tmp.replace(path)
    return path


def load_auth_credentials(etc: Path | None = None) -> tuple[str, str]:
    user = os.environ.get("ETHERGOLD_FIELD_UI_USER", "").strip()
    password = os.environ.get("ETHERGOLD_FIELD_UI_PASSWORD", "").strip()
    path = auth_path(etc)
    if path.is_file():
        try:
            lines = path.read_text(encoding="utf-8").splitlines()
            for line in lines:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if line.startswith("user="):
                    user = user or line.split("=", 1)[1].strip()
                elif line.startswith("password="):
                    password = password or line.split("=", 1)[1].strip()
                elif ":" in line and not user:
                    u, p = line.split(":", 1)
                    user, password = u.strip(), p.strip()
        except OSError:
            pass
    return user or DEFAULT_AUTH_USER, password or DEFAULT_AUTH_PASSWORD


def ensure_default_auth_file(etc: Path | None = None) -> Path:
    path = auth_path(etc)
    desired = (
        "# EtherGold field UI login (server-side only)\n"
        f"user={DEFAULT_AUTH_USER}\n"
        f"password={DEFAULT_AUTH_PASSWORD}\n"
    )
    if path.is_file():
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError:
            return path
        if any(f"password={old}" in raw for old in _LEGACY_DEFAULT_PASSWORDS):
            path.write_text(desired, encoding="utf-8")
            try:
                os.chmod(path, 0o600)
            except OSError:
                pass
        return path
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(desired, encoding="utf-8")
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass
    return path


def uart_socket_id(_cfg: FieldConfig | None = None) -> int:
    """Internal uartd socket index. Always 1 on a production Pi (one HAT).

    Not a bank seat and not GA FIXTURE_POS. Launchers may still read
    FIXTURE_POS from the environment as a socket override; Apply must not
    write that key.
    """
    return 1


def derive_fixture_pos(cfg: FieldConfig) -> int:
    """Deprecated alias of uart_socket_id. Do not use for cabinet identity."""
    return uart_socket_id(cfg)


def hs_path_value(cfg: FieldConfig) -> str:
    hs = normalize_hs_path(cfg.hs_path)
    return hs if hs in HS_PATHS else "uart"


def apply_hs_path_pins(hs_path: str) -> dict[str, Any]:
    """Pi HS: drive OIS_OUT / M-S_OUT idle low. UART HS: do not touch Pi pins.

    Best-effort. Missing pinctrl (Windows tests, non-Pi) is skipped, not a failed Apply.
    Never high-Z on BCM10/11 when the jumpers are on PI.
    """
    path = normalize_hs_path(hs_path)
    if path != "pi":
        return {"ok": True, "skipped": "uart"}
    pinctrl = shutil.which("pinctrl")
    if not pinctrl:
        return {"ok": True, "skipped": "no-pinctrl"}
    import subprocess

    lines: list[str] = []
    for bcm in HS_OUT_IDLE_LOW_BCMS:
        try:
            completed = subprocess.run(
                [pinctrl, "set", str(bcm), "op", "dl"],
                check=False,
                capture_output=True,
                text=True,
                timeout=8,
            )
        except OSError as exc:
            return {"ok": False, "error": str(exc), "lines": lines}
        if completed.returncode != 0:
            err = (completed.stderr or completed.stdout or "").strip()
            return {"ok": False, "error": f"BCM{bcm}: {err}", "lines": lines}
        lines.append(f"BCM{bcm}=op dl")
    return {"ok": True, "pins": lines}


def is_listen_profile(cfg: FieldConfig) -> bool:
    return cfg.profile in LISTEN_PROFILES


def profile_wants_dyzineframe(cfg: FieldConfig) -> bool:
    if cfg.display_dyzineframe is not None:
        return bool(cfg.display_dyzineframe)
    # listen_ohd = Atlas TopBox HDMI, DyzineFrame TBX 1920×1080 (machine hooks)
    # plus HTTP :31234 progressives (Win11 OHD can use the same feed).
    # listen_ohd_server = same listen + HTTP, no local DF (OHD-only site).
    # wifi_bank_bridge stays headless. atlas_wifi_star keeps cabinet DF.
    return cfg.profile in ("listen_ohd", "atlas_wifi_star")


def validate_field_config(cfg: FieldConfig) -> list[str]:
    errors: list[str] = []
    if cfg.profile in WITHDRAWN_PROFILES:
        errors.append(
            "atlas_copper is withdrawn — use listen_ohd (TopBox) or "
            "listen_ohd_server (headless OHD HTTP), never drive the bus"
        )
    elif cfg.profile not in PROFILES:
        errors.append(f"unknown profile {cfg.profile!r}")
    if normalize_hs_path(cfg.hs_path) not in HS_PATHS:
        errors.append(f"unknown hs_path {cfg.hs_path!r} (uart or pi)")
    if not (cfg.asset_id or "").strip():
        errors.append("asset_id is required")
    try:
        bank = int(cfg.bank)
    except (TypeError, ValueError):
        errors.append("bank must be an integer")
        bank = -1
    else:
        if bank < 0:
            errors.append("bank must be >= 0")
    try:
        pos = int(cfg.bank_position)
    except (TypeError, ValueError):
        errors.append("bank_position must be an integer")
        pos = 0
    else:
        if pos < 0:
            errors.append("bank_position must be >= 0 (0 = unset)")
    try:
        theme = int(cfg.pog_cc_theme)
    except (TypeError, ValueError):
        errors.append("pog_cc_theme must be an integer 1–5")
    else:
        if theme < 1 or theme > 5:
            errors.append("pog_cc_theme must be 1–5 (Slots/Numbers/Rainbow/Fire/Quiet)")
    try:
        vol = int(cfg.pog_cc_volume)
    except (TypeError, ValueError):
        errors.append("pog_cc_volume must be an integer 0–62")
    else:
        if vol < 0 or vol > 62:
            errors.append("pog_cc_volume must be 0–62 (DS1881)")

    if cfg.profile == "wifi_bank_bridge":
        if cfg.node_role not in ("side_a", "side_b"):
            errors.append("wifi_bank_bridge requires node_role side_a or side_b")
    elif cfg.profile == "atlas_wifi_star":
        if cfg.node_role not in ("master_hub", "slave_spoke"):
            errors.append("atlas_wifi_star requires node_role master_hub or slave_spoke")
        if cfg.node_role == "slave_spoke":
            if not TID_RE.match(sanitize_field_tid(cfg.tid)):
                errors.append("slave_spoke requires tid as 4 hex digits (POG422 TID)")
    elif cfg.node_role:
        errors.append(f"profile {cfg.profile} does not use node_role")

    # Listen uses the same 4-hex GAMESTATS TID as POG_TID / listen filter.
    # Optional until Grab GAMESTATS succeeds. Leftover junk is ignored, not
    # an error — do not block listen Apply. Hub / wifi_bank_bridge leftover
    # is also ignored (spoke tunnel key only).

    path = (cfg.enrollment_path or "").strip()
    loc = (cfg.enrollment_location or "").strip()
    if path.lower().strip("/") in ("__new__", "new"):
        errors.append("enrollment_path looks like the new-site placeholder — type a real Path")
    if path and not loc:
        errors.append("enrollment_location is required when enrollment_path is set")
    if loc and not path:
        errors.append("enrollment_path is required when enrollment_location is set")

    if cfg.profile in ("wifi_bank_bridge", "atlas_wifi_star"):
        if not (cfg.uplink or "").strip():
            errors.append("uplink interface is required for WiFi profiles")

    return errors


def _env_lines(mapping: dict[str, str]) -> str:
    return "".join(f"{k}={v}\n" for k, v in mapping.items())


def render_role_env(cfg: FieldConfig) -> dict[str, str]:
    role = "listen" if is_listen_profile(cfg) else "edge"
    return {"ETHERGOLD_ROLE": role}


def render_ethergold_env(cfg: FieldConfig) -> dict[str, str]:
    inject = "0" if is_listen_profile(cfg) else "1"
    out = {
        "CHANNEL": "both",
        "POG422_UARTD": "1",
        "POG422_UARTD_INJECT": inject,
        "ASSET_ID": cfg.asset_id.strip(),
        "FIELD_BANK": str(int(cfg.bank)),
        "FIELD_PROFILE": cfg.profile,
        "UART_SOCKET_ID": str(uart_socket_id(cfg)),
        "POG422_HS_PATH": hs_path_value(cfg),
    }
    if int(cfg.bank_position) > 0:
        out["BANK_POSITION"] = str(int(cfg.bank_position))
    if is_listen_profile(cfg):
        out["ETHERGOLD_CURRENT_GAME_IPC"] = "write"
    tid = sanitize_field_tid(cfg.tid)
    if TID_RE.match(tid) and (
        is_listen_profile(cfg)
        or (cfg.profile == "atlas_wifi_star" and cfg.node_role == "slave_spoke")
    ):
        out["FIELD_TID"] = tid
    if cfg.ois_wifi_plane and cfg.profile in ("wifi_bank_bridge", "atlas_wifi_star"):
        out["OIS_WIFI_PLANE"] = "1"
    return out


def render_edge_env(cfg: FieldConfig) -> dict[str, str]:
    if is_listen_profile(cfg):
        return {"EDGE_MODE": "0"}

    out: dict[str, str] = {
        "EDGE_MODE": "1",
        "EDGE_UPLINK": cfg.uplink or "wlan0",
        "EDGE_GAME_IF": cfg.game_if or "eth1",
        "EDGE_WIFI_SSID": cfg.wifi_ssid or "POGCOM",
        "EDGE_DHCP_ONCE": "1",
    }
    if cfg.wifi_psk:
        out["EDGE_WIFI_PSK"] = cfg.wifi_psk

    if cfg.profile == "wifi_bank_bridge":
        out["EDGE_ROLE"] = "peer-a" if cfg.node_role == "side_a" else "peer-b"
        # single-hop: no EDGE_TOPOLOGY
    else:
        # atlas_wifi_star
        out["EDGE_BANK"] = str(int(cfg.bank))
        out["EDGE_BANK_WLAN"] = "1"
        out["EDGE_TID_UNICAST"] = "1"
        out["EDGE_OPEN_ENROLL"] = "1"
        if cfg.node_role == "master_hub":
            out["EDGE_TOPOLOGY"] = "hub"
            out["EDGE_ROLE"] = "hub"
        else:
            out["EDGE_TOPOLOGY"] = "spoke"
            out["EDGE_ROLE"] = "spoke"
            spoke_tid = sanitize_field_tid(cfg.tid)
            if TID_RE.match(spoke_tid):
                out["FIELD_TID"] = spoke_tid
            # Spoke overlay index remains private; launchers may map later.
    if cfg.ois_wifi_plane:
        out["OIS_WIFI_PLANE"] = "1"
    return out


def render_all(cfg: FieldConfig) -> dict[str, dict[str, str]]:
    return {
        "role.env": render_role_env(cfg),
        "ethergold.env": render_ethergold_env(cfg),
        "edge.env": render_edge_env(cfg),
    }


def mask_secrets(cfg: FieldConfig) -> dict[str, Any]:
    data = cfg.to_dict()
    if data.get("wifi_psk"):
        data["wifi_psk"] = "***"
    return data


def preview_apply(cfg: FieldConfig) -> dict[str, Any]:
    errors = validate_field_config(cfg)
    rendered = render_all(cfg) if not errors else {}
    return {
        "ok": not errors,
        "errors": errors,
        "uart_socket_id": uart_socket_id(cfg) if not errors else None,
        "dyzineframe": profile_wants_dyzineframe(cfg) if not errors else None,
        "generated": rendered,
        "services_will_change": _service_notes(cfg) if not errors else [],
    }


def _service_notes(cfg: FieldConfig) -> list[str]:
    notes = [
        "CHANNEL=both (machine + OIS)",
        f"ETHERGOLD_ROLE={render_role_env(cfg)['ETHERGOLD_ROLE']}",
        "Apply writes env files only — no reboot, no listen/collector restart",
    ]
    if cfg.profile in ("wifi_bank_bridge", "atlas_wifi_star"):
        notes.append("WireGuard/gretap tunnel bring-up (machine on VPN)")
        if cfg.ois_wifi_plane:
            notes.append("OIS target: 6GHz WiFi + HMAC (not inside VPN)")
    if cfg.profile == "listen_ohd_server":
        notes.append("DyzineFrame off — HTTP :31234 progressives for Win11 OHD 3840×1080, no local display")
    elif profile_wants_dyzineframe(cfg):
        notes.append("DyzineFrame autostart enabled (TBX 1920×1080 TopBox)")
        notes.append("HTTP :31234 progressives on (local DF and Win11 OHD can both consume)")
    else:
        notes.append("DyzineFrame autostart disabled")
    hs = normalize_hs_path(cfg.hs_path)
    if hs == "pi":
        notes.append(
            "Pi HS (GPIO J19): J3/J4 on PI. Apply idle-drives BCM10/11 low. "
            "Listen senses HS on BCM9 (M-S_IN) / BCM8 (OIS_IN) and keeps XR UART RX. "
            "Workshop — not field-published."
        )
    else:
        notes.append("UART HS (HAT / XR): J3/J4 on UART. Apply does not drive Pi HS pins.")
    return notes


def backup_etc(etc: Path | None = None, var: Path | None = None) -> Path:
    e = etc_dir(etc)
    v = var_dir(var)
    stamp = time.strftime("%Y%m%d-%H%M%S")
    dest = v / "config-backup" / stamp
    dest.mkdir(parents=True, exist_ok=True)
    for name in ("field-config.json", "role.env", "ethergold.env", "edge.env"):
        src = e / name
        if src.is_file():
            shutil.copy2(src, dest / name)
    (dest / "README.txt").write_text(
        f"EtherGold field config backup {stamp}\n", encoding="utf-8"
    )
    latest = v / "config-backup" / "latest"
    if latest.exists() or latest.is_symlink():
        try:
            latest.unlink()
        except OSError:
            pass
    try:
        latest.symlink_to(dest)
    except OSError:
        (v / "config-backup" / "latest.path").write_text(str(dest), encoding="utf-8")
    return dest


def write_env_file(path: Path, mapping: dict[str, str]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with tmp.open("w", encoding="utf-8", newline="\n") as handle:
        handle.write("# Generated by EtherGold field_config — do not hand-edit\n")
        for key, val in mapping.items():
            handle.write(f"{key}={val}\n")
    tmp.replace(path)


def apply_field_config(
    cfg: FieldConfig,
    *,
    etc: Path | None = None,
    var: Path | None = None,
    invoke_role_script: bool = False,
    dry_run: bool = False,
) -> dict[str, Any]:
    preview = preview_apply(cfg)
    if not preview["ok"]:
        return preview
    if dry_run:
        preview["applied"] = False
        preview["dry_run"] = True
        return preview

    e = etc_dir(etc)
    v = var_dir(var)
    backup = backup_etc(e, v)
    save_field_config(cfg, e)
    rendered = preview["generated"]
    write_env_file(e / "role.env", rendered["role.env"])
    write_env_file(e / "ethergold.env", rendered["ethergold.env"])
    write_env_file(e / "edge.env", rendered["edge.env"])
    ensure_default_auth_file(e)
    preview["hs_pins"] = apply_hs_path_pins(hs_path_value(cfg))
    preview["hostname"] = _maybe_stamp_hostname(cfg)
    preview["cabinet_identity"] = _persist_apply_identity(cfg)

    # Field Apply never reboots and never restarts listen/collector.
    # set_ethergold_role.sh / install_collector_service.sh stay off this path.
    role_rc: int | None = None
    if invoke_role_script:
        role_rc = _invoke_set_role(cfg, e)

    preview["applied"] = True
    preview["backup"] = str(backup)
    preview["role_script_rc"] = role_rc
    # Onsite enroll is Identity, not a DyzineFrame-only side effect. POG-CC USB
    # /asset later re-upserts the same row when config.ini already has a Path.
    preview["enrollment_local"] = _ensure_enrollment_overlay(cfg)
    if profile_wants_dyzineframe(cfg):
        preview["dyzineframe"] = _stamp_dyzineframe_identity(cfg)
    else:
        preview["dyzineframe"] = {
            "ok": False,
            "skipped": "profile has no local DyzineFrame",
        }
    return preview


def _replace_ini_key(text: str, key: str, value: str) -> tuple[str, bool]:
    pattern = re.compile(rf"^(\s*{re.escape(key)}\s*=\s*).*$", re.IGNORECASE | re.MULTILINE)
    if not pattern.search(text):
        return text, False
    return pattern.sub(rf"\g<1>{value}", text, count=1), True


def _set_or_append_ini_key(text: str, key: str, value: str) -> tuple[str, bool]:
    text, hit = _replace_ini_key(text, key, value)
    if hit:
        return text, True
    if re.search(r"^\[TOPBOX\]", text, re.MULTILINE | re.IGNORECASE):
        text = re.sub(
            r"(^\[TOPBOX\][^\n]*\n)",
            rf"\1{key} = {value}\n",
            text,
            count=1,
            flags=re.MULTILINE | re.IGNORECASE,
        )
        return text, True
    return text, False


def _import_enrollment():
    candidates = []
    env = (os.environ.get("DYZINEFRAME_ROOT") or "").strip()
    if env:
        candidates.append(Path(env))
    here = Path(__file__).resolve()
    # EtherGold/dev → Cursor OHD Project/DyzineFrame (workshop tests)
    candidates.append(here.parents[2] / "DyzineFrame")
    home = os.environ.get("HOME") or os.environ.get("USERPROFILE") or ""
    if home:
        candidates.append(Path(home) / "DyzineFrame")
    candidates.append(Path("/home/topbox/DyzineFrame"))
    for root in candidates:
        if (root / "enrollment.py").is_file():
            s = str(root)
            if s not in sys.path:
                sys.path.insert(0, s)
            import enrollment  # noqa: WPS433

            return enrollment
    return None


def enrollment_locations_payload(csv_path: str = "") -> dict[str, Any]:
    mod = _import_enrollment()
    if mod is None:
        return {"ok": False, "error": "enrollment.py not found", "locations": [], "assets": {}}
    try:
        locations = mod.list_locations(csv_path)
        assets = mod.assets_by_number(csv_path)
    except OSError as exc:
        return {"ok": False, "error": str(exc), "locations": [], "assets": {}}
    return {"ok": True, "locations": locations, "assets": assets}


def _ensure_enrollment_overlay(cfg: FieldConfig) -> dict[str, Any]:
    """Identity Apply: upsert this asset in Enrollment.csv (onsite enroll or machine move)."""
    asset = (cfg.asset_id or "").strip()
    path = (cfg.enrollment_path or "").strip()
    location = (cfg.enrollment_location or "").strip()
    if not asset.isdigit() or not path or not location:
        return {"ok": True, "skipped": "no enrollment row"}
    mod = _import_enrollment()
    if mod is None:
        return {"ok": False, "error": "enrollment.py not found"}
    try:
        upsert = getattr(mod, "upsert_enrollment_from_identity", None)
        if upsert is not None:
            local = upsert(int(asset), location, path)
            if not local:
                return {"ok": True, "skipped": "no enrollment row"}
        else:
            upsert_row = getattr(mod, "upsert_enrollment_row", None) or mod.append_local_enrollment_row
            local = upsert_row(int(asset), location, path)
    except (OSError, ValueError) as exc:
        return {"ok": False, "error": str(exc)}
    return {"ok": True, "path": local, "asset": asset, "enrollment_path": path, "enrollment_location": location}


def _stamp_dyzineframe_identity(cfg: FieldConfig) -> dict[str, Any]:
    """Patch asset/bank in DyzineFrame config.ini in place. No process restart.

    Uses line replacement so comments in config.ini are preserved.
    """
    raw = (os.environ.get("DYZINEFRAME_CONFIG") or "/home/topbox/DyzineFrame/config.ini").strip()
    path = Path(raw)
    if not path.is_file():
        return {"ok": False, "skipped": str(path)}
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        return {"ok": False, "error": str(exc), "path": str(path)}
    original = text
    updated: list[str] = []
    asset = (cfg.asset_id or "").strip()
    if asset.isdigit():
        for key in ("asset_number", "pog_asset_number"):
            text, hit = _set_or_append_ini_key(text, key, asset)
            if hit:
                updated.append(key)
    text, hit = _set_or_append_ini_key(text, "bank_number", str(int(cfg.bank)))
    if hit:
        updated.append("bank_number")
    text, hit = _set_or_append_ini_key(text, "display_mode", "TBX")
    if hit:
        updated.append("display_mode")
    if int(cfg.bank_position) > 0:
        text, hit = _set_or_append_ini_key(text, "bank_position", str(int(cfg.bank_position)))
        if hit:
            updated.append("bank_position")
    enabled = "True" if bool(cfg.log_upload_enabled) else "False"
    text, hit = _set_or_append_ini_key(text, "log_upload_enabled", enabled)
    if hit:
        updated.append("log_upload_enabled")
    if (cfg.enrollment_path or "").strip():
        text, hit = _set_or_append_ini_key(text, "enrollment_path", cfg.enrollment_path.strip())
        if hit:
            updated.append("enrollment_path")
    if (cfg.enrollment_location or "").strip():
        loc = cfg.enrollment_location.strip()
        text, hit = _set_or_append_ini_key(text, "enrollment_location", loc)
        if hit:
            updated.append("enrollment_location")
    try:
        theme = int(cfg.pog_cc_theme)
    except (TypeError, ValueError):
        theme = 1
    if theme < 1 or theme > 5:
        theme = 1
    text, hit = _set_or_append_ini_key(text, "pog_cc_theme", str(theme))
    if hit:
        updated.append("pog_cc_theme")
    try:
        vol = int(cfg.pog_cc_volume)
    except (TypeError, ValueError):
        vol = 5
    if vol < 0 or vol > 62:
        vol = 5
    text, hit = _set_or_append_ini_key(text, "pog_cc_volume", str(vol))
    if hit:
        updated.append("pog_cc_volume")
    if not updated:
        return {"ok": False, "error": "no matching keys", "path": str(path)}
    if text == original:
        return {
            "ok": True,
            "path": str(path),
            "unchanged": True,
            "updated": updated,
            "asset_id": asset,
            "bank": int(cfg.bank),
        }
    tmp = path.with_suffix(path.suffix + ".tmp")
    try:
        with tmp.open("w", encoding="utf-8", newline="\n") as handle:
            handle.write(text)
        tmp.replace(path)
    except OSError as exc:
        return {"ok": False, "error": str(exc), "path": str(path)}
    return {
        "ok": True,
        "path": str(path),
        "updated": updated,
        "asset_id": asset,
        "bank": int(cfg.bank),
    }


def _invoke_set_role(cfg: FieldConfig, etc: Path) -> int:
    """Call set_ethergold_role.sh when present (Pi). Returns exit code or -1 if skipped."""
    import subprocess

    script = Path(__file__).resolve().parent / "set_ethergold_role.sh"
    if not script.is_file():
        return -1
    role = render_role_env(cfg)["ETHERGOLD_ROLE"]
    env = os.environ.copy()
    env["UART_SOCKET_ID"] = "1"
    env["CHANNEL"] = "both"
    cmd = ["bash", str(script), role]
    if role == "edge":
        edge = render_edge_env(cfg)
        cmd.extend(
            [
                "--role",
                edge.get("EDGE_ROLE", "peer-a"),
                "--uplink",
                edge.get("EDGE_UPLINK", "wlan0"),
            ]
        )
        if edge.get("EDGE_GAME_IF"):
            cmd.extend(["--game-if", edge["EDGE_GAME_IF"]])
        if profile_wants_dyzineframe(cfg):
            cmd.append("--display-on")
        else:
            cmd.append("--display-off")
    try:
        completed = subprocess.run(cmd, env=env, check=False)
        return int(completed.returncode)
    except OSError:
        return -1


def status_snapshot(cfg: FieldConfig | None = None, etc: Path | None = None) -> dict[str, Any]:
    cfg = cfg or load_field_config(etc)
    eg_version = ""
    try:
        from pog422_core import EG_VERSION, EG_VERSION_SLUG

        eg_version = f"{EG_VERSION} ({EG_VERSION_SLUG})"
    except Exception:
        eg_version = "unknown"
    cabinet: dict[str, Any] = {}
    try:
        from cabinet_identity import load_cabinet_identity

        cabinet = load_cabinet_identity()
    except Exception:
        cabinet = {}
    term = int(cabinet.get("term_number") or cabinet.get("machine_number") or 0)
    role = str(cabinet.get("machine_role") or "")
    pos = int(cfg.bank_position or cabinet.get("bank_position") or 0)
    tid_hex = str(cabinet.get("tid_hex") or cabinet.get("pog_tid") or "") or None
    machine_number = int(cabinet.get("machine_number") or 0) or None
    return {
        "asset_id": cfg.asset_id,
        "bank": cfg.bank,
        "tid": cfg.tid or None,
        "tid_hex": tid_hex,
        "pog_tid": tid_hex,
        "machine_number": machine_number,
        "term_number": machine_number,
        "machine_role": role or None,
        "mode_label": cabinet.get("mode_label") or None,
        "bank_position": pos or None,
        "hostname": cabinet.get("topbox_host") or hostname_for_asset(cfg.asset_id),
        "profile": cfg.profile,
        "hs_path": hs_path_value(cfg),
        "node_role": cfg.node_role or None,
        "eg_version": eg_version,
        "channel": "both",
        "ois_wifi_plane": bool(cfg.ois_wifi_plane),
        "dyzineframe": profile_wants_dyzineframe(cfg),
        "fixture_pos_shown": False,
        "enrollment_location": cfg.enrollment_location or None,
        "enrollment_path": cfg.enrollment_path or None,
        "log_upload_enabled": bool(cfg.log_upload_enabled),
    }


def hostname_for_asset(asset_id: str) -> str:
    from cabinet_identity import hostname_for_asset as _hn

    return _hn(asset_id)


def _maybe_stamp_hostname(cfg: FieldConfig) -> dict[str, Any]:
    """Set OS hostname to topbox_<asset>. Never rename the Linux user topbox."""
    asset = (cfg.asset_id or "").strip()
    wanted = hostname_for_asset(asset)
    # Opt-in: live .144 stays hostname topbox until Chris asks to stamp topbox_<asset>.
    if os.environ.get("ETHERGOLD_STAMP_HOSTNAME", "").strip() not in ("1", "true", "yes"):
        return {"ok": False, "skipped": "ETHERGOLD_STAMP_HOSTNAME not set", "wanted": wanted}
    if os.name != "posix":
        return {"ok": False, "skipped": "not-posix", "wanted": wanted}
    if not asset:
        return {"ok": False, "skipped": "no-asset", "wanted": wanted}
    import shutil
    import subprocess

    ctl = shutil.which("hostnamectl")
    if not ctl:
        return {"ok": False, "skipped": "no-hostnamectl", "wanted": wanted}
    try:
        completed = subprocess.run(
            [ctl, "set-hostname", wanted],
            check=False,
            capture_output=True,
            text=True,
        )
    except OSError as exc:
        return {"ok": False, "error": str(exc), "wanted": wanted}
    return {
        "ok": completed.returncode == 0,
        "wanted": wanted,
        "rc": completed.returncode,
        "stderr": (completed.stderr or "")[:400],
    }


def _persist_apply_identity(cfg: FieldConfig) -> dict[str, Any]:
    out: dict[str, Any] = {"bank_position": int(cfg.bank_position or 0)}
    tid = sanitize_field_tid(cfg.tid)
    if is_listen_profile(cfg) and TID_RE.match(tid):
        try:
            from cabinet_identity import persist_gamestats_identity

            out["pog_tid"] = persist_gamestats_identity(pog_tid=tid)
        except Exception as exc:
            out["pog_tid_error"] = str(exc)
    if int(cfg.bank_position) <= 0:
        return out
    try:
        from cabinet_identity import persist_bank_position

        out["saved"] = persist_bank_position(int(cfg.bank_position))
    except Exception as exc:
        out["error"] = str(exc)
    return out
