#!/usr/bin/env bash
# meshbellctl — the one command a bridge operator has to remember.
#
# WHY THIS EXISTS. Standing a bridge up was already a one-liner; keeping one alive was
# not. Everything an operator needed afterwards was a different incantation on a
# different platform: `systemctl status meshbell-bridge` here, `launchctl list` there,
# `curl localhost:4190/health | python3 -m json.tool` to see if the radio was found,
# `journalctl -u` versus `tail` for logs, an `rm -rf` copied out of an install
# transcript to uninstall, and NOTHING AT ALL for updating — a bridge installed once ran
# that version until somebody rebuilt it by hand.
#
# The people running these are neighbors with a spare Raspberry Pi, not sysadmins. A
# maintenance story they cannot remember is a bridge that silently rots, and a rotted
# bridge looks exactly like a quiet week to everyone relying on it.
#
# It is a GUEST like everything else here (ADR-008): it touches its own install
# directory, its own service label, and nothing else.
set -euo pipefail

PORT="${MESHBELL_PORT:-4190}"
BASE_URL="${MESHBELL_BASE_URL:-https://meshbell.com}"

case "$(uname -s 2>/dev/null || echo Unknown)" in
  Linux)  PLATFORM=linux;  DEFAULT_DIR=/opt/meshbell ;;
  Darwin) PLATFORM=macos;  DEFAULT_DIR="$HOME/.meshbell" ;;
  *)      echo "!! meshbellctl supports Linux and macOS. On Windows use the PowerShell installer." >&2; exit 1 ;;
esac
# A Linux bridge can be either a system service (installed as root, /opt/meshbell) or a
# per-user one (no root, ~/.meshbell). Which one is DETECTED rather than assumed: a
# meshbellctl that always spoke to `systemctl --system` would report a perfectly healthy
# per-user bridge as "not installed", and then fail to restart it.
# Matches install.sh: the default directory keeps the plain unit name, anything else gets
# one derived from where it lives, so two bridges on one machine cannot fight over the
# same service.
unit_basename() {
  local dir="$1" default="$2"
  if [[ "$dir" == "$default" ]]; then echo "meshbell-bridge"
  else echo "meshbell-bridge-$(printf '%s' "$dir" | tr -c 'A-Za-z0-9' '-' | sed 's/^-*//; s/-*$//; s/--*/-/g')"; fi
}
UNIT_NAME="$(unit_basename "${MESHBELL_DIR:-$DEFAULT_DIR}" "$DEFAULT_DIR").service"
USER_UNIT="$HOME/.config/systemd/user/$UNIT_NAME"
SYSTEMCTL_SCOPE=""
if [[ $PLATFORM == linux ]]; then
  if [[ -n "${MESHBELL_DIR:-}" ]]; then
    :   # an explicit directory wins; the scope is worked out from the unit below
  elif [[ ! -d "$DEFAULT_DIR" && -d "$HOME/.meshbell" ]]; then
    DEFAULT_DIR="$HOME/.meshbell"
  fi
  if [[ -f "$USER_UNIT" ]] && [[ ! -f "/etc/systemd/system/$UNIT_NAME" ]]; then
    SYSTEMCTL_SCOPE="--user"
  fi
fi

DIR="${MESHBELL_DIR:-$DEFAULT_DIR}"
ENV_FILE="$DIR/bridge.env"
# The bridge's real port may live in bridge.env; a meshbellctl that always probed 4190
# would report a healthy bridge on another port as dead. The environment still wins.
if [[ -z "${MESHBELL_PORT:-}" && -f "$ENV_FILE" ]]; then
  _env_port="$(sed -n 's/^MESHBELL_PORT=//p' "$ENV_FILE" | tail -n1 | tr -dc '0-9')"
  [[ -n "$_env_port" ]] && PORT="$_env_port"
fi
LABEL="com.meshbell.bridge"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"

say()  { printf '==> %s\n' "$*"; }
warn() { printf '!! %s\n' "$*" >&2; }

need_install() {
  [[ -d "$DIR" ]] || { warn "no bridge installed at $DIR"; echo "   install one:  curl -fsSL $BASE_URL/install.sh | bash" >&2; exit 1; }
}

# Root is needed for the systemd half and NOT for the macOS half, and asking for it
# unconditionally would train an operator to sudo things that do not need it.
as_root() {
  # A per-user install owns everything it touches, so asking for a password there would
  # be theatre -- and on a headless box `curl | bash` cannot answer a prompt anyway.
  if [[ $PLATFORM == linux && -z "$SYSTEMCTL_SCOPE" && $EUID -ne 0 ]]; then sudo "$@"; else "$@"; fi
}

# ------------------------------------------------------------------ status ---

