#!/usr/bin/env bash
#=============================================================================
#  keel.sh — KeelOS marine bridge deployment                        [ BETA ]
#
#  One script. A few questions. Underway.
#
#  *** BETA NOTICE ***********************************************************
#  KeelOS is under active testing. THUS: this is Beta software. Expect rough
#  edges, keep backups, and do not make it the only instrumentation between
#  you and the water. Dry-run first: keel.sh --dry-run
#  ***************************************************************************
#
#  Turns the Linux you already run into a marine instrument bridge:
#  CAN bus, NMEA 2000, J1939, J1708 bridging, NMEA 0183, Signal K,
#  and a kiosk gauge dashboard — installing ONLY what your answers require.
#
#  Usage:
#    sudo ./keel.sh                    interactive wizard
#    sudo ./keel.sh --answers f.yml    unattended (fleet) install
#    sudo ./keel.sh --dry-run          show the plan, touch nothing
#    sudo ./keel.sh --uninstall        remove everything keel.sh added
#    ./keel.sh --detect                hardware probe report only (no root)
#    ./keel.sh --hat-guide             Waveshare / CAN HAT selection guide
#
#  Idempotent: re-run any time; it converges instead of clobbering.
#  Everything installed is recorded in $STATE_DIR/manifest for uninstall.
#
#  Version: 0.MAYBE (target 2027-01-01, subject to weather and tide)
#=============================================================================
set -Eeuo pipefail

readonly KEEL_VERSION="0.MAYBE-pi45.1"
readonly KEEL_NODE_VERSION="22.23.2"  # Signal K requires Node >=22; Node 22 still supports ARMv7 + ARM64
readonly STATE_DIR="/var/lib/keelos"
readonly MANIFEST="${STATE_DIR}/manifest"
readonly ANSWER_CACHE="${STATE_DIR}/answers.yml"
readonly LOG_FILE="/var/log/keelos-install.log"

#--------------------------------------------------------------------------
# Colors (only when stdout is a terminal)
#--------------------------------------------------------------------------
if [[ -t 1 ]]; then
  C_RESET=$'\e[0m';  C_DIM=$'\e[2m'
  C_AMBER=$'\e[38;5;214m'; C_GREEN=$'\e[38;5;79m'
  C_RED=$'\e[38;5;203m';   C_BLUE=$'\e[38;5;110m'
  C_BOLD=$'\e[1m'
else
  C_RESET='' C_DIM='' C_AMBER='' C_GREEN='' C_RED='' C_BLUE='' C_BOLD=''
fi

say()  { printf '%s\n' "${1-}"; }
info() { say "${C_BLUE}::${C_RESET} ${1-}"; }
ok()   { say "${C_GREEN} +${C_RESET} ${1-}"; }
skip() { say "${C_DIM} -${C_RESET} ${C_DIM}${1-}${C_RESET}"; }
warn() { say "${C_AMBER} !${C_RESET} ${1-}"; }
die()  { say "${C_RED}xx${C_RESET} ${1-}" >&2; exit 1; }

log()  { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >> "$LOG_FILE" 2>/dev/null || true; }

on_err() {
  local line=$1
  say ""
  warn "keel.sh ran aground at line ${line}."
  warn "Log: ${LOG_FILE} — re-running is safe, the script converges."
}
trap 'on_err $LINENO' ERR

banner() {
cat <<'EOF'
     _  __         _  ___  ___
    | |/ /___  ___| |/ _ \/ __|      one script
    | ' </ -_)/ -_) | (_) \__ \      a few questions
    |_|\_\___|\___|_|\___/|___/      underway
EOF
say "    ${C_DIM}marine bridge deployment · v${KEEL_VERSION}${C_RESET} ${C_AMBER}${C_BOLD}[BETA]${C_RESET}"
say "    ${C_AMBER}Under testing — Beta software. Not for primary navigation.${C_RESET}"
say ""
}

#--------------------------------------------------------------------------
# Globals set by detection / wizard
#--------------------------------------------------------------------------
DRY_RUN=0
UNINSTALL=0
DETECT_ONLY=0
HAT_GUIDE_ONLY=0
ASSUME_YES=0
ANSWER_FILE=""

PKG_MGR=""            # apt | dnf | pacman
BOOT_CONFIG=""        # /boot/firmware/config.txt or /boot/config.txt
PI_MODEL=""           # human-readable, empty if not a Pi
PI_GEN=0               # 4 | 5 | 0 (other/unknown)
IS_PI=0
ARCH="$(uname -m)"
OS_PRETTY="Linux"
OS_CODENAME=""

A_HAT=""              # ws-rs485-12m | ws-rs485-8m | ws-2chfd | ws-2ch-plus | ws-2ch | pican-m | usb | none
A_PROTO=""            # n2k | j1939 | both | none
A_J1708="n"           # y | n
A_0183="n"            # y | n
A_KIOSK="n"           # y | n
NEED_SIGNALK=0        # set by resolve() when n2k or kiosk is chosen

# Current Raspberry Pi OS uses Chromium; retain a fallback for older images.
pick_chromium() {
  case "$PKG_MGR" in
    apt)
      local candidate
      candidate="$(apt-cache policy chromium 2>/dev/null | awk '/Candidate:/ {print $2; exit}')"
      if [[ -n $candidate && $candidate != "(none)" ]]; then
        echo chromium
      elif apt-cache show chromium-browser >/dev/null 2>&1; then
        echo chromium-browser
      else
        # Current Pi OS calls the package chromium; let apt produce a useful
        # error instead of silently choosing a package name that does not exist.
        echo chromium
      fi ;;
    *) echo chromium ;;
  esac
}

