#!/bin/bash
# =============================================================================
# machine-setup — distro packages
# =============================================================================
#
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
#
# ── The rule: install what is missing, never touch what is there ──
#
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
# the repository has a newer one. On a machine somebody already uses, that can
# move a version they chose deliberately, and the setup script is the last thing
# that should be doing that behind their back.
#
# So every install here goes through pkg_install, which queries the package
# database first, installs only the subset that is genuinely absent, and prints
# both lists before doing it. A package already present is never named on a
# command line at all.
#
# ── Why per-package-manager lists rather than a translation table ──
#
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
# and some packages are not a package elsewhere at all: apt-transport-https,
# lsb-release and software-properties-common are apt concepts. A canonical-name
# table with per-manager overrides hides both of those behind indirection. A
# plain `case $PM` says what each system actually gets, in one place, and matches
# the shape scripts/setup-old/setup.sh already used.

[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
MACHINE_SETUP_PACKAGES_LOADED=1

# What the last pkg_install/tools_install actually put on the machine, as opposed
# to what it was asked for. Read by the caller to write an honest summary line:
# without it every section reports its whole list as installed, including the
# packages it deliberately left alone.
LAST_INSTALLED=()
LAST_KEPT=()
LAST_SKIPPED=()

# -----------------------------------------------------------------------------
# The sections
# -----------------------------------------------------------------------------

# Core: what this script itself would break without, plus the command-line tools
# that make a machine worth sitting at.
#
# The first six are load-bearing and each is used by a later step — curl fetches
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
# archive, and ca-certificates is what makes any of the fetching work. The rest
# are the environment: nothing calls them, they are here because a box you use
# should have them.
#
# Four entries earn a note.
#
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
# darwin and win32 ONLY, so on Linux its install script always falls through to
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
# the failure surfaces as a broken terminal sidecar rather than as a missing
# package. build-essential below is the other half of the same requirement.
#
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
# has no equivalent at all, so it is not a name to translate. Installing the
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
# is what the apt-daily timers read, and on this host no package owns that file.
# The section makes sure it is there.
#
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
# moment it installs, an address failing to log in five times in ten minutes is
# blocked for ten, including yours. That is the point of it and it is worth
# having by default, but it is why it belongs in this comment rather than being
# thought of as one more binary. An existing install with its own jails is
# untouched, because pkg_install never names a package that is already there.
#
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
# distribution's default alongside it. It stays in core because anything that
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
pkgs_core() {
  case "$PM" in
    apt)
      # apt-transport-https and lsb-release are not tools — they are what lets a
      # later step add the Docker repository. They have no counterpart on the
      # other systems.
      #
      # software-properties-common is still here and is no longer needed by
      # anything: it provides `add-apt-repository`, and the fastfetch PPA was its
      # only caller until that was removed on 2026-08-14 (Docker writes its own
      # sources.list.d entry by hand). Left in deliberately rather than dropped
      # in the same change — it is one small package, and pulling it is a
      # separate decision from removing the tool that wanted it.
      echo curl ca-certificates gnupg git jq unzip \
        apt-transport-https lsb-release software-properties-common \
        wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
        fail2ban unattended-upgrades
      ;;
    pacman)
      echo curl ca-certificates gnupg git jq unzip \
        wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
        fail2ban
      ;;
    dnf)
      echo curl ca-certificates gnupg2 git jq unzip \
        wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
        fail2ban
      ;;
    brew)
      # curl, unzip and the TLS roots ship with macOS; the compilers come from
      # the Xcode command line tools, which is not a formula — see xcode_clt_*.
      # brotli is here because macOS ships the library but not the CLI.
      echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
      ;;
  esac
}

# -----------------------------------------------------------------------------
# Querying
# -----------------------------------------------------------------------------

# Is this package installed right now?
#
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
# package that was removed but left its config behind — that state would be read
# as "present" and the package would never be reinstalled.
pkg_is_installed() {
  case "$PM" in
    apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
    pacman) pacman -Qi "$1" &>/dev/null ;;
    dnf) rpm -q "$1" &>/dev/null ;;
    brew) brew list --formula "$1" &>/dev/null ;;
    *) return 1 ;;
  esac
}

# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------

