#!/usr/bin/env bash
# IROMUSIC installer for macOS and Debian/Ubuntu Linux.
#
#   curl -fsSL https://iromusic.live/install.sh | bash
#
# What it does: asks the IROMUSIC release catalog (https://api.iro.mobi) for the
# newest build for this machine, downloads it over HTTPS, checks the SHA-256
# from the catalog, and installs it. Nothing is changed before the hash matches.
#   macOS  universal DMG (Apple Silicon and Intel) -> /Applications (or ~/Applications)
#   Linux  .deb for amd64 or arm64 via apt-get (needs sudo, Ubuntu 24.04 or newer)
#
# Dependencies are required, not optional. The .deb declares the libraries the app
# needs to start (GTK, EGL/GLES, libsecret, jsoncpp, libmpv); the script reads that list
# from the verified package and installs whatever is missing before the app: it refreshes
# the package lists and, on Ubuntu, turns on the universe repository when it is off.
# macOS needs nothing else; the script only checks the OS version the app declares.
#
# Why a script: a file downloaded by a browser is quarantined by macOS and the
# app is not notarized yet, so macOS reports it as damaged. A file fetched with
# curl is not quarantined. The script also clears the attribute on the installed
# app and tells you so. Read this file first if you like:
#   curl -fsSL https://iromusic.live/install.sh | less
#
# Options: --dry-run    show what would be installed, change nothing
#          --uninstall  remove IROMUSIC (your data stays)
#          --no-color   plain output (also: NO_COLOR=1, or any non-terminal)
#          --no-launch  do not offer to start IROMUSIC when the install finishes
#          --help
# Environment: IROMUSIC_INSTALL_DIR  macOS install directory (default /Applications)
#              NO_COLOR              disable colors (https://no-color.org)
#              FORCE_COLOR           keep colors when the output is not a terminal
#
# Output: colors, symbols and the progress bar appear only on a terminal; piped or
# logged output is plain text. Symbols fall back to ASCII outside a UTF-8 locale.
#
# The whole script is wrapped in functions and ends with `main "$@"`: a download
# that is cut short runs nothing, and no child process can read the rest of the
# script from stdin.
set -euo pipefail

API_ORIGIN="https://api.iro.mobi"
APP_BUNDLE_ID="dev.iromusic.iromusic"
APP_DIR_NAME="iromusic.app"
PAGE_URL="https://iromusic.live/#downloads"
INSTALLER_URL="https://iromusic.live/install.sh"

TMP_DIR=""
MOUNT_DIR=""
DRY_RUN=0
NO_COLOR_FLAG=0
LAUNCH=1
ELAPSED=""
LISTS_TRIED=0 # apt-get update is attempted at most once per run
APT_DIR="${IROMUSIC_APT_DIR:-/etc/apt}" # the variable is a test seam

# ---- terminal UI --------------------------------------------------------------
ESC="$(printf '\033')"
UI_TTY=0
UI_COLOR=0
UI_UNI=0
C_RESET="" C_BOLD="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW="" C_ACCENT=""
SYM_OK="+" SYM_FAIL="x" SYM_WARN="!" SYM_ARROW=">" SYM_MARK="*" BAR_ON="#" BAR_OFF="-"
SPIN_FRAMES="| / - \\"
SPIN_PID=""
SPIN_MSG=""
SPIN_FILE=""
SPIN_TOTAL=0

ui_init() {
  UI_TTY=0
  if [ -t 1 ] && [ -t 2 ]; then UI_TTY=1; fi
  UI_COLOR=0
  if [ "$NO_COLOR_FLAG" -eq 0 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
    if [ "$UI_TTY" -eq 1 ] || [ -n "${FORCE_COLOR:-}" ]; then UI_COLOR=1; fi
  fi
  C_RESET="" C_BOLD="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW="" C_ACCENT=""
  if [ "$UI_COLOR" -eq 1 ]; then
    C_RESET="${ESC}[0m" C_BOLD="${ESC}[1m" C_DIM="${ESC}[2m"
    C_RED="${ESC}[31m" C_GREEN="${ESC}[32m" C_YELLOW="${ESC}[33m"
    case "${COLORTERM:-}" in
      truecolor | 24bit) C_ACCENT="${ESC}[38;2;225;151;243m" ;; # the site's lavender accent
      *) C_ACCENT="${ESC}[35m" ;;
    esac
  fi
  case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
    *UTF-8* | *utf-8* | *UTF8* | *utf8*) UI_UNI=1 ;;
    *) UI_UNI=0 ;;
  esac
  if [ "$UI_UNI" -eq 1 ]; then
    SYM_OK="✓" SYM_FAIL="✗" SYM_WARN="!" SYM_ARROW="→" SYM_MARK="◆" BAR_ON="━" BAR_OFF="─"
    SPIN_FRAMES="⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏"
  else
    SYM_OK="+" SYM_FAIL="x" SYM_WARN="!" SYM_ARROW=">" SYM_MARK="*" BAR_ON="#" BAR_OFF="-"
    SPIN_FRAMES="| / - \\"
  fi
}

