#!/bin/bash
# =============================================================================
# machine-setup — shared foundation
# =============================================================================
#
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
# declares state and functions and must never install, write or restart
# anything. Sourcing it has to be safe at any point, including from a step that
# is only being read for its variables.
#
# The one thing it expects from its caller, because they are facts about the
# entry point rather than about this library:
#
#   SCRIPT_DIR     directory of the script being run
#   PROGRESS_FILE  where completed step names are recorded
#
# Everything else below is owned here.

# Guard against being sourced twice — steps will eventually source this
# directly so they can be run on their own, and re-running it would reset
# SUMMARY and lose everything recorded so far.
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
MACHINE_SETUP_BASE_LOADED=1

# -----------------------------------------------------------------------------
# Shared state
# -----------------------------------------------------------------------------

SUMMARY=()      # what was done, printed at the end
ERRORS=()       # non-fatal failures, printed at the end
CURRENT_STEP=""
SKIP_STEP=false

# What machine this is. Filled in by detect_os() before any step runs; every step
# after that branches on these rather than assuming apt on x86_64.
OS=""          # os-release ID: ubuntu | debian | arch | fedora | macos | …
OS_NAME=""     # pretty name, for the banner
OS_VERSION=""  # version id; empty on rolling releases
PM=""          # apt | pacman | dnf | brew
ARCH=""        # amd64 | arm64, normalised — upstream tarballs disagree on spelling
IS_WSL=false

# What this box is FOR. Asked once in pre-flight and consulted by the steps
# afterwards, because several of them have a different right answer per role and
# no way to work it out on their own:
#
#   homelab  a machine you physically control on a network you own
#   vps      rented, public IP, someone else's DHCP and console
#   dev      a laptop or desktop you sit at
#
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
# :- default rather than a plain assignment, which would wipe what the caller
# passed in before ask_machine_role ever looked at it.
MACHINE_ROLE="${MACHINE_ROLE:-}"

# -----------------------------------------------------------------------------
# Output
# -----------------------------------------------------------------------------

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

info() { echo -e "${CYAN}::${NC} $*"; }
ok() { echo -e "   ${GREEN}OK${NC}: $*"; }
warn() { echo -e "   ${YELLOW}WARN${NC}: $*"; }
fail() {
  echo -e "   ${RED}FAIL${NC}: $*"
  exit 1
}

# -----------------------------------------------------------------------------
# Steps and resume
# -----------------------------------------------------------------------------
#
# A step announces itself, and is skipped when its name is already in the
# progress file. step_ok records it. The pattern at each call site is:
#
#   step "Name"
#   if ! skip; then
#     …
#     step_ok
#   fi

# -----------------------------------------------------------------------------
# Remembering the answers
# -----------------------------------------------------------------------------
#
# Pre-flight asks four things — role, account, where Officer goes — and every
# section needs them. Asking again on every run made a resumed run re-answer
# questions it had already been told, and made --only unusable: four questions to
# reach one section.
#
# Saved beside the progress file, and loaded before anything is asked. The
# environment still wins, so SETUP_USERNAME=x on the command line overrides what
# was saved.

ANSWERS_FILE="${ANSWERS_FILE:-}"

save_answers() {
  [[ -n "$ANSWERS_FILE" ]] || return 0
  cat >"$ANSWERS_FILE" <<EOF
# Written by machine-setup. Delete this to be asked again.
MACHINE_ROLE=${MACHINE_ROLE}
SETUP_USERNAME=${USERNAME}
OFFICER_ROOT=${OFFICER_ROOT}
EOF
  chmod 600 "$ANSWERS_FILE"
}