cmd_status() {
  need_install
  # The one-line answer first, from the bridge's own fold. Everything after it is
  # detail for whoever wants it; a neighbor stopping at line one has been served.
  local overall
  overall="$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health/simple" 2>/dev/null \
             | python3 -c "import sys,json
try:
    d=json.load(sys.stdin)
    words={'ok':'healthy','attention':'needs a look','down':'NOT WORKING'}
    worst=[c for c in d.get('checks') or [] if c.get('state')==d.get('status')]
    why=(' (' + worst[0]['id'] + ')') if worst and d.get('status')!='ok' else ''
    print(words.get(d.get('status'),'unknown')+why)
except Exception: print('')" 2>/dev/null || true)"
  [[ -n "$overall" ]] && echo "overall:  $overall"
  echo "install:  $DIR"
  echo "version:  $(cat "$DIR/VERSION" 2>/dev/null || echo 'unknown (installed from a source tree)')"

  local running="no"
  if [[ $PLATFORM == linux ]]; then
    if systemctl $SYSTEMCTL_SCOPE is-active --quiet "$UNIT_NAME" 2>/dev/null; then running="yes"; fi
    # `|| true` matters twice over: is-enabled exits non-zero for a unit that is not
    # installed, and `pipefail` would otherwise make that the pipeline's status and kill
    # the whole command under `set -e` -- so asking after a missing bridge would abort
    # instead of reporting one.
    local enabled; enabled="$(systemctl $SYSTEMCTL_SCOPE is-enabled "$UNIT_NAME" 2>/dev/null | head -n1 || true)"
    local scope="system"; [[ -z "$SYSTEMCTL_SCOPE" ]] || scope="per-user"
    echo "service:  $UNIT_NAME ($scope, ${enabled:-not installed}, running=$running)"
  else
    if launchctl list 2>/dev/null | grep -q "$LABEL"; then running="yes"; fi
    echo "service:  $LABEL (running=$running)"
  fi

  # The service being up is not the same as the bridge working, and the difference is
  # the whole point of asking: a process that starts and then cannot open its port, or
  # opens it and finds no radio, is the failure that looks healthy from systemd.
  # Fetched BEFORE the updates line, because the bridge is the only thing that knows
  # whether it can update itself; the "not answering" verdict is still reported below.
  local health
  if ! health="$(curl -s --max-time 3 "http://127.0.0.1:$PORT/health" 2>/dev/null)"; then health=""; fi

  # Whether app-driven over-the-air updates are possible. A token is NECESSARY and was
  # never sufficient: only a systemd USER unit can replace and restart itself, so a
  # system install answers 501 unsupported_install to the app's update -- and this line
  # used to promise the opposite on the strength of a token existing in bridge.env.
  # The bridge's own /health carries `admin.ota_supported`; ask it. Never PRINT the
  # secret here (status output gets pasted into chats and issues) -- only whether one
  # is set, and how to see it.
  local ota=""
  if [[ -n "$health" ]]; then
    ota="$(printf '%s' "$health" | python3 -c "import sys,json
try:
    supported=(json.load(sys.stdin).get('admin') or {}).get('ota_supported')
    print('yes' if supported is True else ('no' if supported is False else ''))
except Exception: print('')" 2>/dev/null || true)"
  fi
  if [[ ! -f "$ENV_FILE" ]] || ! grep -q "^MESHBELL_ADMIN_TOKEN=." "$ENV_FILE" 2>/dev/null; then
    echo "updates:  app cannot update this bridge yet - run: meshbellctl token"
  elif [[ "$ota" == yes ]]; then
    echo "updates:  app can update this bridge over Wi-Fi (token set; see it with: meshbellctl token)"
  elif [[ "$ota" == no ]]; then
    echo "updates:  this bridge updates HERE, not over Wi-Fi - run: meshbellctl update"
    echo "          (over-the-air needs a per-user install; the token still works for"
    echo "           releasing the radio, compacting the log and retuning)"
  else
    echo "updates:  token set (see it with: meshbellctl token); whether the app can"
    echo "          update over Wi-Fi is unknown - the bridge did not say"
  fi

  if [[ -z "$health" ]]; then
    warn "the bridge is not answering on port $PORT"
    echo "   logs:  meshbellctl logs"
    return 1
  fi
  python3 - "$health" <<'PY'
import json, sys

# Every key here was read off a running bridge's /health, not guessed. The first draft of
# this renderer invented `radio.connected` and `discovery.advertising`, neither of which
# exists, so it reported a perfectly healthy bridge as having no radio and no discovery.
try:
    h = json.loads(sys.argv[1])
except Exception:
    print("health:   answered, but not with JSON this version understands")
    sys.exit(0)

radio = h.get("radio") or {}

state = radio.get("state") or "unknown"
if radio.get("port"):
    # `node_name` is the RADIO's name, announced on the mesh. It is not the bridge's
    # name, and reporting it as one made a bridge with a named radio look configured
    # when MESHBELL_BRIDGE_NAME had never been set.
    node = radio.get("node_name")
    line = f"{state} on {radio['port']}" + (f' as "{node}"' if node else "")
    # `region` is the whole region record, not a code: printing it raw dumped the FCC
    # legal note and every source URL into the middle of a status line.
    region = radio.get("region")
    if isinstance(region, dict):
        region = region.get("display_name") or region.get("code")
    if region:
        line += f", {region}"
    if radio.get("transmit_allowed") is False:
        line += ", NOT allowed to transmit"
    print(f"radio:    {line}")
else:
    why = radio.get("last_error") or radio.get("detail") or "no companion radio found"
    print(f"radio:    {state} - {why}")

for key, label in (("ipaws", "official:"), ("meteoalarm", "weather: ")):
    rail = h.get(key) or {}
    if rail.get("enabled"):
        note = "receiving" if rail.get("held") else "on, nothing received yet"
        if rail.get("last_error"):
            note = f"on, last error: {rail['last_error']}"
        print(f"{label} {note}, holding {rail.get('held', 0)}")
    elif key == "ipaws":
        # Said plainly, because this is the state every bridge ships in, and the app
        # tells its neighbours about it in as many words.
        print(f"{label} OFF - neighbours are told this bridge carries no official alerts")
        print("          turn it on:  meshbellctl config MESHBELL_IPAWS 1")

disc = h.get("discovery") or {}
if disc.get("advertised"):
    print("found by: phones on this network find it by themselves")
else:
    print(f"found by: ADDRESS ONLY - {disc.get('reason') or 'mDNS is not advertising'}")

if h.get("degraded"):
    print(f"degraded: {h['degraded']}")
PY

  echo ""
  cmd_address
}

# ----------------------------------------------------------------- address ---