declare -a PKGS=()            # packages to install
declare -a OVERLAYS=()        # dtoverlay lines for boot config
declare -a UNITS=()           # systemd units to enable
declare -a CAN_IFACES=()      # SocketCAN interfaces KeelOS should raise

#--------------------------------------------------------------------------
# Argument parsing
#--------------------------------------------------------------------------
usage() { sed -n '2,23p' "$0" | sed 's/^#//;s/^ //'; exit 0; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run)    DRY_RUN=1 ;;
    --uninstall)  UNINSTALL=1 ;;
    --detect)     DETECT_ONLY=1 ;;
    --hat-guide)  HAT_GUIDE_ONLY=1 ;;
    --yes|-y)     ASSUME_YES=1 ;;
    --answers)    ANSWER_FILE="${2:?--answers needs a file}"; shift ;;
    --help|-h)    usage ;;
    *) die "Unknown flag: $1 (try --help)" ;;
  esac
  shift
done

need_root() {
  [[ $EUID -eq 0 ]] || die "This action needs root. Try: sudo $0 ${*:-}"
}

run() {  # run <cmd...>  — respects --dry-run, logs everything
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: $*${C_RESET}"
  else
    log "RUN $*"
    "$@"
  fi
}

record() {  # record <type> <value>  — manifest entry for uninstall
  (( DRY_RUN )) && return 0
  mkdir -p "$STATE_DIR"
  grep -qxF "$1|$2" "$MANIFEST" 2>/dev/null || printf '%s|%s\n' "$1" "$2" >> "$MANIFEST"
}

#--------------------------------------------------------------------------
# Detection — the boat tells us what it can
#--------------------------------------------------------------------------
detect_pkg_mgr() {
  if   command -v apt-get >/dev/null 2>&1; then PKG_MGR="apt"
  elif command -v dnf     >/dev/null 2>&1; then PKG_MGR="dnf"
  elif command -v pacman  >/dev/null 2>&1; then PKG_MGR="pacman"
  else die "No supported package manager found (need apt, dnf, or pacman)."
  fi
}

detect_platform() {
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    OS_PRETTY="${PRETTY_NAME:-${NAME:-Linux}}"
    OS_CODENAME="${VERSION_CODENAME:-}"
  fi

  if [[ -r /proc/device-tree/model ]]; then
    PI_MODEL="$(tr -d '\0' < /proc/device-tree/model)"
    if [[ $PI_MODEL == *"Raspberry Pi"* ]]; then
      IS_PI=1
      [[ $PI_MODEL == *"Raspberry Pi 4"* ]] && PI_GEN=4
      [[ $PI_MODEL == *"Raspberry Pi 5"* ]] && PI_GEN=5
    fi
  fi

  # Raspberry Pi OS Bookworm/Trixie use /boot/firmware/config.txt. Keep the
  # Bullseye path as a compatibility fallback for older Pi 4 deployments.
  if   [[ -f /boot/firmware/config.txt ]]; then BOOT_CONFIG="/boot/firmware/config.txt"
  elif [[ -f /boot/config.txt          ]]; then BOOT_CONFIG="/boot/config.txt"
  fi
}

probe_spi() {  # 0 if SPI device nodes present
  compgen -G "/dev/spidev*" >/dev/null 2>&1
}

probe_usb_can() {  # prints matching USB CAN interfaces, if any
  # gs_usb-class devices show up as canX with a gs_usb driver
  local d
  for d in /sys/class/net/can*; do
    [[ -e $d ]] || continue
    if readlink -f "$d/device/driver" 2>/dev/null | grep -q gs_usb; then
      basename "$d"
    fi
  done
}

probe_socketcan() {  # prints all CAN interfaces with their bound driver when known
  local d ifc drv
  for d in /sys/class/net/can*; do
    [[ -e $d ]] || continue
    ifc="$(basename "$d")"
    drv="$(basename "$(readlink -f "$d/device/driver" 2>/dev/null)" 2>/dev/null || true)"
    if [[ -n $drv && $drv != driver ]]; then printf '%s(%s)\n' "$ifc" "$drv"; else printf '%s\n' "$ifc"; fi
  done
}

probe_serial() {  # prints candidate NMEA 0183 / RS485 serial ports
  [[ -e /dev/serial0 ]] && printf "%s\n" /dev/serial0
  compgen -G "/dev/ttyUSB*" 2>/dev/null || true
  compgen -G "/dev/ttyACM*" 2>/dev/null || true
  compgen -G "/dev/ttyAMA*" 2>/dev/null || true
}