# A finished step: "  ✓ label   detail".
ok() {
  spin_stop
  printf '  %s%s%s %s' "$C_GREEN" "$SYM_OK" "$C_RESET" "$1"
  if [ -n "${2:-}" ]; then printf '  %s%s%s' "$C_DIM" "$2" "$C_RESET"; fi
  printf '\n'
}
info() { spin_stop; printf '  %s%s%s %s\n' "$C_ACCENT" "$SYM_ARROW" "$C_RESET" "$*"; }
warn() { spin_stop; printf '  %s%s%s %s\n' "$C_YELLOW" "$SYM_WARN" "$C_RESET" "$*" >&2; }
note() { printf '    %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
# kv <key> <value>: aligned detail line.
kv() { printf '    %s%-9s%s %s\n' "$C_DIM" "$1" "$C_RESET" "$2"; }
die() {
  spin_stop
  if [ "$UI_UNI" -eq 1 ] || [ "$UI_COLOR" -eq 1 ]; then
    printf '\n  %s%s%s %s\n' "$C_RED" "$SYM_FAIL" "$C_RESET" "$*" >&2
  else
    printf 'error: %s\n' "$*" >&2
  fi
  exit 1
}

# die_hint <message> <hint>...: the error, then indented lines saying what to do next.
die_hint() {
  local msg="$1" hint
  shift
  spin_stop
  if [ "$UI_UNI" -eq 1 ] || [ "$UI_COLOR" -eq 1 ]; then
    printf '\n  %s%s%s %s\n' "$C_RED" "$SYM_FAIL" "$C_RESET" "$msg" >&2
  else
    printf 'error: %s\n' "$msg" >&2
  fi
  for hint in "$@"; do printf '    %s%s%s\n' "$C_DIM" "$hint" "$C_RESET" >&2; done
  exit 1
}

human_size() { awk -v b="${1:-0}" 'BEGIN { printf "%.1f MB", b / 1000000 }'; }

# The "  ━━━━━──── 42%  20.3 / 48.3 MB" tail of a download line.
progress_suffix() {
  local got pct filled i on="" off=""
  got=0
  # The file appears only once curl has connected; reading a missing file would print a shell error.
  if [ -f "$SPIN_FILE" ]; then got="$(wc -c < "$SPIN_FILE" | tr -d ' ')"; fi
  if [ "${SPIN_TOTAL:-0}" -gt 0 ]; then
    pct=$((got * 100 / SPIN_TOTAL))
    [ "$pct" -gt 100 ] && pct=100
    filled=$((pct * 24 / 100))
    i=0
    while [ "$i" -lt 24 ]; do
      if [ "$i" -lt "$filled" ]; then on="$on$BAR_ON"; else off="$off$BAR_OFF"; fi
      i=$((i + 1))
    done
    printf '  %s%s%s%s%s%s %3d%%  %s%s / %s%s' "$C_ACCENT" "$on" "$C_RESET" "$C_DIM" "$off" "$C_RESET" \
      "$pct" "$C_DIM" "$(human_size "$got")" "$(human_size "$SPIN_TOTAL")" "$C_RESET"
  else
    printf '  %s%s%s' "$C_DIM" "$(human_size "$got")" "$C_RESET"
  fi
}

# spin_start <message>: an animated line on a terminal; nothing at all otherwise
# (the matching ok() prints the one line that ends up in logs). Set SPIN_FILE and
# SPIN_TOTAL BEFORE calling to show download progress.
spin_start() {
  SPIN_MSG="$1"
  [ "$UI_TTY" -eq 1 ] || return 0
  printf '%s[?25l' "$ESC" >&2 # hide the cursor while animating
  (
    read -r -a frames <<< "$SPIN_FRAMES"
    i=0
    while :; do
      line="  ${C_ACCENT}${frames[$((i % ${#frames[@]}))]}${C_RESET} ${SPIN_MSG}"
      if [ -n "$SPIN_FILE" ]; then line="$line$(progress_suffix)"; fi
      printf '\r%s[2K%s' "$ESC" "$line" >&2
      i=$((i + 1))
      sleep 0.1
    done
  ) </dev/null &
  SPIN_PID=$!
}

# Always safe to call: stops the animation, clears its line, restores the cursor.
spin_stop() {
  if [ -n "$SPIN_PID" ]; then
    kill "$SPIN_PID" 2>/dev/null || true
    wait "$SPIN_PID" 2>/dev/null || true
    SPIN_PID=""
    printf '\r%s[2K%s[?25h' "$ESC" "$ESC" >&2
  fi
  SPIN_FILE=""
  SPIN_TOTAL=0
}

system_label() {
  case "$(uname -s)" in
    Darwin) printf 'macOS %s · %s' "$(sw_vers -productVersion 2>/dev/null || echo '')" "$(uname -m)" ;;
    Linux)
      local pretty="Linux"
      if [ -r /etc/os-release ]; then pretty="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-Linux}")"; fi
      printf '%s · %s' "$pretty" "$(uname -m)"
      ;;
    *) printf '%s' "$(uname -s)" ;;
  esac
}

