#!/bin/bash
################################################################################
# install-sitrepnet-suite.sh
#
# Builds and installs, from the latest available release of each:
#   FLDIGI, FLRIG, FLMSG, FLAMP  - built from source   (w1hkj.org)
#   JS8Call                     - official AppImage    (js8call.com)
#   JS8Spotter                  - Python source         (sitrepnet.com)
#   SitRepMapper                - Python source         (sitrepnet.com)
#
# Targets Debian-family Linux (Debian, Ubuntu, Linux Mint, Pop!_OS) and
# Raspberry Pi OS (32-bit and 64-bit).
#
# Based loosely on the build pattern used by gitlab.com/amrron/setup-scripts,
# trimmed down to just these programs with no GUI dependency.
#
# Usage:
#   ./install-sitrepnet-suite.sh [options]
#
# Options:
#   --only=NAME[,NAME...]   Only act on the named program(s): fldigi,flrig,flmsg,
#                           flamp,js8call,js8spotter,sitrepmapper. Skips the
#                           interactive selection prompt below.
#   --skip-deps             Don't apt-get install build/runtime dependencies
#   --force                 Rebuild/reinstall even if already at the latest version
#   --check-only             Just report installed vs. latest versions, then exit
#   --build-dir=DIR         Directory to download/install into (default: ~/src/fldigi-suite)
#   --jobs=N                 Parallel make jobs for the source builds (default: auto)
#   --no-desktop-entries     Skip creating desktop launcher icons
#   --fldigi-version=X.Y.Z   Skip the latest-version lookup and use this exact version
#   --flrig-version=X.Y.Z    Same, for FLRIG
#   --flmsg-version=X.Y.Z    Same, for FLMSG
#   --flamp-version=X.Y.Z    Same, for FLAMP
#   --js8call-version=X.Y.Z  Same, for JS8Call
#   --js8spotter-version=X.Y.Z    Same, for JS8Spotter
#   --sitrepmapper-version=X.Y.Z  Same, for SitRepMapper
#   --allow-unknown-latest   If a program's latest-version source can't be reached
#                            and no explicit --*-version was given, fall back to a
#                            hardcoded "last known good" version instead of skipping
#                            the program. May not be the true latest — a loud
#                            warning is printed whenever this path is taken.
#   -h, --help               Show this help
#
# Program selection:
#   With no --only given and a real terminal attached, the script shows what's
#   installed vs. latest for every program, then asks which (if any) to SKIP —
#   press Enter to install everything. Non-interactive runs (piped, scripted,
#   no --only) default to installing everything, same as pressing Enter.
#
# Notes for Raspberry Pi:
#   FLDIGI's compile is memory-hungry. On boards with < 2GB RAM this script
#   defaults to a single make job (-j1) to avoid the compiler being OOM-killed.
#   If a build still fails partway through, try adding swap and re-running.
#
# Notes on --allow-unknown-latest / --*-version:
#   Each program's source files are hosted on the same domain as its version
#   listing, so if that whole site/API is down these options can't get you a
#   fresh download either — nothing can fetch code from an unreachable host.
#   They help when the *version listing* fails to parse or is briefly flaky
#   while the file server itself is still up. The script performs no
#   checksum/signature verification on downloads, so only fetch from hosts
#   you trust.
################################################################################

set -uo pipefail

SCRIPT_VERSION="1.2.1"
SCRIPT_DATE="2026-08-27"

ACTION='\033[1;90m'
READY='\033[1;92m'
FINISHED='\033[1;96m'
ERR='\033[0;31m'
NC='\033[0m'

BUILD_DIR="$HOME/src/fldigi-suite"
ONLY=""
SKIP_DEPS=0
FORCE=0
CHECK_ONLY=0
JOBS=""
DESKTOP_ENTRIES=1
ALLOW_UNKNOWN_LATEST=0
FAILURES=()

# The full program roster this script manages, and how each one is built/installed:
#   autotools    - ./configure && make && sudo make install, from a w1hkj.org tarball
#   appimage     - a prebuilt AppImage binary downloaded from a GitHub release
#   js8spotter   - a Python source .zip with its own desktop_icon_setup.sh
#   sitrepmapper - a Python source .zip installed into a venv, with its own .desktop file
ALL_PROGRAM_KEYS=(fldigi flrig flmsg flamp js8call js8spotter sitrepmapper)
declare -A PROGRAM_BIN=(
  [fldigi]=fldigi [flrig]=flrig [flmsg]=flmsg [flamp]=flamp
  [js8call]=js8call [js8spotter]=js8spotter [sitrepmapper]=sitrepmapper
)
declare -A PROGRAM_PRETTY=(
  [fldigi]=FLDIGI [flrig]=FLRIG [flmsg]=FLMSG [flamp]=FLAMP
  [js8call]=JS8Call [js8spotter]=JS8Spotter [sitrepmapper]=SitRepMapper
)
declare -A PROGRAM_KIND=(
  [fldigi]=autotools [flrig]=autotools [flmsg]=autotools [flamp]=autotools
  [js8call]=appimage [js8spotter]=js8spotter [sitrepmapper]=sitrepmapper
)

