#!/usr/bin/env bash
# Bench EtherGold update — pull, compare versions, selective rebuild, USB pause.
#
# Does NOT start bridge/listen unless UPDATE_START=1.
# Default print: cd ~/EtherGold/dev && bash run_ethergold.sh
# (role / EDGE / POS / CHANNEL / profile all from env files).
#
# Usage:
#   cd ~/EtherGold/sync
#   cp update.env.example ~/EtherGold/update.env   # once
#   bash update.sh
#
# Env (see update.env.example + fixture-bridge.env + /etc/ethergold/role.env):
#   HW_DEBUG=1              rebuild inject/uartd(/driver) with porch timeline
#   SKIP_PULL=1             skip Dropbox pull
#   UPDATE_REEXEC=1         internal: already re-exec'd after pull (do not set)
#   FORCE_INJECT=1          always make clean && make
#   FORCE_DRIVER=1          always install_pi.sh
#   UPDATE_RUN=auto|none    auto (default) → print run_ethergold.sh; none → skip
#   UPDATE_START=1          exec run_ethergold.sh after update (foreground)
#   DRY_RUN=1               report plan only (no pull/make/install)
#   SKIP_CPU_AFFINITY=1     skip OHD CPU/IRQ affinity install
#   SKIP_ETHERGOLD_HTTP=1   skip ethergold-http restart (field status/config UI)
#   SKIP_POGCC_COM=1        skip ethergold-pogcc-com unit sync
#
# Prefer env files over long CLI lines. CLI exports override files.

if grep -q $'\r' "${BASH_SOURCE[0]}" 2>/dev/null; then
  sed -i 's/\r$//' "${BASH_SOURCE[0]}"
  exec bash "${BASH_SOURCE[0]}" "$@"
fi

set -euo pipefail

if [[ "$(id -un)" == root ]]; then
  echo "FATAL: do not run update.sh as root/sudo." >&2
  echo "  Run as topbox; the script sudo's only install/modprobe steps." >&2
  exit 1
fi

SYNC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=ethergold_paths.sh
source "${SYNC}/ethergold_paths.sh"

DEV="${ETHERGOLD_DEV}"
INJECT="${DEV}/inject-shim"
DRIVER="${DEV}/xr-usb-serial-pog422"
MOD_NAME="xr_usb_serial_common"
UPDATE_ENV="${ETHERGOLD_ROOT}/update.env"
UPDATE_ENV_EXAMPLE="${SYNC}/update.env.example"

# Preserve CLI overrides before sourcing env files.
_CLI_HW_DEBUG="${HW_DEBUG-}"
_CLI_SKIP_PULL="${SKIP_PULL-}"
_CLI_SKIP_DYZINEFRAME="${SKIP_DYZINEFRAME-}"
_CLI_SKIP_ETHERGOLD="${SKIP_ETHERGOLD-}"
_CLI_FORCE_INJECT="${FORCE_INJECT-}"
_CLI_FORCE_DRIVER="${FORCE_DRIVER-}"
_CLI_SKIP_DRIVER="${SKIP_DRIVER-}"
_CLI_UPDATE_RUN="${UPDATE_RUN-}"
_CLI_UPDATE_START="${UPDATE_START-}"
_CLI_DRY_RUN="${DRY_RUN-}"
_CLI_SKIP_CPU_AFFINITY="${SKIP_CPU_AFFINITY-}"
_CLI_SKIP_ETHERGOLD_HTTP="${SKIP_ETHERGOLD_HTTP-}"
_CLI_SKIP_POGCC_COM="${SKIP_POGCC_COM-}"

if [[ -f "${SYNC}/source_fixture_env.sh" ]]; then
  # shellcheck source=/dev/null
  source "${SYNC}/source_fixture_env.sh"
fi

if [[ -f "${UPDATE_ENV}" ]]; then
  if grep -q $'\r' "${UPDATE_ENV}" 2>/dev/null; then
    sed -i 's/\r$//' "${UPDATE_ENV}" 2>/dev/null || true
  fi
  # shellcheck source=/dev/null
  set -a
  source "${UPDATE_ENV}"
  set +a
elif [[ -f "${UPDATE_ENV_EXAMPLE}" ]]; then
  echo "NOTE: ${UPDATE_ENV} missing — using defaults from update.env.example"
  echo "  cp ${UPDATE_ENV_EXAMPLE} ${UPDATE_ENV}"
  if grep -q $'\r' "${UPDATE_ENV_EXAMPLE}" 2>/dev/null; then
    sed -i 's/\r$//' "${UPDATE_ENV_EXAMPLE}" 2>/dev/null || true
  fi
  # shellcheck source=/dev/null
  set -a
  source "${UPDATE_ENV_EXAMPLE}"
  set +a
fi

