# Shared OTA helpers (DyzineFrame + EtherGold). Source from sync/*.sh.
# shellcheck shell=bash

ota_read_version() {
  local f="${1:-}/VERSION.txt"
  if [[ -f "${f}" ]]; then
    tr -d '\r' < "${f}" | head -n 1 | tr -d '[:space:]'
  fi
}

# Kill python3/python PIDs whose args contain needle. Never pkill -f (matches ssh).
ota_kill_python_cmd() {
  local needle="$1"
  local pid args
  while read -r pid args; do
    [[ -n "${pid}" ]] || continue
    case " ${args} " in
      *" ${needle}"*|*"/${needle}"*|*" ${needle} "*)
        kill -9 "${pid}" 2>/dev/null || true
        ;;
    esac
  done < <(ps -C python3 -o pid=,args= 2>/dev/null; ps -C python -o pid=,args= 2>/dev/null)
}

ota_python_has_cmd() {
  local needle="$1"
  local pid args
  while read -r pid args; do
    [[ -n "${pid}" ]] || continue
    case " ${args} " in
      *" ${needle}"*|*"/${needle}"*|*" ${needle} "*)
        return 0
        ;;
    esac
  done < <(ps -C python3 -o pid=,args= 2>/dev/null; ps -C python -o pid=,args= 2>/dev/null)
  return 1
}

ota_restart_dyzineframe() {
  local root="${1:-${HOME}/DyzineFrame}"
  # Kill DyzineFrame.py only. Leave dyzineframe_supervisor.py (and UI) running so
  # they can respawn HDMI. OTA HTTP often has no DISPLAY; killing the supervisor
  # left nothing to restart DF.
  ota_kill_python_cmd "DyzineFrame.py"
  rm -f "${root}/run/dyzineframe.lock" "${root}/run/supervisor.hold" 2>/dev/null || true
  if ota_python_has_cmd "dyzineframe_supervisor.py"; then
    echo "ota_restart_dyzineframe: supervisor still up — it will respawn DyzineFrame"
    return 0
  fi
  echo "ota_restart_dyzineframe: no supervisor — starting one"
  export DISPLAY="${DISPLAY:-:0}"
  if [[ -z "${XAUTHORITY:-}" && -f "${HOME}/.Xauthority" ]]; then
    export XAUTHORITY="${HOME}/.Xauthority"
  fi
  if [[ -x "${root}/run_supervisor.sh" ]]; then
    nohup "${root}/run_supervisor.sh" >> "${root}/log/supervisor.log" 2>&1 &
  elif [[ -x "${root}/runit.sh" ]]; then
    nohup "${root}/runit.sh" >> "${root}/log/log.txt" 2>&1 &
  fi
}

ota_restart_ethergold_listen() {
  # Do not SIGKILL ethergold_listen_supervisor.py — that is collector MainPID.
  # KillMode=process: systemctl restart does not SIGKILL pog422_uartd.
  # Never reference ethergold-listen.service (does not exist).
  if [[ "${SKIP_ETHERGOLD_LISTEN_RESTART:-0}" == "1" ]]; then
    echo "ota: skip ethergold-collector (SKIP_ETHERGOLD_LISTEN_RESTART=1)"
    return 0
  fi
  # Drop stale listen.py so the new supervisor respawns this tree. Leave uartd.
  ota_kill_python_cmd "pog422_listen.py"
  ota_kill_python_cmd "pog422_fixture_listen.py"
  if ! systemctl cat ethergold-collector.service >/dev/null 2>&1; then
    echo "ota: ethergold-collector.service not installed — skip listen restart"
    return 0
  fi
  if sudo -n systemctl restart ethergold-collector; then
    echo "ota: ethergold-collector restarted (KillMode=process)"
    return 0
  fi
  echo "WARN: sudo -n systemctl restart ethergold-collector failed" >&2
}

# Field UI Python is loaded once. After prod rsync, HTML on disk can be newer
# than the running process (login session vs Basic). Restart HTTP only.
# Never touch ethergold-collector. Never reboot.
ota_restart_ethergold_http() {
  if [[ "${SKIP_ETHERGOLD_HTTP:-0}" == "1" ]]; then
    echo "ota: skip ethergold-http (SKIP_ETHERGOLD_HTTP=1)"
    return 0
  fi
  if ! systemctl cat ethergold-http.service >/dev/null 2>&1; then
    echo "ota: ethergold-http.service not installed — skip"
    return 0
  fi
  if sudo -n systemctl restart ethergold-http; then
    echo "ota: ethergold-http restarted"
    return 0
  fi
  echo "WARN: sudo -n systemctl restart ethergold-http failed — UI may serve stale Python" >&2
  return 0
}