# Last known-good versions, used only as an opt-in fallback (--allow-unknown-latest)
# when a program's version source can't be reached or parsed. Confirmed live 2026-08-27.
declare -A FALLBACK_VER=(
  [fldigi]="4.2.13" [flrig]="2.0.12" [flmsg]="4.0.24" [flamp]="2.2.14"
  [js8call]="3.0.3" [js8spotter]="1.20" [sitrepmapper]="1.13.0"
)
declare -A MANUAL_VER

log()   { echo -e "${ACTION}==> $*${NC}"; }
ok()    { echo -e "${READY}$*${NC}"; }
warn()  { echo -e "${ERR}$*${NC}" >&2; }
die()   { warn "$*"; exit 1; }

################################################################################
# Argument parsing
################################################################################
for arg in "$@"; do
  case "$arg" in
    --only=*) ONLY="${arg#*=}" ;;
    --skip-deps) SKIP_DEPS=1 ;;
    --force) FORCE=1 ;;
    --check-only) CHECK_ONLY=1 ;;
    --build-dir=*) BUILD_DIR="${arg#*=}" ;;
    --jobs=*) JOBS="${arg#*=}" ;;
    --no-desktop-entries) DESKTOP_ENTRIES=0 ;;
    --fldigi-version=*) MANUAL_VER[fldigi]="${arg#*=}" ;;
    --flrig-version=*) MANUAL_VER[flrig]="${arg#*=}" ;;
    --flmsg-version=*) MANUAL_VER[flmsg]="${arg#*=}" ;;
    --flamp-version=*) MANUAL_VER[flamp]="${arg#*=}" ;;
    --js8call-version=*) MANUAL_VER[js8call]="${arg#*=}" ;;
    --js8spotter-version=*) MANUAL_VER[js8spotter]="${arg#*=}" ;;
    --sitrepmapper-version=*) MANUAL_VER[sitrepmapper]="${arg#*=}" ;;
    --allow-unknown-latest) ALLOW_UNKNOWN_LATEST=1 ;;
    -h|--help)
      sed -n '2,63p' "$0" | sed 's/^# \{0,1\}//'
      exit 0
      ;;
    *)
      die "Unknown option: $arg (use --help)"
      ;;
  esac
done

want() {
  # want <name> -> true if --only wasn't given, or names $1
  [[ -z "$ONLY" ]] && return 0
  [[ ",$ONLY," == *",$1,"* ]]
}

################################################################################
# Opening banner
################################################################################
print_banner() {
  echo -e "${FINISHED}"
  echo "################################################################################"
  echo "  SitRepNet FLDIGI Suite Setup"
  echo "  Version ${SCRIPT_VERSION} (${SCRIPT_DATE})"
  echo "  For more information, see SitRepNet.com"
  echo "################################################################################"
  echo -e "${NC}"
  echo "This script installs the latest available release of each of:"
  echo "  FLDIGI, FLRIG, FLMSG, FLAMP  - built from source (w1hkj.org)"
  echo "  JS8Call                     - official AppImage  (js8call.com)"
  echo "  JS8Spotter, SitRepMapper     - Python programs    (sitrepnet.com)"
  echo
  echo "It will:"
  echo "  1. Show what's currently installed vs. the latest available version"
  echo "  2. Let you deselect anything you don't want (default: install everything)"
  echo "  3. Install required dependencies via apt (asks for your sudo password)"
  echo "  4. Download and install whatever is missing or out of date"
  echo
  echo "Nothing is installed without your confirmation at the sudo password prompt."
  echo "Run with --check-only to see versions without installing anything, or"
  echo "--help for the full list of options."
  echo

  # Only pause when there's an actual person at the terminal to read this —
  # skip it for piped/non-interactive runs so automation doesn't hang forever.
  if [[ -t 0 ]]; then
    read -rp "Press Enter to continue..." _
  fi
}

################################################################################
# OS / arch detection
################################################################################
detect_os() {
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    PLAT="${ID:-unknown}"
    PLAT_LIKE="${ID_LIKE:-}"
  else
    PLAT="unknown"
    PLAT_LIKE=""
  fi
  ARCH="$(uname -m)"

  case "$PLAT $PLAT_LIKE" in
    *debian*|*raspbian*|*ubuntu*|*linuxmint*|*pop*)
      ;;
    *)
      warn "This script targets Debian-family distros (Debian/Ubuntu/Raspberry Pi OS/Mint)."
      warn "Detected: PLAT='$PLAT' ID_LIKE='$PLAT_LIKE'. Continuing anyway, but apt-based"
      warn "dependency installation may fail on a non-Debian system."
      ;;
  esac

  log "OS: $PLAT (like: ${PLAT_LIKE:-n/a}), arch: $ARCH"
}

################################################################################
# sudo warm-up (keep credentials cached for the duration of the script)
################################################################################
ensure_sudo() {
  log "This script needs sudo to install build dependencies and 'make install'."
  sudo -v || die "sudo access is required."
  ( while true; do sudo -n true; sleep 60; kill -0 "$$" 2>/dev/null || exit; done ) 2>/dev/null &
  SUDO_KEEPALIVE_PID=$!
  trap '[[ -n "${SUDO_KEEPALIVE_PID:-}" ]] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
}