banner() {
  printf '\n  %s%s%s %sIROMUSIC%s %sinstaller%s\n' "$C_ACCENT" "$SYM_MARK" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_DIM" "$C_RESET"
  printf '    %s%s%s\n\n' "$C_DIM" "$(system_label)" "$C_RESET"
}

# summary <title>: the closing block; callers add kv/note lines after it.
summary() {
  printf '\n  %s%s%s %s%s%s\n\n' "$C_GREEN" "$SYM_OK" "$C_RESET" "$C_BOLD" "$1" "$C_RESET"
}
finish() { printf '\n  %sDone in %ss%s\n\n' "$C_DIM" "${ELAPSED:-$SECONDS}" "$C_RESET"; }

# can_prompt: a real terminal on stdout/stderr and a readable /dev/tty. Piped from curl, stdin is the
# script itself, so answers must come from /dev/tty; logs, CI and pipes never get a question.
can_prompt() {
  [ "$UI_TTY" -eq 1 ] && { : </dev/tty; } 2>/dev/null
}

# ask_yes_no <question>: Enter means yes; anything but y/yes means no.
ask_yes_no() {
  local answer=""
  printf '\n  %s%s%s %s %s[Y/n]%s ' "$C_ACCENT" "$SYM_ARROW" "$C_RESET" "$1" "$C_DIM" "$C_RESET" >/dev/tty
  IFS= read -r answer </dev/tty || answer=n
  case "$answer" in "" | [Yy] | [Yy][Ee][Ss]) return 0 ;; *) return 1 ;; esac
}

# offer_launch <macos|linux> [app path]: the closing question. Not asked on --dry-run, --uninstall,
# --no-launch or without a terminal; on Linux only when a graphical session exists.
offer_launch() {
  [ "$LAUNCH" -eq 1 ] || return 0
  ELAPSED="$SECONDS" # the time spent answering is not install time
  can_prompt || return 0
  if [ "$1" = linux ] && [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
    note "No graphical session here. Start IROMUSIC from your desktop, or run: iromusic"
    return 0
  fi
  if ask_yes_no "Launch IROMUSIC now?"; then
    if [ "$1" = macos ]; then
      open "$2" >/dev/null 2>&1 || { warn "could not open IROMUSIC; start it from Launchpad or Finder"; return 0; }
    else
      have iromusic || { warn "iromusic is not on PATH; start it from your applications menu"; return 0; }
      nohup iromusic </dev/null >/dev/null 2>&1 &
      disown 2>/dev/null || true
    fi
    ok "Launching IROMUSIC"
  else
    note "Start it any time from your applications."
  fi
}

usage() {
  cat <<'EOF'
IROMUSIC installer (macOS, Debian/Ubuntu Linux)

  curl -fsSL https://iromusic.live/install.sh | bash
  curl -fsSL https://iromusic.live/install.sh | bash -s -- --uninstall

Options:
  --dry-run    resolve the build for this machine and print it; change nothing
  --uninstall  remove IROMUSIC (settings and downloads are kept)
  --no-color   plain output (also NO_COLOR=1, or any non-terminal)
  --no-launch  do not offer to start IROMUSIC when the install finishes
  --help       show this text

Environment:
  IROMUSIC_INSTALL_DIR  macOS install directory (default /Applications, else ~/Applications)

Other platforms: https://iromusic.live/#downloads
EOF
}

cleanup() {
  spin_stop
  if [ -n "$MOUNT_DIR" ] && [ -d "$MOUNT_DIR" ]; then
    hdiutil detach "$MOUNT_DIR" -quiet >/dev/null 2>&1 || hdiutil detach "$MOUNT_DIR" -force -quiet >/dev/null 2>&1 || true
  fi
  if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
    rm -rf "$TMP_DIR"
  fi
}

# ---- helpers ------------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }

# curl with the same guard rails everywhere: HTTPS only (redirects too), TLS 1.2+,
# bounded connect time, retries for flaky links.
fetch() {
  curl --fail --location --silent --show-error --proto '=https' --proto-redir '=https' \
    --tlsv1.2 --connect-timeout 15 --retry 3 --retry-delay 2 "$@"
}

sha256_of() {
  if have sha256sum; then
    sha256sum "$1" | cut -d ' ' -f 1
  else
    shasum -a 256 "$1" | cut -d ' ' -f 1
  fi
}

# catalog_entry <platform> <variant>: the newest file's JSON object. The catalog is
# one compact JSON document whose `latest` list comes first; the `versions` history
# after it is cut off so it can never win.
catalog_entry() {
  printf '%s' "$CATALOG" \
    | sed 's/"versions":.*$//' \
    | tr '}' '\n' \
    | grep -F "\"platform\":\"$1\"" \
    | grep -F "\"variant\":\"$2\"" \
    | head -n 1 \
    || true
}

# catalog_field <platform> <variant> <field>: a string field of that entry.
catalog_field() {
  catalog_entry "$1" "$2" | sed -n "s/.*\"$3\":\"\\([^\"]*\\)\".*/\\1/p" || true
}

# catalog_int <platform> <variant> <field>: a numeric field of that entry.
catalog_int() {
  catalog_entry "$1" "$2" | sed -n "s/.*\"$3\":\\([0-9][0-9]*\\).*/\\1/p" || true
}

# resolve <platform> <variant>: sets VERSION, SHA256, SIZE, URL or dies with a clear message.
resolve() {
  local platform="$1" variant="$2"
  VERSION="$(catalog_field "$platform" "$variant" version)"
  SHA256="$(catalog_field "$platform" "$variant" sha256)"
  SIZE="$(catalog_int "$platform" "$variant" size_bytes)"
  [ -n "$SIZE" ] || SIZE=0
  case "$VERSION" in
    [0-9]*.[0-9]*.[0-9]*) ;;
    *) die "no published $platform build ($variant) in the release catalog. See $PAGE_URL" ;;
  esac
  case "$SHA256" in
    *[!0-9a-f]* | "") die "the catalog entry for $platform/$variant has no valid SHA-256; refusing to install" ;;
  esac
  [ "${#SHA256}" -eq 64 ] || die "the catalog entry for $platform/$variant has no valid SHA-256; refusing to install"
  URL="$API_ORIGIN/api/v1/releases/latest/$platform/$variant/download"
}