# The question every neighbour actually asks, and the one a bridge answered worst:
# "what do I type?" A phone that cannot see the mDNS advert - a guest network, client
# isolation, or simply the other of two SSIDs in one house - needs a literal URL, and
# finding one used to mean knowing to run `ip addr` and knowing which answer was right.
#
# EVERY address is printed, not a guess at the best one. A machine with a wired link and
# two radios has several; they are all correct on their own network, and the one that
# works is the one on the same network as the phone in your hand.
cmd_address() {
  local found=""
  local lan=() other=()
  while read -r ip; do
    [[ -z "$ip" ]] && continue
    found=1
    case "$ip" in
      # 100.64/10 is the carrier-grade NAT range Tailscale hands out. Printing a tailnet
      # address under "same Wi-Fi" is worse than not printing it: a neighbour types it,
      # nothing answers, and they conclude the bridge is broken when it is simply not
      # theirs to reach.
      100.6[4-9].*|100.[7-9][0-9].*|100.1[0-1][0-9].*|100.12[0-7].*)
        other+=("  http://$ip:$PORT/app   (only devices on your tailnet)") ;;
      169.254.*)
        other+=("  http://$ip:$PORT/app   (link-local, usually not what you want)") ;;
      10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
        lan+=("  http://$ip:$PORT/app") ;;
      *)
        other+=("  http://$ip:$PORT/app   (public address — check before sharing)") ;;
    esac
  done < <(host_addresses)

  if [[ -z "$found" ]]; then
    warn "this machine has no non-loopback address, so nothing else can reach it"
    return 1
  fi

  if [[ ${#lan[@]} -gt 0 ]]; then
    echo "open on a phone on the same Wi-Fi:"
    printf '%s\n' "${lan[@]}"
    # More than one is the normal case in a house with two SSIDs or a wired Pi, and the
    # rule is simple enough to say rather than make somebody work out.
    [[ ${#lan[@]} -gt 1 ]] && echo "  (one per network this machine is on — use the one matching the phone's Wi-Fi)"
  fi
  local short; short="$(hostname -s 2>/dev/null || true)"
  [[ -z "$short" ]] || echo "  http://$short.local:$PORT/app   (when the network passes mDNS)"
  if [[ ${#other[@]} -gt 0 ]]; then
    echo "also reachable, but not by a neighbour on your Wi-Fi:"
    printf '%s\n' "${other[@]}"
  fi
}

# Non-loopback IPv4s, however this machine likes to report them. `hostname -I` is a
# Linux-ism absent on macOS and `ipconfig getifaddr` is the reverse, so both are tried
# before falling back to parsing.
host_addresses() {
  if hostname -I >/dev/null 2>&1; then
    hostname -I | tr ' ' '\n' | grep -E '^[0-9]+\.' || true
  elif command -v ipconfig >/dev/null 2>&1; then
    for iface in $(networksetup -listallhardwareports 2>/dev/null | awk '/Device:/{print $2}'); do
      ipconfig getifaddr "$iface" 2>/dev/null || true
    done
  elif command -v ip >/dev/null 2>&1; then
    ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1
  else
    ifconfig 2>/dev/null | awk '/inet /{print $2}' | grep -v '^127\.' || true
  fi
}

# ------------------------------------------------------------------- guest ---

# The emergency-day answer to "how does everyone else get on". install-ap.sh stands up a
# Wi-Fi network that reaches this bridge AND NOTHING ELSE -- not the house, not the
# internet, not the Pi's own SSH -- so neighbours can be handed a network instead of the
# house passphrase. It has been in the package all along with nothing pointing at it.
cmd_guest() {
  need_install
  local script="$DIR/install-ap.sh"
  [[ -f "$script" ]] || { warn "install-ap.sh is not in this install (run: meshbellctl update)"; exit 1; }

  local ssid="" iface="" start=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --start)     start=1 ;;
      --ssid)      ssid="${2:?--ssid needs a name}"; shift ;;
      --ssid=*)    ssid="${1#*=}" ;;
      --interface) iface="${2:?--interface needs a name}"; shift ;;
      --interface=*) iface="${1#*=}" ;;
      *) warn "unknown option '$1'"; exit 1 ;;
    esac
    shift
  done

  # WHAT IS ALREADY TRUE, before any instructions. Somebody asking about the guest network
  # on the day they need it wants to know whether it is up, not to be handed a recipe.
  local live=""
  if command -v nmcli >/dev/null 2>&1; then
    live="$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null \
            | awk -F: '$2=="802-11-wireless"{print $1}' | grep -i meshbell | head -n1 || true)"
  fi

  if [[ -n "$start" ]]; then
    # The prefix is not decoration: install-ap.sh REFUSES an SSID that does not start with
# "meshbell", wifi_join.py documents it, and it is what the app tells neighbors to look
# for. This default was plain "Meshbell", which that check would reject, so the guest
# helper and the real installer disagreed about what a Meshbell network is called.
[[ -n "$ssid" ]] || ssid="meshbell-$(hostname -s 2>/dev/null || echo guest)"
    if [[ -z "$iface" ]]; then
      # The interface NOT carrying this machine's own connection, since standing an access
      # point on the radio that reaches the house takes the house down with it.
      local busy; busy="$(nmcli -t -f DEVICE,STATE device 2>/dev/null | awk -F: '$2=="connected"{print $1}' | head -n1 || true)"
      iface="$(nmcli -t -f DEVICE,TYPE device 2>/dev/null \
               | awk -F: -v busy="$busy" '$2=="wifi" && $1!=busy {print $1}' | head -n1 || true)"
    fi
    if [[ -z "$iface" ]]; then
      warn "no spare Wi-Fi interface found for a guest network"
      echo "   This machine has one radio and it is carrying its own connection." >&2
      echo "   A USB Wi-Fi adapter gives it a second, or name one: meshbellctl guest --start --interface wlan1" >&2
      exit 1
    fi
    say "standing up '$ssid' on $iface (needs your password)"
    # NOT as_root: that helper skips sudo on a per-user install, where install-ap.sh
    # then refuses with "needs root" -- the documented command was broken for the
    # default install shape. The AP touches system network config; root is simply
    # required, whoever owns the bridge.
    if [[ $EUID -ne 0 ]]; then
      sudo bash "$script" --interface "$iface" --ssid "$ssid" || exit 1
    else
      bash "$script" --interface "$iface" --ssid "$ssid" || exit 1
    fi
    # The bridge announces the network to join, so a phone that can hear it but not reach
    # it can still say where to go. Restarting is what picks the value up -- and that
    # restart is announced, not swallowed: `1>&2` keeps cmd_config's and cmd_restart's
    # confirmations in front of the operator while the guest report below owns stdout.
    printf '==> recording the network name with the bridge; it will restart\n' >&2
    cmd_config MESHBELL_JOIN_SSID "$ssid" 1>&2
    live="$ssid"
  fi

  local url; url="http://$(host_addresses | head -n1):$PORT/app"
  if [[ -n "$live" ]]; then
    echo "guest network: $live is up"
  else
    echo "No Meshbell guest network is running on this machine."
    echo ""
    echo "Start one (a Wi-Fi that reaches this bridge and NOTHING else: not the house,"
    echo "not the internet, not this machine's own SSH, not the other phones on it):"
    echo "  meshbellctl guest --start --ssid \"meshbell-oak-st\""
    echo ""
  fi
  echo "Neighbours join that Wi-Fi and open:"
  echo "  $url"
  echo ""
  qr_for "$url"
}

# A QR in the terminal, because on the day this matters nobody is typing an address off
# somebody else's screen. Print it, photograph it, tape it to a door.
#
# Best effort, and quiet about failing: qrencode is a package the operator may not have
# and `qrcode` is the bridge's one optional third-party import, so a node without either
# still works and still prints the address above.
#
# Written as two plain `if`s rather than `heredoc || { fallback }`: a heredoc opened on a
# line that also carries a multi-line `||` block confuses bash about where the body starts,
# which is a syntax error several lines further down than the mistake.
qr_for() {
  local url="$1"
  if command -v qrencode >/dev/null 2>&1; then
    echo "Or point a camera at this:"
    qrencode -t ANSIUTF8 "$url" 2>/dev/null && return 0
  fi
  if python3 -c 'import qrcode' >/dev/null 2>&1; then
    echo "Or point a camera at this:"
    python3 -c 'import sys, qrcode; q = qrcode.QRCode(border=2); q.add_data(sys.argv[1]); q.make(fit=True); q.print_ascii(invert=True)' "$url" 2>/dev/null && return 0
  fi
  echo "(for a scannable code: sudo apt install qrencode)"
}

# -------------------------------------------------------------------- maps ---

# Map packs, which is the difference between a bridge that can draw its neighbourhood and
# one that shows a grey box.
#
# WHY FETCHING RATHER THAN BUILDING. Building tiles needs an OSM regional extract, a JVM,
# planetiler, a headless Chromium and about an hour, and the source is always raw
# OpenStreetMap built by us -- never a bulk pull from tile.openstreetmap.org, which their
# usage policy forbids and which build-raster.sh refuses. Nobody setting up a bridge for
# their street is going to do that, so somebody builds a pack once and everyone else
# fetches it.
cmd_maps() {
  need_install
  local script="$DIR/install-tiles.sh"
  [[ -f "$script" ]] || { warn "install-tiles.sh is not in this install (run: meshbellctl update)"; exit 1; }

  local action="${1:-list}"
  case "$action" in
    list)
      echo "installed here:"
      local meta; meta="$(curl -s --max-time 5 "http://127.0.0.1:$PORT/tiles/meta" 2>/dev/null || true)"
      if [[ -n "$meta" ]]; then
        python3 - "$meta" <<'PYMAPS'
import json, sys
try:
    m = json.loads(sys.argv[1])
except Exception:
    print("  (this bridge did not answer with a map manifest)"); raise SystemExit(0)
if not m.get("available"):
    print("  none. Pick one below and run: meshbellctl maps install <name>")
else:
    b = m.get("bounds") or []
    where = f"{b[1]:.3f},{b[0]:.3f} to {b[3]:.3f},{b[2]:.3f}" if len(b) == 4 else "area unknown"
    print(f"  {', '.join(m.get('variants', []))} · z{m.get('minzoom')}-{m.get('maxzoom')} · {where}")
PYMAPS
      else
        echo "  (the bridge is not answering, so what it holds is unknown)"
      fi
      echo ""
      echo "available to fetch:"
      # The catalogue goes to a FILE and the path is passed as an argument. Piping it
      # into `python3 - <<EOF` looks right and silently discards it: the heredoc is
      # python's stdin, so there is nothing left for the program to read. Cost an
      # afternoon once already, in the health renderer.
      local catalogue; catalogue="$(mktemp)"
      if curl -fsSL --max-time 20 -H 'Accept-Encoding: identity' \
              "${MESHBELL_TILES_BASE:-https://meshbell.com/tiles}/index.json" -o "$catalogue" 2>/dev/null; then
        python3 - "$catalogue" <<'PYLIST'
import json, sys
try:
    doc = json.load(open(sys.argv[1]))
except Exception:
    print("  (the catalogue did not read as JSON)"); raise SystemExit(0)
packs = doc.get("packs", [])
if not packs:
    print("  (none published yet)"); raise SystemExit(0)
for pack in packs:
    size = sum(v.get("bytes", 0) for v in pack.get("variants", [])) / 1e6
    b = pack.get("bbox") or []
    where = f"{b[1]:.2f},{b[0]:.2f} to {b[3]:.2f},{b[2]:.2f}" if len(b) == 4 else ""
    print(f"  {pack['name']:<18} {size:6.0f} MB  z{pack.get('minzoom')}-{pack.get('maxzoom')}  {where}")
print()
print("  install one:  meshbellctl maps install <name>")
print("  one theme only, about half the size:  meshbellctl maps install <name> --variant light")
PYLIST
      else
        echo "  (could not reach the pack catalogue)"
      fi
      rm -f "$catalogue"
      ;;
    install)
      shift || true
      local name="${1:?usage: meshbellctl maps install <name> [--variant light|dark]}"
      shift || true
      say "fetching '$name' (this replaces any pack already installed)"
      bash "$script" "$name" "$@" || exit 1
      # The bridge reads the manifest once and holds it, so a freshly installed pack is
      # invisible until it looks again.
      cmd_restart
      ;;
    *) warn "unknown maps command '$action' (try: list, install)"; exit 1 ;;
  esac
}