################################################################################
# Shared dependencies
#
# Covers FLDIGI + FLRIG + FLMSG + FLAMP's FLTK / audio / usb build stack, plus
# a couple of small tools (unzip, libfuse2) shared by the AppImage/Python
# programs. Each of those also installs its own program-specific packages
# (Python, Tk, etc.) separately in its own install function.
################################################################################
install_dependencies() {
  if [[ $SKIP_DEPS -eq 1 ]]; then
    log "Skipping dependency installation (--skip-deps)."
    return 0
  fi

  # A half-finished dpkg transaction (common on a freshly-imaged Pi, e.g. from
  # raspi-config's first-boot setup or an unattended-upgrade still finishing)
  # makes apt-get refuse to install anything at all until this is run. It's a
  # no-op if nothing is actually pending, so it's safe to always run first.
  log "Checking for interrupted dpkg transactions..."
  sudo dpkg --configure -a || {
    FAILURES+=("'sudo dpkg --configure -a' failed; system package database may need manual repair")
    warn "dpkg --configure -a failed; dependency install below will likely fail as a result."
    return 1
  }

  log "Installing build dependencies via apt..."
  sudo apt-get update || warn "apt-get update failed; continuing with existing package lists."

  # apt-cache show can report a package name that has no actual installable
  # candidate (e.g. a transitional/obsoleted name) — apt-cache policy's
  # "Candidate:" line is what apt-get install actually resolves against, so
  # check against that instead of relying on apt-cache show succeeding.
  local jpeg_pkg="libjpeg9-dev"
  if ! pkg_has_candidate "$jpeg_pkg"; then
    jpeg_pkg="libjpeg62-turbo-dev"
  fi

  # AppImages (JS8Call) need libfuse2 to run directly without an explicit
  # --appimage-extract-and-run flag; some newer distros renamed it in the
  # 64-bit time_t transition.
  local fuse_pkg="libfuse2"
  if ! pkg_has_candidate "$fuse_pkg"; then
    fuse_pkg="libfuse2t64"
  fi

  local pkgs=(
    build-essential git wget curl unzip pkg-config autoconf automake libtool texinfo
    libfltk1.3-dev "$jpeg_pkg" libpng-dev libxft-dev libxinerama-dev libxcursor-dev
    libsndfile1-dev libsamplerate0-dev portaudio19-dev libpulse-dev
    libusb-1.0-0-dev libudev-dev "$fuse_pkg"
  )

  # apt-get install fails ALL-OR-NOTHING if even one named package has no
  # candidate on this system. Rather than let one bad/renamed package name
  # take down the whole dependency install, drop anything unresolvable here
  # (with a specific warning) and install what's left.
  local resolved_pkgs=()
  for p in "${pkgs[@]}"; do
    if pkg_has_candidate "$p"; then
      resolved_pkgs+=("$p")
    else
      FAILURES+=("Package '$p' has no installation candidate on this system — skipped; the build may fail without it")
      warn "'$p' is not installable on this system (no candidate) — skipping it."
    fi
  done

  sudo apt-get install -y "${resolved_pkgs[@]}" || {
    FAILURES+=("Failed to install one or more build dependencies (${resolved_pkgs[*]})")
    warn "Dependency install failed; skipping the build steps below since they'd fail too."
    return 1
  }
  return 0
}

################################################################################
# Helpers
################################################################################
pkg_has_candidate() {
  # $1 = apt package name -> 0 if apt actually has an installable candidate.
  # (apt-cache show can succeed for a transitional/obsoleted name that has no
  # real candidate — apt-cache policy's Candidate: line is what apt-get
  # install actually resolves against.)
  local candidate
  candidate=$(apt-cache policy "$1" 2>/dev/null | awk -F': ' '/^  Candidate:/{print $2; exit}')
  [[ -n "$candidate" && "$candidate" != "(none)" ]]
}

fetch_tag_name_json() {
  # $1 = URL to a JSON doc containing a "tag_name": "vX.Y.Z" field (GitHub
  # releases API, and the same shape SitRepNet's own apps use for their
  # in-app update checks) -> the version with any leading "v" stripped.
  local json tag
  json=$(curl -sf -m 20 "$1") || { echo "UNKNOWN"; return; }
  tag=$(echo "$json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"v?([^"]+)".*/\1/')
  [[ -n "$tag" ]] && echo "$tag" || echo "UNKNOWN"
}

installed_version() {
  # $1 = program key, $2 = binary name
  local key="$1" bin="$2"
  case "${PROGRAM_KIND[$key]}" in
    autotools)
      if hash "$bin" 2>/dev/null; then
        "$bin" --version 2>/dev/null | awk 'NR==1{print $2}'
      else
        echo "NOT INSTALLED"
      fi
      ;;
    appimage)
      local marker="${BUILD_DIR}/${key}/.installed_version"
      [[ -f "$marker" ]] && cat "$marker" || echo "NOT INSTALLED"
      ;;
    js8spotter)
      local f="${BUILD_DIR}/${key}/js8spotter.py"
      if [[ -f "$f" ]]; then
        grep -m1 '^swversion' "$f" | sed -E 's/^swversion *= *"([^"]+)".*/\1/'
      else
        echo "NOT INSTALLED"
      fi
      ;;
    sitrepmapper)
      local f="${BUILD_DIR}/${key}/config.py"
      if [[ -f "$f" ]]; then
        grep -m1 '^APP_VERSION' "$f" | sed -E "s/^APP_VERSION *= *'([^']+)'.*/\1/"
      else
        echo "NOT INSTALLED"
      fi
      ;;
  esac
}

