#!/usr/bin/env bash
# hyn-view -- network-first system monitor for Ubuntu Server
#
# Entry point: resolves its own install location, loads the library, then either
# runs the interactive loop or handles a one-shot subcommand.
#
# Requires bash 4.3+ for namerefs and associative arrays. Ubuntu 22.04 ships
# 5.1 and 24.04 ships 5.2, so this is not a practical limit on the target.

set -uo pipefail

if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
  printf 'hyn: needs bash 4.3 or newer (found %s)\n' "${BASH_VERSION:-unknown}" >&2
  exit 1
fi

# Resolve through symlinks: npm installs the command as a symlink in the global
# bin dir, so $0 is not where the library lives.
_self=${BASH_SOURCE[0]}
while [[ -L $_self ]]; do
  _target=$(readlink "$_self")
  if [[ $_target == /* ]]; then
    _self=$_target
  else
    _self=$(cd -P "${_self%/*}" 2>/dev/null && printf '%s/%s' "$PWD" "$_target")
  fi
done
HYN_ROOT=$(cd -P "${_self%/*}/.." 2>/dev/null && pwd) || {
  printf 'hyn: cannot locate install directory\n' >&2
  exit 1
}
HYN_LIB="$HYN_ROOT/lib"
export HYN_LIB HYN_ROOT

for _m in core ui net collect highway speedtest notify alerts report update panels cloud; do
  # shellcheck source=/dev/null
  source "$HYN_LIB/$_m.sh" || {
    printf 'hyn: failed to load %s\n' "$HYN_LIB/$_m.sh" >&2
    exit 1
  }
done
unset _m _self _target

# ---------------------------------------------------------------------------
# state
# ---------------------------------------------------------------------------
VIEW=dash
RESIZED=0
QUIT=0
STATUS_MSG='' STATUS_UNTIL=0
IFACE_OVERRIDE=''

on_winch() { RESIZED=1; }
on_quit() { QUIT=1; }

boot() {
  cfg_load
  color_detect
  theme_load "${CFG[theme]}"
  ui_init
  collect_init
  panels_enabled
  # Non-blocking: reads a cache, and only spawns a detached fetch when stale.
  update_startup
  return 0
}

# ---------------------------------------------------------------------------
# sampling
# ---------------------------------------------------------------------------
TICK=0
LAST_MS=0

sample_all() {
  local now ms
  now_ms_v; now=$NOW_MS
  ms=$((now - LAST_MS))
  ((LAST_MS == 0)) && ms=0
  LAST_MS=$now
  ((TICK++))

  net_sample "$ms"
  [[ -n $IFACE_OVERRIDE ]] && NET_WAN=$IFACE_OVERRIDE
  net_snmp "$ms"
  net_sockstat
  cpu_sample "$ms"
  mem_sample
  sys_sample
  disk_sample "$ms"
  psi_sample
  thermal_read

  # Staggered work: each of these is either a fork or a large read, so they run
  # on their own cadence rather than every tick.
  local tcp_iv=${CFG[tcp_states_interval]}
  [[ $tcp_iv =~ ^[0-9]+$ ]] || tcp_iv=5
  ((tcp_iv < 1)) && tcp_iv=1
  if cfg_on tcp_states && ((TICK % tcp_iv == 1 || TICK == 1)); then net_tcp_states; fi

  local lat_iv=${CFG[latency_interval]}
  [[ $lat_iv =~ ^[0-9]+$ ]] || lat_iv=10
  ((lat_iv < 2)) && lat_iv=2
  if ((TICK == 1 || TICK % lat_iv == 0)); then net_latency_spawn; fi
  net_latency_read

  if cfg_on public_ip && ((TICK == 1 || TICK % 1800 == 0)); then net_pubip_spawn; fi
  net_pubip_read

  ((TICK % 30 == 1)) && cpu_freq

  if [[ $VIEW == proc || $VIEW == dash ]]; then
    local rows=${CFG[proc_rows]}
    [[ $rows =~ ^[0-9]+$ ]] || rows=8
    [[ $VIEW == proc ]] && rows=$((TERM_ROWS))
    proc_sample "$ms" "$rows" "${CFG[proc_sort]}"
  fi

  cfg_on highway_track && hw_sample "$ms"
  ((TICK % 15 == 1)) && sys_failed_units
  return 0
}

# ---------------------------------------------------------------------------
# keys
# ---------------------------------------------------------------------------
set_status() {
  STATUS_MSG=$1
  STATUS_UNTIL=$((${EPOCHSECONDS:-0} + ${2:-4}))
  return 0
}

cycle_theme() {
  local -a list=()
  local t cur=$THEME_NAME i=0 next
  while read -r t; do list+=("$t"); done < <(theme_list)
  ((${#list[@]} == 0)) && return 0
  for i in "${!list[@]}"; do
    [[ ${list[i]} == "$cur" ]] && break
  done
  next=${list[(i + 1) % ${#list[@]}]}
  CFG[theme]=$next
  theme_load "$next"
  _GRAD=()
  fb_invalidate
  set_status "theme: $next"
  return 0
}

# Flips between the two visual presets live, so the difference is visible rather
# than described in a config comment.
cycle_profile() {
  if [[ ${CFG[profile]} == performance ]]; then CFG[profile]=best; else CFG[profile]=performance; fi
  # A live toggle is an explicit choice, so clear the remembered explicit keys
  # for the ones the profile owns; otherwise the preset could not move them.
  local k
  for k in graph graph_gradient graph_axis graph_stats interval proc_rows net_history_detail; do
    unset "CFG_EXPLICIT[$k]"
  done
  profile_apply
  _BR_SIZE=''
  fb_invalidate
  set_status "profile: ${CFG[profile]} (graph ${CFG[graph]}, ${CFG[interval]}s)"
  return 0
}

cycle_iface() {
  local i=0 n=${#NET_IFACES[@]}
  ((n == 0)) && return 0
  for i in "${!NET_IFACES[@]}"; do
    [[ ${NET_IFACES[i]} == "$NET_WAN" ]] && break
  done
  IFACE_OVERRIDE=${NET_IFACES[(i + 1) % n]}
  NET_WAN=$IFACE_OVERRIDE
  set_status "interface: $NET_WAN"
  return 0
}

adjust_interval() {
  local dir=$1 cur=${CFG[interval]}
  parse_fixed3_v "$cur"
  local ms=$FIX3
  if ((dir > 0)); then ms=$((ms + 250)); else ms=$((ms - 250)); fi
  ((ms < 250)) && ms=250
  ((ms > 10000)) && ms=10000
  fmt_fixed_v "$ms" 1000 2
  CFG[interval]=$FMT_OUT
  set_status "refresh: ${CFG[interval]}s"
  return 0
}

handle_key() {
  local k=$1
  case $k in
    q | Q) QUIT=1 ;;
    $'\003') QUIT=1 ;;
    1) VIEW=dash; fb_invalidate ;;
    2) VIEW=net; fb_invalidate ;;
    3) VIEW=proc; fb_invalidate ;;
    4) VIEW=node; fb_invalidate ;;
    h | H | '?') VIEW=help; fb_invalidate ;;
    t | T) cycle_theme ;;
    p | P) cycle_profile ;;
    u | U)
      if [[ ${CFG[net_unit]} == bits ]]; then CFG[net_unit]=bytes; else CFG[net_unit]=bits; fi
      set_status "units: ${CFG[net_unit]}" ;;
    m | M)
      if [[ ${CFG[proc_sort]} == cpu ]]; then CFG[proc_sort]=mem; else CFG[proc_sort]=cpu; fi
      set_status "sort: ${CFG[proc_sort]}" ;;
    i | I) cycle_iface ;;
    r | R) fb_invalidate; set_status 'redrawn' ;;
    s | S)
      # Detached so a 20 second measurement cannot freeze the UI. The result
      # lands in the history file and the panel picks it up on its own.
      { st_run 1 >/dev/null 2>&1; } &
      set_status 'speed test running in background…' 30 ;;
    +) adjust_interval 1 ;;
    -) adjust_interval -1 ;;
    $'\033')
      # Drain the rest of an escape sequence so arrow keys are not read as
      # individual command letters.
      read -rsn2 -t 0.02 _ 2>/dev/null || true ;;
  esac
  return 0
}

# ---------------------------------------------------------------------------
# help view
# ---------------------------------------------------------------------------
render_help() {
  local w=$TERM_COLS h=$TERM_ROWS
  local -a P=()
  header_line "$w"
  fb_reset
  fb_add "$HDR_OUT"
  panel_open P "$w" 'KEYS' 'h to return'
  panel_row P "$w" "$(kv '1 2 3 4' 'dashboard / network / processes / node' 14)"
  panel_row P "$w" "$(kv 't' "cycle theme (now: $THEME_NAME)" 14)"
  panel_row P "$w" "$(kv 'p' "visual profile (now: ${CFG[profile]}, graph ${CFG[graph]})" 14)"
  panel_row P "$w" "$(kv 'u' "toggle bits / bytes (now: ${CFG[net_unit]})" 14)"
  panel_row P "$w" "$(kv 'm' "sort processes by cpu / mem (now: ${CFG[proc_sort]})" 14)"
  panel_row P "$w" "$(kv 'i' 'cycle monitored interface' 14)"
  panel_row P "$w" "$(kv 's' 'run a speed test now (background)' 14)"
  panel_row P "$w" "$(kv '+ -' "refresh interval (now: ${CFG[interval]}s)" 14)"
  panel_row P "$w" "$(kv 'r' 'force full redraw' 14)"
  panel_row P "$w" "$(kv 'q' 'quit' 14)"
  panel_close P "$w"
  fb_addmany "${P[@]}"

  local -a P2=()
  panel_open P2 "$w" 'PATHS' ''
  panel_row P2 "$w" "$(kv 'config' "$HYN_ETC/config" 12)"
  panel_row P2 "$w" "$(kv 'themes' "$HYN_ETC/themes, $HYN_ROOT/themes" 12)"
  panel_row P2 "$w" "$(kv 'state' "$(state_dir)" 12)"
  panel_row P2 "$w" "$(kv 'colors' "depth $COLOR_DEPTH, theme $THEME_NAME" 12)"
  panel_close P2 "$w"
  fb_addmany "${P2[@]}"

  local -a P3=()
  panel_open P3 "$w" 'ABOUT' "v$HYN_VERSION"
  panel_row P3 "$w" "${C[bold]}$HYN_AUTHOR${C[reset]}   ${C[dim]}$HYN_AUTHOR_URL${C[reset]}"
  panel_row P3 "$w" "${C[dim]}$HYN_COPYRIGHT · MIT licence · github.com/AryanVBW/HYN-view${C[reset]}"
  if update_read; then
    if ((UPD_AVAILABLE)); then
      panel_row P3 "$w" "${C[warn]}$G_UP v$UPD_LATEST is available${C[reset]} ${C[dim]}— sudo hyn update --yes${C[reset]}"
    else
      panel_row P3 "$w" "${C[ok]}$G_DOT up to date${C[reset]} ${C[dim]}(npm latest $UPD_LATEST)${C[reset]}"
    fi
  fi
  panel_close P3 "$w"
  fb_addmany "${P3[@]}"

  while ((${#FB[@]} < h - 1)); do fb_add ''; done
  footer_line "$w"
  FB[h - 1]=$FTR_OUT
  return 0
}

# ---------------------------------------------------------------------------
# main loop
# ---------------------------------------------------------------------------
# Offer setup before the alternate screen is entered, so the prompts are plain
# scrollable text and Ctrl-C leaves a normal terminal behind.
maybe_onboard() {
  cfg_on onboarding || return 0
  [[ -t 0 && -t 1 ]] || return 0
  is_first_run || return 0
  source "$HYN_LIB/wizard.sh" 2>/dev/null || return 0
  declare -F onboard_prompt >/dev/null || return 0
  onboard_prompt
  # Re-read whatever it wrote, so the first frame reflects the new answers.
  cfg_load
  color_detect
  theme_load "${CFG[theme]}"
  _GRAD=()
  ui_init
  panels_enabled
  return 0
}

run_tui() {
  [[ -t 1 ]] || die "not a terminal -- use 'hyn snapshot' for scripts and cron"
  term_setup
  trap 'term_restore' EXIT
  trap 'on_quit' INT TERM HUP
  trap 'on_winch' WINCH

  local iv key
  # Prime the counters so the first visible frame shows real rates rather than
  # since-boot averages.
  sample_all
  iv=${CFG[interval]}

  while ((QUIT == 0)); do
    if ((RESIZED)); then
      RESIZED=0
      term_size
      fb_invalidate
    fi
    sample_all
    case $VIEW in
      net) render_net_full ;;
      proc) render_proc_full ;;
      node) render_node_full ;;
      help) render_help ;;
      *) render_dash ;;
    esac
    if [[ -n $STATUS_MSG ]]; then
      if ((${EPOCHSECONDS:-0} < STATUS_UNTIL)); then
        fit_v " ${C[accent]}${C[bold]}$STATUS_MSG${C[reset]}" "$TERM_COLS"
        FB[TERM_ROWS - 1]=$FIT_OUT
      else
        STATUS_MSG=''
      fi
    fi
    fb_flush

    iv=${CFG[interval]}
    [[ $iv =~ ^[0-9]+([.][0-9]+)?$ ]] || iv=1
    # read doubles as the sleep: one syscall for both waiting and input, so
    # keys feel instant without a busy poll.
    if read -rsn1 -t "$iv" key 2>/dev/null; then
      handle_key "$key"
    fi
  done
  term_restore
  return 0
}

# ---------------------------------------------------------------------------
# one-shot subcommands
# ---------------------------------------------------------------------------
cmd_snapshot() {
  local json=0 a
  for a in "$@"; do [[ $a == --json ]] && json=1; done
  # Two samples a second apart: rates are deltas and a single read cannot
  # produce them. Reporting since-boot averages as "current" would be a lie.
  sample_all
  sleep 1
  sample_all
  sys_whoami
  sys_sessions 1
  local iface=${NET_WAN:-none}

  if ((json)); then
    net_retrans_permille
    st_history_read 1
    printf '{\n'
    printf '  "host": "%s",\n' "$HOSTNAME_S"
    printf '  "running_as": "%s",\n' "$RUN_AS"
    printf '  "sessions": ['
    local si sfirst=1
    for ((si = 0; si < ${#SESS_USER[@]}; si++)); do
      ((sfirst)) || printf ', '
      sfirst=0
      printf '{"user": "%s", "tty": "%s", "from": "%s"}' \
        "${SESS_USER[si]}" "${SESS_TTY[si]}" "${SESS_FROM[si]}"
    done
    printf '],\n'
    printf '  "kernel": "%s",\n' "$KERNEL"
    printf '  "uptime_s": %s,\n' "$UPTIME_S"
    printf '  "load": [%s, %s, %s],\n' "${LOAD1:-0}" "${LOAD5:-0}" "${LOAD15:-0}"
    printf '  "cpu": {"pct": %s, "user": %s, "sys": %s, "iowait": %s, "steal": %s, "cores": %s},\n' \
      "$CPU_PCT" "$CPU_USER" "$CPU_SYS" "$CPU_IOWAIT" "$CPU_STEAL" "$CPU_COUNT"
    printf '  "memory": {"total": %s, "used": %s, "pct": %s, "swap_used": %s},\n' \
      "$MEM_TOTAL" "$MEM_USED" "$MEM_PCT" "$SWAP_USED"
    printf '  "network": {"iface": "%s", "rx_bps": %s, "tx_bps": %s, "rx_total": %s, "tx_total": %s,\n' \
      "$iface" "${NET_RXR[$iface]:-0}" "${NET_TXR[$iface]:-0}" "${NET_RX[$iface]:-0}" "${NET_TX[$iface]:-0}"
    printf '    "rx_err": %s, "tx_err": %s, "rx_drop": %s, "tx_drop": %s,\n' \
      "${NET_RERR[$iface]:-0}" "${NET_TERR[$iface]:-0}" "${NET_RDROP[$iface]:-0}" "${NET_TDROP[$iface]:-0}"
    printf '    "retrans_per_s": %s, "retrans_permille": %s, "listen_drops": %s,\n' \
      "${SNMPR[Tcp.RetransSegs]:-0}" "$NET_RETRANS_PM" "${SNMPR[TcpExt.ListenDrops.raw]:-0}"
    printf '    "tcp_estab": %s, "tcp_timewait": %s, "conntrack_pct": %s},\n' \
      "${TCPST[ESTAB]:-0}" "${TCPST[TIME_WAIT]:-0}" "${CT_PCT:-0}"
    printf '  "latency_us": {'
    local first=1 k
    for k in "${!LAT_MS[@]}"; do
      ((first)) || printf ', '
      first=0
      printf '"%s": %s' "$k" "${LAT_MS[$k]}"
    done
    printf '},\n'
    printf '  "speedtest": {"ts": %s, "down_bps": %s, "up_bps": %s, "latency_us": %s, "note": "%s"},\n' \
      "$ST_LAST_TS" "$ST_LAST_DOWN" "$ST_LAST_UP" "$ST_LAST_LAT" "$ST_LAST_NOTE"
    printf '  "highway": {"present": %s, "health": "%s", "why": "%s", "version": "%s", "latest": "%s",\n' \
      "$HW_PRESENT" "$HW_HEALTH" "$HW_HEALTH_WHY" "$HW_VERSION" "$HW_LATEST"
    printf '    "units_active": %s, "units_failed": %s, "pid": %s, "rss": %s, "cpu_tenths": %s,\n' \
      "$HW_ACTIVE" "$HW_FAILED" "$HW_PID" "$HW_RSS" "$HW_CPU"
    printf '    "tunnel": "%s", "journal_err_1h": %s, "update_available": %s}\n' \
      "$HW_NEBULA" "$HW_JOURNAL_ERR" "$HW_UPDATE"
    printf '}\n'
    return 0
  fi

  printf '%s  %s  %s\n' "$HOSTNAME_S" "$DISTRO" "$KERNEL"
  printf 'user     running as %s (uid %s)\n' "$RUN_AS" "$RUN_UID"
  if ((${#SESS_USER[@]} == 0)); then
    printf 'sessions nobody logged in\n'
  else
    local si
    for ((si = 0; si < ${#SESS_USER[@]}; si++)); do
      printf 'session  %-14s %-10s from %s\n' "${SESS_USER[si]}" "${SESS_TTY[si]}" "${SESS_FROM[si]}"
    done
  fi
  printf 'uptime   %s   load %s %s %s\n' "$(fmt_dur "$UPTIME_S")" "${LOAD1:-?}" "${LOAD5:-?}" "${LOAD15:-?}"
  printf 'cpu      %s%%  usr %s sys %s io %s steal %s  (%s cores)\n' \
    "$CPU_PCT" "$CPU_USER" "$CPU_SYS" "$CPU_IOWAIT" "$CPU_STEAL" "$CPU_COUNT"
  printf 'memory   %s / %s (%s%%)   swap %s\n' \
    "$(fmt_size "$MEM_USED")" "$(fmt_size "$MEM_TOTAL")" "$MEM_PCT" "$(fmt_size "$SWAP_USED")"
  printf 'net %-6s down %s  up %s   total %s / %s\n' "$iface" \
    "$(fmt_rate "${NET_RXR[$iface]:-0}")" "$(fmt_rate "${NET_TXR[$iface]:-0}")" \
    "$(fmt_size "${NET_RX[$iface]:-0}")" "$(fmt_size "${NET_TX[$iface]:-0}")"
  net_retrans_permille
  printf 'net err  rx %s tx %s   drop rx %s tx %s   retrans %s/s\n' \
    "${NET_RERR[$iface]:-0}" "${NET_TERR[$iface]:-0}" \
    "${NET_RDROP[$iface]:-0}" "${NET_TDROP[$iface]:-0}" "${SNMPR[Tcp.RetransSegs]:-0}"
  local k
  for k in "${!LAT_MS[@]}"; do
    printf 'ping %-8s %s ms  loss %s%%\n' "$k" "$(fmt_fixed "${LAT_MS[$k]}" 1000 2)" "${LAT_LOSS[$k]}"
  done
  st_history_read 1
  if ((ST_LAST_DOWN > 0)); then
    printf 'speedtest down %s  up %s  %s ago\n' \
      "$(fmt_rate "$ST_LAST_DOWN")" "$(fmt_rate "$ST_LAST_UP")" \
      "$(fmt_dur $((${EPOCHSECONDS:-0} - ST_LAST_TS)))"
  fi
  if ((HW_PRESENT)); then
    printf 'highway  %s (%s)  version %s  latest %s\n' \
      "$HW_HEALTH" "$HW_HEALTH_WHY" "${HW_VERSION:-unknown}" "${HW_LATEST:-unknown}"
    ((HW_PID > 0)) && printf 'highway  pid %s  rss %s  cpu %s%%  tunnel %s\n' \
      "$HW_PID" "$(fmt_size "$HW_RSS")" "$(fmt_fixed "$HW_CPU" 10 1)" "${HW_NEBULA:-none}"
  fi
  if ((${#FAILED_UNITS[@]} > 0)); then
    printf 'failed   %s\n' "${FAILED_UNITS[*]}"
  fi
  return 0
}

cmd_speedtest() {
  local json=0 force=1 a
  for a in "$@"; do
    case $a in
      --json) json=1 ;;
      --respect-guard) force=0 ;;
    esac
  done
  ((json == 0)) && printf 'hyn: measuring (provider: %s)…\n' "$(st_provider)" >&2
  st_run "$force"
  local rc=$?
  st_print "$json"
  return $rc
}

cmd_history() {
  local json=0 n=20 a
  for a in "$@"; do
    case $a in
      --json) json=1 ;;
      [0-9]*) n=$a ;;
    esac
  done
  st_history_read 1 || { printf 'hyn: no speed test history yet\n' >&2; return 1; }
  local total=${#ST_H_TS[@]} start=$((${#ST_H_TS[@]} - n)) i
  ((start < 0)) && start=0
  if ((json)); then
    printf '['
    local first=1
    for ((i = start; i < total; i++)); do
      ((first)) || printf ','
      first=0
      printf '\n  {"ts": %s, "down_bps": %s, "up_bps": %s, "latency_us": %s, "note": "%s"}' \
        "${ST_H_TS[i]}" "${ST_H_DOWN[i]}" "${ST_H_UP[i]}" "${ST_H_LAT[i]}" "${ST_H_NOTE[i]}"
    done
    printf '\n]\n'
    return 0
  fi
  printf '%-20s %12s %12s %10s  %s\n' 'WHEN' 'DOWN' 'UP' 'PING' 'NOTE'
  for ((i = start; i < total; i++)); do
    printf -v when '%(%Y-%m-%d %H:%M)T' "${ST_H_TS[i]}"
    printf '%-20s %12s %12s %10s  %s\n' "$when" \
      "$(fmt_rate "${ST_H_DOWN[i]}")" "$(fmt_rate "${ST_H_UP[i]}")" \
      "$(fmt_fixed "${ST_H_LAT[i]}" 1000 1)ms" "${ST_H_NOTE[i]}"
  done
  return 0
}

cmd_theme() {
  local sub=${1:-list}
  case $sub in
    list)
      local t
      while read -r t; do
        if [[ $t == "${CFG[theme]}" ]]; then printf '* %s\n' "$t"; else printf '  %s\n' "$t"; fi
      done < <(theme_list)
      ;;
    current | show) printf '%s\n' "${CFG[theme]}" ;;
    set)
      local name=${2:-}
      [[ -n $name ]] || die 'usage: hyn theme set <name>'
      theme_path "$name" >/dev/null || die "unknown theme: $name (try: hyn theme list)"
      config_set theme "$name"
      printf 'hyn: theme set to %s\n' "$name"
      ;;
    preview)
      local name=${2:-${CFG[theme]}} i
      theme_load "$name" || die "unknown theme: $name"
      printf '%stheme %s%s\n' "${C[accent]}${C[bold]}" "$name" "${C[reset]}"
      for i in 0 10 25 40 55 70 85 100; do
        printf '  %3d%% %s\n' "$i" "$(bar "$i" 40)"
      done
      printf '  %sok%s  %swarn%s  %scrit%s  %srx%s  %stx%s  %sdim%s\n' \
        "${C[ok]}" "${C[reset]}" "${C[warn]}" "${C[reset]}" "${C[crit]}" "${C[reset]}" \
        "${C[rx]}" "${C[reset]}" "${C[tx]}" "${C[reset]}" "${C[dim]}" "${C[reset]}"
      ;;
    *) die "usage: hyn theme [list|set <name>|current|preview <name>]" ;;
  esac
  return 0
}

cmd_alerts() {
  local sub=${1:-check}
  [[ -n ${1:-} ]] && shift || true
  case $sub in
    check | run)
      local quiet=0 a
      for a in "$@"; do [[ $a == --quiet ]] && quiet=1; done
      cfg_on alert_enabled || { ((quiet)) || printf 'hyn: alerting is disabled (alert_enabled=off)\n'; return 0; }
      alerts_run "$quiet"
      alerts_log_new
      ;;
    list | rules)
      # Evaluate everything and show each rule's current value against its
      # threshold, so an operator can see why something is or is not firing.
      alerts_state_load
      alerts_collect
      alerts_evaluate
      printf '%-22s %-5s %s\n' 'RULE' 'STATE' 'DETAIL'
      local i
      for ((i = 0; i < ${#AL_ID[@]}; i++)); do
        printf '%-22s %s%-5s%s %s\n' "${AL_ID[i]}" \
          "$( [[ ${AL_SEV[i]} == crit ]] && printf '%s' "${C[crit]}" || printf '%s' "${C[warn]}")" \
          "${AL_SEV[i]}" "${C[reset]}" "${AL_MSG[i]}"
      done
      local id
      for id in "${!_AL_SEEN[@]}"; do
        local firing=0 j
        for ((j = 0; j < ${#AL_ID[@]}; j++)); do [[ ${AL_ID[j]} == "$id" ]] && firing=1; done
        ((firing)) && continue
        printf '%-22s %s%-5s%s\n' "$id" "${C[ok]}" 'ok' "${C[reset]}"
      done
      printf '\n%d rules evaluated, %d firing (%d crit, %d warn, %d info)\n' \
        "${#_AL_SEEN[@]}" "$AL_FIRING" "$AL_CRIT" "$AL_WARN" "$AL_INFO"
      ;;
    test)
      # Force a notification regardless of state, to prove the path end to end.
      alerts_state_load
      alerts_collect
      alerts_evaluate
      AL_ID+=(selftest) AL_SEV+=(warn) AL_NEW+=(1) AL_VAL+=(1)
      AL_MSG+=('Test alert from `hyn alerts test` — nothing is actually wrong')
      ((AL_FIRING++)); ((AL_WARN++))
      if alerts_notify 1; then printf 'hyn: test alert delivered\n'
      else printf 'hyn: could not deliver: %s\n' "${NOTIFY_LAST_ERR:-unknown}" >&2; return 1; fi
      ;;
    state)
      local f
      f=$(alert_state_file)
      if [[ -r $f ]]; then
        printf '%-22s %-9s %-20s %s\n' 'RULE' 'STATE' 'SINCE' 'LAST NOTIFIED'
        local id st since notified val
        while IFS=$'\t' read -r id st since notified val; do
          local s1='-' s2='-'
          [[ $since =~ ^[0-9]+$ ]] && ((since > 0)) && printf -v s1 '%(%Y-%m-%d %H:%M)T' "$since"
          [[ $notified =~ ^[0-9]+$ ]] && ((notified > 0)) && printf -v s2 '%(%Y-%m-%d %H:%M)T' "$notified"
          printf '%-22s %-9s %-20s %s\n' "$id" "$st" "$s1" "$s2"
        done <"$f"
      else
        printf 'hyn: no alert state yet (run: hyn alerts check)\n'
      fi
      ;;
    clear)
      is_root || warn 'not root: clearing only your user state'
      rm -f "$(alert_state_file)"
      printf 'hyn: alert state cleared; the next check starts from scratch\n'
      ;;
    log)
      local n=${1:-25}
      [[ $n =~ ^[0-9]+$ ]] || n=25
      report_alerts 720
      local total=${#RA_TS[@]} start=$((${#RA_TS[@]} - n)) i
      ((start < 0)) && start=0
      ((total == 0)) && { printf 'hyn: no alerts recorded yet\n'; return 0; }
      for ((i = start; i < total; i++)); do
        printf '%(%Y-%m-%d %H:%M)T  %-5s %s\n' "${RA_TS[i]}" "${RA_SEV[i]}" "${RA_MSG[i]}"
      done
      ;;
    *) die 'usage: hyn alerts [check|list|test|state|log [N]|clear]' ;;
  esac
  return 0
}

cmd_report() {
  report_run "$@"
}

cmd_notify() {
  local sub=${1:-status}
  [[ -n ${1:-} ]] && shift || true
  case $sub in
    test)
      # Queues one event with the portal exactly as an alert would. The portal
      # decides who receives it; this machine cannot and does not know.
      notify_configured || die 'this machine is not paired with the portal. Run: sudo hyn link'
      local body
      body="Test message from hyn-view on $HOSTNAME_S.

  distro    ${DISTRO:-unknown}
  kernel    ${KERNEL:-unknown}
  node      ${CFG[cloud_node_id]:-unknown}

Nothing is wrong; you asked for a test."
      if NOTIFY_CATEGORY=test notify_send info "[hyn] Test from $HOSTNAME_S" "$body"; then
        printf 'hyn: queued with the portal. It resolves the recipient and sends.\n'
        printf '     delivery, including any failure, is recorded on the Account page.\n'
      else
        printf 'hyn: could not queue it: %s\n' "${NOTIFY_LAST_ERR:-unknown error}" >&2
        return 1
      fi
      ;;
    status)
      secrets_load
      # There is deliberately almost nothing to show. Everything that used to be
      # listed here -- provider, API key, recipient, sender -- is now the portal's,
      # and printing a local copy of it would be printing a guess.
      if notify_configured; then
        printf 'delivery        web portal (%s)\n' "$(cloud_url)"
        printf 'node            %s\n' "${CFG[cloud_node_id]:-unknown}"
        printf 'recipient       set on the portal Account page for this machine\n'
        printf 'provider        the portal deployment'"'"'s account; no key on this server\n'
      else
        printf 'delivery        none -- this machine is not paired\n'
        printf '                pair it with: sudo hyn link\n'
      fi
      printf 'min severity    %s\n' "${CFG[alert_min_severity]}"
      printf 'check interval  every %s min\n' "${CFG[alert_interval_min]}"
      printf 'repeat          every %s h while still firing\n' "${CFG[alert_repeat_hours]}"
      printf 'daily report    %s\n' "$(cfg_on report_enabled && printf 'at %s' "${CFG[report_at]}" || printf 'disabled')"
      budget_check || true
      printf 'queued today    %s of max %s\n' "$NOTIFY_SENT_TODAY" "${CFG[notify_max_per_day]}"
      # The only credential this machine holds, reported as present or absent and
      # never by value.
      if has_secret cloud_node_token; then
        printf 'node token      present in %s (0600)\n' "$(secrets_path)"
      else
        printf 'node token      absent -- nothing can be sent\n'
      fi
      ;;
    *) die 'usage: hyn notify [status|test]' ;;
  esac
  return 0
}

cmd_config() {
  local sub=${1:-show}
  case $sub in
    path) config_file_rw; printf '\n' ;;
    show)
      local k
      for k in $(printf '%s\n' "${!CFG[@]}" | sort); do
        printf '%-26s %s\n' "$k" "${CFG[$k]}"
      done
      ;;
    get) [[ -n ${2:-} ]] || die 'usage: hyn config get <key>'; printf '%s\n' "${CFG[$2]:-}" ;;
    pull) cloud_config_pull 0 ;;
    set)
      [[ -n ${2:-} && $# -ge 3 ]] || die 'usage: hyn config set <key> <value>'
      config_set "$2" "$3"
      printf 'hyn: %s = %s (in %s)\n' "$2" "$3" "$(config_file_rw)"
      ;;
    edit)
      local f
      f=$(config_file_rw)
      mkdir -p "${f%/*}" 2>/dev/null
      [[ -f $f ]] || printf '# hyn-view config -- see `hyn config show` for keys\n' >"$f"
      "${EDITOR:-vi}" "$f"
      ;;
    *) die 'usage: hyn config [show|get <k>|set <k> <v>|pull|path|edit]' ;;
  esac
  return 0
}

cmd_doctor() {
  local ok=0 warn=0 fix=0 a
  for a in "$@"; do
    case $a in
      --fix | --repair) fix=1 ;;
      *) die "doctor: unknown option $a" ;;
    esac
  done
  _chk() {
    local state=$1 label=$2 detail=${3:-}
    local mark
    case $state in
      ok) mark="${C[ok]}ok  ${C[reset]}"; ((ok++)) ;;
      warn) mark="${C[warn]}warn${C[reset]}"; ((warn++)) ;;
      *) mark="${C[crit]}fail${C[reset]}"; ((warn++)) ;;
    esac
    printf '  [%s] %-28s %s\n' "$mark" "$label" "$detail"
  }
  printf '%shyn-view %s -- environment check%s\n\n' "${C[bold]}" "$HYN_VERSION" "${C[reset]}"

  _chk ok 'bash' "$BASH_VERSION"
  if [[ -r $HYN_PROC/stat ]]; then _chk ok 'procfs' "$HYN_PROC"; else _chk fail 'procfs' "$HYN_PROC missing -- Linux only"; fi
  if [[ -r $HYN_PROC/net/dev ]]; then _chk ok 'net counters' '/proc/net/dev'; else _chk fail 'net counters' 'unavailable'; fi
  if [[ -r $HYN_PROC/net/snmp ]]; then _chk ok 'tcp counters' '/proc/net/snmp'; else _chk warn 'tcp counters' 'no retransmit stats'; fi
  if [[ -d $HYN_PROC/pressure ]]; then _chk ok 'psi' '/proc/pressure'; else _chk warn 'psi' 'kernel without CONFIG_PSI'; fi
  if net_conntrack; then _chk ok 'conntrack' "$CT_COUNT/$CT_MAX"; else _chk warn 'conntrack' 'module not loaded'; fi
  case $COLOR_DEPTH in
    24) _chk ok 'colour' 'truecolor' ;;
    256) _chk ok 'colour' '256' ;;
    16) _chk warn 'colour' '16 -- gradients will band' ;;
    *) _chk warn 'colour' 'disabled' ;;
  esac
  if cfg_on ascii; then _chk warn 'glyphs' 'ASCII fallback (no UTF-8 locale)'; else _chk ok 'glyphs' "unicode (${LC_ALL:-${LANG:-default}})"; fi
  # Explicit if/else rather than `have x && _chk ok || _chk warn`: in that form a
  # non-zero return from the success branch silently runs the failure branch too.
  if have curl; then _chk ok 'curl' 'present'; else _chk warn 'curl' 'missing -- no speed test or update check'; fi
  if have ping; then _chk ok 'ping' 'present'; else _chk warn 'ping' 'missing -- latency falls back to TCP connect'; fi
  if have systemctl; then _chk ok 'systemd' 'present'; else _chk warn 'systemd' 'missing -- no unit tracking'; fi
  local sd
  sd=$(state_dir)
  if mkdir -p "$sd" 2>/dev/null && [[ -w $sd ]]; then _chk ok 'state dir' "$sd"; else _chk fail 'state dir' "$sd not writable"; fi

  # How this machine receives future fixes. On a box nobody logs into, this is the
  # only route in, so a policy that never installs is worth saying out loud rather
  # than leaving in a config file to be discovered later.
  case ${CFG[auto_update]} in
    install) _chk ok 'update policy' 'install — fixes arrive by themselves' ;;
    check)
      _chk warn 'update policy' 'check — this machine will NOT install fixes by itself'
      printf '       %swithout shell access that is permanent. Set it on the portal Account\n' "${C[dim]}"
      printf '       page, or: sudo hyn config set auto_update install%s\n' "${C[reset]}" ;;
    off)
      _chk warn 'update policy' 'off — no update will ever be applied or even looked for'
      printf '       %ssudo hyn config set auto_update install%s\n' "${C[dim]}" "${C[reset]}" ;;
    *) _chk fail 'update policy' "unrecognised value: ${CFG[auto_update]}" ;;
  esac
  update_read
  if ((UPD_CHECKED > 0)); then
    _chk ok 'registry check' "last looked $(fmt_dur $((${EPOCHSECONDS:-0} - UPD_CHECKED))) ago${UPD_LATEST:+, latest $UPD_LATEST}"
  else
    _chk warn 'registry check' 'never — the record timer performs it, so this should fill in within minutes'
  fi

  printf '\n%sDelivery%s\n' "${C[bold]}" "${C[reset]}"
  secrets_load
  # One path, so one check. Everything this section used to verify -- six provider
  # credentials, a sender domain, an SMTP port, a recipient list -- is now the
  # portal's, and a local copy of it could only ever be a stale guess.
  if notify_configured; then
    _chk ok 'delivery' "web portal ($(cloud_url))"
    _chk ok 'recipient' 'resolved by the portal from this node'"'"'s owner'
    if has_secret cloud_node_token; then
      _chk ok 'node token' "present in $(secrets_path)"
    else
      _chk fail 'node token' 'missing; pair again with: sudo hyn link'
    fi
    if have curl; then _chk ok 'transport' 'curl present'; else _chk fail 'transport' 'curl is required to reach the portal'; fi
    case $(cloud_url) in
      https://*) _chk ok 'endpoint' 'https' ;;
      http://127.0.0.1* | http://localhost*) _chk warn 'endpoint' 'loopback (test endpoint)' ;;
      *) _chk fail 'endpoint' 'not https -- the agent refuses to send a token in clear text' ;;
    esac
    budget_check || true
    if ((NOTIFY_SENT_TODAY < ${CFG[notify_max_per_day]})); then
      _chk ok 'daily budget' "$NOTIFY_SENT_TODAY of ${CFG[notify_max_per_day]} queued"
    else
      _chk warn 'daily budget' "$NOTIFY_SENT_TODAY of ${CFG[notify_max_per_day]} -- further messages are suppressed today"
    fi
  else
    # Not a fault. An unpaired machine is a working local monitor that cannot mail
    # anyone, and saying so is different from saying something is broken.
    _chk ok 'delivery' 'none yet -- pair this machine to enable email: sudo hyn link'
  fi
  local sf="$HYN_ETC/secrets"
  if [[ -f $sf ]]; then
    local perm=''
    have stat && perm=$(stat -c '%a' "$sf" 2>/dev/null)
    if [[ $perm == 600 ]]; then _chk ok 'secrets perms' "$sf is 0600"
    else _chk fail 'secrets perms' "$sf is ${perm:-?}, should be 600: sudo chmod 600 $sf"; fi
  else
    _chk warn 'secrets file' "$sf does not exist yet"
  fi
  # Nothing running on this box can report that this box is off, so the check that
  # matters is the one made from outside it. That is the portal's watchdog now, not
  # a ping URL configured per machine.
  if notify_configured; then
    _chk ok 'outage detection' 'the portal alerts the owner after three missed heartbeats'
  else
    _chk warn 'outage detection' 'nothing will report this host going offline until it is paired: sudo hyn link'
  fi

  printf '\n%sTimers%s\n' "${C[bold]}" "${C[reset]}"
  if have systemctl; then
    # An intentionally-off timer is not a warning. Reporting one as "warn" is how
    # a correctly installed machine ends up looking broken, which is worse than
    # saying nothing: it teaches the operator that this output does not mean
    # anything. So each stopped timer is asked why, and only an unexplained one
    # counts against the tally.
    source "$HYN_LIB/setup.sh" 2>/dev/null || true
    local u st nxt why
    for u in hyn-speedtest.timer hyn-record.timer hyn-alerts.timer hyn-report.timer hyn-push.timer; do
      if ! systemctl cat "$u" >/dev/null 2>&1; then
        _chk warn "$u" 'not installed -- run: sudo hyn doctor --fix'
        continue
      fi
      st=$(systemctl is-active "$u" 2>/dev/null)
      if [[ $st == active ]]; then
        nxt=$(systemctl show -p NextElapseUSecRealtime --value "$u" 2>/dev/null)
        _chk ok "$u" "active${nxt:+, next $nxt}"
        continue
      fi
      why=''
      declare -F setup_timer_reason >/dev/null && why=$(setup_timer_reason "$u")
      if [[ -n $why ]]; then
        _chk ok "$u" "off by design -- $why"
      else
        _chk warn "$u" "$st -- expected to be running: sudo hyn doctor --fix"
      fi
    done
    # Not a timer and never enabled: it is started by name when the portal asks
    # for an update. Its absence is why an update request would be refused, so it
    # is worth naming here rather than leaving it to be discovered.
    if systemctl cat hyn-update.service >/dev/null 2>&1; then
      _chk ok 'hyn-update.service' 'installed (on demand, not scheduled)'
    else
      _chk warn 'hyn-update.service' 'missing -- portal updates cannot install: sudo hyn doctor --fix'
    fi
  else
    _chk warn 'systemd' 'not present, nothing is scheduled'
  fi

  printf '\n%sHighway node%s\n' "${C[bold]}" "${C[reset]}"
  hw_binary 1
  if ((HW_PRESENT)); then
    _chk ok 'binary' "$HW_BIN ($(fmt_size "$HW_SIZE"))"
    hw_units 1
    if ((HW_UNIT_COUNT > 0)); then
      _chk ok 'units' "${HW_UNITS[*]}"
    else
      _chk warn 'units' "no units match '${CFG[highway_units]}'"
    fi
    if hw_version; then
      _chk ok 'version' "$HW_VERSION (via $HW_VERSION_SRC)"
    else
      _chk warn 'version' 'not readable without executing the binary (see highway_version_probe)'
    fi
  else
    _chk warn 'binary' "not found at $HW_BIN"
  fi
  printf '\n%sSpeed test%s\n' "${C[bold]}" "${C[reset]}"
  _chk ok 'provider' "$(st_provider)"
  if st_history_read 1 && ((${#ST_H_TS[@]} > 0)); then
    _chk ok 'history' "${#ST_H_TS[@]} results in $(st_file)"
  else
    _chk warn 'history' 'empty -- run: hyn speedtest'
  fi
  if have systemctl && systemctl list-unit-files 'hyn-speedtest.timer' --no-legend --plain 2>/dev/null | grep -q hyn; then
    _chk ok 'timer' "$(systemctl is-active hyn-speedtest.timer 2>/dev/null)"
  else
    _chk warn 'timer' 'not installed -- run: sudo hyn doctor --fix'
  fi

  if ((${#CFG_WARNINGS[@]} > 0)); then
    printf '\n%sconfig notes%s\n' "${C[bold]}" "${C[reset]}"
    local m
    for m in "${CFG_WARNINGS[@]}"; do printf '  - %s\n' "$m"; done
  fi
  printf '\n%d ok, %d to look at\n' "$ok" "$warn"

  # The portal tells an operator to run this command when a machine goes quiet,
  # which is only useful if it can also put it right. Rewriting the units and
  # reapplying the schedule is the fix for every stopped-timer cause there is:
  # units from an older release, a timer disabled before the machine was linked,
  # or an interrupted update.
  if ((fix)); then
    printf '\n%sRepair%s\n' "${C[bold]}" "${C[reset]}"
    is_root || die 'repair needs root: sudo hyn doctor --fix'
    have systemctl || { warn 'no systemd on this machine, so there is nothing to repair'; return 0; }
    source "$HYN_LIB/setup.sh" || die 'cannot load setup helpers'
    setup_run --no-wizard || return 1
    if cfg_on cloud_enabled && cloud_linked; then
      printf '\nhyn: sending a reading now to confirm the portal is receiving it\n'
      cloud_push 0 0 || return 1
    fi
  fi
  return 0
}

cmd_about() {
  printf '\n  %s%shyn-view%s %s\n' "${C[accent]}" "${C[bold]}" "${C[reset]}" "$HYN_VERSION"
  printf '  %sNetwork-first system monitor for Ubuntu Server%s\n\n' "${C[dim]}" "${C[reset]}"
  printf '  %sAuthor%s     %s%s%s\n' "${C[dim]}" "${C[reset]}" "${C[bold]}" "$HYN_AUTHOR" "${C[reset]}"
  printf '  %sGitHub%s     %s\n' "${C[dim]}" "${C[reset]}" "$HYN_AUTHOR_URL"
  printf '  %sProject%s    https://github.com/AryanVBW/HYN-view\n' "${C[dim]}" "${C[reset]}"
  printf '  %sLicence%s    MIT\n' "${C[dim]}" "${C[reset]}"
  printf '  %s%s%s\n\n' "${C[dim]}" "$HYN_COPYRIGHT" "${C[reset]}"
  printf '  %sinstalled at%s  %s\n' "${C[dim]}" "${C[reset]}" "$HYN_ROOT"
  update_read && printf '  %slatest on npm%s %s%s\n' "${C[dim]}" "${C[reset]}" "$UPD_LATEST" \
    "$( ((UPD_AVAILABLE)) && printf '  (update available)' )"
  printf '\n'
  return 0
}

cmd_help() {
  # printf rather than a heredoc: bash 5.3 feeds heredocs through a pipe, and on
  # platforms with a small initial pipe buffer (macOS starts at 512 bytes) a
  # heredoc this size deadlocks before `cat` is even exec'd. Linux pipes are
  # 64 KiB so it would work there, but `hyn help` should never be able to hang.
  printf '%s\n' \
    "hyn-view $HYN_VERSION -- network-first system monitor for Ubuntu Server" \
    '' \
    'usage: hyn [command] [options]' \
    '' \
    '  (no command)          interactive dashboard' \
    '  net                   open on the network view' \
    '  proc | top            open on the process view' \
    '  node                  open on the Highway node view' \
    '' \
    '  snapshot [--json]     one-shot reading, for cron, alerting and ssh' \
    '  speedtest [--json] [--respect-guard]' \
    '                        measure throughput now and record it' \
    '  history [N] [--json]  recorded speed test results' \
    '' \
    '  alerts check          evaluate every rule now (what the timer runs)' \
    '  alerts list           every rule, its value, and whether it is firing' \
    '  alerts test           queue a test alert with the portal' \
    '  alerts state | log    firing state, or what has fired recently' \
    '  report [--send]       daily report: print it, or email it' \
    '  notify status | test  portal delivery state, or queue a test message' \
    '  record                sample metrics once (what the record timer runs)' \
    '' \
    '  link                  pair this server with the web portal (root)' \
    '  unlink                forget the portal credential (root)' \
    '  push                  send one reading to the portal now' \
    '  cloud status          portal URL, node id, and when the last push was' \
    '  config pull           fetch monitoring settings from the portal' \
    '' \
    '  update [--check] [--yes]  check npm for a newer release, or install it' \
    '  about                 author, licence and version detail' \
    '' \
    '  theme list | set <name> | current | preview <name>' \
    '  config show | get <k> | set <k> <v> | path | edit' \
    '  doctor                check the environment end to end' \
    '  doctor --fix          reinstall the units and timers, then push (root)' \
    '  onboard               full guided setup: mode, theme, alerts, reports' \
    '  setup                 re-apply config and timers, no questions (root)' \
    '  uninstall             remove units and links (--purge drops data too)' \
    '  version | help' \
    '' \
    'keys in the dashboard: 1-4 views, t theme, p profile, u units, m sort,' \
    'i interface, s speed test, +/- refresh, h help, q quit' \
    '' \
    'profile=best gives gradient braille graphs, a time axis and a 1s refresh.' \
    'profile=performance gives block graphs and 2s, for about a third less CPU.' \
    '' \
    "config:   $HYN_ETC/config  (or ~/.config/hyn-view/config)" \
    "secrets:  $HYN_ETC/secrets  (mode 0600, API keys only)" \
    "state:    $(state_dir)" \
    '' \
    "hyn-view $HYN_VERSION -- $HYN_COPYRIGHT" \
    "$HYN_AUTHOR  <$HYN_AUTHOR_URL>"
  return 0
}

# ---------------------------------------------------------------------------
# dispatch
# ---------------------------------------------------------------------------
main() {
  boot
  local cmd=${1:-}
  [[ -n $cmd ]] && shift || true
  case $cmd in
    '' | dash | dashboard) maybe_onboard; run_tui ;;
    net | network) VIEW=net; maybe_onboard; run_tui ;;
    proc | top | ps) VIEW=proc; maybe_onboard; run_tui ;;
    node | highway | hyn) VIEW=node; maybe_onboard; run_tui ;;
    snapshot | once) cmd_snapshot "$@" ;;
    speedtest | speed | st) cmd_speedtest "$@" ;;
    history | hist) cmd_history "$@" ;;
    theme | themes) cmd_theme "$@" ;;
    config | cfg) cmd_config "$@" ;;
    doctor | check) cmd_doctor "$@" ;;
    setup | install-service)
      source "$HYN_LIB/setup.sh" || die 'cannot load setup helpers'
      setup_run "$@" ;;
    wizard | configure)
      die 'there is no local notification setup any more: recipients, provider and schedule live in the portal. Pair with `sudo hyn link`, then use its Account page. For local display and threshold options run `hyn onboard`.' ;;
    onboard | onboarding | firstrun)
      source "$HYN_LIB/wizard.sh" || die 'cannot load the setup wizard'
      onboard_run "$@" ;;
    alerts | alert) cmd_alerts "$@" ;;
    link | pair) cloud_link "$@" ;;
    unlink) cloud_unlink "$@" ;;
    push)
      if [[ ${1:-} == --scheduled ]]; then cloud_push 0 1; else cloud_push 0 0; fi ;;
    cloud)
      case ${1:-status} in
        status | '') cloud_status ;;
        push) cloud_push 0 0 ;;
        pull | config) cloud_config_pull 0 ;;
        run-command | command) cloud_run_pending 0 ;;
        link) shift; cloud_link "$@" ;;
        unlink) shift; cloud_unlink "$@" ;;
        *) die "unknown cloud subcommand: $1 (try 'hyn cloud status')" ;;
      esac ;;
    report) cmd_report "$@" ;;
    record)
      record_sample
      # The record timer is the only one enabled unconditionally, paired or not,
      # so it is the only place an update check is guaranteed to happen on every
      # installed machine. Without this an unpaired box -- one nobody logs into and
      # nobody linked -- would never look for a release again, and there is no
      # second mechanism to reach it. The check itself reads a cache and only
      # spawns a detached fetch when that cache is older than
      # update_check_hours, so this costs nothing on the runs in between.
      update_startup
      ;;
    notify) cmd_notify "$@" ;;
    uninstall | remove)
      source "$HYN_LIB/setup.sh" || die 'cannot load setup helpers'
      setup_uninstall "$@" ;;
    update | upgrade) cmd_update "$@" ;;
    about | credits) cmd_about ;;
    version | --version | -v)
      printf 'hyn-view %s\n' "$HYN_VERSION"
      printf '%s\n' "$HYN_COPYRIGHT" ;;
    help | --help | -h) cmd_help ;;
    *) die "unknown command: $cmd (try 'hyn help')" ;;
  esac
  return $?
}

main "$@"