# Restore CLI overrides.
[[ -n "${_CLI_HW_DEBUG}" ]] && HW_DEBUG="${_CLI_HW_DEBUG}"
[[ -n "${_CLI_SKIP_PULL}" ]] && SKIP_PULL="${_CLI_SKIP_PULL}"
[[ -n "${_CLI_SKIP_DYZINEFRAME}" ]] && SKIP_DYZINEFRAME="${_CLI_SKIP_DYZINEFRAME}"
[[ -n "${_CLI_SKIP_ETHERGOLD}" ]] && SKIP_ETHERGOLD="${_CLI_SKIP_ETHERGOLD}"
[[ -n "${_CLI_FORCE_INJECT}" ]] && FORCE_INJECT="${_CLI_FORCE_INJECT}"
[[ -n "${_CLI_FORCE_DRIVER}" ]] && FORCE_DRIVER="${_CLI_FORCE_DRIVER}"
[[ -n "${_CLI_SKIP_DRIVER}" ]] && SKIP_DRIVER="${_CLI_SKIP_DRIVER}"
[[ -n "${_CLI_UPDATE_RUN}" ]] && UPDATE_RUN="${_CLI_UPDATE_RUN}"
[[ -n "${_CLI_UPDATE_START}" ]] && UPDATE_START="${_CLI_UPDATE_START}"
[[ -n "${_CLI_DRY_RUN}" ]] && DRY_RUN="${_CLI_DRY_RUN}"
[[ -n "${_CLI_SKIP_CPU_AFFINITY}" ]] && SKIP_CPU_AFFINITY="${_CLI_SKIP_CPU_AFFINITY}"
[[ -n "${_CLI_SKIP_ETHERGOLD_HTTP}" ]] && SKIP_ETHERGOLD_HTTP="${_CLI_SKIP_ETHERGOLD_HTTP}"
[[ -n "${_CLI_SKIP_POGCC_COM}" ]] && SKIP_POGCC_COM="${_CLI_SKIP_POGCC_COM}"
unset _CLI_HW_DEBUG _CLI_SKIP_PULL _CLI_SKIP_DYZINEFRAME _CLI_SKIP_ETHERGOLD \
  _CLI_FORCE_INJECT _CLI_FORCE_DRIVER _CLI_SKIP_DRIVER _CLI_UPDATE_RUN _CLI_UPDATE_START \
  _CLI_DRY_RUN _CLI_SKIP_CPU_AFFINITY _CLI_SKIP_ETHERGOLD_HTTP _CLI_SKIP_POGCC_COM

HW_DEBUG="${HW_DEBUG:-1}"
SKIP_PULL="${SKIP_PULL:-0}"
SKIP_DYZINEFRAME="${SKIP_DYZINEFRAME:-1}"
SKIP_ETHERGOLD="${SKIP_ETHERGOLD:-0}"
APPLY_BENCH_PROFILE="${APPLY_BENCH_PROFILE:-0}"
FORCE_INJECT="${FORCE_INJECT:-0}"
FORCE_DRIVER="${FORCE_DRIVER:-0}"
SKIP_DRIVER="${SKIP_DRIVER:-0}"
UPDATE_RUN="${UPDATE_RUN:-auto}"
UPDATE_START="${UPDATE_START:-0}"
DRY_RUN="${DRY_RUN:-0}"
SKIP_CPU_AFFINITY="${SKIP_CPU_AFFINITY:-0}"
SKIP_ETHERGOLD_HTTP="${SKIP_ETHERGOLD_HTTP:-0}"
SKIP_POGCC_COM="${SKIP_POGCC_COM:-0}"

# Legacy aliases: bridge|listen → same unified entry (role comes from env).
case "${UPDATE_RUN}" in
  bridge|listen|auto) UPDATE_RUN=auto ;;
esac

_banner() {
  echo ""
  echo "=============================================================================="
  echo "$*"
  echo "=============================================================================="
}

_read_tree_eg_version() {
  python3 - <<PY
import re, sys
from pathlib import Path
# Prefer pog422_core.py (Phase B rename); fixture_core may be a star-import shim.
candidates = [
    Path("${DEV}") / "pog422_core.py",
    Path("${DEV}") / "pog422_fixture_core.py",
]
core = next((p for p in candidates if p.is_file()), None)
ipc = Path("${INJECT}") / "pog422_ipc.h"
if core is None:
    sys.exit(f"missing EG core under {Path('${DEV}')}")
text = core.read_text(encoding="utf-8", errors="replace")
m = re.search(r'^EG_VERSION\s*=\s*"([^"]+)"', text, re.M)
if not m:
    sys.exit(f"EG_VERSION not found in {core}")
eg = m.group(1)
mn = re.search(r'^EG_COMPONENT_MIN_VERSION\s*=\s*"([^"]+)"', text, re.M)
mx = re.search(r'^EG_COMPONENT_MAX_VERSION\s*=\s*([^\n]+)', text, re.M)
min_v = mn.group(1) if mn else eg
max_raw = mx.group(1).strip() if mx else f'"{eg}"'
if max_raw == "EG_VERSION":
    max_v = eg
else:
    mq = re.match(r'"([^"]+)"', max_raw)
    max_v = mq.group(1) if mq else eg

def parse(v):
    parts = []
    for tok in str(v).split("."):
        if not tok.isdigit():
            return None
        parts.append(int(tok))
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])

def in_range(v, lo, hi):
    pv, plo, phi = parse(v), parse(lo), parse(hi)
    if pv is None or plo is None or phi is None:
        return False
    return plo <= pv <= phi

ipc_ver = None
if ipc.is_file():
    it = ipc.read_text(encoding="utf-8", errors="replace")
    im = re.search(r'#define\s+POG422_EG_VERSION\s+"([^"]+)"', it)
    if im:
        ipc_ver = im.group(1)

# Components: ipc/uartd ABI may lag the aggregate EG_VERSION.
# Rebuild uartd/inject only when UARTD_ABI_VERSION moves.
abi = re.search(r'^UARTD_ABI_VERSION\s*=\s*"([^"]+)"', text, re.M)
abi_ver = abi.group(1) if abi else eg
if ipc_ver is not None and ipc_ver != abi_ver:
    sys.exit(
        f"BOM stale: ipc={ipc_ver} != UARTD_ABI_VERSION={abi_ver} — bump pog422_ipc.h "
        f"POG422_EG_VERSION with the uartd ABI (not EG_VERSION={eg})"
    )
print(eg)
PY
}

_eg_core_path() {
  if [[ -f "${DEV}/pog422_core.py" ]]; then
    echo "${DEV}/pog422_core.py"
  else
    echo "${DEV}/pog422_fixture_core.py"
  fi
}