latest_version() {
  # $1 = program key
  local key="$1"
  case "${PROGRAM_KIND[$key]}" in
    autotools)
      local html
      html=$(curl -sf -m 20 "https://www.w1hkj.org/files/$key/") || { echo "UNKNOWN"; return; }
      echo "$html" | grep -Eo "${key}-[0-9]+(\.[0-9]+)+\.tar\.gz" | sort -V | tail -n 1 \
        | sed -E "s/${key}-([0-9.]+)\.tar\.gz/\1/"
      ;;
    appimage)
      fetch_tag_name_json "https://api.github.com/repos/JS8Call-improved/JS8Call-improved/releases/latest"
      ;;
    js8spotter)
      fetch_tag_name_json "https://sitrepnet.com/js8spotter-download/js8spotter_version.json"
      ;;
    sitrepmapper)
      fetch_tag_name_json "https://sitrepnet.com/sitrepmapper-download/sitrepmapper_version.json"
      ;;
  esac
}

version_is_current() {
  # $1 = installed, $2 = latest -> 0 (true) if installed >= latest
  [[ "$1" == "NOT INSTALLED" || "$1" == "" ]] && return 1
  [[ "$2" == "UNKNOWN" || "$2" == "" ]] && return 1
  dpkg --compare-versions "$1" ge "$2"
}

configure_flags() {
  # Native-optimized build only makes sense on x86_64; ARM boards (Pi) use
  # plain ./configure to avoid -march=native issues on heterogeneous ARM cores.
  if [[ "$ARCH" == "x86_64" ]]; then
    echo "--enable-optimizations=native"
  else
    echo ""
  fi
}

pick_jobs() {
  if [[ -n "$JOBS" ]]; then
    echo "$JOBS"
    return
  fi
  local mem_mb
  mem_mb=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)
  if [[ "$mem_mb" -gt 0 && "$mem_mb" -lt 2048 ]]; then
    echo 1
  else
    nproc 2>/dev/null || echo 1
  fi
}

################################################################################
# Known upstream source fixes
#
# Small, targeted patches for build failures in the current upstream release
# that would otherwise break an out-of-the-box build on a modern toolchain.
# Each one is a no-op once the underlying issue is fixed upstream.
################################################################################
apply_known_source_fixes() {
  # $1 = program key, $2 = source directory
  local key="$1" srcdir="$2"

  if [[ "$key" == "flmsg" ]]; then
    # flmsg 4.0.24: widgets/font_browser.cxx calls pthread_create() without
    # including <pthread.h>. G++ has never allowed implicit function
    # declarations (unlike C), so this is a hard compile error on any modern
    # toolchain (seen on GCC 14 / Debian 13 "Trixie") — it likely only ever
    # worked because some other header transitively pulled in pthread.h.
    local f="${srcdir}/src/widgets/font_browser.cxx"
    if [[ -f "$f" ]] && ! grep -q '#include <pthread.h>' "$f"; then
      log "Applying known upstream fix: adding missing #include <pthread.h> to widgets/font_browser.cxx"
      sed -i '1i #include <pthread.h>' "$f"
    fi
  fi
}

################################################################################
# Generic build-from-source routine
################################################################################
build_and_install() {
  # $1 = program name (fldigi|flrig|flmsg|flamp), $2 = version
  local name="$1" version="$2"
  local tarball="${name}-${version}.tar.gz"
  local url="https://www.w1hkj.org/files/${name}/${tarball}"
  local srcdir="${BUILD_DIR}/${name}-${version}"
  local jobs
  jobs=$(pick_jobs)

  mkdir -p "$BUILD_DIR"

  log "Downloading ${name} ${version}..."
  ( cd "$BUILD_DIR" && wget -N "$url" ) || {
    FAILURES+=("$name: failed to download $url")
    return 1
  }

  log "Extracting ${name} ${version}..."
  ( cd "$BUILD_DIR" && tar xzf "$tarball" ) || {
    FAILURES+=("$name: failed to extract $tarball")
    return 1
  }

  [[ -d "$srcdir" ]] || {
    FAILURES+=("$name: expected source directory $srcdir not found after extraction")
    return 1
  }

  apply_known_source_fixes "$name" "$srcdir"

  log "Configuring ${name} (jobs=$jobs, arch=$ARCH)..."
  local flags
  flags=$(configure_flags)
  ( cd "$srcdir" && ./configure $flags ) || {
    FAILURES+=("$name: ./configure failed")
    return 1
  }

  log "Compiling ${name} (this can take a while, especially on a Pi)..."
  ( cd "$srcdir" && make -j"$jobs" ) || {
    FAILURES+=("$name: make failed (on a memory-constrained Pi, retry with --jobs=1 or add swap)")
    return 1
  }

  log "Installing ${name}..."
  ( cd "$srcdir" && sudo make install ) || {
    FAILURES+=("$name: make install failed")
    return 1
  }

  rm -f "${BUILD_DIR}/${tarball}"
  ok "${name} ${version} installed successfully."
  return 0
}