# -------------------------------------------------------------------- logs ---

# Wherever this install actually keeps its log: bridge.env may point the state directory
# somewhere else entirely, which is exactly when somebody needs to find it.
bridge_log_path() {
  # TWO candidates can exist and disagree: the unit's StandardOutput path was baked
  # at install time ($DIR/state), while bridge.env's MESHBELL_STATE_DIR may have
  # moved since. On the bench this once served a three-day-old error tail as if it
  # were live. The newest existing file is the one the service is writing.
  local state="" a b newest=""
  [[ -f "$ENV_FILE" ]] && state="$(sed -n 's/^MESHBELL_STATE_DIR=//p' "$ENV_FILE" | tail -n1)"
  a="${state:+$state/bridge.log}"
  b="$DIR/state/bridge.log"
  for f in "$a" "$b"; do
    [[ -n "$f" && -f "$f" ]] || continue
    if [[ -z "$newest" || "$f" -nt "$newest" ]]; then newest="$f"; fi
  done
  printf '%s' "${newest:-${a:-$b}}"
}

cmd_logs() {
  need_install
  if [[ $PLATFORM == linux ]]; then
    # The unit logs to a file because journald on a Pi often keeps nothing for user
    # services. The journal is still tried first, for a host where it does work.
    local state_log; state_log="$(bridge_log_path)"
    if [[ -n "$state_log" && -s "$state_log" ]]; then
      tail -n "${1:-100}" "$state_log"
    elif [[ -n "$SYSTEMCTL_SCOPE" ]]; then
      journalctl --user -u "$UNIT_NAME" -n "${1:-100}" --no-pager
    else
      as_root journalctl -u "$UNIT_NAME" -n "${1:-100}" --no-pager
    fi
  else
    tail -n "${1:-100}" "$DIR/state/bridge.log"
  fi
}

# ------------------------------------------------------------------ config ---

# The admin token, printed for the one person who runs this on the bridge itself.
# It is what a phone types in once to keep this bridge's software up to date over
# Wi-Fi (the app signs updates with it; the token never crosses the air). `token`
# shows the current one, generating and persisting one if the bridge has none;
# `token --rotate` replaces it (every phone must then be re-entered).
cmd_token() {
  need_install
  if [[ ! -f "$ENV_FILE" ]]; then
    warn "no $ENV_FILE — run 'meshbellctl update' first"
    exit 1
  fi
  local rotate=0
  [[ "${1:-}" == "--rotate" || "${1:-}" == "--new" ]] && rotate=1
  local current; current="$(sed -n 's/^MESHBELL_ADMIN_TOKEN=//p' "$ENV_FILE" | head -n1)"
  if [[ -n "$current" && $rotate -eq 0 ]]; then
    echo "$current"
    return 0
  fi
  local token; token="$(python3 -c 'import secrets; print(secrets.token_hex(8))' 2>/dev/null || true)"
  if [[ -z "$token" ]]; then
    warn "could not generate a token (python3 missing?); set one by hand:"
    echo "   meshbellctl config MESHBELL_ADMIN_TOKEN <your-secret>" >&2
    exit 1
  fi
  # SAY IT BEFORE THE PAUSE. `token` reads like a query and its help text read like one
  # too, but on this branch it WRITES bridge.env and RESTARTS the bridge -- several
  # seconds of a service being down, from a verb the operator thought would print
  # something. It used to happen in silence, because the line below sent cmd_config's
  # `MESHBELL_ADMIN_TOKEN set` and cmd_restart's `bridge restarted and answering on port
  # N` to /dev/null along with everything else.
  if [[ $rotate -eq 1 ]]; then
    printf '==> replacing the admin token; the bridge will restart\n' >&2
  else
    printf '==> no admin token is set; making one now — the bridge will restart\n' >&2
  fi
  # Reuse cmd_config's writer so quoting and restart stay in one place. `1>&2` rather
  # than `>/dev/null`: both confirmations (and the "nothing is answering yet" warning)
  # reach the operator, while stdout stays the bare token and `TOKEN=$(meshbellctl token)`
  # keeps working.
  cmd_config MESHBELL_ADMIN_TOKEN "$token" 1>&2
  chmod 0600 "$ENV_FILE" 2>/dev/null || true
  if [[ $rotate -eq 1 ]]; then
    echo "rotated. Every phone that updates this bridge must be re-entered."
  fi
  echo "$token"
}