# Collector drop-in: KillMode=process + User=topbox. Restarts collector only
# if the live MainPID is not topbox (needed once on listen-stock leftovers).
ota_apply_uartd_persist_dropin() {
  local script="${1:-${ETHERGOLD_SYNC:-${HOME}/EtherGold/sync}/install_uartd_persist_dropin.sh}"
  if [[ "${SKIP_UARTD_PERSIST_DROPIN:-0}" == "1" ]]; then
    echo "ota: skip uartd persist drop-in (SKIP_UARTD_PERSIST_DROPIN=1)"
    return 0
  fi
  if [[ ! -f "${script}" ]]; then
    echo "ota: install_uartd_persist_dropin.sh missing — skip"
    return 0
  fi
  bash "${script}" || {
    echo "WARN: uartd persist drop-in not applied (need sudo -n)" >&2
    return 0
  }
}

# BCM10/11 idle low on every Pi (Rev E HS OUT; older HAT pads unconnected).
ota_install_hs_out_idle() {
  local script="${1:-${ETHERGOLD_SYNC:-${HOME}/EtherGold/sync}/install_hs_out_idle.sh}"
  if [[ "${SKIP_HS_OUT_IDLE:-0}" == "1" ]]; then
    echo "ota: skip HS OUT idle (SKIP_HS_OUT_IDLE=1)"
    return 0
  fi
  if [[ ! -f "${script}" ]]; then
    echo "ota: install_hs_out_idle.sh missing — skip"
    return 0
  fi
  bash "${script}" || {
    echo "WARN: HS OUT idle not installed (need sudo -n pinctrl)" >&2
    return 0
  }
}

# Hourly stage + 03:00 promote. Listen-stock only installed DyzineFrame timers
# (Lucky Street 10.1.1.39 stayed on EG 4.0.0.4 with no EG schedule). Idempotent.
ota_install_eg_deploy_schedule() {
  local script="${1:-${ETHERGOLD_SYNC:-${HOME}/EtherGold/sync}/install_pi_deploy_schedule.sh}"
  if [[ "${SKIP_EG_DEPLOY_SCHEDULE:-0}" == "1" ]]; then
    echo "ota: skip EG deploy timers (SKIP_EG_DEPLOY_SCHEDULE=1)"
    return 0
  fi
  if [[ ! -f "${script}" ]]; then
    echo "ota: install_pi_deploy_schedule.sh missing — skip"
    return 0
  fi
  bash "${script}" || {
    echo "WARN: EG deploy timers not installed (need systemd --user + linger)" >&2
    return 0
  }
}

ota_json_get() {
  local file="$1"
  local key="$2"
  python3 - "$file" "$key" <<'PY'
import json, sys
path, key = sys.argv[1], sys.argv[2]
try:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
except Exception:
    sys.exit(1)
val = data.get(key, "")
if val is None:
    val = ""
print(val)
PY
}

ota_promote_held() {
  local live_root="$1"
  local staging_root="$2"
  local hold="${live_root}/run/promote-hold"
  [[ -f "${hold}" ]] || return 1
  local from=""
  from="$(grep '^from_version=' "${hold}" 2>/dev/null | head -n 1 | cut -d= -f2- | tr -d '\r')"
  [[ -n "${from}" ]] || return 1
  local staged
  staged="$(ota_read_version "${staging_root}")"
  [[ "${staged}" == "${from}" ]]
}

ota_write_promote_hold() {
  local live_root="$1"
  local from_ver="$2"
  mkdir -p "${live_root}/run"
  {
    echo "from_version=${from_ver}"
    echo "held_at=$(date -Is)"
  } > "${live_root}/run/promote-hold"
}

ota_clear_promote_hold() {
  rm -f "${1}/run/promote-hold"
}

ota_trim() {
  tr -d '\r' < "${1}" 2>/dev/null | tr -d '[:space:]'
}

ota_live_version() {
  local live="$1"
  local lv
  lv="$(ota_read_version "${live}")"
  [[ -z "${lv}" ]] && lv="$(ota_read_version "${live}/prod")"
  printf '%s' "${lv}"
}

# Compare A.B.C.D stamps. Prints -1 / 0 / 1. Empty if either is not dotted-numeric
# (dated USB stamps like 2026-07-08-h stay incomparable — do not auto-park).
ota_version_cmp() {
  python3 -c '
import sys
def parse(s):
    s = (s or "").strip()
    if not s:
        return None
    parts = []
    for tok in s.split("."):
        if not tok.isdigit():
            return None
        parts.append(int(tok))
    if not parts:
        return None
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])
a, b = parse(sys.argv[1]), parse(sys.argv[2])
if a is None or b is None:
    raise SystemExit(0)
print(-1 if a < b else 1 if a > b else 0)
' "$1" "$2" 2>/dev/null || true
}

ota_version_is_older() {
  local c
  c="$(ota_version_cmp "$1" "$2")"
  [[ "${c}" == "-1" ]]
}

