#!/bin/sh
# aimx install script — POSIX sh (dash / busybox compatible).
#
# Usage:
#   curl -fsSL https://aimx.email/install.sh | sh
#   curl -fsSL https://aimx.email/install.sh | sh -s -- --tag 1.2.3
#
# Thin wrapper around the binary: download → install → exec `aimx setup`.
# The binary owns the operator-facing wizard (welcome banner, six-step
# checklist, agents setup handoff, closing message). Upgrades are
# non-interactive: stop service → swap binary → start service.
#
# Modelled on `just.systems/install.sh` — `say` / `err` / `need` /
# `download` helper idioms, no bashisms, HTTPS-only trust anchor.

set -eu

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

GITHUB_REPO="uzyn/aimx"
GITHUB_API="https://api.github.com/repos/${GITHUB_REPO}/releases"
GITHUB_DL="https://github.com/${GITHUB_REPO}/releases/download"
DEFAULT_PREFIX="/usr/local/bin"
UNSUPPORTED_DOC="https://aimx.email/book/installation.html#unsupported-platforms"

# Config path used by backup_existing_config. Overridable for tests via
# AIMX_INSTALL_CONFIG_PATH; production always points at /etc/aimx/config.toml.
AIMX_CONFIG_TOML="${AIMX_INSTALL_CONFIG_PATH:-/etc/aimx/config.toml}"

# ---------------------------------------------------------------------------
# Helpers (say / err / need / download)
# ---------------------------------------------------------------------------

say() {
    printf 'install: %s\n' "$1" >&2
}

verbose() {
    if [ "${AIMX_VERBOSE:-0}" = "1" ]; then
        printf 'install: %s\n' "$1" >&2
    fi
}

err() {
    printf 'install: error: %s\n' "$1" >&2
    cleanup
    exit 1
}

need() {
    if ! command -v "$1" >/dev/null 2>&1; then
        err "required command not found: $1"
    fi
}

# Create temp dir and arm cleanup trap. Safe on every exit path.
_td=""
cleanup() {
    if [ -n "${_td}" ] && [ -d "${_td}" ]; then
        rm -rf "${_td}"
        _td=""
    fi
}

# ---------------------------------------------------------------------------
# UI helpers (color when TTY + !NO_COLOR, plain otherwise).
# Kept thin: the binary owns the section/step rendering. The shell only
# emits the install-time progress lines (download / extract / install).
# ---------------------------------------------------------------------------

_ui_color_enabled() {
    if [ -n "${NO_COLOR:-}" ]; then
        return 1
    fi
    if [ ! -t 2 ]; then
        return 1
    fi
    return 0
}

_ui_paint() {
    # $1 = ansi code, $2 = text
    if _ui_color_enabled; then
        printf '\033[%sm%s\033[0m' "$1" "$2"
    else
        printf '%s' "$2"
    fi
}

ui_info() {
    _msg="$1"
    printf '%s %s\n' "$(_ui_paint 34 '[info]')" "${_msg}" >&2
}

ui_warn() {
    _msg="$1"
    printf '%s %s\n' "$(_ui_paint 33 '[warn]')" "${_msg}" >&2
}

ui_error() {
    _msg="$1"
    printf '%s %s\n' "$(_ui_paint 31 '[error]')" "${_msg}" >&2
}

ui_success() {
    _msg="$1"
    printf '%s %s\n' "$(_ui_paint 32 '[ok]')" "${_msg}" >&2
}

# Thin two-line install banner. The full six-step checklist + per-step
# ticking lives in the Rust binary's `aimx setup` wizard.
print_install_banner() {
    printf '\n' >&2
    printf '%s\n' "$(_ui_paint '1;35' 'AIMX installer')" >&2
    printf '%s\n' "$(_ui_paint 2 '  downloading and installing AIMX...')" >&2
    printf '\n' >&2
}

# Port-check banner. Replaces the install banner when --port-check-only
# is set, so it is visually obvious nothing is installing.
print_port_check_banner() {
    printf '\n' >&2
    printf '%s\n' "$(_ui_paint '1;35' 'AIMX port 25 connectivity check')" >&2
    printf '%s\n' "$(_ui_paint 2 '  no install will be performed')" >&2
    printf '\n' >&2
}

# download <url> <path>
#   Prefers curl; falls back to wget. Refuses non-HTTPS URLs. Honors
#   GITHUB_TOKEN for api.github.com calls so rate-limited CI runs succeed.
download() {
    _url="$1"
    _dst="$2"
    case "${_url}" in
        https://*) : ;;
        *) err "refusing non-HTTPS URL: ${_url}" ;;
    esac
    _auth_hdr=""
    case "${_url}" in
        https://api.github.com/*)
            if [ -n "${GITHUB_TOKEN:-}" ]; then
                _auth_hdr="Authorization: Bearer ${GITHUB_TOKEN}"
            fi
            ;;
    esac
    verbose "GET ${_url}"
    if command -v curl >/dev/null 2>&1; then
        if [ -n "${_auth_hdr}" ]; then
            curl --proto '=https' --tlsv1.2 -fsSL -H "${_auth_hdr}" \
                -o "${_dst}" "${_url}"
        else
            curl --proto '=https' --tlsv1.2 -fsSL -o "${_dst}" "${_url}"
        fi
    elif command -v wget >/dev/null 2>&1; then
        if [ -n "${_auth_hdr}" ]; then
            wget --https-only -q --header="${_auth_hdr}" \
                -O "${_dst}" "${_url}"
        else
            wget --https-only -q -O "${_dst}" "${_url}"
        fi
    else
        err "need curl or wget on PATH"
    fi
}