cmd_config() {
  need_install
  if [[ ! -f "$ENV_FILE" ]]; then
    warn "no $ENV_FILE — this bridge predates configurable installs"
    echo "   run 'meshbellctl update' to get one (your data in $DIR/state is untouched)" >&2
    exit 1
  fi
  # No arguments: show what is actually SET, not the whole commented template. An
  # operator asking "what is this bridge doing" wants the answer, not the manual.
  if [[ $# -eq 0 ]]; then
    echo "config:   $ENV_FILE"
    local shown=0
    while IFS= read -r line; do
      [[ "$line" =~ ^[[:space:]]*# ]] && continue
      [[ -z "${line// }" ]] && continue
      # Never print a secret back to a terminal that may be logged or shared.
      if [[ "$line" == MESHBELL_ADMIN_TOKEN=* ]]; then echo "  MESHBELL_ADMIN_TOKEN=(set)"; else echo "  $line"; fi
      shown=1
    done < "$ENV_FILE"
    [[ $shown -eq 1 ]] || echo "  (nothing set — every default is in the file's comments)"
    return 0
  fi

  local key="$1"; shift
  [[ "$key" == MESHBELL_* ]] || key="MESHBELL_${key^^}"
  if [[ $# -eq 0 ]]; then
    grep -E "^${key}=" "$ENV_FILE" || echo "$key is not set (the default applies)"
    return 0
  fi
  local value="$1"
  local tmp; tmp="$(mktemp)"
  grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
  [[ -z "$value" ]] || printf '%s=%s\n' "$key" "$value" >> "$tmp"
  as_root cp "$tmp" "$ENV_FILE"
  rm -f "$tmp"
  say "$key set"
  cmd_restart
}

# --------------------------------------------------------------- release-ble ---

# HAND THE BLUETOOTH RADIO BACK TO A PHONE. A MeshCore companion serves exactly one
# central at a time, so a bridge configured for one locks out every phone in the
# house -- and the symptom on the phone is a pairing that never completes, which
# reads as broken hardware rather than as a bridge doing its job (issue #7).
#
# This is `config MESHBELL_BLE ""` with the two things that verb cannot do: it stops
# the radio in the RUNNING bridge, so the slot opens now instead of after a restart,
# and it drops the link at bluetoothd, which the bridge's own teardown cannot do for
# a connection it never completed (the bench, 2026-08-25). It walks the same admin
# route the app's button does, so the shell and the phone are one rail rather than
# two implementations that drift.
#
# There is no `claim-ble`: taking a radio BACK is a decision made here, with the
# address in front of you -- meshbellctl config MESHBELL_BLE <address>.
cmd_release_ble() {
  need_install
  local token=""
  [[ -f "$ENV_FILE" ]] && token="$(sed -n 's/^MESHBELL_ADMIN_TOKEN=//p' "$ENV_FILE" | head -n1)"
  if [[ -z "$token" ]]; then
    warn "no admin token in $ENV_FILE — set one first:  meshbellctl token"
    exit 1
  fi
  local body="{}"
  [[ -n "${1:-}" ]] && body="$(printf '{"address":"%s"}' "$1")"
  # Status and body together: the one answer that matters most (released, but the
  # file could not be written) is a 500 that still carries what happened, and a
  # `curl -f` would throw exactly that body away.
  local answer code
  answer="$(curl -sS --max-time 15 -X POST \
            -H "X-Meshbell-Admin: $token" -H 'Content-Type: application/json' \
            -d "$body" -w $'\n%{http_code}' \
            "http://127.0.0.1:$PORT/admin/release-ble" 2>/dev/null || true)"
  code="${answer##*$'\n'}"
  answer="${answer%$'\n'*}"
  if [[ -z "$code" || ! "$code" =~ ^[0-9]+$ ]]; then
    if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
      warn "the bridge did not answer this request — meshbellctl logs"
    else
      warn "the bridge is not answering on port $PORT — start it (meshbellctl start), then run this again"
    fi
    exit 1
  fi
  local detail; detail="$(_jget "$answer" "d.get('detail','')")"
  case "$code" in
    200)
      local freed; freed="$(_jget "$answer" "', '.join(r['address'] for r in d.get('released',[]))")"
      say "released ${freed:-the Bluetooth radio}"
      [[ -n "$detail" ]] && note "$detail"
      return 0 ;;
    409)
      # Nothing to release is the state the caller asked for, not a failure.
      say "${detail:-this bridge is not set to use a Bluetooth radio}"
      return 0 ;;
    *)
      warn "${detail:-the bridge refused the request (HTTP $code) — meshbellctl logs}"
      exit 1 ;;
  esac
}

# ----------------------------------------------------------------- lifecycle ---

cmd_restart() {
  need_install
  if [[ $PLATFORM == linux ]]; then
    if [[ -n "$SYSTEMCTL_SCOPE" ]]; then systemctl --user restart "$UNIT_NAME"
    else as_root systemctl restart "$UNIT_NAME"; fi
  else
    launchctl unload "$PLIST" 2>/dev/null || true
    launchctl load -w "$PLIST" 2>/dev/null || true
  fi
  # Wait for the port rather than announcing success the instant the service manager
  # accepts the command: "restarted" and "serving again" are different facts.
  for _ in 1 2 3 4 5 6 7 8; do
    if curl -s --max-time 2 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
      say "bridge restarted and answering on port $PORT"; return 0
    fi
    sleep 1
  done
  warn "restarted, but nothing is answering on port $PORT yet — meshbellctl logs"
}

cmd_stop() {
  need_install
  if [[ $PLATFORM == linux && -n "$SYSTEMCTL_SCOPE" ]]; then systemctl --user stop "$UNIT_NAME"
  elif [[ $PLATFORM == linux ]]; then as_root systemctl stop "$UNIT_NAME"
  else launchctl unload "$PLIST" 2>/dev/null || true; fi
  say "stopped (it will come back at boot; 'meshbellctl uninstall' removes it)"
}

cmd_start() { cmd_restart; }

# ----------------------------------------------------------------- compact ---

# Retention, on demand. The bridge folds its own log on a schedule at startup —
# records past their class windows are copied out into a fresh log, the old file is
# replaced, and a dated receipt line (records before/after) is appended to
# state/retention-log.ndjson. The fold never runs under a serving bridge (the swap
# replaces the database file), so this verb is two moves in one: record the request
# with the running bridge, then restart it so the fold runs at that start.
cmd_compact() {
  need_install
  local token=""
  [[ -f "$ENV_FILE" ]] && token="$(sed -n 's/^MESHBELL_ADMIN_TOKEN=//p' "$ENV_FILE" | head -n1)"
  if [[ -z "$token" ]]; then
    warn "no admin token in $ENV_FILE — set one first:  meshbellctl token"
    exit 1
  fi
  if ! curl -fsS --max-time 5 -X POST -H "X-Meshbell-Admin: $token" \
       "http://127.0.0.1:$PORT/admin/compact" >/dev/null 2>&1; then
    # Two different failures deserve two different answers: a bridge that is not
    # running at all, and one that answered no to this request.
    if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
      warn "the bridge refused the request (wrong token, or no MESHBELL_STATE_DIR to keep a durable log in) — meshbellctl logs"
    else
      warn "the bridge is not answering on port $PORT — start it (meshbellctl start), then run this again"
    fi
    exit 1
  fi
  say "compaction requested; restarting so the fold runs now"
  cmd_restart
  # The receipt is the proof the fold ran and the record of what it dropped —
  # found by the same two candidates as the log file (bridge_log_path's reasoning).
  local state="" receipt=""
  [[ -f "$ENV_FILE" ]] && state="$(sed -n 's/^MESHBELL_STATE_DIR=//p' "$ENV_FILE" | tail -n1)"
  for f in "${state:+$state/retention-log.ndjson}" "$DIR/state/retention-log.ndjson"; do
    [[ -n "$f" && -f "$f" ]] && { receipt="$f"; break; }
  done
  if [[ -n "$receipt" ]]; then
    say "latest receipt ($receipt):"
    tail -n 1 "$receipt"
  else
    # NOT "the fold did not run". This script reads the receipt file and nothing else,
    # and a missing receipt is TWO different events: a fold that refused before the swap
    # (the log is untouched, every record still there) and a fold that ran, replaced the
    # log, and could not be receipted (records are gone and nothing signed for it). The
    # bridge's own stderr is the only place that distinguishes them, and asserting the
    # first is the exact lie bridge.py stopped telling in 166b311.
    warn "no receipt found. The bridge log says which happened: a fold that refused before the swap (the log is untouched), or a fold that ran and could not be receipted (the log was folded and those records are gone) — meshbellctl logs"
  fi
}

# ------------------------------------------------------------------ doctor ---

# WHY THIS EXISTS. Answering "is this working?" took a full day of a developer with a
# terminal, and every failure along the way was individually invisible: a stale
# MESHBELL_SERIAL=none pin that reported "no radio attached" with a radio attached; a USB
# port wedged by an overcurrent trip, which produced the same message as wrong firmware; a
# radio on the community channel while the bridge expected its own; a phone on a different
# Wi-Fi entirely; a service worker serving a cached page so every error looked like a dead
# bridge; a captive portal that fired but never released the network. Each was a
# reasonable-looking green light somewhere else.
#
# An operator has one phone and no logs. This walks the whole path a neighbor takes and
# says, in the words a person thinks in, which parts are proven and which are only
# assumed. It NEVER prints a green line it has not actually tested: a check that cannot
# run says so, because "cannot check" and "fine" are the two answers this whole product
# exists to keep apart.

DOCTOR_FAILS=0
DOCTOR_WARNS=0
ok()    { printf '  [ ok ] %s\n' "$*"; }
bad()   { printf '  [FAIL] %s\n' "$*"; DOCTOR_FAILS=$((DOCTOR_FAILS + 1)); }
meh()   { printf '  [ ?? ] %s\n' "$*"; DOCTOR_WARNS=$((DOCTOR_WARNS + 1)); }
note()  { printf '         %s\n' "$*"; }

_health_json() { curl -fsS --max-time 5 "http://127.0.0.1:$PORT/health" 2>/dev/null; }

_jget() {  # _jget <json> <python expression over d>
  printf '%s' "$1" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); raise SystemExit(0)
try: print($2)
except Exception: print('')
" 2>/dev/null
}

cmd_doctor() {
  need_install
  echo "Meshbell bridge check"
  echo "  install $DIR"
  echo "  version $(cat "$DIR/VERSION" 2>/dev/null || echo unknown)"
  echo

  echo "The bridge itself"
  local running="no"
  if [[ $PLATFORM == linux ]]; then
    systemctl $SYSTEMCTL_SCOPE is-active --quiet "$UNIT_NAME" 2>/dev/null && running="yes"
  else
    launchctl list 2>/dev/null | grep -q "$LABEL" && running="yes"
  fi
  [[ $running == yes ]] && ok "the service is running" || bad "the service is not running (meshbellctl start)"

  local health; health="$(_health_json || true)"
  if [[ -z "$health" ]]; then
    bad "the bridge did not answer on port $PORT"
    note "without that, nothing below can be checked. Try: meshbellctl logs"
    echo; echo "$DOCTOR_FAILS problem(s) found."; return 1
  fi
  ok "it answers on port $PORT"

  # THE BRIDGE'S OWN VERDICT. /health/simple is the one fold every surface shares --
  # the watch runs the checks (and any heals) on its own thread and this just renders
  # them, so doctor can no longer disagree with the web app about the same node. Each
  # line below was tested by the bridge within the last minute, with proof ages.
  local simple; simple="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/health/simple" 2>/dev/null || true)"
  if [[ -n "$simple" ]]; then
    echo
    echo "The bridge's own checks (proof, not config)"
    while IFS=$'\t' read -r wstate wid wcode wdetail; do
      [[ -n "$wid" ]] || continue
      case "$wstate" in
        ok)        ok  "$wid: $wdetail" ;;
        attention) meh "$wid: $wdetail" ;;
        down)      bad "$wid: $wdetail" ;;
      esac
    done < <(printf '%s' "$simple" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: raise SystemExit(0)
for c in d.get('checks') or []:
    detail = (c.get('detail') or c.get('code') or '')[:110].replace('\t',' ').replace('\n',' ')
    age = c.get('proof_age_s')
    if age is not None:
        detail += ' (proved %ds ago)' % int(age)
    print('\t'.join([str(c.get('state','down')), str(c.get('id','?')), str(c.get('code','')), detail]))
")
  else
    meh "this bridge does not serve /health/simple yet (older bridge software)"
    note "update it and doctor gains the watch's own verdicts: meshbellctl update"
  fi

  local tls; tls="$(_jget "$health" "(d.get('discovery') or {}).get('app_url_secure') or ''")"
  if [[ -n "$tls" ]]; then
    if curl -fsSk --max-time 5 "${tls%/app}/health" >/dev/null 2>&1; then
      ok "the secure door answers too"
    else
      meh "the secure door is advertised but did not answer"
    fi
  fi

  echo
  echo "The radio"
  local rstate rdetail rmatch rtune
  rstate="$(_jget "$health" "(d.get('radio') or {}).get('state') or ''")"
  rdetail="$(_jget "$health" "str((d.get('radio') or {}).get('detail') or '')[:90]")"
  rmatch="$(_jget "$health" "((d.get('radio') or {}).get('tuning') or {}).get('matches')")"
  rtune="$(_jget "$health" "(lambda c: '%s MHz / %s kHz / SF%s / CR%s' % (c.get('freq_mhz'), c.get('bw_khz'), c.get('sf'), c.get('cr')) if c else '')(((d.get('radio') or {}).get('tuning') or {}).get('current') or {})")"
  case "$rstate" in
    up)
      ok "a radio is attached and answering"
      if [[ "$rmatch" == "True" ]]; then
        ok "it is on this bridge's channel  $rtune"
      else
        bad "it is NOT on this bridge's channel"
        note "on $rtune"
        note "neighbors on the expected channel cannot hear this node."
        note "Set the channel you actually use: meshbellctl config MESHBELL_CHANNEL freq/bw/sf/cr"
      fi ;;
    absent)
      meh "no radio is attached"
      note "$rdetail"
      note "If one IS plugged in: check MESHBELL_SERIAL is not pinned to none, and that"
      note "the port is not wedged (a USB overcurrent does this, and it looks identical"
      note "to wrong firmware). A read-only esptool chip-id resets it."
      ;;
    lost)
      bad "the radio link DIED (it was up and stopped answering, or the device vanished)"
      note "$rdetail"
      note "A USB overcurrent wedges the port and looks identical to wrong firmware;"
      note "a read-only esptool chip-id resets it. The bridge keeps retrying."
      ;;
    "") meh "the bridge reported no radio state at all" ;;
    *)  bad "the radio is $rstate"; note "$rdetail" ;;
  esac

  local carrying count
  carrying="$(_jget "$health" "(d.get('transports') or {}).get('carrying')")"
  count="$(_jget "$health" "(d.get('transports') or {}).get('count')")"
  if [[ -n "$count" && "$count" != "None" ]]; then
    if [[ "$carrying" == "$count" ]]; then
      ok "$carrying of $count transport(s) carrying"
    else
      meh "$carrying of $count transport(s) carrying"
      note "a transport that never came up reads the same as a quiet neighborhood."
    fi
  fi

  echo
  echo "What a neighbor gets"
  local ssid; ssid="$(_jget "$health" "(d.get('discovery') or {}).get('join_ssid') or ''")"
  if [[ -z "$ssid" ]]; then
    meh "this bridge does not run its own Wi-Fi"
    note "Neighbors must already be on your network AND be told an address, and on a"
    note "network with internet an iPhone cannot open the plain address at all."
    note "Give it its own Wi-Fi so there is nothing to type:"
    # `meshbellctl guest --start` over a bare `sudo install-ap.sh --interface wlan1`:
    # it FINDS the spare adapter instead of guessing that it is wlan1 (on this Pi it is
    # not), it sudos correctly on both install shapes, and it restarts once so the QR
    # and /qr/wifi are live immediately. It runs the same install-ap.sh underneath, so
    # the two surfaces still name one thing -- see the doc comment on cmd_guest.
    note "  meshbellctl guest --start --ssid meshbell-<your-street>"
    note "(that runs $DIR/install-ap.sh for you, on whichever spare adapter it finds)"
  else
    ok "it runs its own Wi-Fi: $ssid"
    local probe
    probe="$(curl -fsS --max-time 5 -H 'Host: captive.apple.com' \
             "http://127.0.0.1:$PORT/hotspot-detect.html" 2>/dev/null || true)"
    if printf '%s' "$probe" | grep -qi "Success"; then
      ok "the captive portal answers (it remembers this machine pressed Continue)"
      note "from a NEW phone it shows the welcome page instead -- that is the test"
      note "that matters, and only a phone can run it."
    elif printf '%s' "$probe" | grep -q "Continue"; then
      ok "a joining phone gets the welcome page"
    else
      bad "the captive portal did not answer"
      note "a neighbor would join and see nothing open by itself."
    fi
    if curl -fsS --max-time 5 "http://127.0.0.1:$PORT/portal/done" 2>/dev/null | grep -q "connected"; then
      ok "pressing Continue releases the network"
    else
      bad "the release step did not answer"
      note "without it a phone keeps using cellular and cannot reach this bridge at all."
    fi
    # DNS/DHCP truth lives in the watch's wifi_door_dhcp check above (a liveness
    # probe of NetworkManager's dnsmasq), not in whether a config file exists -- the
    # file survives every way the service can die. Only the config's PRESENCE is
    # worth adding here, as the hint it actually is.
    if [[ ! -r /etc/NetworkManager/dnsmasq-shared.d/meshbell-ap.conf ]]; then
      meh "the captive-DNS config file is missing or unreadable"
      note "without it, ${MESHBELL_AP_HOSTNAME:-meshbell.lan} will not resolve for a neighbor."
      note "re-run install-ap.sh to restore it."
    fi
  fi

  echo
  echo "The log"
  local held recs
  held="$(_jget "$health" "(d.get('assertions') or {}).get('held')")"
  recs="$(_jget "$health" "(d.get('inbound') or {}).get('total')")"
  [[ -n "$held" && "$held" != "None" ]] && ok "holding $held signed record(s)" \
    || meh "this bridge is not keeping an append-only log"
  local parr pund
  parr="$(_jget "$health" "((d.get('transports') or {}).get('pump') or {}).get('arrived')")"
  pund="$(_jget "$health" "((d.get('transports') or {}).get('pump') or {}).get('undecodable')")"
  if [[ -n "$parr" && "$parr" != "None" ]]; then
    note "heard $parr frame(s) since start, $pund of them other apps' traffic --"
    note "on a shared community channel that is the mesh being alive, not an error."
  fi

  echo
  if (( DOCTOR_FAILS > 0 )); then
    echo "$DOCTOR_FAILS problem(s) and $DOCTOR_WARNS thing(s) worth a look."
    echo "Nothing above is guesswork: each line was tested just now."
    return 1
  fi
  if (( DOCTOR_WARNS > 0 )); then
    echo "No failures. $DOCTOR_WARNS thing(s) worth a look."
  else
    echo "Everything checked passed."
  fi
  echo
  echo "The only test this cannot do for you is the one that matters most:"
  echo "join the Wi-Fi from a phone you have not used before and see what happens."
  return 0
}