_read_uartd_abi_version() {
  # ELF stamp in pog422_ipc.h / UARTD_ABI_VERSION — not EG_VERSION.
  python3 - <<PY
import re, sys
from pathlib import Path
text = Path("$(_eg_core_path)").read_text(encoding="utf-8", errors="replace")
m = re.search(r'^UARTD_ABI_VERSION\s*=\s*"([^"]+)"', text, re.M)
eg = re.search(r'^EG_VERSION\s*=\s*"([^"]+)"', text, re.M)
print(m.group(1) if m else (eg.group(1) if eg else "none"))
PY
}

_read_component_min_version() {
  python3 - <<PY
import re
from pathlib import Path
text = Path("$(_eg_core_path)").read_text(encoding="utf-8", errors="replace")
m = re.search(r'^EG_COMPONENT_MIN_VERSION\s*=\s*"([^"]+)"', text, re.M)
eg = re.search(r'^EG_VERSION\s*=\s*"([^"]+)"', text, re.M)
print(m.group(1) if m else (eg.group(1) if eg else "0.0.0.0"))
PY
}

_read_component_max_version() {
  python3 - <<PY
import re
from pathlib import Path
text = Path("$(_eg_core_path)").read_text(encoding="utf-8", errors="replace")
eg = re.search(r'^EG_VERSION\s*=\s*"([^"]+)"', text, re.M).group(1)
mx = re.search(r'^EG_COMPONENT_MAX_VERSION\s*=\s*([^\n]+)', text, re.M)
if not mx:
    print(eg)
    raise SystemExit
raw = mx.group(1).strip()
if raw == "EG_VERSION":
    print(eg)
else:
    m = re.match(r'"([^"]+)"', raw)
    print(m.group(1) if m else eg)
PY
}

_read_driver_module_version() {
  # Version the tree's XR sources build (may lag Python EG_VERSION).
  local src="${DRIVER}/xr_usb_serial_common.c"
  if [[ ! -f "${src}" ]]; then
    echo "none"
    return
  fi
  python3 - <<PY
import re
from pathlib import Path
text = Path("${src}").read_text(encoding="utf-8", errors="replace")
m = re.search(r'MODULE_VERSION\("([^"]+)"\)', text)
print(m.group(1) if m else "none")
PY
}

_version_in_range() {
  local ver="$1" min_v="$2" max_v="$3"
  python3 -c "
ver, lo, hi = '''${ver}''', '''${min_v}''', '''${max_v}'''
def parse(v):
    parts = []
    for tok in str(v).split('.'):
        if not tok.isdigit():
            raise SystemExit(1)
        parts.append(int(tok))
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])
try:
    pv, plo, phi = parse(ver), parse(lo), parse(hi)
except Exception:
    raise SystemExit(1)
raise SystemExit(0 if plo <= pv <= phi else 1)
"
}

_binary_best_eg_version() {
  # Highest A.B.C.D string found in a binary (uartd / .so version stamp).
  local bin="$1"
  [[ -f "${bin}" ]] || { echo "none"; return; }
  strings "${bin}" 2>/dev/null | python3 -c '
import sys, re
best = None
for line in sys.stdin:
    for m in re.finditer(r"\b(\d+\.\d+\.\d+\.\d+)\b", line):
        parts = tuple(int(x) for x in m.group(1).split("."))
        if best is None or parts > best:
            best = parts
print(".".join(str(x) for x in best) if best else "none")
'
}

_binary_has_string() {
  local bin="$1" needle="$2"
  [[ -f "${bin}" ]] || return 1
  strings "${bin}" 2>/dev/null | grep -Fq -- "${needle}"
}

_inject_needs_rebuild() {
  local abi_ver="$1"
  local uartd="${INJECT}/pog422_uartd"
  local so="${INJECT}/libpog422_inject.so"

  if [[ "${FORCE_INJECT}" == "1" ]]; then
    echo "FORCE_INJECT=1"
    return 0
  fi
  if [[ ! -x "${uartd}" || ! -f "${so}" ]]; then
    echo "missing uartd/so"
    return 0
  fi
  local bin_ver
  bin_ver="$(_binary_best_eg_version "${uartd}")"
  # uartd/inject follow UARTD_ABI_VERSION (pog422_ipc.h), not Python EG_VERSION.
  if [[ "${bin_ver}" == "none" || "${bin_ver}" != "${abi_ver}" ]]; then
    echo "uartd version ${bin_ver} != UARTD_ABI ${abi_ver}"
    return 0
  fi
  local so_ver
  so_ver="$(_binary_best_eg_version "${so}")"
  if [[ "${so_ver}" == "none" || "${so_ver}" != "${abi_ver}" ]]; then
    echo "libpog422_inject.so version ${so_ver} != UARTD_ABI ${abi_ver}"
    return 0
  fi
  if [[ "${HW_DEBUG}" == "1" ]]; then
    if ! _binary_has_string "${uartd}" "POG422_HW_DEBUG=1"; then
      echo "want HW_DEBUG=1 but uartd lacks POG422_HW_DEBUG=1 marker"
      return 0
    fi
  else
    if _binary_has_string "${uartd}" "POG422_HW_DEBUG=1"; then
      echo "want HW_DEBUG=0 but uartd was built with HW_DEBUG=1"
      return 0
    fi
  fi
  # Source newer than objects → rebuild
  local src
  for src in \
    "${INJECT}/pog422_inject.c" \
    "${INJECT}/pog422_uartd.c" \
    "${INJECT}/pog422_ipc.h" \
    "${INJECT}/pog422_hw_debug.h"; do
    if [[ -f "${src}" && "${src}" -nt "${uartd}" ]]; then
      echo "source newer than uartd: $(basename "${src}")"
      return 0
    fi
  done
  return 1
}

_driver_loaded_ver() {
  if [[ -r "/sys/module/${MOD_NAME}/version" ]]; then
    tr -d '\n' < "/sys/module/${MOD_NAME}/version"
  else
    echo "none"
  fi
}