# resolve_step <platform> <variant>: the catalog step of every flow.
resolve_step() {
  have curl || die "curl is required"
  spin_start "Reading the release catalog"
  CATALOG="$(fetch "$API_ORIGIN/api/v1/releases")" || die "could not reach $API_ORIGIN; check your connection and try again"
  resolve "$1" "$2"
  ok "Release catalog" "IROMUSIC $VERSION"
}

# dry_run <what> <to>: print the plan and change nothing.
dry_run() {
  printf '  %s%s%s %sDry run%s %s· nothing will be changed%s\n\n' "$C_ACCENT" "$SYM_MARK" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_DIM" "$C_RESET"
  kv "Install" "$1"
  [ -z "${2:-}" ] || kv "To" "$2"
  kv "From" "$URL"
  kv "SHA-256" "$SHA256"
  printf '\n'
}

download_verified() {
  local dest="$1" got total_label=""
  [ "$SIZE" -gt 0 ] && total_label=" ($(human_size "$SIZE"))"
  SPIN_FILE="$dest"
  SPIN_TOTAL="$SIZE"
  spin_start "Downloading IROMUSIC $VERSION$total_label"
  fetch --output "$dest" "$URL" || die "download failed"
  ok "Downloaded" "$(human_size "$(wc -c < "$dest" | tr -d ' ')")"
  spin_start "Verifying checksum"
  got="$(sha256_of "$dest")"
  if [ "$got" != "$SHA256" ]; then
    die "checksum mismatch (expected $(printf '%.12s' "$SHA256")…, got $(printf '%.12s' "$got")…); nothing was installed"
  fi
  ok "Checksum verified" "sha256 $(printf '%.12s' "$SHA256")…"
}

bundle_id_of() {
  /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$1/Contents/Info.plist" 2>/dev/null || true
}

macos_target_dir() {
  if [ -n "${IROMUSIC_INSTALL_DIR:-}" ]; then
    printf '%s' "$IROMUSIC_INSTALL_DIR"
  elif [ -w /Applications ]; then
    printf '%s' /Applications
  else
    printf '%s' "$HOME/Applications"
  fi
}

# ---- macOS --------------------------------------------------------------------
# version_ge <a> <b>: a >= b, comparing dotted numbers ("sort -V" is missing on older macOS).
version_ge() {
  awk -v a="$1" -v b="$2" 'BEGIN {
    na = split(a, x, "."); nb = split(b, y, "."); n = (na > nb) ? na : nb
    for (i = 1; i <= n; i++) { p = x[i] + 0; q = y[i] + 0; if (p > q) exit 0; if (p < q) exit 1 }
    exit 0
  }'
}