help() {
    cat <<'EOF'
AIMX install script

USAGE:
    install.sh [FLAGS]

FLAGS:
    -h, --help               Print this help and exit
        --tag <VERSION>      Install a specific release tag (e.g. 1.2.3);
                             overrides AIMX_VERSION env var. Tags are bare
                             SemVer (no `v` prefix); a caller-supplied `v`
                             is stripped leniently.
        --target <TRIPLE>    Override target auto-detection
                             (x86_64-unknown-linux-gnu,
                              aarch64-unknown-linux-gnu,
                              x86_64-unknown-linux-musl,
                              aarch64-unknown-linux-musl)
        --to <DIR>           Install binary into DIR (default /usr/local/bin);
                             overrides AIMX_PREFIX env var
        --force              Re-install even if target version already present
        --port-check-only    Run port-25 outbound + inbound connectivity checks
                             then exit; no install is performed
        --verify-host <URL>  Verifier base URL for the inbound /probe call
                             (default https://check.aimx.email); overrides
                             AIMX_VERIFY_HOST env var

ENVIRONMENT:
    AIMX_VERSION             Release tag to install (e.g. 1.2.3)
    AIMX_PREFIX              Install directory (default /usr/local/bin)
    AIMX_DRY_RUN=1           Print every step without downloading or installing
    AIMX_VERBOSE=1           Trace HTTP requests and filesystem actions
    AIMX_VERIFY_HOST         Verifier base URL for --port-check-only (default
                             https://check.aimx.email)
    GITHUB_TOKEN             Token for rate-limited GitHub API calls

EXAMPLES:
    # Latest stable into /usr/local/bin
    curl -fsSL https://aimx.email/install.sh | sh

    # Pin a specific tag
    curl -fsSL https://aimx.email/install.sh | sh -s -- --tag 1.2.3

    # Dry-run: see what would happen without installing
    curl -fsSL https://aimx.email/install.sh | AIMX_DRY_RUN=1 sh

    # Port-25 connectivity check only (no install)
    curl -fsSL https://aimx.email/install.sh | sh -s -- --port-check-only

Trust anchor is HTTPS on the GitHub Releases domain. No signature or
checksum verification in this script; skeptical operators can verify
manually via the 'curl + sha256sum -c' block in the release notes.
EOF
}

# ---------------------------------------------------------------------------
# Privilege / invoker helpers
# ---------------------------------------------------------------------------

# SUDO holds the prefix to use for privileged commands. It is either
# empty (when running as root) or "sudo" (when a non-root invoker has
# sudo on PATH). Populated by resolve_sudo_prefix, which must be called
# once early in main(). Defined here so sourced test harnesses see it.
SUDO=""

# resolve_sudo_prefix — set $SUDO to the right privilege prefix:
#   - already root (euid 0)      → SUDO=""  (run commands directly)
#   - non-root with sudo on PATH → SUDO="sudo"
#   - non-root without sudo      → SUDO=""  (call sites will fail with a
#                                  useful error via ensure_sudo before
#                                  ever running a privileged command)
resolve_sudo_prefix() {
    _euid="$(id -u 2>/dev/null || echo 0)"
    if [ "${_euid}" -eq 0 ]; then
        SUDO=""
    elif command -v sudo >/dev/null 2>&1; then
        SUDO="sudo"
    else
        SUDO=""
    fi
}

# prompt_reinstall — ask the operator whether to re-run `aimx setup`
# when the binary is already at the target version. Returns 0 on yes,
# 1 on no / no usable TTY. Default is no (Enter = no), so non-interactive
# callers (CI, fully-scripted) keep today's exit-0 semantics.
#
# _prompt_read prints the prompt only when a TTY is available for the
# answer. Same TTY logic as ensure_sudo and the post-install handoff:
# prefer the script's own stdin if it's already a terminal; fall back
# to /dev/tty when it's a pipe (curl | sh); otherwise no prompt at all
# (so `curl | sh </dev/null` stays quiet — no stray question in CI logs).
_prompt_read() {
    if [ -t 0 ]; then
        printf '%s' "$1" >&2
        read -r _ans
    elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
        printf '%s' "$1" >&2
        read -r _ans </dev/tty
    else
        return 1
    fi
}

prompt_reinstall() {
    _ans=""
    _prompt_read 'AIMX is already installed. Re-run setup to (re)configure it? [y/N] ' || return 1
    case "${_ans}" in
        y | Y | yes | YES | Yes) return 0 ;;
        *) return 1 ;;
    esac
}

ensure_sudo() {
    _euid="$(id -u 2>/dev/null || echo 0)"
    if [ "${_euid}" -eq 0 ]; then
        return 0
    fi
    if command -v sudo >/dev/null 2>&1; then
        if ! sudo -n true >/dev/null 2>&1; then
            ui_info "Administrator privileges required; enter your password"
            # Reattach /dev/tty so `curl | sh` still gets a password prompt.
            # Wrap in a subshell + rc capture so a failing redirect or
            # wrong password yields a user-visible error instead of a
            # silent `set -e` abort.
            _sudo_rc=0
            # Same logic as the post-install handoff: only re-point stdin
            # at /dev/tty when the script's stdin is NOT already a
            # terminal. Redirecting an already-terminal stdin breaks
            # sudo's use_pty bridge on modern distros.
            if [ -t 0 ]; then
                sudo -v || _sudo_rc=$?
            elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
                # shellcheck disable=SC2024 # /dev/tty feeds sudo's password prompt under curl|sh, not a privileged file through sudo
                (sudo -v </dev/tty) || _sudo_rc=$?
            else
                sudo -v || _sudo_rc=$?
            fi
            if [ "${_sudo_rc}" -ne 0 ]; then
                ui_error "failed to obtain sudo credentials"
                exit 1
            fi
        fi
        return 0
    fi
    ui_error "sudo is required for system installs on Linux"
    say "  Install sudo or re-run as root."
    exit 1
}

# detect_invoker
#   Prints the non-root user that should run `aimx agents setup`.
#   Returns 0 with stdout set on success, non-zero when no non-root
#   user can be identified. Kept as a helper for tests + possible future
#   use; nothing in the live install path calls it today.
detect_invoker() {
    if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
        printf '%s' "${SUDO_USER}"
        return 0
    fi
    _me="$(id -un 2>/dev/null || echo '')"
    if [ -n "${_me}" ] && [ "${_me}" != "root" ]; then
        printf '%s' "${_me}"
        return 0
    fi
    return 1
}

# backup_existing_config
#   If /etc/aimx/config.toml exists, rename it to
#   config.toml.bak-YYYYMMDD-HHMMSS-<pid> (UTC). On failure, err out
#   rather than silently continuing. Only config.toml is backed up —
#   DKIM keys and TLS certs are left in place so deliverability survives
#   re-runs. The $$ (pid) suffix prevents collision between concurrent
#   invocations that land in the same second.
backup_existing_config() {
    _cfg="${AIMX_CONFIG_TOML}"
    if [ -f "${_cfg}" ]; then
        _ts="$(date -u +%Y%m%d-%H%M%S)"
        _bak="${_cfg}.bak-${_ts}-$$"
        if ${SUDO} mv -f "${_cfg}" "${_bak}"; then
            ui_info "backed up existing config to ${_bak}"
        else
            err "failed to back up existing ${_cfg}"
        fi
    fi
}

# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------

detect_os() {
    _os="$(uname -s)"
    case "${_os}" in
        Linux) printf 'linux' ;;
        *)
            err "AIMX is Linux-only; detected ${_os}. See ${UNSUPPORTED_DOC}"
            ;;
    esac
}

detect_arch() {
    _arch="$(uname -m)"
    case "${_arch}" in
        x86_64 | amd64) printf 'x86_64' ;;
        aarch64 | arm64) printf 'aarch64' ;;
        *)
            err "unsupported CPU architecture: ${_arch}. See ${UNSUPPORTED_DOC}"
            ;;
    esac
}