_driver_installed_ver() {
  modinfo -F version "${MOD_NAME}" 2>/dev/null | head -1 || echo "none"
}

_driver_needs_install() {
  local tree_ver="$1"
  local min_v max_v loaded installed tree_mod
  min_v="$(_read_component_min_version)"
  max_v="$(_read_component_max_version)"
  loaded="$(_driver_loaded_ver)"
  installed="$(_driver_installed_ver)"
  tree_mod="$(_read_driver_module_version)"

  if [[ "${FORCE_DRIVER}" == "1" ]]; then
    echo "FORCE_DRIVER=1 (loaded=${loaded} installed=${installed})"
    return 0
  fi
  if [[ "${SKIP_DRIVER}" == "1" ]]; then
    return 1
  fi
  # Legacy string module IDs accepted by pog422_core (not A.B.C.D comparable).
  case "${loaded}" in
    1R-pog422-preciseflags-ioctl-fix|1W-pog422-tx-fifo-errno-ch|1S-*|1T-*)
      return 1
      ;;
  esac
  # Loaded in-range is enough for Python-only stamps (MODULE_VERSION unchanged).
  # When xr_usb_serial_common.c MODULE_VERSION moves, install even if the old
  # loaded module is still inside [EG_COMPONENT_MIN, EG_VERSION].
  if [[ "${tree_mod}" != "none" && "${loaded}" != "${tree_mod}" ]]; then
    echo "loaded=${loaded} != tree MODULE_VERSION ${tree_mod}"
    return 0
  fi
  if [[ "${loaded}" != "none" ]] && _version_in_range "${loaded}" "${min_v}" "${max_v}"; then
    return 1
  fi
  if [[ "${installed}" == "none" ]] || ! _version_in_range "${installed}" "${min_v}" "${max_v}"; then
    echo "installed=${installed} not in [${min_v}, ${max_v}] (tree=${tree_ver})"
    return 0
  fi
  if [[ "${loaded}" == "none" ]] || ! _version_in_range "${loaded}" "${min_v}" "${max_v}"; then
    echo "loaded=${loaded} not in [${min_v}, ${max_v}] (tree=${tree_ver})"
    return 0
  fi
  return 1
}

_tty_ok() {
  ls /dev/ttyXRUSB* >/dev/null 2>&1
}

_is_exar_tty() {
  # Exar XR21B1422 (04e2) only — never FTDI P3 / PermDevMapper /dev/pogcc.
  local dev="$1" vid
  [[ -e "${dev}" ]] || return 1
  case "${dev}" in
    /dev/ttyXRUSB*) return 0 ;;
  esac
  vid="$(udevadm info -q property -n "${dev}" 2>/dev/null | sed -n 's/^ID_VENDOR_ID=//p' | head -n1 || true)"
  [[ "${vid}" == "04e2" ]]
}

_stop_fixture_uart_users() {
  pkill -f pog422_uartd 2>/dev/null || true
  pkill -f pog422_fixture_bridge 2>/dev/null || true
  pkill -f pog422_fixture_listen 2>/dev/null || true
  sleep 0.5
  if command -v fuser >/dev/null 2>&1; then
    for dev in /dev/ttyXRUSB* /dev/ttyUSB*; do
      [[ -e "${dev}" ]] || continue
      _is_exar_tty "${dev}" || continue
      sudo fuser -k "${dev}" 2>/dev/null || true
    done
  fi
  sleep 0.5
}

_rebind_hat() {
  if [[ -x "${DRIVER}/pog422_rebind_xr21.sh" ]]; then
    sudo bash "${DRIVER}/pog422_rebind_xr21.sh" 2>/dev/null || true
  elif [[ -x /usr/local/sbin/pog422_rebind_xr21.sh ]]; then
    sudo /usr/local/sbin/pog422_rebind_xr21.sh 2>/dev/null || true
  fi
}

_wait_tty() {
  local i
  for i in $(seq 1 40); do
    _tty_ok && return 0
    sleep 0.25
  done
  _rebind_hat
  for i in $(seq 1 20); do
    _tty_ok && return 0
    sleep 0.25
  done
  return 1
}

# Disconnect so a stale in-RAM module can unload before/during install_pi.
_prompt_unplug_and_unload() {
  # Mass chain upgrades set SKIP_DRIVER=1 and have no TTY — never block on Enter.
  if [[ "${SKIP_DRIVER}" == "1" ]] || [[ ! -t 0 ]]; then
    _banner "Cycle USB connection (1/2: skip prompt)"
    echo "SKIP_DRIVER/non-interactive — unload attempt without USB prompt"
    echo "current loaded: $(_driver_loaded_ver)"
    _stop_fixture_uart_users
    sudo modprobe -r "${MOD_NAME}" 2>/dev/null || true
    sleep 0.3
    if lsmod | grep -q "^${MOD_NAME}"; then
      echo "WARN: ${MOD_NAME} still loaded (non-interactive) — continuing"
      return 0
    fi
    echo "OK: ${MOD_NAME} unloaded (non-interactive)"
    return 0
  fi
  _banner "Cycle USB connection (1/2: disconnect)"
  echo "Disconnect the Exar hat USB cable so the old driver can unload."
  echo "Leave it disconnected — build/install runs next; reconnect only at step 2/2."
  echo "current loaded: $(_driver_loaded_ver)"
  echo ""
  read -r -p "Press Enter when disconnected (leave cable out)... "
  echo "Stopping fixture UART users + unloading ${MOD_NAME}..."
  _stop_fixture_uart_users
  sudo modprobe -r "${MOD_NAME}" 2>/dev/null || true
  sleep 0.3
  if lsmod | grep -q "^${MOD_NAME}"; then
    echo "FATAL: ${MOD_NAME} still loaded after disconnect — close holders or reboot" >&2
    exit 1
  fi
  echo "OK: ${MOD_NAME} unloaded — keep cable disconnected until step 2/2"
}