# check_macos_version <app>: the app declares the oldest macOS it runs on; the bundle is otherwise
# self-contained (universal binary, frameworks inside), so this is its only requirement.
check_macos_version() {
  local need cur
  need="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$1/Contents/Info.plist" 2>/dev/null || true)"
  cur="$(sw_vers -productVersion 2>/dev/null || true)"
  [ -n "$need" ] && [ -n "$cur" ] || return 0
  version_ge "$cur" "$need" ||
    die_hint "IROMUSIC needs macOS $need or newer; this Mac runs $cur. Nothing was installed." \
      "Update macOS, or use the web app: https://web.iromusic.live"
}

install_macos() {
  have hdiutil && have ditto || die "hdiutil and ditto are required (this must be macOS)"
  banner
  resolve_step macos dmg

  local dir dest new src
  dir="$(macos_target_dir)"
  dest="$dir/$APP_DIR_NAME"

  if [ "$DRY_RUN" -eq 1 ]; then
    dry_run "IROMUSIC $VERSION (universal DMG)" "$dest"
    return 0
  fi

  if pgrep -x iromusic >/dev/null 2>&1; then
    die "IROMUSIC is running. Quit it and run the installer again."
  fi
  if [ -e "$dest" ] && [ "$(bundle_id_of "$dest")" != "$APP_BUNDLE_ID" ]; then
    die "$dest exists and is not IROMUSIC; refusing to replace it"
  fi

  TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iromusic-install.XXXXXX")"
  download_verified "$TMP_DIR/iromusic.dmg"

  spin_start "Installing to $dir"
  mkdir -p "$dir"
  MOUNT_DIR="$TMP_DIR/mnt"
  mkdir -p "$MOUNT_DIR"
  hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT_DIR" "$TMP_DIR/iromusic.dmg" >/dev/null \
    || die "could not open the disk image"
  src="$MOUNT_DIR/$APP_DIR_NAME"
  [ -d "$src" ] || die "the disk image does not contain $APP_DIR_NAME"
  [ "$(bundle_id_of "$src")" = "$APP_BUNDLE_ID" ] || die "the disk image holds an unexpected app; nothing was installed"
  check_macos_version "$src"

  # Stage beside the destination, then swap: an interrupted copy never leaves a
  # half-written app where the old one was.
  new="$dir/.iromusic-new.$$"
  rm -rf "$new"
  ditto "$src" "$new" || { rm -rf "$new"; die "copying the app failed"; }
  rm -rf "$dest"
  mv "$new" "$dest"
  xattr -dr com.apple.quarantine "$dest" >/dev/null 2>&1 || true

  hdiutil detach "$MOUNT_DIR" -quiet >/dev/null 2>&1 || true
  MOUNT_DIR=""
  ok "Installed" "$dest"

  summary "IROMUSIC $VERSION is ready"
  kv "Open" "open \"$dest\""
  kv "Remove" "curl -fsSL $INSTALLER_URL | bash -s -- --uninstall"
  printf '\n'
  note "Not notarized by Apple yet. It was fetched with curl, so macOS"
  note "opens it without the \"damaged\" warning."
  offer_launch macos "$dest"
  finish
}

uninstall_macos() {
  banner
  local dir path removed=0
  for dir in "${IROMUSIC_INSTALL_DIR:-/Applications}" "$HOME/Applications"; do
    path="$dir/$APP_DIR_NAME"
    if [ -d "$path" ] && [ "$(bundle_id_of "$path")" = "$APP_BUNDLE_ID" ]; then
      if [ "$DRY_RUN" -eq 1 ]; then
        info "Dry run: would remove $path"
      else
        pgrep -x iromusic >/dev/null 2>&1 && die "IROMUSIC is running. Quit it and run again."
        rm -rf "$path"
        ok "Removed" "$path"
      fi
      removed=1
    fi
  done
  [ "$removed" -eq 1 ] || ok "IROMUSIC is not installed"
  printf '\n'
  note "Settings and downloads stay in ~/Library (Containers/$APP_BUNDLE_ID)."
  finish
}

# ---- Linux --------------------------------------------------------------------
linux_arch() {
  local arch
  if have dpkg; then
    arch="$(dpkg --print-architecture)"
  else
    arch="$(uname -m)"
  fi
  case "$arch" in
    amd64 | x86_64) printf '%s' amd64 ;;
    arm64 | aarch64) printf '%s' arm64 ;;
    *) die "unsupported CPU architecture: $arch (IROMUSIC ships amd64 and arm64). See $PAGE_URL" ;;
  esac
}