# ------------------------------------------------------------------ update ---

cmd_update() {
  need_install
  local before; before="$(cat "$DIR/VERSION" 2>/dev/null || echo unknown)"
  say "installed: $before"

  local latest; latest="$(curl -fsSL --max-time 15 "$BASE_URL/get/VERSION" 2>/dev/null || echo "")"
  if [[ -z "$latest" ]]; then
    warn "could not reach $BASE_URL to check for a newer bridge"
    exit 1
  fi
  say "published: $latest"
  if [[ "$before" == "$latest" ]]; then
    say "already up to date"
    return 0
  fi

  # "Different" is not "newer". The published package carried no content digest for a
  # long time, and on 2026-08-20 it was three weeks behind the tree -- old enough to
  # predate the whole official-alerts rail. Updating into that would be a DOWNGRADE that
  # silently removed a neighbourhood's FEMA alerts, and it would look like maintenance.
  #
  # ORDER BY pkg= (the last commit's epoch), the same field the app's OTA rail and the
  # bridge's own refusal use. Falls back to the old content=-presence heuristic only
  # when a stamp predates pkg= (0 means "no orderable field"), so an old install with
  # no pkg= is still protected from a published package that also lacks one.
  local before_pkg latest_pkg
  before_pkg="$(sed -n 's/.*pkg=\([0-9][0-9]*\).*/\1/p' <<<"$before")"; before_pkg="${before_pkg:-0}"
  latest_pkg="$(sed -n 's/.*pkg=\([0-9][0-9]*\).*/\1/p' <<<"$latest")"; latest_pkg="${latest_pkg:-0}"
  local downgrade=0
  if (( before_pkg > 0 || latest_pkg > 0 )); then
    (( latest_pkg < before_pkg )) && downgrade=1
  elif [[ "$before" == *content=* && "$latest" != *content=* ]]; then
    downgrade=1
  fi
  if (( downgrade )); then
    warn "the published package is older than what is installed here"
    echo "   installed: $before" >&2
    echo "   published: $latest" >&2
    echo "   Updating would REMOVE features this bridge has. Nothing was changed." >&2
    echo "   If you really want the published one: meshbellctl update --force" >&2
    [[ "${1:-}" == "--force" ]] || return 1
    warn "--force given; installing the older published package anyway"
  fi

  # The installer never overwrites bridge.env, so settings and the state directory
  # survive an update. Said out loud because "update" is the word people are most
  # afraid of on a machine holding their neighbourhood's log.
  say "updating — your settings ($ENV_FILE) and data ($DIR/state) are left alone"

  # STOP THE SERVICE FIRST. The installer refuses when the port is already in use, which
  # is a good guard against colliding with somebody else and a fatal one here: the thing
  # holding the port is the bridge being updated. Without this, `meshbellctl update`
  # downloaded, verified, unpacked and then failed on "Port 4190 is already in use" --
  # the update command could never once have worked.
  if [[ $PLATFORM == linux && -n "$SYSTEMCTL_SCOPE" ]]; then
    systemctl --user stop "$UNIT_NAME" 2>/dev/null || true
  elif [[ $PLATFORM == linux ]]; then
    as_root systemctl stop "$UNIT_NAME" 2>/dev/null || true
  else
    launchctl unload "$PLIST" 2>/dev/null || true
  fi

  # The installer starts it again. If the download or the install fails, the bridge is
  # left down -- so say how to bring it back rather than leaving somebody guessing.
  if ! curl -fsSL "$BASE_URL/install.sh" | MESHBELL_DIR="$DIR" MESHBELL_PORT="$PORT" as_root bash; then
    warn "the update failed and the bridge is stopped"
    echo "   bring it back:  meshbellctl restart" >&2
    return 1
  fi
  say "now: $(cat "$DIR/VERSION" 2>/dev/null || echo unknown)"
}