# Loaded as assignments, not sourced as a script: this file sits beside the
# script and is read by a root run, so it should not be able to execute anything.
load_answers() {
  [[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
  local key value
  while IFS='=' read -r key value; do
    [[ "$key" =~ ^[A-Z_]+$ ]] || continue
    [[ -n "$value" ]] || continue
    # The environment wins over what was saved.
    #
    # Written as if/then rather than `[[ … ]] && assign`.
    #
    # That form returns non-zero when the test is false. Harmless on its own —
    # `set -e` exempts the left side of an && list — but here it is the last
    # thing the case runs, the case is the last thing the loop body runs, and the
    # loop is the last thing THE FUNCTION runs. So load_answers returned
    # non-zero, and calling a function that returns non-zero is a plain command
    # failure, which does end the script.
    #
    # It needed the answers file to exist AND the variables to be set already, so
    # it only appeared when running with env overrides. The trap reported "Step:
    # unknown" at a line inside this library, before pre-flight had run.
    #
    # The general rule this is an instance of: a function whose last statement
    # can return non-zero fails when it is called, however innocuous the
    # statement looks.
    case "$key" in
      MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
      SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
      OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
    esac
  done <"$ANSWERS_FILE"
}

# Set by --only. When it is set, every step whose name does not match is passed
# over in silence, and the one that does matches runs regardless of the progress
# file — the point of asking for a single step is to run that step.
ONLY_STEP="${ONLY_STEP:-}"

# ── Steps that do not exist on macOS ──
#
# A Mac running Officer is a DEV MACHINE, never a server. That is not a
# simplification to revisit: nobody puts a laptop behind a public hostname and
# hands it a tailnet exit node, and the sections below are all about being a
# server that is on all the time.
#
# Most would fail rather than misbehave — there is no systemd, no ufw, no
# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be
# wrong, which is worse: stopping a laptop from sleeping, or freezing its address
# on a network it moves between every day.
#
# Keyed on the step title, so the sections themselves stay Linux code with no
# `if macos` branches threaded through them. The reason is printed, because a
# silent skip and a missing step look identical.
declare -A MACOS_SKIP=(
  ["User account"]="accounts are System Settings' business on a Mac, not a script's"
  ["Disk space"]="ballast and swap tuning are server concerns"
  ["Locale"]="macOS manages locale itself"
  ["Timezone"]="macOS manages the timezone itself"
  ["Swap"]="macOS sizes its own swap dynamically"
  ["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
  ["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
  ["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
  ["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
  ["Boot hang"]="a systemd boot ordering fix"
  ["SSH access"]="hardening a door a dev machine should not be opening"
  ["DNS"]="systemd-resolved"
  ["Network address"]="netplan, and a laptop moves between networks by design"
  ["fail2ban"]="brute-force protection for an exposed SSH port"
  ["Unattended upgrades"]="apt; macOS updates through Software Update"
  ["Firewall"]="ufw; macOS has its own application firewall"
  ["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
)

step() {
  CURRENT_STEP="$1"
  # The report follows the step, rather than each section remembering to say
  # which one it is. Twenty-six sections, one place.
  declare -F report_section >/dev/null && report_section "$1"

  if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
    echo ""
    echo -e "${BOLD}── $1 ──${NC}"
    echo -e "   ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
    SKIP_STEP=true
    return
  fi

  if [[ -n "$ONLY_STEP" ]]; then
    if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
      SKIP_STEP=false
      echo ""
      echo -e "${BOLD}── $1 ──${NC}"
    else
      SKIP_STEP=true
    fi
    return
  fi

  if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
    echo -e "   ${GREEN}SKIP${NC}: $1 (already done)"
    SKIP_STEP=true
    return
  fi
  SKIP_STEP=false
  echo ""
  echo -e "${BOLD}── $1 ──${NC}"
}

skip() { [[ "$SKIP_STEP" == true ]]; }

step_ok() {
  # A single step run on its own is not progress through the script, and
  # recording it would make the next full run skip it.
  [[ -n "$ONLY_STEP" ]] && return 0
  echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
}

# Try a command, log error but don't exit
try() {
  local label="$1"
  shift
  if "$@" 2>&1; then
    ok "$label"
  else
    warn "$label — failed (non-critical, continuing)"
    ERRORS+=("$label")
  fi
}

# -----------------------------------------------------------------------------
# Input
# -----------------------------------------------------------------------------

prompt_value() {
  local varname="$1" message="$2" default="$3"
  # If env var already set, use it silently
  if [[ -n "${!varname:-}" ]]; then
    return
  fi
  local input
  if [[ -n "$default" ]]; then
    read -rp "$message [$default]: " input
    eval "$varname=\"\${input:-$default}\""
  else
    read -rp "$message: " input
    eval "$varname=\"\$input\""
  fi
}

# Show long output a screen at a time.
#
# Only when there is a terminal to page on: with output redirected or piped —
# a transcript, a log, the test harness — it has to come through whole, and a
# pager would either block or mangle it. `more` rather than `less` because it
# exits at the end of the file instead of sitting there waiting to be quit,
# which is what you want for something you asked to read once.
page() {
  if [[ -t 1 ]] && command -v more &>/dev/null; then
    more
  else
    cat
  fi
}

# Ask before acting. Every section that changes the machine goes through this, so
# a run is a sequence of things you agreed to rather than a wall of output you
# read afterwards to find out what happened.
#
# Enter means yes — unlike the machine-role question, which has no default. These
# are "do the thing you already asked for", and making twenty of them require a
# deliberate keystroke would train people to hold the y key down.
#
# ASSUME_YES=1 answers all of them, for an unattended run.

# A numbered menu's answer, or its own default when running unattended.
#
#   menu_answer DNS_CHOICE "  Which one? (1-5) [1]: "
#
# ── Why empty, rather than a default passed in ──
#
# Every menu in this script reads its choice and then consumes it as
# `${CHOICE:-<n>}`, so the default already lives at the point of use — which is the
# right place, next to the options it selects between. Setting the variable EMPTY is
# therefore exactly what pressing Enter does, and it cannot drift from the default
# the prompt advertises the way a second copy passed in here would.
#
# `read <<<''` rather than `eval` or `declare -g`: no eval, and `declare -g` is bash
# 4.2+, which rules out the bash 3.2 that macOS still ships.
#
# The prompt is still printed, with the reason, because a transcript that silently
# skips a question reads as a question that was never asked.
menu_answer() {
  local var="$1" prompt="$2"
  if [[ "${UNATTENDED:-}" == "1" ]]; then
    printf '%s%s\n' "$prompt" "— unattended, taking the default"
    read -r "$var" <<<''
    return 0
  fi
  read -rp "$prompt" "$var" || {
    echo ""
    fail "No answer."
  }
}

confirm() {
  local message="${1:-Proceed?}"
  # Second argument flips the default. Most questions here are "do the thing you
  # already asked for" and Enter should mean yes; a few are genuine extras, where
  # defaulting to yes would have people agreeing to them by reflex.
  local default="${2:-y}"
  # Third is the name of a function that explains the question. Where one is
  # given, `?` becomes an answer — so the explanation is available to whoever
  # wants it without being in the way of whoever does not.
  local help_fn="${3:-}"
  local answer prompt

  [[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }

  if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
  [[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"

  while true; do
    # EOF is not a yes. Without this an unattended run without ASSUME_YES would
    # spin here forever.
    if ! read -rp "  ${message} ${prompt}: " answer; then
      echo ""
      fail "No answer. Set ASSUME_YES=1 to run without prompts."
    fi
    [[ -z "$answer" ]] && answer="$default"
    case "$answer" in
      y | Y | yes | Yes) return 0 ;;
      n | N | no | No) return 1 ;;
      "?")
        if [[ -n "$help_fn" ]]; then
          echo ""
          "$help_fn" | page
          echo ""
        else
          warn "Answer y or n."
        fi
        ;;
      *) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
    esac
  done
}

# Which account this machine is being set up for.
#
# Asked at the top because two later questions default off it — where Officer is
# installed, and where the disk ballast goes — so it has to be settled before
# either is put to the user.
#
# Defaults to whoever invoked sudo. On a re-run, or on a machine that is already
# somebody's, that is the answer every time, and typing it again is a chance to
# typo it into creating a second account.
#
# SETUP_USERNAME in the environment answers it ahead of time. Deliberately not
# USERNAME: that name is set by some login environments, and a variable this
# script silently obeys should not be one that might already be in the
# environment for unrelated reasons.
ask_username() {
  local default="${SUDO_USER:-}" answer

  # root invoked the script directly rather than through sudo. It is never the
  # account being set up, so there is nothing to suggest.
  [[ "$default" == "root" ]] && default=""

  if [[ -n "${SETUP_USERNAME:-}" ]]; then
    answer="$SETUP_USERNAME"
  else
    echo ""
    info "Which account is this machine for?"
    echo "     The account you log in and work as, day to day. It will be created"
    echo "     if it does not exist."
    echo ""
    warn "Strongly advised: use a normal account, not root."
    echo "     Working as root means everything runs with no safety net. A typo in"
    echo "     a path deletes instead of refusing, anything you run has the whole"
    echo "     machine, and nothing distinguishes you from a process that got out"
    echo "     of hand. sudo gives you the same power when you ask for it, and"
    echo "     only then — which is why root is not accepted as an answer here."

    # Whether this was started FROM a root session, which usually means root is
    # how they log in. That is exactly the situation the advice above is for, and
    # the one where general advice is easiest to assume is aimed at somebody else.
    #
    # Two ways to be in it, and the second is the one that hides: no SUDO_USER at
    # all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose
    # default account is uid 0 under an ordinary-looking name, so `sudo` from it
    # sets SUDO_USER to something that looks like a normal user and is not.
    local invoker_uid=""
    [[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)"

    if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then
      echo ""
      if [[ -n "${SUDO_USER:-}" ]]; then
        warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account."
      else
        warn "You are running this as root directly, not through sudo."
      fi
      echo "     If that is how you normally log into this machine, now is the"
      echo "     moment to make an account and stop doing that."
    fi
    echo ""
    while [[ -z "${answer:-}" ]]; do
      if ! read -rp "  Username${default:+ [$default]}: " answer; then
        echo ""
        fail "No answer. Set SETUP_USERNAME=<name> to answer this ahead of time."
      fi
      answer="${answer:-$default}"
      [[ -z "$answer" ]] && warn "There is no default here — type a username."
    done
  fi

  # The portable shape of a Linux account name. Worth checking rather than
  # letting adduser refuse it later, because by then several questions have been
  # answered against a name that was never going to work.
  [[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] ||
    fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore."
  # By uid, not by name. "root" is a label — what makes an account root is uid 0,
  # and some providers ship an image whose default login is uid 0 under a
  # friendlier name. Refusing only the string would let exactly that case through,
  # which is the one worth catching.
  local answer_uid
  answer_uid="$(id -u "$answer" 2>/dev/null || true)"
  if [[ "$answer_uid" == "0" ]]; then
    if [[ "$answer" == "root" ]]; then
      fail "root is not the account to set up here — see the warning above."
    fi
    fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here."
  fi

  USERNAME="$answer"

  # Looked up, not assumed. The original built "/home/$USERNAME", which is merely
  # the usual answer — an account created with a different home, or one whose home
  # was moved, would have every later step writing to a directory that is not
  # theirs.
  USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
  [[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
}

# Where Officer will live.
#
# Asked in pre-flight with the rest of the questions rather than at the point it
# is first needed, because it decides the shape of several later steps — the
# directory the repository is cloned into, where DATA_PATH sits beside it, and
# which filesystem the app store's containers bind-mount out of. Answering it
# once at the start also means the run can be described before it begins.
#
# One directory holding four, per docs/sidecar-app-store.md:
#
#   <root>/platform/       the app
#   <root>/data/           DATA_PATH
#   <root>/dockers/        services the app store provisioned
#   <root>/capabilities/   the file-based item store
#
# OFFICER_ROOT in the environment answers it ahead of time.
ask_officer_root() {
  local default="${USER_HOME}/officerdev" answer

  if [[ -n "${OFFICER_ROOT:-}" ]]; then
    answer="$OFFICER_ROOT"
  else
    echo ""
    info "Where should Officer be installed?"
    echo "     One directory holding the app, its data, the item store and any"
    echo "     containers the app store provisions — so it can be moved, backed"
    echo "     up or deleted as a unit."
    echo ""
    if ! read -rp "  Path [${default}]: " answer; then
      echo ""
      fail "No answer. Set OFFICER_ROOT=<path> to answer this ahead of time."
    fi
    answer="${answer:-$default}"
  fi

  # A leading ~ arrives as a literal when it comes from a read or an environment
  # variable — nothing expands it there — and would create a directory named "~".
  answer="${answer/#\~/$USER_HOME}"

  [[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'"

  OFFICER_ROOT="${answer%/}"
}

# The account's PRIMARY GROUP, asked of the system rather than assumed to be
# named after the user.
#
# Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on
# most machines — but not on one where the account came from LDAP, or was made
# with `useradd -g users`, or is a cloud image with a shared group. There
# `chown user:user` fails with "invalid group" and `install -g user` refuses,
# both of which abort the step.
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }

# Run a block as the created user (login shell, inherits HOME)
as_user() {
  sudo -u "$USERNAME" -i bash -c "$1"
}

# -----------------------------------------------------------------------------
# sudoers
# -----------------------------------------------------------------------------

# Grant an account passwordless sudo, safely.
#
# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo
# to repair it, so on a remote machine that is unrecoverable short of a rescue
# console. The same is true of one with loose permissions: sudo refuses to read
# its own configuration and every sudo on the box fails.
#
# The original wrote the file into /etc/sudoers.d first and validated it after,
# with a chmod later still. Both of those leave a window where a broken or
# world-readable sudoers file is live. This validates a temp file first and then
# places it with its mode in a single install(1) — so what lands in /etc is
# already known good and already 0440.
grant_passwordless_sudo() {
  # Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
  # $a before it has been assigned, so b comes out with the name missing — which
  # here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
  # each one silently overwriting the last, and has_passwordless_sudo never
  # finding the file it was looking for.
  local user="$1"
  local dest="/etc/sudoers.d/99-${user}-nopasswd"
  local tmp
  tmp="$(mktemp)"

  [[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"

  echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"

  if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
    rm -f "$tmp"
    fail "visudo rejected the sudoers entry for '${user}' — not installing it"
  fi

  install -m 0440 -o root -g root "$tmp" "$dest"
  rm -f "$tmp"
}

has_passwordless_sudo() {
  local user="$1"
  [[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] ||
    grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null
}

# -----------------------------------------------------------------------------
# Operating system detection
# -----------------------------------------------------------------------------
#
# Read one key out of /etc/os-release without leaking the rest of it into this
# script. That file defines NAME, VERSION and ID — all generic enough to collide
# with something here — so it is sourced in a subshell and only the one value
# asked for comes back.
os_release() {
  [[ -r /etc/os-release ]] || return 1
  # shellcheck disable=SC1091
  (
    . /etc/os-release 2>/dev/null
    printf '%s' "${!1:-}"
  )
}

# Identify the machine, or refuse to guess.
#
# /etc/os-release rather than probing for a binary: a box can have more than one
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
# converted box), and only os-release can say which distribution the machine
# actually IS, or give a version worth reporting.
#
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
detect_os() {
  local kernel like
  kernel="$(uname -s)"

  case "$kernel" in
    Darwin)
      OS="macos"
      OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
      OS_NAME="macOS ${OS_VERSION}"
      PM="brew"
      ;;
    Linux)
      OS="$(os_release ID || true)"
      OS_NAME="$(os_release PRETTY_NAME || true)"
      OS_VERSION="$(os_release VERSION_ID || true)"
      like="$(os_release ID_LIKE || true)"

      case "$OS" in
        ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
        arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
        fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
        *)
          case " $like " in
            *" debian "* | *" ubuntu "*) PM="apt" ;;
            *" arch "*) PM="pacman" ;;
            *" fedora "* | *" rhel "*) PM="dnf" ;;
          esac
          ;;
      esac

      # WSL reports itself as Linux, but has no real systemd session: masking
      # sleep targets, restarting logind and anything touching the boot path
      # either fail or silently do nothing. Worth knowing before those steps run.
      if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
      ;;
    MINGW* | MSYS* | CYGWIN*)
      fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
      ;;
    *)
      fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
      ;;
  esac

  # Normalised once here because upstream projects spell it differently:
  # Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
  case "$(uname -m)" in
    x86_64 | amd64) ARCH="amd64" ;;
    aarch64 | arm64) ARCH="arm64" ;;
    *) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
  esac

  [[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
  [[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
}

# -----------------------------------------------------------------------------
# Machine role
# -----------------------------------------------------------------------------

# The interface packets actually leave by, which is not always the first one up.
default_iface() {
  ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
}

# Ask what this machine is, unless the environment already said.
#
# Asked in pre-flight rather than at the point of use so that the run knows its
# own shape before it starts: the steps that care are spread from swap through to
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
# a provisioning run is how people start answering without reading.
#
# NO DEFAULT, deliberately, and it is the only question in the script like that.
# A guessed default is right often enough to be trusted and wrong in exactly the
# case that costs the most: pinning a static IP on a rented box, or leaving the
# firewall open on one. Every branch downstream is about what this machine is
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
ask_machine_role() {
  # Not a question on a Mac. Officer on macOS is a dev helper on a machine
  # somebody sits at — there is no homelab or VPS answer that would make sense,
  # and every section that branches on the role branches toward "server".
  if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then
    MACHINE_ROLE="dev"
    info "macOS — treated as a dev machine. The server-only sections are skipped."
    return
  fi

  if [[ -n "$MACHINE_ROLE" ]]; then
    case "$MACHINE_ROLE" in
      homelab | vps | dev) return ;;
      *) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
    esac
  fi

  echo ""
  info "What is this machine? Several later steps depend on the answer."
  echo "  [1] homelab — yours, on a network you control"
  echo "  [2] vps     — rented, public IP, provider's DHCP and console"
  echo "  [3] dev     — a laptop or desktop you sit at"
  echo ""

  local choice
  while [[ -z "$MACHINE_ROLE" ]]; do
    # A failed read means EOF, not a wrong answer — without this the loop would
    # spin forever when stdin is closed, which is how an unattended run hangs.
    if ! read -rp "  Which one? (1/2/3): " choice; then
      fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
    fi
    case "$choice" in
      1 | homelab) MACHINE_ROLE=homelab ;;
      2 | vps) MACHINE_ROLE=vps ;;
      3 | dev) MACHINE_ROLE=dev ;;
      "") warn "There is no default here — pick 1, 2 or 3." ;;
      *) warn "Not one of the options: '$choice'" ;;
    esac
  done
}

# Convenience for the steps that branch on it.
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