# Reconnect + verify loaded sysfs version matches tree BOM.
_prompt_replug_and_verify() {
  local want="$1"
  local loaded

  if [[ "${SKIP_DRIVER}" == "1" ]] || [[ ! -t 0 ]]; then
    _banner "Cycle USB connection (2/2: skip prompt)"
    echo "SKIP_DRIVER/non-interactive — modprobe + wait tty, no Enter prompt"
    if ! lsmod | grep -q "^${MOD_NAME}"; then
      sudo modprobe "${MOD_NAME}" 2>/dev/null || true
    fi
    _rebind_hat
    _wait_tty || true
    loaded="$(_driver_loaded_ver)"
    echo "loaded(sysfs)=${loaded} want=${want} (non-interactive; not fatal if mismatch)"
    return 0
  fi

  _banner "Cycle USB connection (2/2: reconnect)"
  if ! lsmod | grep -q "^${MOD_NAME}"; then
    sudo modprobe "${MOD_NAME}"
  fi
  echo "Reconnect the Exar hat USB cable so the new driver can enumerate."
  echo "want loaded version: ${want}"
  echo ""
  read -r -p "Press Enter when the cable is reconnected... "
  echo "Waiting for /dev/ttyXRUSB* ..."
  if ! _wait_tty; then
    echo "FATAL: /dev/ttyXRUSB* still missing after reconnect" >&2
    exit 1
  fi
  loaded="$(_driver_loaded_ver)"
  echo "loaded(sysfs)=${loaded}  installed(modinfo)=$(_driver_installed_ver)"
  if [[ "${loaded}" != "${want}" ]]; then
    echo "FATAL: after USB cycle loaded=${loaded} want=${want}" >&2
    echo "  Try: sudo reboot, then: SKIP_PULL=1 bash update.sh" >&2
    exit 1
  fi
  echo "OK: USB cycle complete — ${MOD_NAME} ${loaded}  nodes: $(ls /dev/ttyXRUSB* | tr '\n' ' ')"
}

_wait_usb_cycle() {
  local want="$1"
  _banner "Cycle USB connection"
  _prompt_unplug_and_unload
  _prompt_replug_and_verify "${want}"
}

_verify_driver() {
  local want="$1"
  local loaded
  loaded="$(_driver_loaded_ver)"
  if [[ "${loaded}" != "${want}" ]] || ! _tty_ok; then
    return 1
  fi
  return 0
}

_print_final_command() {
  local bridge_env="${ETHERGOLD_ROOT}/fixture-bridge.env"
  local role_env="/etc/ethergold/role.env"
  local edge_env="/etc/ethergold/edge.env"
  [[ -f "${edge_env}" ]] || edge_env="/etc/fixture/edge.env"
  local role="topbox"
  local edge_mode="0"
  local profile="${ETHERGOLD_PROFILE:-}"

  _banner "Start command (env-driven)"
  echo "Options come from env files (not the command line):"
  if [[ -f "${role_env}" ]]; then
    # shellcheck disable=SC1090
    # shellcheck source=/dev/null
    source "${role_env}" 2>/dev/null || true
    role="${ETHERGOLD_ROLE:-${role}}"
  fi
  if [[ -f "${edge_env}" ]]; then
    # shellcheck disable=SC1090
    # shellcheck source=/dev/null
    source "${edge_env}" 2>/dev/null || true
    edge_mode="${EDGE_MODE:-0}"
  fi
  if [[ -z "${profile}" ]]; then
    if [[ "${ETHERGOLD_BENCH:-0}" == "1" || "${FIXTURE_PROD:-}" == "0" ]]; then
      profile="bench"
    else
      profile="prod"
    fi
  fi
  echo "  role=${role}  EDGE_MODE=${edge_mode}  profile=${profile}"
  if [[ -f "${bridge_env}" ]]; then
    grep -E '^(FIXTURE_POS|PI_LABEL|CHANNEL|FIXTURE_PROD|ETHERGOLD_PROFILE|POG422_UARTD|POG422_UARTD_INJECT)=' "${bridge_env}" 2>/dev/null || true
  else
    echo "WARN: missing ${bridge_env} — run: bash ${SYNC}/install_fixture_bridge_env.sh --ensure"
  fi
  echo ""
  if [[ "${UPDATE_RUN}" == "none" ]]; then
    echo "(UPDATE_RUN=none — no launcher printed)"
    return 0
  fi
  echo "cd ${DEV} && bash run_ethergold.sh"
  if [[ "${edge_mode}" == "1" ]]; then
    echo "# EDGE: run_ethergold auto-elevates with sudo -E"
  fi
  if [[ "${role}" == "listen" ]]; then
    echo "# listen role → run_listen_* inside run_ethergold.sh"
  fi
}

_maybe_start_ethergold() {
  if [[ "${UPDATE_RUN}" == "none" || "${UPDATE_START}" != "1" ]]; then
    return 0
  fi
  if [[ "${DRY_RUN}" == "1" ]]; then
    echo "DRY_RUN=1 — would exec: bash ${DEV}/run_ethergold.sh"
    return 0
  fi
  _banner "UPDATE_START=1 — starting run_ethergold.sh (foreground)"
  cd "${DEV}"
  exec bash "${DEV}/run_ethergold.sh"
}

# --- main ---

_banner "EtherGold bench update"
echo "dev=${DEV}"
echo "HW_DEBUG=${HW_DEBUG}  UPDATE_RUN=${UPDATE_RUN}  DRY_RUN=${DRY_RUN}"
echo "FIXTURE_POS=${FIXTURE_POS:-unset}  CHANNEL=${CHANNEL:-unset}"

if [[ ! -d "${DEV}" ]]; then
  echo "FATAL: missing ${DEV}" >&2
  exit 1
fi