# glibc_at_least <major.minor>: the bundle is built on Ubuntu 24.04 (glibc 2.38).
glibc_at_least() {
  local have_v
  have_v="$(getconf GNU_LIBC_VERSION 2>/dev/null | sed 's/^glibc //')"
  [ -n "$have_v" ] || return 0
  [ "$(printf '%s\n%s\n' "$1" "$have_v" | sort -V | head -n 1)" = "$1" ]
}

# need_root: sets SUDO to "" (already root) or "sudo", asking for the password up front
# so the prompt is not drawn over an animation and apt's output can stay hidden.
need_root() {
  SUDO=""
  if [ "$(id -u)" -ne 0 ]; then
    have sudo || die "sudo is required to $1 a package (or run this as root)"
    SUDO="sudo"
    info "Administrator access is needed to $1 the package"
    sudo -v || die "could not get administrator access"
  fi
}

# run_priv <log> <command> <args...>: a privileged command with its output in a log
# (shown only when the step fails).
run_priv() {
  local log="$1"
  shift
  # stdin is closed for the child: it must not read the rest of a piped script.
  # shellcheck disable=SC2086
  $SUDO env DEBIAN_FRONTEND=noninteractive "$@" >"$log" 2>&1 </dev/null
}

# run_apt <log> <args...>: apt-get through run_priv.
run_apt() {
  local log="$1"
  shift
  run_priv "$log" apt-get "$@"
}

# log_tail <log>: the last lines of a failed step, as dim notes on stderr.
log_tail() {
  [ -f "$1" ] || return 0
  tail -n "${2:-12}" "$1" | while IFS= read -r line; do note "$line"; done >&2
}

# A half-configured package makes apt try, and often fail, to finish it while it installs ours
# (a kernel whose DKMS module does not build is the classic case). Say so before doing anything.
check_dpkg_state() {
  local audit names
  audit="$(dpkg --audit 2>/dev/null || true)"
  [ -n "$audit" ] || return 0
  names="$(printf '%s\n' "$audit" | sed -n 's/^ \([A-Za-z0-9][A-Za-z0-9.+:_-]*\) .*/\1/p' | head -n 4 | tr '\n' ' ')"
  names="${names% }"
  if [ "$DRY_RUN" -eq 1 ]; then
    warn "dpkg reports packages that are not fully configured (${names:-see dpkg --audit}); a real install would stop"
    return 0
  fi
  die_hint "this system has packages that are not fully configured (${names:-see dpkg --audit}); apt would try to finish them and fail, so nothing was installed" \
    "Repair the package system, then run the installer again:" \
    "sudo dpkg --configure -a && sudo apt-get -f install"
}

# apt_failed_packages <log>: the names apt lists under "Errors were encountered while processing:".
apt_failed_packages() {
  sed -n '/^Errors were encountered while processing:/,$p' "$1" \
    | sed '1d' \
    | sed -n 's/^ *\([A-Za-z0-9][A-Za-z0-9.+:_-]*\) *$/\1/p' \
    | head -n 8 | tr '\n' ' ' | sed 's/ $//'
}

# dpkg_installed <package>: fully installed (a virtual or unknown name is not).
dpkg_installed() {
  local status
  status="$(dpkg -s "$1" 2>/dev/null || true)"
  case "$status" in *"Status: install ok installed"*) return 0 ;; *) return 1 ;; esac
}

iromusic_installed() { dpkg_installed iromusic; }

# apt_fail <log> <install|remove>: name the packages apt choked on, or show its last lines.
apt_fail() {
  local log="$1" pkgs
  pkgs="$(apt_failed_packages "$log")"
  spin_stop
  if [ -n "$pkgs" ]; then
    die_hint "apt could not finish configuring other packages: $pkgs" \
      "IROMUSIC did not cause this. Repair the package system, then run the installer again:" \
      "sudo dpkg --configure -a && sudo apt-get -f install"
  fi
  log_tail "$log"
  die "apt-get could not $2 IROMUSIC"
}

# ---- Linux dependencies -------------------------------------------------------
# The package is the source of truth for what the app needs: its Depends line is read from
# the verified .deb, so this script never carries a second copy of the list.

deb_depends() { dpkg-deb -f "$1" Depends 2>/dev/null || true; }

# missing_dependencies <depends>: for every group "a | b" with no installed alternative, the
# first alternative, one per line. Version constraints are left to apt.
missing_dependencies() {
  local groups group alts alt name first satisfied
  IFS=',' read -r -a groups <<<"$1"
  for group in "${groups[@]}"; do
    IFS='|' read -r -a alts <<<"$group"
    first="" satisfied=0
    for alt in "${alts[@]}"; do
      name="$(printf '%s' "$alt" | sed 's/([^)]*)//g; s/\[[^]]*\]//g; s/:.*$//; s/[[:space:]]//g')"
      [ -n "$name" ] || continue
      [ -n "$first" ] || first="$name"
      if dpkg_installed "$name"; then satisfied=1; fi
    done
    if [ "$satisfied" -eq 0 ] && [ -n "$first" ]; then printf '%s\n' "$first"; fi
  done
}