detect_report() {
  banner
  info "Hardware probe"
  say  "   Platform ....... ${PI_MODEL:-generic $(uname -m)}"
  if (( PI_GEN == 4 || PI_GEN == 5 )); then say "   Pi generation .. ${PI_GEN}"; else say "   Pi generation .. other/unknown"; fi
  say  "   OS ............. ${OS_PRETTY}${OS_CODENAME:+ (${OS_CODENAME})}"
  say  "   Architecture ... ${ARCH}"
  say  "   Kernel ......... $(uname -r)"
  say  "   Pkg manager .... ${PKG_MGR}"
  say  "   Boot config .... ${BOOT_CONFIG:-n/a (not a Pi-style boot)}"
  if probe_spi; then say "   SPI ............ present ($(compgen -G '/dev/spidev*' | tr '\n' ' '))"
  else               say "   SPI ............ not enabled (wizard can enable it)"; fi
  local allcan; allcan="$(probe_socketcan | tr '\n' ' ' || true)"
  say  "   SocketCAN ...... ${allcan:-none detected}"
  local usbcan; usbcan="$(probe_usb_can | tr '\n' ' ' || true)"
  say  "   USB CAN ........ ${usbcan:-none detected}"
  local serials; serials="$(probe_serial | tr '\n' ' ')"
  say  "   Serial ports ... ${serials:-none detected}"
  say ""
}

#--------------------------------------------------------------------------
# Wizard — the questions from the brochure, for real
#--------------------------------------------------------------------------
ask() {  # ask <var> <prompt> <default>
  local __var=$1 __prompt=$2 __def=${3-} __ans
  if (( ASSUME_YES )); then
    printf -v "$__var" '%s' "$__def"; return 0
  fi
  read -r -p "${C_GREEN}> ${C_RESET}${__prompt} " __ans || true
  printf -v "$__var" '%s' "${__ans:-$__def}"
}

load_answers() {  # minimal flat-YAML reader: "key: value" lines only
  local f=$1 k v
  [[ -r $f ]] || die "Answer file not readable: $f"
  while IFS=':' read -r k v; do
    k="${k//[[:space:]]/}"; v="${v//[[:space:]]/}"
    case "$k" in
      hat)    A_HAT="$v"   ;;
      proto)  A_PROTO="$v" ;;
      j1708)  A_J1708="$v" ;;
      nmea0183) A_0183="$v" ;;
      kiosk)  A_KIOSK="$v" ;;
      ''|'#'*) : ;;
    esac
  done < "$f"
  info "Answers loaded from ${f}"
}

waveshare_hat_guide() {
  say "${C_AMBER}${C_BOLD}Waveshare / CAN HAT selection guide${C_RESET}"
  say ""
  say "  1) Waveshare RS485 CAN HAT — current 12 MHz board"
  say "     One MCP2515 CAN channel + one RS485 UART channel. Look for a 12.000 MHz"
  say "     crystal/oscillator on the board. Current Waveshare instructions use GPIO25."
  say ""
  say "  2) Waveshare RS485 CAN HAT — legacy 8 MHz board"
  say "     Same product family, but older boards (Waveshare says purchases before"
  say "     Aug 2019 may be 8 MHz). Pick this only if the oscillator is marked 8 MHz."
  say ""
  say "  3) Waveshare 2-CH CAN FD HAT Rev2.1+ — MCP2518FD, factory Mode A"
  say "     Two CAN/CAN-FD channels. Rev2.1+ is printed on the rear. Factory Mode A"
  say "     uses CAN0=SPI0.0/GPIO25 and CAN1=SPI1.0/GPIO24. Set logic jumper to 3.3V."
  say "     Waveshare's product page lists Pi through 4B; Pi 5 is not explicitly listed."
  say ""
  say "  4) Waveshare 2-CH CAN HAT+ — dual MCP2515 (recommended Waveshare dual-CAN for Pi 5)"
  say "     The PCB says '2-CH CAN HAT+' and uses the HAT+ form factor/EEPROM. Waveshare"
  say "     explicitly lists Raspberry Pi 5 support. Default config uses SPI1 CS1/CS2."
  say ""
  say "  5) Waveshare 2-CH CAN HAT — older non-Plus dual MCP2515 board"
  say "     PCB name does NOT have '+'. Waveshare lists support through Pi 4B, not Pi 5."
  say "     Prefer option 4 on a Pi 5."
  say ""
  say "  6) PiCAN-M — MCP2515 + NMEA 0183 (16 MHz MCP2515 setup)"
  say "  7) USB SocketCAN adapter — gs_usb/candleLight-style adapter; no SPI overlay"
  say "  8) No CAN hardware — serial/NMEA 0183 only"
  say ""
  say "${C_DIM}Tip: do not choose by connector shape alone. Read the exact PCB product name,"
  say "chip marking (MCP2515 vs MCP2518FD), and oscillator marking before continuing.${C_RESET}"
  say ""
}

validate_selection() {
  case "$A_HAT" in
    ws-rs485) A_HAT="ws-rs485-12m" ;; # backwards-compatible answer files
  esac

  case "$A_HAT" in
    ws-rs485-12m|ws-rs485-8m|ws-2chfd|ws-2ch-plus|ws-2ch|pican-m|usb|none) ;;
    *) die "Unknown hat value '${A_HAT}'. Run --hat-guide or use the interactive wizard." ;;
  esac
  case "$A_PROTO" in n2k|j1939|both|none) ;; *) die "Unknown proto value '${A_PROTO}'." ;; esac
  case "${A_J1708,,}" in y|n) ;; *) die "j1708 must be y or n." ;; esac
  case "${A_0183,,}" in y|n) ;; *) die "nmea0183 must be y or n." ;; esac
  case "${A_KIOSK,,}" in y|n) ;; *) die "kiosk must be y or n." ;; esac

  if (( IS_PI )) && (( PI_GEN != 4 && PI_GEN != 5 )); then
    warn "This installer is hardened for Raspberry Pi 4/5; detected: ${PI_MODEL}."
  fi
  if (( PI_GEN == 5 )); then
    case "$A_HAT" in
      ws-2chfd)
        warn "Waveshare's 2-CH CAN FD HAT page does not explicitly list Pi 5 support."
        warn "The standard MCP251XFD overlays are available on modern Pi kernels, but this board/Pi 5 pairing is best-effort."
        ;;
      ws-2ch)
        warn "The older Waveshare 2-CH CAN HAT is documented through Pi 4B, not Pi 5."
        warn "For Pi 5, the Waveshare 2-CH CAN HAT+ (option 4) is the documented choice."
        ;;
    esac
  fi
}