# Keep launch options in fixture-bridge.env (not on the printed command line).
_banner "Ensure fixture-bridge.env"
if [[ "${DRY_RUN}" == "1" ]]; then
  echo "DRY_RUN=1 — would run: bash install_fixture_bridge_env.sh --ensure"
else
  bash "${SYNC}/install_fixture_bridge_env.sh" --ensure
  # Re-load so version banner / print summary see upserted keys.
  if [[ -f "${SYNC}/source_fixture_env.sh" ]]; then
    # shellcheck source=/dev/null
    source "${SYNC}/source_fixture_env.sh"
  fi
fi

if [[ "${SKIP_PULL}" != "1" ]]; then
  _banner "Pull from Dropbox"
  if [[ "${DRY_RUN}" == "1" ]]; then
    echo "DRY_RUN=1 — would run: SKIP_DYZINEFRAME=${SKIP_DYZINEFRAME} SKIP_ETHERGOLD=${SKIP_ETHERGOLD} bash pull_all_from_dropbox.sh"
  else
    SKIP_DYZINEFRAME="${SKIP_DYZINEFRAME}" \
      SKIP_ETHERGOLD="${SKIP_ETHERGOLD}" \
      APPLY_BENCH_PROFILE="${APPLY_BENCH_PROFILE}" \
      bash "${SYNC}/pull_all_from_dropbox.sh"
    # bash keeps the inode opened at start. Pull replaces sync/update.sh on
    # disk; TREE_VER then comes from the new Python while inject/driver
    # logic is still the old script (4.0.0.22 FATAL vs EG tip 4.0.0.23).
    if [[ "${UPDATE_REEXEC:-0}" != "1" ]]; then
      echo "Pulled tree — re-exec ${SYNC}/update.sh (SKIP_PULL=1) so this process uses the new script"
      UPDATE_REEXEC=1 SKIP_PULL=1 exec bash "${SYNC}/update.sh"
    fi
  fi
else
  echo "SKIP_PULL=1 — using tree already on disk"
fi

TREE_VER="$(_read_tree_eg_version)"
ABI_VER="$(_read_uartd_abi_version)"
COMP_MIN="$(_read_component_min_version)"
COMP_MAX="$(_read_component_max_version)"
DRIVER_MOD_VER="$(_read_driver_module_version)"
LOADED="$(_driver_loaded_ver)"
INSTALLED="$(_driver_installed_ver)"

_banner "Version compare (tree vs built vs loaded)"
echo "tree EG_VERSION (pulled):     ${TREE_VER}"
echo "uartd ABI (ipc):              ${ABI_VER}"
echo "component range:              [${COMP_MIN}, ${COMP_MAX}]"
echo "driver MODULE_VERSION (src):  ${DRIVER_MOD_VER}"
echo "driver installed (modinfo):   ${INSTALLED}"
echo "driver loaded (sysfs):        ${LOADED}"
if [[ -x "${INJECT}/pog422_uartd" ]]; then
  BIN_VER="$(_binary_best_eg_version "${INJECT}/pog422_uartd")"
  if [[ "${BIN_VER}" == "${ABI_VER}" ]]; then
    echo "inject/uartd binary:          ${BIN_VER} (matches UARTD_ABI)"
  else
    echo "inject/uartd binary:          ${BIN_VER} != UARTD_ABI ${ABI_VER} (rebuild required)"
  fi
  if _binary_has_string "${INJECT}/pog422_uartd" "POG422_HW_DEBUG=1"; then
    echo "inject/uartd HW_DEBUG build:  yes"
  else
    echo "inject/uartd HW_DEBUG build:  no"
  fi
else
  echo "inject/uartd binary:          missing"
fi
if [[ -f "${INJECT}/libpog422_inject.so" ]]; then
  SO_VER="$(_binary_best_eg_version "${INJECT}/libpog422_inject.so")"
  if [[ "${SO_VER}" == "${ABI_VER}" ]]; then
    echo "libpog422_inject.so:          ${SO_VER} (matches UARTD_ABI)"
  else
    echo "libpog422_inject.so:          ${SO_VER} != UARTD_ABI ${ABI_VER} (rebuild required)"
  fi
fi

NEED_INJECT=0
NEED_DRIVER=0
INJECT_REASON=""
DRIVER_REASON=""
if INJECT_REASON="$(_inject_needs_rebuild "${ABI_VER}")"; then
  NEED_INJECT=1
fi
if DRIVER_REASON="$(_driver_needs_install "${TREE_VER}")"; then
  NEED_DRIVER=1
fi

echo ""
echo "plan: inject_rebuild=${NEED_INJECT}${INJECT_REASON:+ (${INJECT_REASON})}"
echo "plan: driver_install=${NEED_DRIVER}${DRIVER_REASON:+ (${DRIVER_REASON})}"

if [[ "${DRY_RUN}" == "1" ]]; then
  _banner "DRY_RUN complete — no builds"
  _print_final_command
  exit 0
fi

if [[ "${NEED_INJECT}" == "1" ]]; then
  _banner "Rebuild inject + uartd (HW_DEBUG=${HW_DEBUG})"
  if [[ ! -d "${INJECT}" ]]; then
    echo "FATAL: missing ${INJECT}" >&2
    exit 1
  fi
  (
    cd "${INJECT}"
    make clean
    make HW_DEBUG="${HW_DEBUG}" -j"$(nproc)"
  )
  BIN_VER="$(_binary_best_eg_version "${INJECT}/pog422_uartd")"
  if [[ "${BIN_VER}" != "${ABI_VER}" ]]; then
    echo "FATAL: after make, uartd version ${BIN_VER} != UARTD_ABI ${ABI_VER} (not EG_VERSION ${TREE_VER})" >&2
    exit 1
  fi
  SO_VER="$(_binary_best_eg_version "${INJECT}/libpog422_inject.so")"
  if [[ "${SO_VER}" != "${ABI_VER}" ]]; then
    echo "FATAL: after make, libpog422_inject.so version ${SO_VER} != UARTD_ABI ${ABI_VER} (not EG_VERSION ${TREE_VER})" >&2
    exit 1
  fi
  echo "OK: inject/uartd rebuilt ${BIN_VER} UARTD_ABI=${ABI_VER} HW_DEBUG=${HW_DEBUG}"