detect_libc() {
    # Presence of a musl dynamic loader under /lib/ld-musl-* signals musl.
    # Otherwise assume glibc — aimx only ships gnu + musl Linux builds.
    for _musl in /lib/ld-musl-* /lib64/ld-musl-*; do
        if [ -e "${_musl}" ]; then
            printf 'musl'
            return 0
        fi
    done
    printf 'gnu'
}

compose_target() {
    _arch="$1"
    _libc="$2"
    printf '%s-unknown-linux-%s' "${_arch}" "${_libc}"
}

# Map a canonical Rust target triple (e.g. `x86_64-unknown-linux-gnu`) to the
# shortened artifact-filename form used by release tarballs
# (`x86_64-linux-gnu`). The canonical triple is still used for
# `cargo build --target`, `aimx --version`, and operator-facing error
# messages — only the tarball filename drops the `-unknown-` vendor field.
artifact_target() {
    printf '%s' "$1" | sed 's/-unknown-/-/'
}

# ---------------------------------------------------------------------------
# Version resolution
# ---------------------------------------------------------------------------

# resolve_latest_tag
#   Fetch https://api.github.com/repos/uzyn/aimx/releases/latest, pluck the
#   "tag_name" value with grep + sed. Deliberately does NOT use jq — matches
#   the just.systems installer.
resolve_latest_tag() {
    _body="${_td}/release.json"
    download "${GITHUB_API}/latest" "${_body}"
    _tag="$(grep -m1 '"tag_name":' "${_body}" \
        | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/')"
    if [ -z "${_tag}" ]; then
        err "could not parse tag_name from GitHub latest-release response"
    fi
    printf '%s' "${_tag}"
}

# Strip the leading "v" from a tag (v1.2.3 -> 1.2.3) since tarball asset
# names embed the bare version per release.yml. Tags are bare SemVer,
# but this stays lenient against legacy inputs.
tag_to_version() {
    printf '%s' "$1" | sed 's/^v//'
}

# ---------------------------------------------------------------------------
# Running-binary version parsing (upgrade path)
# ---------------------------------------------------------------------------

# parse_installed_tag <bin-path>
#   Runs <bin-path> --version and extracts the second whitespace-separated
#   token, matching the format:
#     aimx <tag> (<git-sha>) <target-triple> built <date>
#   Returns empty string on any failure.
parse_installed_tag() {
    _bin="$1"
    if [ ! -x "${_bin}" ]; then
        return 0
    fi
    _out="$("${_bin}" --version 2>/dev/null || true)"
    if [ -z "${_out}" ]; then
        return 0
    fi
    case "${_out}" in
        aimx\ *) : ;;
        *) return 0 ;;
    esac
    printf '%s' "${_out}" | awk '{print $2}'
}

# Compare two SemVer-ish tags. Prints "older" / "equal" / "newer" describing
# the relationship of $1 relative to $2. Strips the leading "v" and compares
# dot-separated numeric segments pairwise; any pre-release suffix is compared
# lexicographically *only* as a tiebreaker (pre-release < release per SemVer).
compare_tags() {
    _a="$(tag_to_version "$1")"
    _b="$(tag_to_version "$2")"

    _a_core="$(printf '%s' "${_a}" | sed 's/[-+].*//')"
    _b_core="$(printf '%s' "${_b}" | sed 's/[-+].*//')"
    _a_pre="$(printf '%s' "${_a}" | sed -n 's/^[^-]*-\(.*\)$/\1/p')"
    _b_pre="$(printf '%s' "${_b}" | sed -n 's/^[^-]*-\(.*\)$/\1/p')"

    _a1="$(printf '%s' "${_a_core}" | cut -d. -f1)"
    _a2="$(printf '%s' "${_a_core}" | cut -d. -f2)"
    _a3="$(printf '%s' "${_a_core}" | cut -d. -f3)"
    _b1="$(printf '%s' "${_b_core}" | cut -d. -f1)"
    _b2="$(printf '%s' "${_b_core}" | cut -d. -f2)"
    _b3="$(printf '%s' "${_b_core}" | cut -d. -f3)"
    : "${_a1:=0}" "${_a2:=0}" "${_a3:=0}"
    : "${_b1:=0}" "${_b2:=0}" "${_b3:=0}"

    for _pair in "${_a1} ${_b1}" "${_a2} ${_b2}" "${_a3} ${_b3}"; do
        # shellcheck disable=SC2086
        set -- ${_pair}
        if [ "$1" -lt "$2" ]; then
            printf 'older'
            return 0
        fi
        if [ "$1" -gt "$2" ]; then
            printf 'newer'
            return 0
        fi
    done

    if [ -z "${_a_pre}" ] && [ -z "${_b_pre}" ]; then
        printf 'equal'
        return 0
    fi
    if [ -z "${_a_pre}" ] && [ -n "${_b_pre}" ]; then
        printf 'newer'
        return 0
    fi
    if [ -n "${_a_pre}" ] && [ -z "${_b_pre}" ]; then
        printf 'older'
        return 0
    fi
    if [ "${_a_pre}" = "${_b_pre}" ]; then
        printf 'equal'
        return 0
    fi
    _first="$(printf '%s\n%s\n' "${_a_pre}" "${_b_pre}" | LC_ALL=C sort | head -n1)"
    if [ "${_first}" = "${_a_pre}" ]; then
        printf 'older'
    else
        printf 'newer'
    fi
}

# ---------------------------------------------------------------------------
# Setup-completion probe (Ctrl+C recovery)
# ---------------------------------------------------------------------------

# setup_completed
#   Returns 0 (true) when there is evidence the operator-facing
#   `aimx setup` wizard finished a previous run — specifically, when a
#   service unit file is in place on disk. Returns 1 otherwise.
#
#   This is the gate that lets a re-run after Ctrl+C fall back to the
#   fresh-install path. The binary on disk by itself is not proof that
#   setup completed: the installer drops the binary first and only then
#   exec's `aimx setup`, which is where the systemd / OpenRC unit file
#   actually lands. If the operator Ctrl+Cs out of the wizard before
#   that, the binary is on disk but no unit exists; without this gate
#   the next run would enter the upgrade branch and die on
#   `systemctl start aimx` ("Unit aimx.service not found").
#
#   Probe paths are overridable via AIMX_SETUP_COMPLETED_SYSTEMD /
#   AIMX_SETUP_COMPLETED_OPENRC so the test harness can exercise the
#   helper without touching the host's real `/etc/`.
setup_completed() {
    _systemd_unit="${AIMX_SETUP_COMPLETED_SYSTEMD:-/etc/systemd/system/aimx.service}"
    _openrc_init="${AIMX_SETUP_COMPLETED_OPENRC:-/etc/init.d/aimx}"
    if [ -f "${_systemd_unit}" ] || [ -f "${_openrc_init}" ]; then
        return 0
    fi
    return 1
}