# refresh_lists: apt-get update, once per run. A failure is a warning: the cache may still do.
refresh_lists() {
  [ "$LISTS_TRIED" -eq 0 ] || return 0
  LISTS_TRIED=1
  spin_start "Refreshing package lists"
  if run_apt "$TMP_DIR/apt-update.log" update; then
    ok "Package lists" "refreshed"
  else
    warn "could not refresh the package lists; continuing with what apt has cached"
  fi
}

# Ubuntu, or a system derived from it (the ID lines of os-release).
is_ubuntu() {
  local ids=""
  local file="${IROMUSIC_OS_RELEASE:-/etc/os-release}" # the variable is a test seam
  if [ -r "$file" ]; then ids="$(. "$file" && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"; fi
  case " $ids " in *" ubuntu "*) return 0 ;; *) return 1 ;; esac
}

# universe_missing: Ubuntu whose apt sources name no universe component. libmpv2 lives there.
# The configured sources decide, not the downloaded lists: a slow mirror can leave universe out of
# the lists on a machine where it is enabled, and that must not touch the system's sources.
universe_missing() {
  local sources
  is_ubuntu || return 1
  sources="$(cat "$APT_DIR/sources.list" "$APT_DIR"/sources.list.d/*.list "$APT_DIR"/sources.list.d/*.sources 2>/dev/null \
    | grep -v '^[[:space:]]*#' || true)"
  [ -n "$sources" ] || return 1 # unreadable or empty: not a diagnosis
  case "$sources" in *universe*) return 1 ;; *) return 0 ;; esac
}

# unobtainable_packages <log>: "a, b" for the packages apt says it cannot find or install.
unobtainable_packages() {
  sed -n -e 's/.*Depends: \([A-Za-z0-9][A-Za-z0-9.+:_-]*\).* but it is not installable.*/\1/p' \
    -e 's/^E: Unable to locate package \(.*\)$/\1/p' "$1" 2>/dev/null \
    | sort -u | tr '\n' ' ' | sed 's/ $//; s/ /, /g'
}

# deps_fail <libraries>: nothing was touched; say what is missing and where to go instead.
deps_fail() {
  local blocked
  spin_stop
  blocked="$(unobtainable_packages "$TMP_DIR/apt-deps.log")"
  if [ -n "$blocked" ]; then
    die_hint "IROMUSIC needs $blocked, which this system's package sources do not provide; nothing was installed" \
      "This build targets Ubuntu 24.04's libraries. On other releases use the web app: https://web.iromusic.live"
  fi
  log_tail "$TMP_DIR/apt-deps.log" 6
  die_hint "IROMUSIC needs $1, and apt could not install it; nothing was installed" \
    "Check that this system's package sources are reachable, or use the web app: https://web.iromusic.live"
}

# enable_universe: add-apt-repository, then check the sources really name universe now. The tool
# leaves some setups alone (custom mirrors) while exiting 0, so its exit status proves nothing.
enable_universe() {
  spin_start "Enabling the Ubuntu universe repository"
  if ! have add-apt-repository; then
    run_apt "$TMP_DIR/apt-universe.log" install -y software-properties-common ||
      { spin_stop; log_tail "$TMP_DIR/apt-universe.log" 6; die "could not install software-properties-common to enable universe"; }
  fi
  run_priv "$TMP_DIR/apt-universe.log" add-apt-repository -y universe ||
    { spin_stop; log_tail "$TMP_DIR/apt-universe.log" 6; die "could not enable the Ubuntu universe repository"; }
  if universe_missing; then
    die_hint "universe is still off in your apt sources (add-apt-repository leaves custom mirrors alone); nothing was installed" \
      "Add universe to the Components line of your Ubuntu source in $APT_DIR/sources.list.d/ubuntu.sources" \
      "(or on the deb lines of $APT_DIR/sources.list), run sudo apt-get update, then run the installer again."
  fi
  run_apt "$TMP_DIR/apt-update.log" update || warn "could not refresh the package lists after enabling universe"
  ok "Ubuntu universe repository" "enabled (libmpv2 is in it)"
}

# install_deps <depends>: apt resolves alternatives and virtual packages itself.
install_deps() {
  spin_start "Installing dependencies"
  run_apt "$TMP_DIR/apt-deps.log" satisfy -y --no-install-recommends "$1"
}