cmd_version() {
  echo "installed: $(cat "$DIR/VERSION" 2>/dev/null || echo 'unknown')"
  echo "published: $(curl -fsSL --max-time 10 "$BASE_URL/get/VERSION" 2>/dev/null || echo 'could not reach the internet')"
}

# --------------------------------------------------------------- uninstall ---

cmd_uninstall() {
  need_install
  # The state directory holds the neighbourhood's signed log and this node's keyring.
  # Deleting it silently because somebody typed "uninstall" would destroy the one thing
  # that cannot be downloaded again.
  echo "This removes the service and $DIR, INCLUDING $DIR/state"
  echo "(the signed log and this bridge's keyring)."
  read -r -p "Type the word 'remove' to confirm: " answer
  [[ "$answer" == "remove" ]] || { say "left alone"; exit 0; }

  if [[ $PLATFORM == linux && -n "$SYSTEMCTL_SCOPE" ]]; then
    systemctl --user disable --now "$UNIT_NAME" 2>/dev/null || true
    rm -f "$USER_UNIT"
    systemctl --user daemon-reload
  elif [[ $PLATFORM == linux ]]; then
    as_root systemctl disable --now "$UNIT_NAME" 2>/dev/null || true
    as_root rm -f "/etc/systemd/system/$UNIT_NAME"
    as_root systemctl daemon-reload
  else
    launchctl unload "$PLIST" 2>/dev/null || true
    rm -f "$PLIST"
  fi
  as_root rm -rf "$DIR"
  # Only if it points HERE. Removing a shared command that belongs to a different
  # install is how uninstalling one bridge breaks another.
  for link in /usr/local/bin/meshbellctl "$HOME/.local/bin/meshbellctl"; do
    if [[ -L "$link" && "$(readlink -f "$link" 2>/dev/null)" == "$(readlink -f "$DIR/meshbellctl" 2>/dev/null)" ]]; then
      as_root rm -f "$link" 2>/dev/null || true
    fi
  done
  say "removed. Nothing of Meshbell's is left on this machine."
}