# ---------------------------------------------------------------------------
# Service control (upgrade path)
# ---------------------------------------------------------------------------

stop_service() {
    if command -v systemctl >/dev/null 2>&1; then
        if systemctl is-active --quiet aimx 2>/dev/null; then
            say "stopping aimx.service (systemd)"
            ${SUDO} systemctl stop aimx || err "systemctl stop aimx failed"
            printf 'systemd'
            return 0
        fi
        # systemd is present but the unit is inactive (manual
        # `systemctl stop`, fresh install, etc.). Still emit the
        # `systemd` tag so `start_service` is invoked after the swap
        # — without it the daemon never restarts on the new binary.
        printf 'systemd'
        return 0
    fi
    if command -v rc-service >/dev/null 2>&1; then
        say "stopping aimx.service (openrc)"
        ${SUDO} rc-service aimx stop 2>/dev/null || true
        printf 'openrc'
        return 0
    fi
    say "warning: no systemd or OpenRC detected; skipping service stop"
    printf 'unknown'
}

start_service() {
    _init="$1"
    case "${_init}" in
        systemd)
            say "starting aimx.service (systemd)"
            ${SUDO} systemctl start aimx
            ;;
        openrc)
            say "starting aimx.service (openrc)"
            ${SUDO} rc-service aimx start
            ;;
        unknown)
            say "warning: unrecognized init system; not starting aimx.service"
            ;;
    esac
}

# Detect a manually-launched `aimx serve` process running outside
# systemd / OpenRC. Used on the upgrade path: when no init system
# manages the unit but a stray `aimx serve` is still bound to the
# binary on disk, the operator's swap will leave the OLD process
# running on the new path. We never signal the process — just warn,
# name the PID, and ask the operator to restart it manually.
detect_manual_aimx_serve() {
    _binary_path="$1"
    if ! command -v pgrep >/dev/null 2>&1; then
        return 0
    fi
    _pids="$(pgrep -f "${_binary_path} serve" 2>/dev/null || true)"
    if [ -z "${_pids}" ]; then
        return 0
    fi
    # If systemd or OpenRC manages the unit we trust their lifecycle
    # hooks; only warn when neither claims the daemon.
    if command -v systemctl >/dev/null 2>&1 \
        && systemctl status aimx >/dev/null 2>&1; then
        return 0
    fi
    if command -v rc-service >/dev/null 2>&1 \
        && rc-service aimx status >/dev/null 2>&1; then
        return 0
    fi
    for _pid in ${_pids}; do
        say "warning: detected manually-launched 'aimx serve' (pid ${_pid}) outside systemd/OpenRC"
    done
    say "  the upgrade swaps the binary on disk but cannot restart this process — restart it manually."
}

# ---------------------------------------------------------------------------
# Port-25 connectivity check (--port-check-only)
# ---------------------------------------------------------------------------
#
# Mirrors `aimx portcheck` semantics for evaluators who want to verify a VPS
# can reach SMTP before installing. Outbound: TCP-connect to <host>:25,
# expect 220 banner, send EHLO, accept on 250 SP, send QUIT. Inbound: GET
# ${VERIFY_HOST}/probe; loose substring match for "reachable":true.

# Strip scheme + path/port from a verify-host URL → bare hostname.
derive_smtp_host() {
    _vh="$1"
    case "${_vh}" in
        https://*) _vh="${_vh#https://}" ;;
        http://*) _vh="${_vh#http://}" ;;
    esac
    # Drop trailing path.
    _vh="${_vh%%/*}"
    # Drop trailing :port (IPv6 in brackets is out of scope per plan).
    _vh="${_vh%%:*}"
    printf '%s' "${_vh}"
}

port_check_have_python3() {
    command -v python3 >/dev/null 2>&1
}

port_check_have_nc() {
    command -v nc >/dev/null 2>&1
}

port_check_have_bash() {
    command -v bash >/dev/null 2>&1
}

# Outbound EHLO via python3. Returns 0 on success, 1 on protocol fail.
port_check_outbound_python() {
    _h="$1"
    _p="$2"
    python3 - "${_h}" "${_p}" <<'PYEOF'
import socket, sys
host, port = sys.argv[1], int(sys.argv[2])
try:
    s = socket.create_connection((host, port), timeout=10)
except Exception:
    sys.exit(1)
s.settimeout(5)
try:
    f = s.makefile('rwb', buffering=0)
    banner = f.readline().decode('latin-1', 'replace')
    if not banner.startswith('220'):
        sys.exit(1)
    f.write(b'EHLO aimx\r\n'); f.flush()
    while True:
        line = f.readline().decode('latin-1', 'replace')
        if not line:
            sys.exit(1)
        if line.startswith('250 '):
            break
        if not line.startswith('250-'):
            sys.exit(1)
    try:
        f.write(b'QUIT\r\n'); f.flush()
    except Exception:
        pass
finally:
    try: s.close()
    except Exception: pass
sys.exit(0)
PYEOF
}

# Outbound EHLO via nc. Stream-based; tolerates BSD/GNU/ncat quirks by
# avoiding -q / -N / -c entirely.
port_check_outbound_nc() {
    _h="$1"
    _p="$2"
    { printf 'EHLO aimx\r\n'; sleep 1; printf 'QUIT\r\n'; sleep 1; } \
        | nc -w 5 "${_h}" "${_p}" 2>/dev/null \
        | awk 'BEGIN{seen=0; ok=0}
               /^220 /{seen=1}
               /^220-/{seen=1}
               /^250 /{if(seen)ok=1}
               END{exit ok?0:1}'
}

# Outbound EHLO via bash /dev/tcp. Last resort; uses bash -c so the rest of
# the script stays POSIX sh.
port_check_outbound_bash() {
    _h="$1"
    _p="$2"
    bash -c '
exec 3<>"/dev/tcp/$1/$2" || exit 1
read -r -t 5 banner <&3 || exit 1
case "$banner" in 220*) ;; *) exit 1 ;; esac
printf "EHLO aimx\r\n" >&3
ok=0
while read -r -t 5 line <&3; do
  case "$line" in
    250\ *) ok=1; break ;;
    250-*) ;;
    *) exit 1 ;;
  esac
done
[ "$ok" = "1" ] || exit 1
printf "QUIT\r\n" >&3 2>/dev/null || true
exit 0
' _ "${_h}" "${_p}"
}

# Run outbound check via the first available tool. Sets _PORT_CHECK_NO_TOOL=1
# when no usable tool is on PATH (caller maps that to exit 2).
_PORT_CHECK_NO_TOOL=0
port_check_outbound() {
    _h="$1"
    _p="${2:-25}"
    _PORT_CHECK_NO_TOOL=0
    if port_check_have_python3; then
        port_check_outbound_python "${_h}" "${_p}"
        return $?
    fi
    if port_check_have_nc; then
        port_check_outbound_nc "${_h}" "${_p}"
        return $?
    fi
    if port_check_have_bash; then
        port_check_outbound_bash "${_h}" "${_p}"
        return $?
    fi
    _PORT_CHECK_NO_TOOL=1
    return 1
}