create_desktop_entry() {
  # $1 = binary/name, $2 = pretty name
  [[ $DESKTOP_ENTRIES -eq 1 ]] || return
  hash "$1" 2>/dev/null || return
  mkdir -p "$HOME/Desktop"
  cat > "$HOME/Desktop/${1}.desktop" <<EOF
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Name=${2}
Exec=${1}
Icon=${1}
Categories=Network;HamRadio;
EOF
  chmod +x "$HOME/Desktop/${1}.desktop"
}

################################################################################
# JS8Call — official AppImage (js8call.com / GitHub releases)
################################################################################
install_js8call() {
  local version="$1"
  local dir="${BUILD_DIR}/js8call"
  mkdir -p "$dir"

  local asset_suffix=""
  case "$ARCH" in
    x86_64)  asset_suffix="x86_64.AppImage" ;;
    aarch64) asset_suffix="aarch64.AppImage" ;;
    *)
      FAILURES+=("js8call: no AppImage published for architecture '$ARCH' (only x86_64 and aarch64 are available upstream)")
      warn "JS8Call: no AppImage available for this architecture ($ARCH) — skipping."
      return 1
      ;;
  esac

  local json url
  json=$(curl -sf -m 20 "https://api.github.com/repos/JS8Call-improved/JS8Call-improved/releases/latest") || {
    FAILURES+=("js8call: failed to query the GitHub releases API")
    return 1
  }
  url=$(echo "$json" | grep -o "\"browser_download_url\": *\"[^\"]*${asset_suffix}\"" | head -1 | sed -E 's/.*"(https:[^"]+)"/\1/')
  [[ -n "$url" ]] || {
    FAILURES+=("js8call: could not find a *${asset_suffix} asset in the latest GitHub release")
    return 1
  }

  log "Downloading JS8Call ${version} (${asset_suffix})..."
  curl -sfL -m 300 -o "${dir}/js8call.AppImage.tmp" "$url" || {
    FAILURES+=("js8call: failed to download $url")
    rm -f "${dir}/js8call.AppImage.tmp"
    return 1
  }
  mv "${dir}/js8call.AppImage.tmp" "${dir}/js8call.AppImage"
  chmod +x "${dir}/js8call.AppImage"

  # Extract just the bundled icon for the desktop entry. --appimage-extract
  # unpacks the squashfs directly and doesn't need FUSE, unlike running the
  # AppImage normally — purely cosmetic, so any failure here is non-fatal.
  local icon=""
  ( cd "$dir" && ./js8call.AppImage --appimage-extract >/dev/null 2>&1 )
  if [[ -L "${dir}/squashfs-root/.DirIcon" ]]; then
    local real_icon
    real_icon=$(readlink -f "${dir}/squashfs-root/.DirIcon" 2>/dev/null)
    if [[ -n "$real_icon" && -f "$real_icon" ]]; then
      cp "$real_icon" "${dir}/js8call-icon.${real_icon##*.}" 2>/dev/null \
        && icon="${dir}/js8call-icon.${real_icon##*.}"
    fi
  fi
  rm -rf "${dir}/squashfs-root"

  echo "$version" > "${dir}/.installed_version"

  # Terminal launch parity with the other programs. Non-fatal if it fails —
  # the program itself is already installed and usable via its desktop icon.
  if sudo tee /usr/local/bin/js8call >/dev/null <<EOF
#!/bin/sh
exec "${dir}/js8call.AppImage" "\$@"
EOF
  then
    sudo chmod +x /usr/local/bin/js8call
  else
    warn "Failed to create the /usr/local/bin/js8call launcher (JS8Call is still installed; launch it via its desktop icon or ${dir}/js8call.AppImage)."
    FAILURES+=("js8call: failed to create /usr/local/bin/js8call launcher")
  fi

  if [[ $DESKTOP_ENTRIES -eq 1 ]]; then
    # Register in the standard XDG applications directory so JS8Call shows
    # up in the desktop's Applications/Activities menu, not just as a
    # ~/Desktop icon (unlike the other six programs, nothing else installs
    # this automatically for an AppImage).
    mkdir -p "$HOME/.local/share/applications" "$HOME/Desktop"
    cat > "$HOME/.local/share/applications/js8call.desktop" <<EOF
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Name=JS8Call
Exec=${dir}/js8call.AppImage
Icon=${icon:-application-x-executable}
Categories=Network;HamRadio;
EOF
    chmod +x "$HOME/.local/share/applications/js8call.desktop"
    cp "$HOME/.local/share/applications/js8call.desktop" "$HOME/Desktop/js8call.desktop"
    chmod +x "$HOME/Desktop/js8call.desktop"
    update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
  fi

  ok "JS8Call ${version} installed successfully."
  return 0
}