# ---------------------------------------------------------------- dispatch ---

usage() {
  cat <<'EOF'
meshbellctl — run and maintain a Meshbell bridge

  meshbellctl status              is it working, and is it carrying official alerts?
  meshbellctl address             what a neighbour types on this Wi-Fi
  meshbellctl guest               a Wi-Fi that reaches this bridge and nothing else
  meshbellctl guest --start       stand that Wi-Fi up now
  meshbellctl maps                what map this bridge has, and what it could have
  meshbellctl maps install <name> fetch a map pack for your area
  meshbellctl logs [n]            the last n lines (default 100)
  meshbellctl config              what this bridge is set to
  meshbellctl config KEY VALUE    change one setting and restart
  meshbellctl release-ble         let a phone claim this bridge's Bluetooth radio:
                                  stop using it now, and stay off it after a restart
  meshbellctl doctor              check the whole path a neighbour takes, and say what failed
  meshbellctl token               print the admin token the app uses to update this
                                  bridge, MAKING one — which restarts the bridge — if
                                  none is set (--rotate replaces it and restarts too;
                                  every phone must then be re-entered)
  meshbellctl update              fetch a newer bridge; settings and data are kept
  meshbellctl compact             fold records past their retention windows out of
                                  the log now; a dated receipt is kept in state/
  meshbellctl version             installed vs published
  meshbellctl restart|stop|start
  meshbellctl uninstall           remove the service, the files, and the log

Examples
  meshbellctl config MESHBELL_IPAWS 1            carry official FEMA alerts
  meshbellctl config BRIDGE_NAME "Oak St"        what neighbours see it called
EOF
}

case "${1:-status}" in
  status)    shift || true; cmd_status "$@" ;;
  doctor|check) cmd_doctor ;;
  address|url|where) cmd_address ;;
  guest)     shift || true; cmd_guest "$@" ;;
  maps)      shift || true; cmd_maps "$@" ;;
  logs)      shift || true; cmd_logs "$@" ;;
  config)    shift || true; cmd_config "$@" ;;
  release-ble) shift || true; cmd_release_ble "$@" ;;
  token)     shift || true; cmd_token "$@" ;;
  update)    shift || true; cmd_update "$@" ;;
  compact)   cmd_compact ;;
  version)   cmd_version ;;
  restart)   cmd_restart ;;
  start)     cmd_start ;;
  stop)      cmd_stop ;;
  uninstall) cmd_uninstall ;;
  -h|--help|help) usage ;;
  *) warn "unknown command '${1}'"; usage; exit 1 ;;
esac