else
  echo "OK: inject/uartd already matches UARTD_ABI ${ABI_VER} (HW_DEBUG=${HW_DEBUG})"
fi

if [[ "${NEED_DRIVER}" == "1" ]]; then
  _banner "Install XR driver ${DRIVER_MOD_VER} (HW_DEBUG=${HW_DEBUG}; tree=${TREE_VER})"
  if [[ ! -f "${DRIVER}/install_pi.sh" ]]; then
    echo "FATAL: missing ${DRIVER}/install_pi.sh" >&2
    exit 1
  fi

  NEED_REPLUG=0
  # Stale in-RAM module with open tty nodes blocks unload — disconnect first.
  if lsmod | grep -q "^${MOD_NAME}" \
    && ! _version_in_range "$(_driver_loaded_ver)" "${COMP_MIN}" "${COMP_MAX}" \
    && { _tty_ok || ls /dev/ttyUSB* >/dev/null 2>&1; }; then
    echo "Loaded driver is out of range with tty nodes — cycle USB (disconnect now)."
    _prompt_unplug_and_unload
    NEED_REPLUG=1
  fi

  set +e
  HW_DEBUG="${HW_DEBUG}" bash "${DRIVER}/install_pi.sh"
  install_rc=$?
  set -e
  if [[ "${install_rc}" -ne 0 ]]; then
    echo ""
    echo "FATAL: install_pi.sh failed (rc=${install_rc}) — not a USB step." >&2
    echo "  Check console output above for the real reason (often EXPECTED_VER vs MODULE_VERSION)." >&2
    echo "  Kernel make failures also write ${DEV}/ERRORS.txt — ignore that file if it is stale." >&2
    echo "  Common fix if you see Permission denied:" >&2
    echo "    sudo chown -R \$(id -un):\$(id -gn) ${DRIVER}" >&2
    echo "    SKIP_PULL=1 bash update.sh" >&2
    exit "${install_rc}"
  fi

  # After install, expect the tree's MODULE_VERSION (not Python EG_VERSION).
  if [[ "${NEED_REPLUG}" == "1" ]] || ! _verify_driver "${DRIVER_MOD_VER}"; then
    if ! _verify_driver "${DRIVER_MOD_VER}"; then
      echo "New .ko installed but not live / no ttyXRUSB — finish USB cycle (reconnect)."
      _prompt_replug_and_verify "${DRIVER_MOD_VER}"
    elif [[ "${NEED_REPLUG}" == "1" ]]; then
      echo "Driver version OK after install — finish USB cycle (reconnect)."
      _prompt_replug_and_verify "${DRIVER_MOD_VER}"
    fi
  fi
  if ! _verify_driver "${DRIVER_MOD_VER}"; then
    echo "FATAL: driver not loaded at ${DRIVER_MOD_VER} after USB cycle" >&2
    exit 1
  fi
  echo "OK: driver loaded ${DRIVER_MOD_VER}"
else
  echo "OK: driver already in range [${COMP_MIN}, ${COMP_MAX}] (loaded=${LOADED})"
  if ! _tty_ok; then
    if [[ "${SKIP_DRIVER}" == "1" ]] || [[ ! -t 0 ]]; then
      echo "WARN: /dev/ttyXRUSB* missing — SKIP_DRIVER/non-interactive, trying rebind only"
      _rebind_hat
      _wait_tty || echo "WARN: ttyXRUSB still missing after rebind (continuing)"
    else
      echo "WARN: driver version OK but /dev/ttyXRUSB* missing — cycle USB connection."
      _wait_usb_cycle "${DRIVER_MOD_VER}"
    fi
  fi
fi

_banner "BOM check"
(
  cd "${DEV}"
  bash bridge_build_check.sh
) || {
  echo "WARN: bridge_build_check.sh reported issues — inspect before starting" >&2
}

_banner "NetworkManager eth0/eth1 unmanaged (AF_PACKET)"
NM_INSTALL="${DEV}/install_nm_eth_unmanaged.sh"
if [[ -f "${NM_INSTALL}" ]]; then
  if [[ "${DRY_RUN}" == "1" ]]; then
    echo "DRY_RUN=1 — would: sudo bash ${NM_INSTALL}"
  else
    sudo bash "${NM_INSTALL}"
  fi
else
  echo "WARN: missing ${NM_INSTALL}" >&2
fi

_banner "CPU affinity (EG/DF/net lanes + IRQ/RPS)"
# Prefer tip under dev/; field prod/ mirror after build_prod.
_CPU_AFF=""
for _cand in \
  "${DEV}/install_cpu_affinity.sh" \
  "${ETHERGOLD_ROOT}/prod/install_cpu_affinity.sh" \
  "${ETHERGOLD_ROOT}/dev/install_cpu_affinity.sh"; do
  if [[ -f "${_cand}" ]]; then
    _CPU_AFF="${_cand}"
    break
  fi
done
unset _cand
if [[ "${SKIP_CPU_AFFINITY}" == "1" ]]; then
  echo "SKIP_CPU_AFFINITY=1 — leave CPUAffinity / ohd-irq-affinity unchanged"
elif [[ -z "${_CPU_AFF}" ]]; then
  echo "WARN: install_cpu_affinity.sh missing (need EG tip ≥ 3.0.2.11)" >&2
elif [[ "${DRY_RUN}" == "1" ]]; then
  echo "DRY_RUN=1 — would: sudo bash ${_CPU_AFF}"