# Detect whether port 25 is already bound on this host.
# Echoes "free" | "occupied" | "unknown".
port_check_detect_occupancy() {
    if command -v ss >/dev/null 2>&1; then
        if ss -tln 2>/dev/null | awk '{print $4}' | grep -E ':25$' >/dev/null 2>&1; then
            printf 'occupied'
        else
            printf 'free'
        fi
        return 0
    fi
    if command -v netstat >/dev/null 2>&1; then
        if netstat -tln 2>/dev/null | awk '{print $4}' | grep -E ':25$' >/dev/null 2>&1; then
            printf 'occupied'
        else
            printf 'free'
        fi
        return 0
    fi
    printf 'unknown'
}

# File where port_check_listener_start writes the python child's stderr.
# Set lazily on first use so non-port-check codepaths don't allocate it.
_PORT_CHECK_LISTENER_STDERR=""

# Privilege prefix the listener spawn / kill / kill-0 use. Set by
# port_check_ensure_inbound_privilege:
#   - already root (euid 0)              → ""    (run directly)
#   - non-root + sudo + creds OK         → "sudo"
#   - non-root + no sudo / creds refused → still "" (caller skips inbound)
_PORT_CHECK_SUDO=""

# Validate that the inbound check has the privilege it needs to bind :25.
# Returns 0 when we can proceed (root, or sudo creds are cached/granted),
# 1 when we cannot (non-root + no sudo, or sudo prompt failed). Mirrors
# install.sh's `ensure_sudo` flow but never `exit`s — port-check failure
# is a soft skip, not a fatal install error.
port_check_ensure_inbound_privilege() {
    _PORT_CHECK_SUDO=""
    _euid="$(id -u 2>/dev/null || echo 0)"
    if [ "${_euid}" -eq 0 ]; then
        return 0
    fi
    if ! command -v sudo >/dev/null 2>&1; then
        return 1
    fi
    if ! sudo -n true >/dev/null 2>&1; then
        ui_info "Inbound check needs root to bind port 25; enter your password"
        _sudo_rc=0
        # Same TTY logic as ensure_sudo: only re-point stdin at /dev/tty
        # when the script's stdin is NOT already a terminal. This matters
        # for `curl ... | sh` where stdin is the curl pipe.
        if [ -t 0 ]; then
            sudo -v || _sudo_rc=$?
        elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
            # shellcheck disable=SC2024 # /dev/tty feeds sudo's password prompt under curl|sh, not a privileged file through sudo
            (sudo -v </dev/tty) || _sudo_rc=$?
        else
            sudo -v || _sudo_rc=$?
        fi
        if [ "${_sudo_rc}" -ne 0 ]; then
            return 1
        fi
    fi
    _PORT_CHECK_SUDO="sudo"
    return 0
}

# Spawn a temp Python SMTP listener on :25. Echoes the PID on stdout so the
# caller can kill it after /probe returns. Mirrors src/portcheck.rs:69-129.
#
# Two non-obvious things matter here:
#
#   1. Python's stdout MUST be redirected to /dev/null (not the default
#      inherited stdout). When the function is called via `$(...)` the
#      subshell's stdout is a capture pipe; the backgrounded python
#      child inherits it and holds the write end open for its full
#      ~30s lifetime, so the parent's read on `$()` blocks until python
#      exits. The result before this redirect: kill -0 ALWAYS reported
#      "dead" because $() didn't return until python had already exited.
#
#   2. Python's stderr is captured to a tmpfile so port_check_inbound can
#      surface the actual bind error (e.g. "Address already in use") on
#      failure instead of a generic "another binder won the race?".
port_check_listener_start() {
    if [ -z "${_PORT_CHECK_LISTENER_STDERR}" ]; then
        _PORT_CHECK_LISTENER_STDERR="$(mktemp -t aimx-portcheck-listener.XXXXXX 2>/dev/null \
            || echo "/tmp/aimx-portcheck-listener.$$")"
    fi
    : > "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
    ${_PORT_CHECK_SUDO} python3 - >/dev/null 2>"${_PORT_CHECK_LISTENER_STDERR}" <<'PYEOF' &
import socket, sys, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
    s.bind(('0.0.0.0', 25))
except Exception as e:
    print("listener bind error:", e, file=sys.stderr)
    sys.exit(1)
s.listen(8)
deadline = time.time() + 30
while time.time() < deadline:
    s.settimeout(max(0.5, deadline - time.time()))
    try:
        c, _ = s.accept()
    except socket.timeout:
        # Idle window expired but the deadline still bounds the loop.
        # Continue so a second probe attempt within the deadline still
        # gets served (mirrors src/portcheck.rs:73-82).
        continue
    except Exception:
        break
    try:
        c.settimeout(10)
        c.sendall(b'220 chkport25 ESMTP\r\n')
        while True:
            data = c.recv(1024)
            if not data:
                break
            up = data.upper()
            if up.startswith(b'EHLO') or up.startswith(b'HELO'):
                c.sendall(b'250 chkport25\r\n')
            elif up.startswith(b'QUIT'):
                c.sendall(b'221 Bye\r\n'); break
            else:
                c.sendall(b'502 Not implemented\r\n')
    except Exception:
        pass
    finally:
        try: c.close()
        except Exception: pass
PYEOF
    printf '%s' "$!"
}

# kill-0 + kill use ${_PORT_CHECK_SUDO} so the non-root caller can signal
# the root-owned python child.
port_check_listener_stop() {
    _pid="$1"
    if [ -z "${_pid}" ]; then
        return 0
    fi
    if ${_PORT_CHECK_SUDO} kill -0 "${_pid}" 2>/dev/null; then
        ${_PORT_CHECK_SUDO} kill "${_pid}" 2>/dev/null || true
        wait "${_pid}" 2>/dev/null || true
    fi
    if [ -n "${_PORT_CHECK_LISTENER_STDERR}" ] \
        && [ -f "${_PORT_CHECK_LISTENER_STDERR}" ]; then
        rm -f "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
    fi
}

# Run the inbound /probe call against ${VERIFY_HOST}. Honors http:// for
# self-hosted dev verifiers (matches validate_verify_host in src/setup.rs).
# Echoes the response body on stdout; caller parses it.
port_check_probe() {
    _url="${VERIFY_HOST}/probe"
    case "${VERIFY_HOST}" in
        https://*)
            curl --proto '=https' --tlsv1.2 -fsS -m 60 "${_url}" 2>/dev/null || true
            ;;
        *)
            curl -fsS -m 60 "${_url}" 2>/dev/null || true
            ;;
    esac
}