ota_park_eg_staging_to_rollback() {
  local staging="$1"
  local rollback="$2"
  local live="$3"
  mkdir -p "${rollback}/prod" "${rollback}/sync"
  if command -v rsync >/dev/null 2>&1; then
    if [[ -d "${staging}/prod" ]]; then
      rsync -a --delete --exclude __pycache__/ --exclude '*.pyc' \
        --exclude logs/ --exclude data/ --exclude rclone.env --exclude '*.env' \
        --exclude progressives.csv --exclude current-game.json \
        --exclude pog422_uartd --exclude pog422_inject \
        "${staging}/prod/" "${rollback}/prod/"
    fi
    if [[ -d "${staging}/sync" ]]; then
      rsync -a --exclude rclone.env --exclude logs/ \
        "${staging}/sync/" "${rollback}/sync/"
    fi
  else
    [[ -d "${staging}/prod" ]] && cp -a "${staging}/prod/." "${rollback}/prod/"
    [[ -d "${staging}/sync" ]] && cp -a "${staging}/sync/." "${rollback}/sync/"
  fi
  if [[ -f "${staging}/VERSION.txt" ]]; then
    cp -a "${staging}/VERSION.txt" "${rollback}/VERSION.txt"
  fi
  {
    echo "parked_at=$(date -Is)"
    echo "parked_version=$(ota_read_version "${staging}")"
    echo "live_version=$(ota_live_version "${live}")"
    echo "reason=staging_older_than_live"
  } > "${rollback}/.rollback-meta"
}

ota_staging_differs_by_stamp() {
  local staging="$1"
  local live="$2"
  local sv lv sm lm
  sv="$(ota_read_version "${staging}")"
  lv="$(ota_read_version "${live}")"
  [[ -z "${lv}" ]] && lv="$(ota_read_version "${live}/prod")"
  sm="$(ota_trim "${staging}/MANIFEST.total" 2>/dev/null || true)"
  lm="$(ota_trim "${live}/MANIFEST.total" 2>/dev/null || true)"
  [[ -z "${lm}" ]] && lm="$(ota_trim "${live}/prod/MANIFEST.total" 2>/dev/null || true)"
  if [[ -n "${sv}" && "${sv}" != "${lv}" ]]; then
    return 0
  fi
  if [[ -n "${sm}" && "${sm}" != "${lm}" ]]; then
    return 0
  fi
  if [[ -n "${sm}" || -n "${sv}" ]]; then
    return 1
  fi
  return 2
}

ota_write_pending_flag() {
  local flag="$1"
  local staging="$2"
  local live="$3"
  mkdir -p "$(dirname "${flag}")"
  {
    echo "staged_at=$(date -Is)"
    echo "staged_version=$(ota_read_version "${staging}")"
    echo "live_version=$(ota_read_version "${live}")"
    echo "staged_manifest=$(ota_trim "${staging}/MANIFEST.total" 2>/dev/null || true)"
    echo "live_manifest=$(ota_trim "${live}/MANIFEST.total" 2>/dev/null || true)"
  } > "${flag}"
}

ota_record_directive() {
  local live_root="$1"
  local id="$2"
  mkdir -p "${live_root}/run"
  {
    echo "id=${id}"
    echo "applied_at=$(date -Is)"
  } > "${live_root}/run/last-rollback-directive"
}

ota_last_directive_id() {
  local live_root="$1"
  grep '^id=' "${live_root}/run/last-rollback-directive" 2>/dev/null | head -n 1 | cut -d= -f2- | tr -d '\r'
}

ota_apply_rollback_directive() {
  local live_root="$1"
  local staging_root="$2"
  local rollback_root="$3"
  local rollback_script="$4"
  local json="${staging_root}/ROLLBACK.json"
  [[ -f "${json}" ]] || return 0
  local id to_ver from_ver last_id live_ver rb_ver
  id="$(ota_json_get "${json}" id 2>/dev/null || true)"
  to_ver="$(ota_json_get "${json}" to_version 2>/dev/null || true)"
  from_ver="$(ota_json_get "${json}" from_version 2>/dev/null || true)"
  if [[ -z "${id}" || -z "${to_ver}" ]]; then
    echo "ROLLBACK.json: missing id or to_version — skip"
    return 0
  fi
  last_id="$(ota_last_directive_id "${live_root}")"
  if [[ "${id}" == "${last_id}" ]]; then
    echo "ROLLBACK.json id=${id} already applied — skip"
    return 0
  fi
  live_ver="$(ota_read_version "${live_root}")"
  [[ -z "${live_ver}" ]] && live_ver="$(ota_read_version "${live_root}/prod")"
  if [[ "${live_ver}" == "${to_ver}" ]]; then
    echo "ROLLBACK.json: live already ${to_ver} — record id, skip"
    ota_record_directive "${live_root}" "${id}"
    return 0
  fi
  rb_ver="$(ota_read_version "${rollback_root}")"
  [[ -z "${rb_ver}" ]] && rb_ver="$(ota_read_version "${rollback_root}/prod")"
  if [[ "${rb_ver}" != "${to_ver}" ]]; then
    echo "ROLLBACK.json skip: rollback snapshot '${rb_ver}' != to_version '${to_ver}'"
    return 0
  fi
  echo "ROLLBACK.json applying id=${id} live ${live_ver} -> ${to_ver}"
  RESTART="${RESTART:-1}" bash "${rollback_script}"
  if [[ -n "${from_ver}" ]]; then
    ota_write_promote_hold "${live_root}" "${from_ver}"
  fi
  ota_record_directive "${live_root}" "${id}"
}