else
  # Install systemd drop-ins + ohd-irq-affinity; restarts ethergold* only if enabled.
  if sudo bash "${_CPU_AFF}"; then
    echo "OK: CPU affinity installed (${_CPU_AFF})"
  else
    echo "WARN: install_cpu_affinity.sh failed (rc=$?) — check nproc/sudo; non-fatal" >&2
  fi
fi
unset _CPU_AFF

_banner "ethergold-http (config + /status UI)"
# progressives_http.py is loaded once at process start — must restart after tip pull
# so HTTP_BUILD and new routes (e.g. /EtherGold/status) match disk.
# Unit WorkingDirectory must be tip (dev/) — prod/ can lag with an old field_config_ui.html.
if [[ "${SKIP_ETHERGOLD_HTTP}" == "1" ]]; then
  echo "SKIP_ETHERGOLD_HTTP=1 — leave ethergold-http process unchanged"
elif [[ "${DRY_RUN}" == "1" ]]; then
  echo "DRY_RUN=1 — would: align ethergold-http to tip ${DEV} + restart"
elif [[ ! -f "${DEV}/install_ethergold_http.sh" ]]; then
  echo "WARN: ${DEV}/install_ethergold_http.sh missing — cannot align HTTP unit" >&2
elif ! systemctl cat ethergold-http.service &>/dev/null; then
  echo "ethergold-http.service not installed — skip (field/collector: install_ethergold_http.sh)"
else
  _http_wd="$(systemctl show ethergold-http.service -p WorkingDirectory --value 2>/dev/null || true)"
  if [[ "${_http_wd}" != "${DEV}" ]]; then
    echo "ethergold-http WorkingDirectory=${_http_wd:-?} ≠ tip ${DEV} — reinstall from tip"
    if sudo bash "${DEV}/install_ethergold_http.sh"; then
      echo "OK: ethergold-http reinstalled from tip"
    else
      echo "WARN: tip install_ethergold_http.sh failed (rc=$?) — trying restart only" >&2
      sudo systemctl restart ethergold-http || true
    fi
  elif sudo systemctl restart ethergold-http; then
    _http_ver="$(curl -sS --max-time 2 http://127.0.0.1:31234/health 2>/dev/null \
      | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' || true)"
    if [[ -n "${_http_ver}" ]]; then
      echo "OK: ethergold-http restarted (${_http_ver})"
    else
      echo "OK: ethergold-http restarted (health probe skipped/unavailable)"
    fi
    unset _http_ver
  else
    echo "WARN: systemctl restart ethergold-http failed (rc=$?) — non-fatal" >&2
  fi
  unset _http_wd
fi

_banner "ethergold-pogcc-com (Heltec CDC + P3; gated by DF pogcc_com_owner)"
# Sync unit from tip. auto: enable only when config.ini owner=ethergold; else stop+disable.
_POGCC_INSTALL=""
for _cand in \
  "${DEV}/install_pogcc_com_service.sh" \
  "${ETHERGOLD_ROOT}/prod/install_pogcc_com_service.sh" \
  "${ETHERGOLD_ROOT}/dev/install_pogcc_com_service.sh"; do
  if [[ -f "${_cand}" ]]; then
    _POGCC_INSTALL="${_cand}"
    break
  fi
done
unset _cand
if [[ "${SKIP_POGCC_COM}" == "1" ]]; then
  echo "SKIP_POGCC_COM=1 — leave ethergold-pogcc-com unchanged"
elif [[ -z "${_POGCC_INSTALL}" ]]; then
  echo "install_pogcc_com_service.sh missing — skip (need EG tip ≥ 3.1.0.1)"
elif [[ "${DRY_RUN}" == "1" ]]; then
  echo "DRY_RUN=1 — would: bash ${_POGCC_INSTALL} (auto mode)"
else
  if bash "${_POGCC_INSTALL}"; then
    echo "OK: pogcc-com sync (${_POGCC_INSTALL})"
  else
    echo "WARN: install_pogcc_com_service.sh failed (rc=$?) — non-fatal" >&2
  fi
fi
unset _POGCC_INSTALL

echo ""
echo "Summary:"
echo "  tree=${TREE_VER}  uartd_abi=${ABI_VER}  range=[${COMP_MIN}, ${COMP_MAX}]"
echo "  driver_src=${DRIVER_MOD_VER}  driver_loaded=$(_driver_loaded_ver)"
echo "  tty=$(_tty_ok && ls /dev/ttyXRUSB* | tr '\n' ' ' || echo missing)"
echo "  HW_DEBUG=${HW_DEBUG}"
echo "  cpu_affinity: $([ "${SKIP_CPU_AFFINITY}" = "1" ] && echo skipped || echo applied-or-attempted)"
echo "  ethergold-http: $([ "${SKIP_ETHERGOLD_HTTP}" = "1" ] && echo skipped || echo restarted-or-n/a)"
echo "  pogcc-com: $([ "${SKIP_POGCC_COM}" = "1" ] && echo skipped || echo synced-or-n/a)"

_print_final_command
_maybe_start_ethergold
echo ""
echo "Done."
echo "  CPU lanes: EG=core3 exclusive; DF=cores 0-2 (OHD_CPU_DF_SET; keeps fireworks fed)."
echo "  New DF boots use runit/run_supervisor + cpu_affinity.sh (same mask)."
echo "  Field UI: http://<pi>:31234/EtherGold/config  /EtherGold/status  /EtherGold/health (after ethergold-http restart above)."
echo "  POG-CC COM: unit synced; active only if DF config pogcc_com_owner=ethergold (else DF keeps CDC)."
if [[ -f "${SYNC}/j19_io_mapper.py" ]]; then
  echo "  J19 connector map (logic probe):  cd ${SYNC} && sudo ./run_j19_io_mapper.sh"
fi