################################################################################
# JS8Spotter — Python source distribution (sitrepnet.com, third-party MIT-
# licensed software by Joe Lyman KF7MIX, mirrored/distributed by SitRepNet)
################################################################################
install_js8spotter() {
  local version="$1"
  local dir="${BUILD_DIR}/js8spotter"
  mkdir -p "$BUILD_DIR"

  # The version.json used for the check above only gives a version tag, not
  # a direct file URL, so find the current download link by scraping the
  # product page — same pattern as the w1hkj.org listings.
  local page url
  page=$(curl -sf -m 20 "https://sitrepnet.com/js8spotter/") || {
    FAILURES+=("js8spotter: failed to fetch the product page to find the download link")
    return 1
  }
  url=$(echo "$page" | grep -Eo 'href="[^"]*js8spotter-[0-9]+_src\.zip"' | head -1 | sed -E 's/^href="([^"]+)"$/\1/')
  [[ -n "$url" ]] || {
    FAILURES+=("js8spotter: could not find the Linux source .zip download link on the product page")
    return 1
  }

  local tmpzip
  tmpzip=$(mktemp -p "$BUILD_DIR" js8spotter-download-XXXXXX.zip)
  log "Downloading JS8Spotter ${version} (source)..."
  curl -sfL -m 120 -o "$tmpzip" "$url" || {
    FAILURES+=("js8spotter: failed to download $url")
    rm -f "$tmpzip"
    return 1
  }

  log "Extracting JS8Spotter ${version}..."
  local extract_tmp
  extract_tmp=$(mktemp -d -p "$BUILD_DIR" js8spotter-extract-XXXXXX)
  unzip -q -o "$tmpzip" -d "$extract_tmp" || {
    FAILURES+=("js8spotter: failed to extract the downloaded zip")
    rm -f "$tmpzip"; rm -rf "$extract_tmp"
    return 1
  }
  rm -f "$tmpzip"

  # The zip's single top-level folder is versioned (e.g. js8spotter-120_src/)
  # — replace the stable install dir with its contents wholesale each update.
  local inner
  inner=$(find "$extract_tmp" -mindepth 1 -maxdepth 1 -type d | head -1)
  [[ -n "$inner" ]] || {
    FAILURES+=("js8spotter: unexpected zip layout (no top-level folder found)")
    rm -rf "$extract_tmp"
    return 1
  }
  rm -rf "$dir"
  mv "$inner" "$dir"
  rm -rf "$extract_tmp"

  [[ -f "${dir}/js8spotter.py" ]] || {
    FAILURES+=("js8spotter: js8spotter.py not found after extraction — unexpected package layout")
    return 1
  }

  # Deps per the project's own README.linux: apt-installable system packages
  # cover everything needed (no requirements.txt is shipped). Best-effort per
  # package so one renamed/missing name doesn't take down the whole install.
  log "Installing JS8Spotter dependencies via apt..."
  local js8spotter_pkgs=(python3 python3-pip python3-tk python3-pil python3-pil.imagetk python3-requests)
  local resolved=()
  local p
  for p in "${js8spotter_pkgs[@]}"; do
    if pkg_has_candidate "$p"; then
      resolved+=("$p")
    else
      warn "'$p' has no installation candidate on this system — skipping (JS8Spotter may not run without it)."
    fi
  done
  sudo apt-get install -y "${resolved[@]}" || warn "Some JS8Spotter dependencies failed to install; it may not run correctly."
  # Optional sound-alert support (tkSnack). Purely best-effort.
  if pkg_has_candidate python3-tksnack; then
    sudo apt-get install -y python3-tksnack || true
  fi

  chmod +x "${dir}/js8spotter.py"

  # The author's own script correctly detects its install location and sets
  # up both ~/.local/share/applications and a ~/Desktop shortcut.
  if [[ -f "${dir}/desktop_icon_setup.sh" && $DESKTOP_ENTRIES -eq 1 ]]; then
    bash "${dir}/desktop_icon_setup.sh" || warn "js8spotter's desktop_icon_setup.sh failed; no desktop icon created."
  fi

  # Terminal launch parity with the other programs. js8spotter.py loads
  # title.txt / its .db file via relative paths, so the working directory
  # must be its install dir. Non-fatal if it fails — already usable via the
  # desktop icon set up above.
  if sudo tee /usr/local/bin/js8spotter >/dev/null <<EOF
#!/bin/sh
cd "${dir}" && exec python3 "${dir}/js8spotter.py" "\$@"
EOF
  then
    sudo chmod +x /usr/local/bin/js8spotter
  else
    warn "Failed to create the /usr/local/bin/js8spotter launcher (JS8Spotter is still installed; launch it via its desktop icon)."
    FAILURES+=("js8spotter: failed to create /usr/local/bin/js8spotter launcher")
  fi

  ok "JS8Spotter ${version} installed successfully."
  return 0
}