# Orchestrate the inbound check. Sets _PORT_CHECK_INBOUND_STATE to one of:
#   pass | fail | skip
# When pass, _PORT_CHECK_INBOUND_IP holds the verifier-detected IP if any.
_PORT_CHECK_INBOUND_STATE=""
_PORT_CHECK_INBOUND_IP=""
_PORT_CHECK_INBOUND_MSG=""
port_check_inbound() {
    _PORT_CHECK_INBOUND_STATE=""
    _PORT_CHECK_INBOUND_IP=""
    _PORT_CHECK_INBOUND_MSG=""

    # Need root (or sudo) to bind :25. Tries sudo cred refresh when
    # non-root + sudo is available; falls back to skip when not.
    if ! port_check_ensure_inbound_privilege; then
        _PORT_CHECK_INBOUND_STATE="skip"
        _PORT_CHECK_INBOUND_MSG="inbound check requires root and sudo is unavailable; re-run as root"
        return 0
    fi

    _occ="$(port_check_detect_occupancy)"
    _listener_pid=""

    if [ "${_occ}" = "occupied" ]; then
        # Existing daemon will reply to /probe. Warn the operator —
        # /probe can't tell aimx apart from Postfix/Sendmail/Exim, so a
        # green [ok] here is only meaningful if the holder is aimx.
        # Mirrors `aimx portcheck`'s Port25Status::OtherProcess handling.
        ui_warn "port 25 is held by another process; verify it's AIMX before running setup"
    else
        # Free or unknown: spawn a temp Python listener if available.
        if ! port_check_have_python3; then
            _PORT_CHECK_INBOUND_STATE="skip"
            _PORT_CHECK_INBOUND_MSG="install python3 or run 'aimx portcheck' after install"
            return 0
        fi
        _listener_pid="$(port_check_listener_start)"
        # Give the listener a moment to bind before /probe fires.
        sleep 1
        # Liveness probe: if the python child died (bind() failed —
        # race / EACCES / port stolen), surface the actual python
        # stderr rather than a generic "unreachable". Mirrors the
        # synchronous bind+error in src/portcheck.rs:44-53.
        if ! ${_PORT_CHECK_SUDO} kill -0 "${_listener_pid}" 2>/dev/null; then
            _PORT_CHECK_INBOUND_STATE="fail"
            _err=""
            if [ -n "${_PORT_CHECK_LISTENER_STDERR}" ] \
                && [ -s "${_PORT_CHECK_LISTENER_STDERR}" ]; then
                _err=" ($(head -n 1 "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null))"
                rm -f "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
            fi
            _PORT_CHECK_INBOUND_MSG="failed to spawn temp listener on :25${_err}"
            _listener_pid=""
            return 0
        fi
    fi

    _body="$(port_check_probe)"

    if [ -n "${_listener_pid}" ]; then
        port_check_listener_stop "${_listener_pid}"
    fi

    case "${_body}" in
        *'"reachable":true'* | *'"reachable": true'*)
            _PORT_CHECK_INBOUND_STATE="pass"
            _PORT_CHECK_INBOUND_IP="$(printf '%s' "${_body}" \
                | sed -n 's/.*"ip"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
            ;;
        *)
            _PORT_CHECK_INBOUND_STATE="fail"
            _PORT_CHECK_INBOUND_MSG="verifier reported port 25 unreachable from the public internet"
            ;;
    esac
}

# Main entry for --port-check-only. Returns the right exit code.
port_check_main() {
    print_port_check_banner

    # Required tool: curl. Exit 2 (missing required tool) per the
    # documented contract — `need` exits 1, which is wrong here.
    if ! command -v curl >/dev/null 2>&1; then
        ui_error "required command not found: curl"
        ui_info "  install curl and re-run"
        return 2
    fi

    _host="$(derive_smtp_host "${VERIFY_HOST}")"
    if [ -z "${_host}" ]; then
        ui_error "could not derive SMTP host from verify-host: ${VERIFY_HOST}"
        return 2
    fi

    # Outbound.
    printf '  Outbound port 25 ... ' >&2
    _ob_rc=0
    port_check_outbound "${_host}" 25 || _ob_rc=$?
    if [ "${_PORT_CHECK_NO_TOOL}" = "1" ]; then
        printf '%s\n' "$(_ui_paint 31 '[fail]')" >&2
        ui_error "no usable outbound tool (need python3, nc, or bash)"
        ui_info "  install python3 or nc and re-run"
        return 2
    fi
    if [ "${_ob_rc}" -eq 0 ]; then
        printf '%s\n' "$(_ui_paint 32 '[ok]')" >&2
        _outbound_ok=1
    else
        printf '%s\n' "$(_ui_paint 31 '[fail]')" >&2
        printf '    %s\n' "could not complete EHLO handshake to ${_host}:25" >&2
        _outbound_ok=0
    fi

    # Inbound.
    printf '  Inbound port 25 .... ' >&2
    port_check_inbound
    case "${_PORT_CHECK_INBOUND_STATE}" in
        pass)
            if [ -n "${_PORT_CHECK_INBOUND_IP}" ]; then
                printf '%s   detected ip: %s\n' "$(_ui_paint 32 '[ok]')" "${_PORT_CHECK_INBOUND_IP}" >&2
            else
                printf '%s\n' "$(_ui_paint 32 '[ok]')" >&2
            fi
            _inbound_ok=1
            ;;
        skip)
            printf '%s   %s\n' "$(_ui_paint 33 '[skip]')" "${_PORT_CHECK_INBOUND_MSG}" >&2
            _inbound_ok=skip
            ;;
        *)
            printf '%s\n' "$(_ui_paint 31 '[fail]')" >&2
            if [ -n "${_PORT_CHECK_INBOUND_MSG}" ]; then
                printf '    %s\n' "${_PORT_CHECK_INBOUND_MSG}" >&2
            fi
            _inbound_ok=0
            ;;
    esac

    printf '\n' >&2
    if [ "${_outbound_ok}" -eq 1 ] && [ "${_inbound_ok}" = "1" ]; then
        ui_success "Port 25 reachable. Run 'curl -fsSL https://aimx.email/install.sh | sh' to install."
        return 0
    fi
    if [ "${_outbound_ok}" -eq 1 ] && [ "${_inbound_ok}" = "skip" ]; then
        ui_info "Outbound OK; inbound check skipped."
        return 0
    fi
    ui_error "Port 25 connectivity check failed."
    return 1
}

# ---------------------------------------------------------------------------
# Install / upgrade
# ---------------------------------------------------------------------------

TAG=""
TARGET=""
PREFIX=""
FORCE=0
DRY_RUN="${AIMX_DRY_RUN:-0}"