# Refresh the package index.
#
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
# rather than at each call site, because forgetting one turns an unattended run
# into one that is silently waiting for a keypress.
pkg_refresh() {
  case "$PM" in
    apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
    pacman) pacman -Sy --noconfirm ;;
    dnf) dnf makecache ;;
    brew) brew update ;;
  esac
}

# What an upgrade would actually move, one package name per line.
#
# Asked before the upgrade runs so the section can name what it is about to
# change rather than asking to be trusted. Needs a refreshed index to be
# accurate, which is why pkg_refresh runs first.
#
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
# which is the same calculation the real run does — as opposed to
# `apt list --upgradable`, which also lists packages that are held back and
# would not actually move.
pkg_upgradable() {
  case "$PM" in
    apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
    pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
    dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
    brew) brew outdated --quiet 2>/dev/null ;;
  esac
}

# Upgrade everything already installed. Separate from pkg_install on purpose:
# this one DOES move versions, so it is a deliberate step rather than something
# that happens as a side effect of installing a tool.
pkg_upgrade_all() {
  case "$PM" in
    apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
    pacman) pacman -Su --noconfirm ;;
    dnf) dnf upgrade -y ;;
    brew) brew upgrade ;;
  esac
}

# The raw install, with no presence check. Use pkg_install instead.
pkg_install_now() {
  case "$PM" in
    apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
    pacman) pacman -S --noconfirm --needed "$@" ;;
    dnf) dnf install -y "$@" ;;
    brew) brew install "$@" ;;
  esac
}

# Announce a section, then install only what is absent from it.
#
#   pkg_install "Core packages" $(pkgs_core)
#
# Prints both lists before touching anything, so the run says what it is about to
# do to this machine and what it is deliberately leaving alone. Returns 0 when
# there was nothing to do.
pkg_install() {
  local label="$1"
  shift

  local pkg
  local -a missing=() present=()
  LAST_SKIPPED=()
  for pkg in "$@"; do
    if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
  done

  LAST_INSTALLED=("${missing[@]}")
  LAST_KEPT=("${present[@]}")

  announce_plan "$label" present missing || {
    # Declining is a fact a reviewer wants: it explains a package being absent
    # later without having to guess whether the script failed or was refused.
    declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
    return 0
  }

  if pkg_install_now "${missing[@]}"; then
    declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
    declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
  else
    declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
    return 1
  fi
}

# Print what a section is about to do and ask permission for it.
#
# Takes the NAMES of the two arrays rather than their contents, because a list
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
  local label="$1"
  local -n _present="$2"
  local -n _missing="$3"

  echo ""
  info "${label} — installs what is missing, keeps what you already have"
  ((${#_present[@]})) && echo "     already here: ${_present[*]}"

  if ((${#_missing[@]} == 0)); then
    echo "     to install:   nothing, all present"
    return 1
  fi

  echo "     to install:   ${_missing[*]}"
  if ! confirm "Proceed?"; then
    warn "skipped by request"
    LAST_INSTALLED=()
    LAST_SKIPPED=("${_missing[@]}")
    return 1
  fi
  return 0
}

# One summary line describing what a section actually did, from LAST_INSTALLED
# and LAST_KEPT. Call straight after pkg_install or tools_install.
summarise_last() {
  local label="$1"
  if ((${#LAST_SKIPPED[@]})); then
    SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
  elif ((${#LAST_INSTALLED[@]} == 0)); then
    SUMMARY+=("$label: already present, nothing installed")
  elif ((${#LAST_KEPT[@]} == 0)); then
    SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
  else
    SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
  fi
}

# -----------------------------------------------------------------------------
# The Xcode command line tools
# -----------------------------------------------------------------------------
#
# macOS's build-essential, and not installable as a formula. It matters here for
# one specific reason: node-pty ships no prebuilt binary for any platform, so
# `bun install` always falls through to node-gyp and needs a working compiler.
# Without this the platform install fails deep inside a dependency tree with an
# error that names neither Xcode nor node-pty.
#
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
# not block until the download finishes. So this asks, and then says to come back,
# rather than pretending to have waited.

xcode_clt_installed() { xcode-select -p &>/dev/null; }

xcode_clt_install() {
  xcode-select --install 2>/dev/null || true
}