################################################################################
# SitRepMapper — Python source distribution (sitrepnet.com)
################################################################################
install_sitrepmapper() {
  local version="$1"
  local dir="${BUILD_DIR}/sitrepmapper"
  mkdir -p "$BUILD_DIR"

  local page url
  page=$(curl -sf -m 20 "https://sitrepnet.com/sitrepmapper/") || {
    FAILURES+=("sitrepmapper: failed to fetch the product page to find the download link")
    return 1
  }
  url=$(echo "$page" | grep -Eo 'href="[^"]*SitRepMapper_v[0-9.]+\.zip"' | head -1 | sed -E 's/^href="([^"]+)"$/\1/')
  [[ -n "$url" ]] || {
    FAILURES+=("sitrepmapper: could not find the .zip download link on the product page")
    return 1
  }

  local tmpzip
  tmpzip=$(mktemp -p "$BUILD_DIR" sitrepmapper-download-XXXXXX.zip)
  log "Downloading SitRepMapper ${version}..."
  curl -sfL -m 120 -o "$tmpzip" "$url" || {
    FAILURES+=("sitrepmapper: failed to download $url")
    rm -f "$tmpzip"
    return 1
  }

  log "Extracting SitRepMapper ${version}..."
  local extract_tmp
  extract_tmp=$(mktemp -d -p "$BUILD_DIR" sitrepmapper-extract-XXXXXX)
  unzip -q -o "$tmpzip" -d "$extract_tmp" || {
    FAILURES+=("sitrepmapper: failed to extract the downloaded zip")
    rm -f "$tmpzip"; rm -rf "$extract_tmp"
    return 1
  }
  rm -f "$tmpzip"

  local inner
  inner=$(find "$extract_tmp" -mindepth 1 -maxdepth 1 -type d | head -1)
  [[ -n "$inner" ]] || {
    FAILURES+=("sitrepmapper: unexpected zip layout (no top-level folder found)")
    rm -rf "$extract_tmp"
    return 1
  }
  rm -rf "$dir"
  mv "$inner" "$dir"
  rm -rf "$extract_tmp"

  [[ -f "${dir}/main.py" ]] || {
    FAILURES+=("sitrepmapper: main.py not found after extraction — unexpected package layout")
    return 1
  }

  log "Installing SitRepMapper system dependencies via apt..."
  local sys_pkgs=(python3 python3-pip python3-venv python3-tk ghostscript)
  local resolved=()
  local p
  for p in "${sys_pkgs[@]}"; do
    if pkg_has_candidate "$p"; then
      resolved+=("$p")
    else
      warn "'$p' has no installation candidate on this system — skipping."
    fi
  done
  sudo apt-get install -y "${resolved[@]}" || warn "Some SitRepMapper system dependencies failed to install."

  # Debian 12+/Trixie block plain 'pip install' at the system level (PEP 668).
  # A --system-site-packages venv sidesteps that: it can still see the
  # apt-installed Tkinter bindings while pip installs the rest (Pillow, mgrs,
  # requests) in isolation, exactly matching the project's own
  # requirements.txt instructions without needing --break-system-packages.
  log "Setting up SitRepMapper's Python environment (this can take a minute)..."
  if [[ ! -d "${dir}/.venv" ]]; then
    python3 -m venv --system-site-packages "${dir}/.venv" || {
      FAILURES+=("sitrepmapper: failed to create Python virtual environment")
      return 1
    }
  fi
  "${dir}/.venv/bin/pip" install --upgrade pip >/dev/null 2>&1
  "${dir}/.venv/bin/pip" install -r "${dir}/requirements.txt" || {
    FAILURES+=("sitrepmapper: 'pip install -r requirements.txt' failed")
    return 1
  }

  # The project ships its own .desktop file (hardcoding /opt/sitrepmapper,
  # its documented install path) — reuse it as-is, only repointing Exec=/
  # Icon= at wherever this script actually installed it.
  if [[ -f "${dir}/sitrepmapper.desktop" && $DESKTOP_ENTRIES -eq 1 ]]; then
    mkdir -p "$HOME/.local/share/applications" "$HOME/Desktop"
    sed -e "s#^Exec=.*#Exec=${dir}/.venv/bin/python3 ${dir}/main.py#" \
        -e "s#^Icon=.*#Icon=${dir}/assets/icon.png#" \
        "${dir}/sitrepmapper.desktop" > "$HOME/.local/share/applications/sitrepmapper.desktop"
    chmod +x "$HOME/.local/share/applications/sitrepmapper.desktop"
    cp "$HOME/.local/share/applications/sitrepmapper.desktop" "$HOME/Desktop/sitrepmapper.desktop"
    chmod +x "$HOME/Desktop/sitrepmapper.desktop"
    update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
  fi

  # Non-fatal if this fails — already usable via the desktop icon set up above.
  if sudo tee /usr/local/bin/sitrepmapper >/dev/null <<EOF
#!/bin/sh
exec "${dir}/.venv/bin/python3" "${dir}/main.py" "\$@"
EOF
  then
    sudo chmod +x /usr/local/bin/sitrepmapper
  else
    warn "Failed to create the /usr/local/bin/sitrepmapper launcher (SitRepMapper is still installed; launch it via its desktop icon)."
    FAILURES+=("sitrepmapper: failed to create /usr/local/bin/sitrepmapper launcher")
  fi

  ok "SitRepMapper ${version} installed successfully."
  return 0
}

################################################################################
# Per-program driver
################################################################################
declare -A LATEST_VER
declare -A LATEST_SRC   # "manual" | "live" | "fallback" | "unknown"

resolve_version() {
  # $1 = program key -> sets LATEST_VER[$1] and LATEST_SRC[$1]
  local key="$1"

  if [[ -n "${MANUAL_VER[$key]:-}" ]]; then
    LATEST_VER[$key]="${MANUAL_VER[$key]}"
    LATEST_SRC[$key]="manual"
    return
  fi

  local live
  live=$(latest_version "$key")
  if [[ "$live" != "UNKNOWN" ]]; then
    LATEST_VER[$key]="$live"
    LATEST_SRC[$key]="live"
    return
  fi

  if [[ $ALLOW_UNKNOWN_LATEST -eq 1 && -n "${FALLBACK_VER[$key]:-}" ]]; then
    LATEST_VER[$key]="${FALLBACK_VER[$key]}"
    LATEST_SRC[$key]="fallback"
    return
  fi

  LATEST_VER[$key]="UNKNOWN"
  LATEST_SRC[$key]="unknown"
}