# Port-check mode: when set, run the same outbound + inbound port-25 checks
# as `aimx portcheck` then exit. No install side effects. Resolved against
# AIMX_VERIFY_HOST and DEFAULT_VERIFY_HOST in main() / resolve_verify_host.
PORT_CHECK_ONLY=0
VERIFY_HOST=""
DEFAULT_VERIFY_HOST="https://check.aimx.email"

parse_args() {
    while [ "$#" -gt 0 ]; do
        case "$1" in
            -h | --help)
                help
                exit 0
                ;;
            --tag)
                [ "$#" -ge 2 ] || err "--tag requires a value"
                TAG="$2"
                shift 2
                ;;
            --tag=*)
                TAG="${1#--tag=}"
                shift
                ;;
            --target)
                [ "$#" -ge 2 ] || err "--target requires a value"
                TARGET="$2"
                shift 2
                ;;
            --target=*)
                TARGET="${1#--target=}"
                shift
                ;;
            --to)
                [ "$#" -ge 2 ] || err "--to requires a value"
                PREFIX="$2"
                shift 2
                ;;
            --to=*)
                PREFIX="${1#--to=}"
                shift
                ;;
            --force)
                FORCE=1
                shift
                ;;
            --port-check-only)
                PORT_CHECK_ONLY=1
                shift
                ;;
            --verify-host)
                [ "$#" -ge 2 ] || err "--verify-host requires a value"
                VERIFY_HOST="$2"
                shift 2
                ;;
            --verify-host=*)
                VERIFY_HOST="${1#--verify-host=}"
                shift
                ;;
            *)
                err "unknown argument: $1 (try --help)"
                ;;
        esac
    done
}

# resolve_verify_host — apply env-var → default fallback for VERIFY_HOST.
# Flag (set by parse_args) wins, AIMX_VERIFY_HOST next, DEFAULT_VERIFY_HOST last.
resolve_verify_host() {
    if [ -z "${VERIFY_HOST}" ] && [ -n "${AIMX_VERIFY_HOST:-}" ]; then
        VERIFY_HOST="${AIMX_VERIFY_HOST}"
    fi
    if [ -z "${VERIFY_HOST}" ]; then
        VERIFY_HOST="${DEFAULT_VERIFY_HOST}"
    fi
}

# validate_verify_host — reject empty / non-http(s) URLs.
validate_verify_host() {
    if [ -z "${VERIFY_HOST}" ]; then
        err "verify-host cannot be empty"
    fi
    case "${VERIFY_HOST}" in
        https://* | http://*) : ;;
        *) err "verify-host must start with http:// or https:// (got: ${VERIFY_HOST})" ;;
    esac
}

# ---------------------------------------------------------------------------
# Post-install handoff (fresh install only)
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