wizard() {
  local pick
  waveshare_hat_guide
  say "${C_AMBER}[1/5] Which CAN interface is physically installed?${C_RESET}"
  ask pick "[1-8]:" "1"
  case "$pick" in
    1) A_HAT="ws-rs485-12m" ;;
    2) A_HAT="ws-rs485-8m"  ;;
    3) A_HAT="ws-2chfd"     ;;
    4) A_HAT="ws-2ch-plus"  ;;
    5) A_HAT="ws-2ch"       ;;
    6) A_HAT="pican-m"      ;;
    7) A_HAT="usb"          ;;
    8) A_HAT="none"         ;;
    *) die "Pick 1-8 and re-run." ;;
  esac

  if [[ $A_HAT != "none" ]]; then
    say ""
    say "${C_AMBER}[2/5] Engine bus protocol?${C_RESET}  [N]MEA 2000 / [J]1939 / [B]oth"
    ask pick "[N/j/b]:" "N"
    case "${pick,,}" in
      j) A_PROTO="j1939" ;;
      b) A_PROTO="both"  ;;
      *) A_PROTO="n2k"   ;;
    esac
  else
    A_PROTO="none"
  fi

  say ""
  say "${C_AMBER}[3/5] Legacy J1708/J1587 diesel to bridge?${C_RESET}  [y/N]"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_J1708="y"

  say ""
  say "${C_AMBER}[4/5] NMEA 0183 serial devices?${C_RESET}  [y/N]"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_0183="y"
  if [[ $A_0183 == "y" ]]; then
    local found; found="$(probe_serial | head -n1 || true)"
    [[ -n $found ]] && info "found ${found} (gpsd/Signal K can use it)"
  fi
  # PiCAN-M has 0183 on-board — bring the serial stack along automatically.
  [[ $A_HAT == "pican-m" ]] && A_0183="y"

  say ""
  say "${C_AMBER}[5/5] Boot into kiosk gauge dashboard?${C_RESET}  [Y/n]"
  ask pick "[Y/n]:" "Y"; [[ ${pick,,} != n* ]] && A_KIOSK="y"
  say ""
  validate_selection
}

save_answers() {
  (( DRY_RUN )) && return 0
  mkdir -p "$STATE_DIR"
  cat > "$ANSWER_CACHE" <<EOF
# keel.sh answers — re-run with: keel.sh --answers ${ANSWER_CACHE}
hat: ${A_HAT}
proto: ${A_PROTO}
j1708: ${A_J1708}
nmea0183: ${A_0183}
kiosk: ${A_KIOSK}
EOF
  ok "Answers cached at ${ANSWER_CACHE}"
}