check_program() {
  # $1 = program key
  local key="$1" bin="${PROGRAM_BIN[$1]}" pretty="${PROGRAM_PRETTY[$1]}"
  want "$key" || return 0

  local installed
  installed=$(installed_version "$key" "$bin")
  resolve_version "$key"

  local latest="${LATEST_VER[$key]}" src="${LATEST_SRC[$key]}"
  local tag=""
  case "$src" in
    manual)   tag=" (manual override)" ;;
    fallback) tag=" (FALLBACK — not confirmed live, may be stale)" ;;
  esac

  printf "  %-13s installed: %-16s latest: %s%s\n" "$pretty" "$installed" "$latest" "$tag"
  if [[ "$src" == "fallback" ]]; then
    warn "  Version source for $pretty was unreachable/unparseable; using hardcoded fallback."
  fi
}

install_program() {
  # $1 = program key
  local key="$1" bin="${PROGRAM_BIN[$1]}" pretty="${PROGRAM_PRETTY[$1]}"
  want "$key" || return 0

  local installed latest="${LATEST_VER[$key]:-UNKNOWN}"
  installed=$(installed_version "$key" "$bin")

  if [[ "$latest" == "UNKNOWN" ]]; then
    warn "Could not determine a version to install for $pretty (its version source was unreachable/unparseable)."
    warn "Re-run with --${key}-version=X.Y.Z to pin a specific version, or --allow-unknown-latest"
    warn "to fall back to the last confirmed-good version (${FALLBACK_VER[$key]:-none known})."
    FAILURES+=("$pretty: could not determine latest version")
    return 1
  fi

  if [[ $FORCE -eq 0 ]] && version_is_current "$installed" "$latest"; then
    ok "$pretty is already at the latest version ($installed). Use --force to rebuild."
    return 0
  fi

  case "${PROGRAM_KIND[$key]}" in
    autotools)   build_and_install "$key" "$latest" && create_desktop_entry "$bin" "$pretty" ;;
    appimage)    install_js8call "$latest" ;;
    js8spotter)  install_js8spotter "$latest" ;;
    sitrepmapper) install_sitrepmapper "$latest" ;;
  esac
}

################################################################################
# Interactive program selection
#
# Shows every program's status, then lets the user opt out of anything they
# don't want. Skipped entirely (defaulting to "install everything") when
# --only was already given on the command line, or when there's no real
# terminal to prompt at (piped/scripted runs).
################################################################################
select_programs_interactively() {
  [[ -n "$ONLY" ]] && return
  [[ -t 0 ]] || return

  echo "Which programs would you like to install? (default: all)"
  local i key
  for i in "${!ALL_PROGRAM_KEYS[@]}"; do
    key="${ALL_PROGRAM_KEYS[$i]}"
    printf "  %d) %s\n" "$((i+1))" "${PROGRAM_PRETTY[$key]}"
  done
  echo
  read -rp "Enter numbers to SKIP, comma-separated (e.g. 2,5), or press Enter to install all: " skip_input
  echo

  [[ -z "$skip_input" ]] && return

  declare -A skip_set
  local tok
  IFS=',' read -ra skip_tokens <<< "$skip_input"
  for tok in "${skip_tokens[@]}"; do
    tok="${tok// /}"
    [[ -n "$tok" ]] && skip_set["$tok"]=1
  done

  local keep=()
  for i in "${!ALL_PROGRAM_KEYS[@]}"; do
    [[ -z "${skip_set[$((i+1))]:-}" ]] && keep+=("${ALL_PROGRAM_KEYS[$i]}")
  done

  if [[ ${#keep[@]} -eq 0 ]]; then
    warn "Every program was skipped — nothing to install."
    exit 0
  fi

  ONLY=$(IFS=,; echo "${keep[*]}")
  local pretty_keep=()
  for key in "${keep[@]}"; do pretty_keep+=("${PROGRAM_PRETTY[$key]}"); done
  log "Will install: ${pretty_keep[*]}"
}

################################################################################
# Main
################################################################################
print_banner
echo
detect_os

echo
log "Checking installed vs. latest versions..."
for p in "${ALL_PROGRAM_KEYS[@]}"; do
  check_program "$p"
done
echo

if [[ $CHECK_ONLY -eq 1 ]]; then
  ok "Check-only mode: nothing was installed."
  exit 0
fi

select_programs_interactively

ensure_sudo

echo
if install_dependencies; then
  log "Installing selected programs..."
  for p in "${ALL_PROGRAM_KEYS[@]}"; do
    install_program "$p"
  done
else
  warn "Skipping all program installs since dependencies failed to install."
  warn "Fix the dependency error above, then re-run this script — it will pick up"
  warn "right where it left off."
fi

echo
if [[ ${#FAILURES[@]} -eq 0 ]]; then
  ok "All requested programs are installed and up to date."
else
  warn "Completed with ${#FAILURES[@]} problem(s):"
  for f in "${FAILURES[@]}"; do
    warn "  - $f"
  done
  exit 1
fi