main() {
    parse_args "$@"

    # Env-var defaults (flags already win).
    if [ -z "${TAG}" ] && [ -n "${AIMX_VERSION:-}" ]; then
        TAG="${AIMX_VERSION}"
    fi
    if [ -z "${PREFIX}" ] && [ -n "${AIMX_PREFIX:-}" ]; then
        PREFIX="${AIMX_PREFIX}"
    fi
    if [ -z "${PREFIX}" ]; then
        PREFIX="${DEFAULT_PREFIX}"
    fi

    # --port-check-only short-circuit. Branches here, BEFORE any
    # privileged or networked install code (no ensure_sudo, no GitHub
    # API call, no filesystem writes). The verify-host env var is
    # specific to the port-check path; gate resolve+validate behind the
    # flag so a stray AIMX_VERIFY_HOST in the operator's shell doesn't
    # block a regular install.
    if [ "${PORT_CHECK_ONLY}" = "1" ]; then
        resolve_verify_host
        validate_verify_host
        port_check_main
        exit $?
    fi

    # Thin install banner. Full six-step wizard checklist is the binary's job.
    print_install_banner

    # Platform detection.
    detect_os >/dev/null
    if [ -z "${TARGET}" ]; then
        _arch="$(detect_arch)"
        _libc="$(detect_libc)"
        TARGET="$(compose_target "${_arch}" "${_libc}")"
    fi
    case "${TARGET}" in
        x86_64-unknown-linux-gnu | aarch64-unknown-linux-gnu \
            | x86_64-unknown-linux-musl | aarch64-unknown-linux-musl) : ;;
        *)
            err "unsupported target triple: ${TARGET}. See ${UNSUPPORTED_DOC}"
            ;;
    esac

    need uname
    need tar
    need mkdir
    need rm
    need install
    need awk
    need sed
    need grep

    # Fail fast on no-sudo: dry-run is honored first so unprivileged
    # auditors can still see what would happen, then ensure_sudo runs
    # before any network call so a non-root user without sudo gets a
    # clear error before we hit the GitHub API.
    if [ "${DRY_RUN}" != "1" ]; then
        ensure_sudo
        resolve_sudo_prefix
    fi

    # Temp dir for downloads.
    _td="$(mktemp -d 2>/dev/null || mktemp -d -t aimx-install)"
    [ -d "${_td}" ] || err "mktemp failed"
    trap cleanup EXIT INT TERM

    # Resolve tag.
    if [ -z "${TAG}" ]; then
        say "resolving latest release from ${GITHUB_API}/latest"
        if [ "${DRY_RUN}" = "1" ]; then
            # Skip network for dry-run.
            TAG="0.0.0"
            say "dry-run: would resolve latest tag from GitHub"
        else
            TAG="$(resolve_latest_tag)"
        fi
    fi
    # Tags are bare SemVer. A caller-supplied `v` prefix would compose a
    # non-existent `/download/v…/` URL; strip it leniently.
    case "${TAG}" in
        v[0-9]*)
            _stripped="${TAG#v}"
            say "normalized tag: ${TAG} -> ${_stripped} (bare SemVer)"
            TAG="${_stripped}"
            ;;
    esac
    _version="$(tag_to_version "${TAG}")"
    _artifact_target="$(artifact_target "${TARGET}")"

    _asset="aimx-${_version}-${_artifact_target}.tar.gz"
    _url="${GITHUB_DL}/${TAG}/${_asset}"
    _install_path="${PREFIX}/aimx"

    say "target:  ${TARGET}"
    say "tarball: ${_url}"
    say "install path: ${_install_path}"

    # Upgrade-vs-fresh decision. Only matters when a binary is already
    # present at ${_install_path}.
    #
    # The binary on disk by itself is not proof that setup completed:
    # the installer drops the binary first and only then exec's
    # `aimx setup`, which is where the systemd / OpenRC unit file
    # actually lands. If the operator Ctrl+Cs out of the wizard before
    # the unit lands, we treat the next run as a fresh install — re-lay
    # the binary at the target tag and re-enter the wizard — rather
    # than entering the upgrade branch and dying on
    # `systemctl start aimx` ("Unit aimx.service not found").
    _installed_tag=""
    _is_upgrade=0
    if [ -x "${_install_path}" ] && ! setup_completed; then
        say "binary present but aimx setup did not complete (no service unit); continuing setup"
        _is_upgrade=0
    elif [ -x "${_install_path}" ]; then
        _installed_tag="$(parse_installed_tag "${_install_path}")"
        if [ -n "${_installed_tag}" ]; then
            _cmp="$(compare_tags "${_installed_tag}" "${TAG}")"
            case "${_cmp}" in
                equal)
                    if [ "${FORCE}" -eq 1 ]; then
                        say "re-installing ${TAG} (--force)"
                        _is_upgrade=1
                    elif [ "${DRY_RUN}" != "1" ] && prompt_reinstall; then
                        # Binary is already correct; skip the swap and
                        # jump straight to the wizard. backup_existing_config
                        # below covers any partial config from an aborted run.
                        # Dry-run never prompts — auditing the script must
                        # stay non-interactive, matching pre-PR behavior on
                        # the equal-version branch.
                        _skip_swap=1
                        _is_upgrade=0
                    else
                        say "AIMX ${_installed_tag} is already installed (pass --force to re-install)"
                        exit 0
                    fi
                    ;;
                newer)
                    err "installed ${_installed_tag} is newer than target ${TAG}; run 'aimx upgrade --version ${TAG} --force' to downgrade explicitly"
                    ;;
                older)
                    say "upgrading ${_installed_tag} -> ${TAG}"
                    _is_upgrade=1
                    ;;
            esac
        else
            say "existing binary at ${_install_path} did not report a parseable version; proceeding as upgrade"
            _is_upgrade=1
        fi
    fi

    if [ "${DRY_RUN}" = "1" ]; then
        say "dry-run: would download ${_url}"
        say "dry-run: would extract tarball under ${_td}"
        if [ "${_is_upgrade}" -eq 1 ]; then
            say "dry-run: would stop aimx.service, swap binary, restart (upgrade)"
        else
            say "dry-run: would ensure_sudo, install to ${_install_path}"
            say "dry-run: would exec 'sudo aimx setup' (binary owns wizard)"
        fi
        say "dry-run: no filesystem changes made"
        exit 0
    fi

    if [ ! -d "${PREFIX}" ]; then
        if ! ${SUDO} mkdir -p "${PREFIX}" 2>/dev/null; then
            err "install prefix ${PREFIX} does not exist and cannot be created"
        fi
    fi

    # Download + extract. Skipped on the equal-version re-run path
    # (operator answered "y" to prompt_reinstall) — the binary on disk
    # already matches the target tag, so we have nothing to download.
    if [ "${_skip_swap:-0}" -ne 1 ]; then
        _tarball="${_td}/${_asset}"
        say "downloading ${_asset}"
        download "${_url}" "${_tarball}"
        verbose "extracting ${_tarball}"
        (cd "${_td}" && tar -xzf "${_asset}") || err "tar extract failed"
        _staged="${_td}/aimx-${_version}-${_artifact_target}/aimx"
        if [ ! -x "${_staged}" ]; then
            err "extracted tarball missing executable aimx at expected path"
        fi
    fi

    if [ "${_is_upgrade}" -eq 1 ]; then
        # Upgrade path: non-interactive by design. Previous setup is
        # preserved; just swap the binary and restart the service.
        detect_manual_aimx_serve "${_install_path}"
        _init="$(stop_service || echo unknown)"

        _prev="${_install_path}.prev"

        if [ -f "${_install_path}" ]; then
            if ! ${SUDO} mv -f "${_install_path}" "${_prev}"; then
                say "✗ failed to preserve ${_install_path} as ${_prev}"
                start_service "${_init}" || true
                err "aborting upgrade; previous binary still in place"
            fi
        fi

        if ! ${SUDO} install -m 0755 "${_staged}" "${_install_path}"; then
            say "✗ install failed; rolling back"
            if [ -f "${_prev}" ]; then
                ${SUDO} mv -f "${_prev}" "${_install_path}" || true
            fi
            start_service "${_init}" || true
            err "upgrade failed at install step; service restored"
        fi

        if ! start_service "${_init}"; then
            say "✗ service start failed; rolling back"
            if [ -f "${_prev}" ]; then
                ${SUDO} mv -f "${_prev}" "${_install_path}" || true
                start_service "${_init}" || true
            fi
            err "upgrade failed at start step; service restored if possible"
        fi

        # Confirm the daemon actually came back up under systemd. On
        # OpenRC `rc-service aimx start` already exits non-zero when
        # the start fails, so the post-start check would just repeat
        # work — keep the check systemd-only.
        if [ "${_init}" = "systemd" ] && command -v systemctl >/dev/null 2>&1; then
            if systemctl is-active --quiet aimx 2>/dev/null; then
                say "aimx.service is active"
            else
                say "warning: aimx.service did not reach active state"
                say "  inspect with: journalctl -u aimx -n 20"
            fi
        fi

        if [ -n "${_installed_tag}" ]; then
            ui_success "AIMX upgraded from ${_installed_tag} to ${TAG}"
        else
            ui_success "AIMX installed at ${TAG}"
        fi
        say "Upgrade complete. Run 'aimx doctor' for health."
        exit 0
    fi

    # Fresh install path. On the equal-version re-run path we skip the
    # binary swap entirely (already at target tag) and fall through to
    # backup_existing_config + `exec aimx setup` below.
    if [ "${_skip_swap:-0}" -ne 1 ]; then
        ${SUDO} install -m 0755 "${_staged}" "${_install_path}" \
            || err "install failed writing ${_install_path}"
        ui_success "AIMX ${TAG} installed to ${_install_path}"
    else
        ui_info "Re-running AIMX setup (binary unchanged)"
    fi

    # Hand off to the binary. `aimx setup` owns the welcome banner, the
    # six-step checklist, and the closing message. Use `exec` so the
    # shell is replaced cleanly. Backup any pre-existing config first so
    # the wizard's writes don't clobber it.
    backup_existing_config
    # When stdin is already the terminal (./install.sh from a real shell),
    # leave it alone. Re-pointing it at /dev/tty creates a separate fd
    # that breaks sudo's `use_pty` bridge on modern distros — the
    # operator's keystrokes stop registering at the first prompt. Only
    # re-attach /dev/tty when stdin is a pipe or redirect (curl|sh).
    if [ -t 0 ]; then
        exec ${SUDO} aimx setup
    elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
        exec ${SUDO} aimx setup </dev/tty
    else
        # No TTY at all (CI, fully-scripted): the wizard will respect
        # AIMX_NONINTERACTIVE=1 if set, or error out itself.
        exec ${SUDO} aimx setup
    fi
}

# Honor INSTALL_SH_TEST=1 so unit tests can source the script to probe
# individual helpers without triggering the full install flow.
if [ "${INSTALL_SH_TEST:-0}" != "1" ]; then
    main "$@"
fi