# ensure_dependencies <deb>: everything the package depends on is installed before the app.
ensure_dependencies() {
  local deps missing shown
  deps="$(deb_depends "$1")"
  [ -n "$deps" ] || return 0 # unreadable: the install below reports what apt cannot find
  missing="$(missing_dependencies "$deps")"
  if [ -z "$missing" ]; then
    ok "Dependencies" "all present"
    return 0
  fi
  shown="$(printf '%s' "$missing" | tr '\n' ' ' | sed 's/ $//; s/ /, /g')"
  info "IROMUSIC needs $shown; installing before the app"
  refresh_lists
  if ! install_deps "$deps"; then
    spin_stop
    if universe_missing; then
      enable_universe
      install_deps "$deps" || deps_fail "$shown"
    else
      deps_fail "$shown"
    fi
  fi
  ok "Dependencies installed" "$shown"
}

# install_deb: apt-get installs the verified package; 0 when IROMUSIC ends up installed.
install_deb() {
  spin_start "Installing with apt-get"
  if run_apt "$TMP_DIR/apt.log" install -y "$TMP_DIR/iromusic.deb"; then
    ok "Installed" "IROMUSIC $VERSION"
  elif iromusic_installed; then
    # apt failed on something else after ours went in: keep the success, flag the problem.
    spin_stop
    warn "apt reported problems with other packages: $(apt_failed_packages "$TMP_DIR/apt.log")"
    ok "Installed" "IROMUSIC $VERSION (fix the package system: sudo dpkg --configure -a)"
  else
    return 1
  fi
}

install_linux() {
  have apt-get && have dpkg || die "this installer supports Debian and Ubuntu (apt). Other distributions: $PAGE_URL"
  banner
  local arch
  arch="$(linux_arch)"
  glibc_at_least 2.38 || die "IROMUSIC needs glibc 2.38 or newer (Ubuntu 24.04 or newer); this system has $(getconf GNU_LIBC_VERSION). Use the web app: https://web.iromusic.live"
  ok "System" "$arch · $(getconf GNU_LIBC_VERSION 2>/dev/null || echo glibc)"
  check_dpkg_state
  resolve_step linux "deb-$arch"

  if [ "$DRY_RUN" -eq 1 ]; then
    dry_run "IROMUSIC $VERSION ($arch .deb) with apt-get" ""
    note "Libraries the package depends on are installed first when they are missing."
    printf '\n'
    return 0
  fi

  TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iromusic-install.XXXXXX")"
  # apt runs its downloader as _apt; a private temp dir would trigger a sandbox notice.
  chmod 755 "$TMP_DIR"
  download_verified "$TMP_DIR/iromusic.deb"
  chmod 644 "$TMP_DIR/iromusic.deb"

  need_root install
  ensure_dependencies "$TMP_DIR/iromusic.deb"
  if ! install_deb; then
    if [ "$LISTS_TRIED" -eq 0 ]; then
      # Stale package lists are the usual reason apt cannot find something: refresh once, retry once.
      spin_stop
      refresh_lists
      install_deb || apt_fail "$TMP_DIR/apt.log" install
    else
      apt_fail "$TMP_DIR/apt.log" install
    fi
  fi

  summary "IROMUSIC $VERSION is ready"
  kv "Start" "iromusic   (or your applications menu)"
  kv "Remove" "curl -fsSL $INSTALLER_URL | bash -s -- --uninstall"
  offer_launch linux
  finish
}

uninstall_linux() {
  have apt-get || die "this installer supports Debian and Ubuntu (apt)"
  banner
  if ! dpkg -s iromusic >/dev/null 2>&1; then
    ok "IROMUSIC is not installed"
    finish
    return 0
  fi
  if [ "$DRY_RUN" -eq 1 ]; then
    info "Dry run: would run apt-get remove iromusic"
    return 0
  fi
  need_root remove
  TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iromusic-install.XXXXXX")"
  spin_start "Removing IROMUSIC"
  run_apt "$TMP_DIR/apt.log" remove -y iromusic || apt_fail "$TMP_DIR/apt.log" remove
  ok "Removed" "IROMUSIC"
  printf '\n'
  note "The libraries installed for it stay. sudo apt-get autoremove drops the ones nothing else uses."
  finish
}

main() {
  local action=install
  ui_init
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --dry-run) DRY_RUN=1 ;;
      --uninstall) action=uninstall ;;
      --no-color) NO_COLOR_FLAG=1 ;;
      --no-launch) LAUNCH=0 ;;
      -h | --help) usage; return 0 ;;
      *) usage >&2; die "unknown option: $1" ;;
    esac
    shift
  done
  ui_init # again: --no-color may have changed the answer

  trap cleanup EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM

  case "$(uname -s)" in
    Darwin) "${action}_macos" ;;
    Linux) "${action}_linux" ;;
    *) die "unsupported system: $(uname -s). Windows and Android downloads: $PAGE_URL" ;;
  esac
}

main "$@"