#--------------------------------------------------------------------------
# Resolution — YOUR answers only
#--------------------------------------------------------------------------
resolve() {
  say "${C_DIM}Resolving dependencies for YOUR answers only:${C_RESET}"

  case "$A_HAT" in
    ws-rs485-12m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=12000000,interrupt=25,spimaxfrequency=2000000")
      OVERLAYS+=("enable_uart=1")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    ws-rs485-8m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=8000000,interrupt=25,spimaxfrequency=1000000")
      OVERLAYS+=("enable_uart=1")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    ws-2chfd)
      # Waveshare Rev2.1+ factory/default Mode A: CAN0=SPI0.0/INT25, CAN1=SPI1.0/INT24.
      OVERLAYS+=("dtoverlay=spi1-3cs")
      OVERLAYS+=("dtoverlay=mcp251xfd,spi0-0,interrupt=25")
      OVERLAYS+=("dtoverlay=mcp251xfd,spi1-0,interrupt=24")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    ws-2ch-plus)
      # Waveshare 2-CH CAN HAT+ documented default mapping (Pi 5 supported by Waveshare).
      OVERLAYS+=("dtoverlay=i2c0")
      OVERLAYS+=("dtoverlay=spi1-3cs")
      OVERLAYS+=("dtoverlay=mcp2515,spi1-1,oscillator=16000000,interrupt=22")
      OVERLAYS+=("dtoverlay=mcp2515,spi1-2,oscillator=16000000,interrupt=13")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    ws-2ch)
      # Older non-Plus HAT. Keep Waveshare's published classic mapping; intended for Pi 4 and older.
      OVERLAYS+=("dtoverlay=mcp2515-can1,oscillator=16000000,interrupt=25")
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    pican-m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    usb)
      PKGS+=(can-utils iproute2)     # gs_usb is in-kernel; no boot overlay required
      CAN_IFACES+=(can0) ;;
    none) skip "skipped: CAN stack (not requested)" ;;
  esac

  if [[ ${#CAN_IFACES[@]} -gt 0 ]]; then
    UNITS+=(keelos-can.service)
  fi

  case "$A_PROTO" in
    n2k|both)  PKGS+=(gpsd) ;;
  esac
  if [[ $A_PROTO == "j1939" || $A_PROTO == "both" ]]; then
    PKGS+=(jq)
    UNITS+=(keelos-j1939.service)
  fi

  if [[ $A_J1708 == "y" ]]; then
    if [[ -x /usr/local/bin/keelos-j1708d ]]; then
      UNITS+=(keelos-j1708-bridge.service)
      ok "queued: J1708 9.6 kbit/s RS-485 -> vcan2 bridge"
    else
      warn "J1708 selected, but /usr/local/bin/keelos-j1708d is not bundled in this script."
      warn "Continuing without enabling the broken J1708 service; install that helper and re-run to enable it."
    fi
  else
    skip "skipped: J1708 bridge (not requested)"
  fi

  if [[ $A_0183 == "y" ]]; then
    # kplex is not consistently packaged on current Debian/Raspberry Pi OS releases.
    # gpsd + Signal K provide a supported serial path without making apt fail on kplex.
    PKGS+=(gpsd gpsd-clients)
  else
    skip "skipped: NMEA 0183 stack (not requested)"
  fi

  if [[ $A_KIOSK == "y" ]]; then
    PKGS+=("$(pick_chromium)" cage)
    UNITS+=(keelos-dashboard.service keelos-kiosk.service)
    NEED_SIGNALK=1
  else
    skip "skipped: kiosk dashboard, browser pkgs (not requested)"
  fi
  if [[ $A_PROTO == "n2k" || $A_PROTO == "both" ]]; then
    UNITS+=(keelos-dashboard.service)
    NEED_SIGNALK=1
  fi

  # Signal K currently requires Node >=22. Install a pinned official Node 22 runtime
  # ourselves so Pi 4 32-bit and Pi 4/5 64-bit don't depend on the distro's Node age.
  if (( NEED_SIGNALK )); then
    PKGS+=(ca-certificates curl xz-utils)
  fi

  mapfile -t PKGS < <(printf '%s\n' "${PKGS[@]}" | sed '/^$/d' | sort -u)
  [[ ${#UNITS[@]} -gt 0 ]] && mapfile -t UNITS < <(printf '%s\n' "${UNITS[@]}" | sort -u)
  [[ ${#CAN_IFACES[@]} -gt 0 ]] && mapfile -t CAN_IFACES < <(printf '%s\n' "${CAN_IFACES[@]}" | sort -u)

  [[ ${#PKGS[@]}       -gt 0 ]] && ok "pkgs:     ${PKGS[*]}"
  (( NEED_SIGNALK ))            && ok "runtime:  Node ${KEEL_NODE_VERSION} + Signal K server"
  [[ $A_KIOSK == "y"          ]] && ok "kiosk:    cage + chromium fullscreen on tty1 -> Signal K"
  [[ ${#OVERLAYS[@]}   -gt 0 ]] && ok "boot cfg: ${#OVERLAYS[@]} managed hardware line(s)"
  [[ ${#CAN_IFACES[@]} -gt 0 ]] && ok "CAN:      ${CAN_IFACES[*]} @ 250 kbit/s via keelos-can.service"
  [[ ${#UNITS[@]}      -gt 0 ]] && ok "units:    ${UNITS[*]}"
  say ""
}

#--------------------------------------------------------------------------
# Signal K — isolated under /opt/keelos, run as its own system user
#--------------------------------------------------------------------------
install_node_runtime() {
  (( NEED_SIGNALK )) || return 0

  local current_major=0
  if command -v node >/dev/null 2>&1; then
    current_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
  fi
  if [[ $current_major =~ ^[0-9]+$ ]] && (( current_major >= 22 )) && command -v npm >/dev/null 2>&1; then
    ok "Node $(node --version) already satisfies Signal K (>=22)"
    return 0
  fi

  local node_arch base tarball root tmp sumline
  case "$ARCH" in
    aarch64|arm64) node_arch="arm64" ;;
    armv7l|armv7*) node_arch="armv7l" ;;
    x86_64|amd64) node_arch="x64" ;;
    *) die "Signal K needs Node >=22; no pinned Node build mapping for architecture '${ARCH}'." ;;
  esac

  base="node-v${KEEL_NODE_VERSION}-linux-${node_arch}"
  tarball="${base}.tar.xz"
  root="/opt/keelos/node-v${KEEL_NODE_VERSION}"

  info "Installing official Node.js v${KEEL_NODE_VERSION} (${node_arch}) for Signal K"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: download + SHA256 verify ${tarball} from nodejs.org${C_RESET}"
    say "${C_DIM}   dry-run: install under ${root} and link node/npm into /usr/local/bin${C_RESET}"
    return 0
  fi

  tmp="$(mktemp -d)"
  curl -fL --retry 3 --retry-delay 2 -o "${tmp}/${tarball}" \
    "https://nodejs.org/dist/v${KEEL_NODE_VERSION}/${tarball}"
  curl -fL --retry 3 --retry-delay 2 -o "${tmp}/SHASUMS256.txt" \
    "https://nodejs.org/dist/v${KEEL_NODE_VERSION}/SHASUMS256.txt"
  sumline="$(grep -E "  ${tarball}$" "${tmp}/SHASUMS256.txt" || true)"
  [[ -n $sumline ]] || die "Could not find ${tarball} in Node.js SHASUMS256.txt"
  ( cd "$tmp" && printf '%s\n' "$sumline" | sha256sum -c - )

  mkdir -p /opt/keelos
  rm -rf "$root"
  tar -xJf "${tmp}/${tarball}" -C /opt/keelos
  mv "/opt/keelos/${base}" "$root"
  record dir "$root"

  local tool
  for tool in node npm npx corepack; do
    [[ -x "${root}/bin/${tool}" ]] || continue
    ln -sfn "${root}/bin/${tool}" "/usr/local/bin/${tool}"
    record file "/usr/local/bin/${tool}"
  done
  hash -r

  current_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
  [[ $current_major =~ ^[0-9]+$ ]] && (( current_major >= 22 )) \
    || die "Node installation completed but Node >=22 is not active."
  rm -rf "$tmp"
  ok "Node $(node --version) ready"
}

install_signalk() {
  (( NEED_SIGNALK )) || return 0
  install_node_runtime
  info "Installing Signal K server"

  # dedicated unprivileged user; video/render/input groups for the kiosk
  if ! id -u keelos >/dev/null 2>&1; then
    run useradd --system --create-home --home-dir /var/lib/keelos/home \
        --shell /usr/sbin/nologin keelos
    record user keelos
  fi
  local g
  for g in video render input dialout; do
    getent group "$g" >/dev/null 2>&1 && run usermod -aG "$g" keelos
  done

  if command -v signalk-server >/dev/null 2>&1; then
    ok "signalk-server already present ($(signalk-server --version 2>/dev/null || echo 'version n/a')) — converging, not reinstalling"
  else
    local prefix="/opt/keelos/npm"
    if (( DRY_RUN )); then
      say "${C_DIM}   dry-run: npm_config_prefix=${prefix} npm install -g --omit=dev signalk-server${C_RESET}"
    else
      mkdir -p "$prefix"
      env npm_config_prefix="$prefix" npm install -g --omit=dev signalk-server
      [[ -x "${prefix}/bin/signalk-server" ]] || die "Signal K npm install finished but executable was not created."
      ln -sfn "${prefix}/bin/signalk-server" /usr/local/bin/signalk-server
      record dir "$prefix"
      record file /usr/local/bin/signalk-server
    fi
    ok "signalk-server installed in ${prefix}"
  fi

  # settings dir owned by the service user; seed a minimal config once
  local skdir="/var/lib/keelos/signalk"
  if (( ! DRY_RUN )); then
    mkdir -p "$skdir"
    if [[ ! -f "$skdir/settings.json" ]]; then
      if [[ $A_PROTO == "n2k" || $A_PROTO == "both" ]]; then
        # Signal K's NMEA 2000 path is SocketCAN -> canboatjs -> n2k-signalk.
        # Do not pre-seed a security strategy; the current Admin UI can create
        # the administrator/security configuration on first use.
        cat > "$skdir/settings.json" <<'EOF'
{
  "interfaces": {},
  "pipedProviders": [
    {
      "id": "keelos-n2k-can0",
      "enabled": true,
      "pipeElements": [
        { "type": "providers/canbus", "options": { "canDevice": "can0" } },
        { "type": "providers/canboatjs" },
        { "type": "providers/n2k-signalk" }
      ]
    }
  ]
}
EOF
      else
        # A kiosk may be requested without NMEA 2000. Start Signal K cleanly
        # and let the user add the appropriate data connection in its Admin UI.
        cat > "$skdir/settings.json" <<'EOF'
{
  "interfaces": {},
  "pipedProviders": []
}
EOF
      fi
    fi
    chown -R keelos:keelos "$skdir" 2>/dev/null || true
  fi
  record file "$skdir/settings.json"
  ok "Signal K settings at ${skdir} (webapp on http://<helm>:3000)"
}

#--------------------------------------------------------------------------
# Apply — idempotent, everything recorded in the manifest
#--------------------------------------------------------------------------
pkg_install() {
  [[ ${#PKGS[@]} -eq 0 ]] && return 0
  info "Installing packages via ${PKG_MGR}"
  case "$PKG_MGR" in
    apt)    run apt-get update -qq
            run env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${PKGS[@]}" ;;
    dnf)    run dnf install -y -q "${PKGS[@]}" ;;
    pacman) run pacman -S --noconfirm --needed "${PKGS[@]}" ;;
  esac
  local p; for p in "${PKGS[@]}"; do record pkg "$p"; done
}

overlay_dir() {
  if [[ -d /boot/firmware/overlays ]]; then printf '%s\n' /boot/firmware/overlays
  elif [[ -d /boot/overlays ]]; then printf '%s\n' /boot/overlays
  else printf '%s\n' ""
  fi
}

validate_overlay_files() {
  [[ ${#OVERLAYS[@]} -eq 0 ]] && return 0
  (( IS_PI )) || return 0
  local odir line name missing=0
  odir="$(overlay_dir)"
  [[ -n $odir ]] || { warn "Raspberry Pi overlay directory not found; cannot preflight CAN overlays."; return 0; }
  for line in "${OVERLAYS[@]}"; do
    [[ $line == dtoverlay=* ]] || continue
    name="${line#dtoverlay=}"
    name="${name%%,*}"
    if [[ ! -e "${odir}/${name}.dtbo" ]]; then
      warn "Missing device-tree overlay: ${odir}/${name}.dtbo"
      missing=1
    fi
  done
  if (( missing )); then
    die "Required Pi overlay(s) are missing. Update Raspberry Pi OS/kernel/firmware, reboot, then re-run keel.sh."
  fi
  ok "Pi device-tree overlay preflight passed"
}

backup_boot_config() {
  [[ -n $BOOT_CONFIG && -f $BOOT_CONFIG ]] || return 0
  (( DRY_RUN )) && return 0
  local bdir="${STATE_DIR}/backups" backup="${STATE_DIR}/backups/config.txt.before-keelos"
  mkdir -p "$bdir"
  if [[ ! -e $backup ]]; then
    cp -a "$BOOT_CONFIG" "$backup"
    ok "Boot config backup: ${backup}"
  fi
}

apply_overlays() {
  [[ ${#OVERLAYS[@]} -eq 0 ]] && return 0
  [[ -n $BOOT_CONFIG ]] || die "CAN HAT selected but no Raspberry Pi boot config was found."
  validate_overlay_files
  info "Writing device-tree overlays to ${BOOT_CONFIG}"
  local marker="# --- keelos begin (managed, do not edit inside) ---"
  local endmark="# --- keelos end ---"
  backup_boot_config
  if (( ! DRY_RUN )); then
    # converge: remove our previous block, then append fresh. Explicit [all]
    # prevents a preceding [pi4]/[pi5]/serial conditional from capturing our block.
    sed -i "/^${marker}$/,/^${endmark}$/d" "$BOOT_CONFIG"
    {
      echo "$marker"
      echo "[all]"
      echo "dtparam=spi=on"
      printf '%s\n' "${OVERLAYS[@]}"
      echo "$endmark"
    } >> "$BOOT_CONFIG"
  else
    say "${C_DIM}   dry-run: [all]${C_RESET}"
    say "${C_DIM}   dry-run: dtparam=spi=on${C_RESET}"
    printf '%s\n' "${OVERLAYS[@]/#/   dry-run: }"
  fi
  record file "$BOOT_CONFIG:keelos-block"
  ok "Overlays staged (take effect after reboot)"
}

apply_can_runtime() {
  [[ ${#CAN_IFACES[@]} -eq 0 ]] && return 0
  info "Installing SocketCAN bring-up helper (does not take over Ethernet/Wi-Fi networking)"
  local helper="/usr/local/sbin/keelos-can-up"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${helper} for ${CAN_IFACES[*]} @ 250000 bit/s${C_RESET}"
    return 0
  fi
  mkdir -p /usr/local/sbin
  {
    cat <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
BITRATE=250000
ACTION="${1:-up}"
shift || true
IFACES=("$@")
[[ ${#IFACES[@]} -gt 0 ]] || { echo "keelos-can-up: no CAN interfaces supplied" >&2; exit 2; }

if [[ $ACTION == down ]]; then
  for ifc in "${IFACES[@]}"; do
    ip link set "$ifc" down 2>/dev/null || true
  done
  exit 0
fi

modprobe can 2>/dev/null || true
modprobe can_raw 2>/dev/null || true
modprobe can_dev 2>/dev/null || true

for ifc in "${IFACES[@]}"; do
  found=0
  for _ in {1..60}; do
    if ip link show "$ifc" >/dev/null 2>&1; then found=1; break; fi
    sleep 0.5
  done
  (( found )) || { echo "keelos-can-up: ${ifc} did not appear within 30 seconds" >&2; exit 1; }
  ip link set "$ifc" down 2>/dev/null || true
  ip link set "$ifc" type can bitrate "$BITRATE" restart-ms 100
  ip link set "$ifc" txqueuelen 1024
  ip link set "$ifc" up
  ip -details link show "$ifc"
done
EOF
  } > "$helper"
  chmod 0755 "$helper"
  record file "$helper"
  ok "CAN helper installed for: ${CAN_IFACES[*]}"
}

write_unit() {  # write_unit <name> <heredoc-on-stdin>
  local name=$1 path="/etc/systemd/system/$1"
  if (( DRY_RUN )); then say "${C_DIM}   dry-run: write ${path}${C_RESET}"; cat >/dev/null; return 0; fi
  cat > "$path"
  record unit "$name"
}

apply_units() {
  [[ ${#UNITS[@]} -eq 0 ]] && return 0
  info "Installing KeelOS services"
  local u
  for u in "${UNITS[@]}"; do
    case "$u" in
      keelos-can.service)
        local can_args="${CAN_IFACES[*]}"
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS SocketCAN bring-up (${can_args})
After=systemd-modules-load.service
Before=keelos-dashboard.service keelos-j1939.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/keelos-can-up up ${can_args}
ExecStop=/usr/local/sbin/keelos-can-up down ${can_args}
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-j1939.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS J1939 CAN logger
After=keelos-can.service
Wants=keelos-can.service

[Service]
ExecStart=/usr/bin/env candump -ta can0
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-j1708-bridge.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS J1708/J1587 RS-485 -> vcan2 bridge
After=network.target

[Service]
ExecStartPre=-/usr/sbin/modprobe vcan
ExecStartPre=-/usr/sbin/ip link add dev vcan2 type vcan
ExecStartPre=-/usr/sbin/ip link set up vcan2
ExecStart=/usr/local/bin/keelos-j1708d --port /dev/ttyUSB1 --baud 9600 --out vcan2
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-dashboard.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS Signal K server (webapp on :3000)
After=network.target keelos-can.service
Wants=network-online.target

[Service]
User=keelos
Group=keelos
Environment=HOME=/var/lib/keelos/home
Environment=NODE_ENV=production
ExecStart=/usr/bin/env signalk-server -c /var/lib/keelos/signalk
Restart=on-failure
RestartSec=3
# helm boxes lose power at the battery switch; be gentle with the SD card
Nice=5

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-kiosk.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS kiosk — fullscreen gauge dashboard on tty1
After=keelos-dashboard.service systemd-user-sessions.service
Wants=keelos-dashboard.service
Conflicts=getty@tty1.service

[Service]
User=keelos
Group=keelos
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
Environment=XDG_RUNTIME_DIR=/run/keelos-kiosk
RuntimeDirectory=keelos-kiosk
# wait (up to 60 s) for Signal K to answer on :3000 before launching the browser
ExecStartPre=/usr/bin/env bash -c 'for i in {1..60}; do (exec 3<>/dev/tcp/127.0.0.1/3000) 2>/dev/null && exec 3>&- && exit 0; sleep 1; done; exit 1'
# cage = single-app Wayland compositor; chromium in kiosk mode, crash dialogs off
ExecStart=/usr/bin/env bash -c 'exec cage -d -- "$(command -v chromium-browser || command -v chromium)" \
  --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-infobars \
  --check-for-update-interval=31536000 \
  --ozone-platform=wayland http://127.0.0.1:3000'
Restart=always
RestartSec=5

[Install]
WantedBy=graphical.target
EOF
        ;;
    esac
    run systemctl daemon-reload
    if (( DRY_RUN )); then
      run systemctl enable "$u"
    else
      run systemctl enable "$u" >/dev/null
    fi
    ok "$u enabled"
  done
}

#--------------------------------------------------------------------------
# Uninstall — remove what we added, and nothing we didn't
#--------------------------------------------------------------------------
do_uninstall() {
  need_root
  banner
  [[ -f $MANIFEST ]] || die "No manifest at ${MANIFEST} — nothing to remove."
  info "Reading manifest"
  local kind val
  # units first, then files, then packages
  while IFS='|' read -r kind val; do
    [[ $kind == unit ]] || continue
    run systemctl disable --now "$val" >/dev/null 2>&1 || true
    run rm -f "/etc/systemd/system/${val}"
    ok "removed unit ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == file ]] || continue
    if [[ $val == *":keelos-block" ]]; then
      local f="${val%:keelos-block}"
      (( DRY_RUN )) || sed -i '/^# --- keelos begin/,/^# --- keelos end ---$/d' "$f"
      ok "removed keelos block from ${f}"
    else
      run rm -f "$val"; ok "removed ${val}"
    fi
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == dir ]] || continue
    run rm -rf "$val"; ok "removed directory ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == npm ]] || continue
    run npm rm -g "$val" >/dev/null 2>&1 || true
    ok "removed npm global ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == user ]] || continue
    run userdel -r "$val" >/dev/null 2>&1 || true
    ok "removed user ${val}"
  done < "$MANIFEST"
  warn "Packages installed by keel.sh are listed below; remove manually if unused"
  grep '^pkg|' "$MANIFEST" | cut -d'|' -f2 | tr '\n' ' '; say ""
  run systemctl daemon-reload
  (( DRY_RUN )) || rm -rf "$STATE_DIR"
  ok "KeelOS uninstalled. Fair winds."
}

#--------------------------------------------------------------------------
# Main
#--------------------------------------------------------------------------
main() {
  detect_pkg_mgr
  detect_platform

  if (( DETECT_ONLY    )); then detect_report; exit 0; fi
  if (( HAT_GUIDE_ONLY )); then banner; waveshare_hat_guide; exit 0; fi
  if (( UNINSTALL      )); then do_uninstall; exit 0; fi

  need_root
  mkdir -p "$(dirname "$LOG_FILE")"; log "keel.sh v${KEEL_VERSION} start"

  banner
  info "Detected: ${PI_MODEL:-generic $(uname -m)} · ${PKG_MGR} · systemd"
  (( DRY_RUN )) && warn "DRY RUN — showing the plan, touching nothing"
  say ""

  if [[ -n $ANSWER_FILE ]]; then
    load_answers "$ANSWER_FILE"
    validate_selection
  else
    wizard
  fi

  resolve
  if (( ! ASSUME_YES && ! DRY_RUN )); then
    warn "BETA: KeelOS is under testing. It will modify boot config and systemd"
    warn "units on this machine. It does not replace the Pi OS Ethernet/Wi-Fi manager."
    local go; read -r -p "${C_GREEN}> ${C_RESET}Proceed with this Beta plan? [Y/n]: " go || true
    [[ ${go,,} == n* ]] && die "Standing down. Nothing was changed."
  fi

  local t0=$SECONDS
  pkg_install
  install_signalk
  apply_overlays
  apply_can_runtime
  apply_units
  save_answers

  say ""
  ok "${C_BOLD}Done in $((SECONDS - t0)) s.${C_RESET} ${C_AMBER}[BETA]${C_RESET}"
  warn "Beta build — verify every gauge against a known-good instrument before trusting it."
  [[ ${#OVERLAYS[@]} -gt 0 ]] && info "Reboot to raise the CAN interfaces."
  info "Re-run anytime — it's idempotent. Uninstall: keel.sh --uninstall"
  log "keel.sh done"
}

main "$@"
