#!/bin/sh
# sling installer — GENERATED from one release manifest at build time. Every download URL, size,
# and digest below is baked from the same build that produced the binaries, so this script can
# verify what it fetches without trusting a second source.
#
# Trust boundary: the digests protect against corruption, truncation, and mixed-deploy downloads.
# They are served from the same origin as the binaries, so they are not an independent signature;
# independently signed and attested releases are planned.
set -eu
umask 022

# ---- baked release facts (substituted by src/installer.ts; do not edit by hand) ----
SLING_VERSION="0.1.69"
SLING_ORIGIN="https://runners.starsling.dev"
DARWIN_ARM64_SHA="07282fb5e9531a2bdd2c026eac5265d7c25cb5653cbdfeb3e37023fa7dc8027a"
DARWIN_ARM64_SIZE="64786290"
LINUX_X64_SHA="7760811387e89c7eca2f19abc60830468e6b6dc15418ee0dbfe2df8f34a70ee2"
LINUX_X64_SIZE="83867104"
# ------------------------------------------------------------------------------------

PROG="sling installer (v$SLING_VERSION)"

fail() {
  phase="$1"; shift
  printf '%s: [%s] %s\n' "$PROG" "$phase" "$*" >&2
  exit 1
}

usage() {
  cat <<EOF
Install the sling CLI, v$SLING_VERSION, from $SLING_ORIGIN.

Usage: install.sh [--install-root DIR] [--bin-dir DIR] [--allow-downgrade] [--check] [--dry-run]
                  [--verbose] [--no-color] [--help]

  --install-root DIR  where versions are stored (default: \$HOME/.local/share/sling)
  --bin-dir DIR       where the \`sling\` symlink goes (default: \$HOME/.local/bin)
  --allow-downgrade   permit installing an OLDER version than the active one (refused by default)
  --check             report the current installation state; change nothing
  --dry-run           print what this run would do; change nothing
  --verbose           print detection and resolution details
  --no-color          never emit color (color is only used on a terminal; NO_COLOR is honoured)
  --help              this text

Running as root requires BOTH --install-root and --bin-dir: root installs never default to \$HOME
paths, and this installer never uses sudo on your behalf.

Environment: SLING_INSTALL_ROOT and SLING_BIN_DIR set the same defaults.
Supported targets: Apple Silicon macOS (arm64), glibc Linux x64.
EOF
}

# Absolute, not "/", no whitespace/control characters — these values end up in paths and rm.
validate_dir_value() {
  name="$1"; value="$2"
  case "$value" in
    /) fail preflight "$name must not be /" ;;
    /*) : ;;
    *) fail preflight "$name must be an absolute path, got: $value" ;;
  esac
  case "$value" in
    *[![:print:]]* | *" "* | *"	"*) fail preflight "$name must not contain whitespace or control characters" ;;
  esac
  # A colon would split the PATH guidance (and the PATH-membership check) at the wrong place.
  case "$value" in
    *:*) fail preflight "$name must not contain colons (PATH treats them as separators)" ;;
  esac
  # Values must be NORMALIZED: //, /./, /../, a trailing slash, or . / .. components make the
  # string name a different directory than it reads — including /, which the first check would
  # otherwise be sidestepped into (e.g. //, /., /tmp/..).
  case "$value" in
    *//* | */./* | */../* | */. | */.. | */) fail preflight "$name must be a normalized path (no //, . or .. components, or trailing /)" ;;
  esac
  # Quotes, backslashes, backticks, and dollar signs are refused outright: these values are echoed
  # into the install manifest and error messages, and hostile values must be unrepresentable.
  case "$value" in
    *[\"\'\\\`\$]*) fail preflight "$name must not contain quotes, backslashes, backticks, or dollar signs" ;;
  esac
}

# ---- arguments (validated before any use) ----
DRY_RUN=0
CHECK_ONLY=0
VERBOSE=0
NO_COLOR_FLAG=0
ALLOW_DOWNGRADE=0
INSTALL_ROOT="${SLING_INSTALL_ROOT:-}"
BIN_DIR="${SLING_BIN_DIR:-}"
while [ $# -gt 0 ]; do
  case "$1" in
    --help|-h) usage; exit 0 ;;
    --dry-run) DRY_RUN=1 ;;
    --check) CHECK_ONLY=1 ;;
    --verbose) VERBOSE=1 ;;
    --no-color) NO_COLOR_FLAG=1 ;;
    --allow-downgrade) ALLOW_DOWNGRADE=1 ;;
    --install-root)
      [ $# -ge 2 ] || fail preflight "--install-root requires a value"
      INSTALL_ROOT="$2"; shift ;;
    --bin-dir)
      [ $# -ge 2 ] || fail preflight "--bin-dir requires a value"
      BIN_DIR="$2"; shift ;;
    --*) fail preflight "unknown option: $1 (see --help)" ;;
    *) fail preflight "unexpected argument: $1 (see --help)" ;;
  esac
  shift
done

# Color: only on a terminal, and only when neither NO_COLOR nor --no-color forbids it. Warnings and
# failures go to stderr uncolored; color is reserved for the final success line.
COLOR_OK=0
if [ -t 1 ] && [ "$NO_COLOR_FLAG" != "1" ] && [ -z "${NO_COLOR:-}" ]; then COLOR_OK=1; fi
c_green=""
c_reset=""
if [ "$COLOR_OK" = "1" ]; then
  c_green="$(printf '\033[32m')"
  c_reset="$(printf '\033[0m')"
fi

warn() { printf '%s: warning: %s\n' "$PROG" "$*" >&2; }
vlog() {
  if [ "$VERBOSE" = "1" ]; then printf '%s: %s\n' "$PROG" "$*"; fi
  return 0
}

# Explicit-path installs are the only form permitted as root: root must never default into $HOME,
# and this installer never invokes sudo. Track explicitness BEFORE defaults are derived.
EXPLICIT_PATHS=0
if [ -n "$INSTALL_ROOT" ] && [ -n "$BIN_DIR" ]; then EXPLICIT_PATHS=1; fi
if [ "$EXPLICIT_PATHS" != "1" ]; then
  # Fail CLOSED on an undeterminable UID: treating a broken `id` as non-root would let an actual
  # root default into $HOME-relative paths. Explicit paths are exempt — they are root-safe anyway.
  run_uid="$(id -u 2>/dev/null || true)"
  case "$run_uid" in
    ''|*[!0-9]*) fail preflight 'cannot determine the current user (id -u failed): pass BOTH --install-root and --bin-dir explicitly' ;;
    0) fail preflight 'running as root: pass BOTH --install-root and --bin-dir (or set SLING_INSTALL_ROOT and SLING_BIN_DIR) — root installs never default to home-relative paths, and sudo is never used on your behalf' ;;
  esac
fi

# HOME is only needed to derive a DEFAULT path — explicit --install-root/--bin-dir installs must
# work in non-login environments where HOME is unset.
if [ -z "$INSTALL_ROOT" ] || [ -z "$BIN_DIR" ]; then
  if [ -z "${HOME:-}" ]; then
    fail preflight 'HOME is not set; set HOME, or pass both --install-root and --bin-dir'
  fi
  case "$HOME" in /*) : ;; *) fail preflight "HOME must be an absolute path, got: $HOME" ;; esac
fi
[ -n "$INSTALL_ROOT" ] || INSTALL_ROOT="$HOME/.local/share/sling"
[ -n "$BIN_DIR" ] || BIN_DIR="$HOME/.local/bin"
validate_dir_value "install root" "$INSTALL_ROOT"
validate_dir_value "bin dir" "$BIN_DIR"

# ---- platform detection (fails precisely BEFORE any filesystem change) ----
UNSUPPORTED_HELP="supported targets: Apple Silicon macOS (arm64), glibc Linux x64. Support for more platforms is planned."
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
  Darwin)
    # A shell under Rosetta reports x86_64; the physical machine is what matters.
    if [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then
      arch="arm64"
    fi
    [ "$arch" = "arm64" ] || fail detect "Intel macOS is not supported. $UNSUPPORTED_HELP"
    ASSET="sling-darwin-arm64"; WANT_SHA="$DARWIN_ARM64_SHA"; WANT_SIZE="$DARWIN_ARM64_SIZE"
    ;;
  Linux)
    [ "$arch" = "x86_64" ] || fail detect "Linux $arch is not supported. $UNSUPPORTED_HELP"
    # Positive glibc identification; musl and unknown libc both refuse before download.
    if [ -e /lib/ld-musl-x86_64.so.1 ]; then
      fail detect "musl Linux is not supported. $UNSUPPORTED_HELP"
    elif [ -e /lib64/ld-linux-x86-64.so.2 ] || (command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qiE 'glibc|gnu libc'); then
      : # glibc confirmed
    else
      fail detect "could not positively identify glibc on this Linux. $UNSUPPORTED_HELP"
    fi
    ASSET="sling-linux-x64"; WANT_SHA="$LINUX_X64_SHA"; WANT_SIZE="$LINUX_X64_SIZE"
    ;;
  *) fail detect "$os is not supported. $UNSUPPORTED_HELP" ;;
esac
ASSET_URL="$SLING_ORIGIN/cli/v$SLING_VERSION/$ASSET"
vlog "detected $os/$arch → $ASSET; origin $SLING_ORIGIN"

SHA12="$(printf '%s' "$WANT_SHA" | cut -c1-12)"
VERSION_DIR="$INSTALL_ROOT/versions/v$SLING_VERSION-$SHA12"
LINK="$BIN_DIR/sling"

# Returns success when $1 > $2; both must be x.y.z (the only format releases carry).
version_gt() {
  ga_rest=${1#*.}; gb_rest=${2#*.}
  ga1=${1%%.*}; ga2=${ga_rest%%.*}; ga3=${ga_rest#*.}
  gb1=${2%%.*}; gb2=${gb_rest%%.*}; gb3=${gb_rest#*.}
  if [ "$ga1" -ne "$gb1" ]; then [ "$ga1" -gt "$gb1" ]; return; fi
  if [ "$ga2" -ne "$gb2" ]; then [ "$ga2" -gt "$gb2" ]; return; fi
  [ "$ga3" -gt "$gb3" ]
}

# Destination handling, split in two: inspect_destination classifies and NEVER fails (it powers
# --check's read-only report), while validate_destination enforces on top of it. Enforcement runs
# twice: once pre-lock for fail-fast UX (and for --dry-run), and again AFTER the lock is held —
# the pre-lock reading is advisory, since a concurrent installer can change the link in between.
#
# Refuse to touch anything we don't manage: $BIN_DIR/sling must be absent, or a symlink to a
# binary in the managed versions layout. A regular file, a foreign symlink, or a symlink to a
# DIRECTORY is someone else's — a directory target is doubly dangerous, because `mv` onto a
# symlinked directory moves INTO it instead of replacing the link.
inspect_destination() {
  PREV_TARGET=""
  LINK_TARGET=""
  LINK_STATE=absent
  if [ -L "$LINK" ]; then
    LINK_TARGET="$(readlink "$LINK")"
    # Managed shape means EXACTLY one clean component between versions/ and /sling: in a `case`
    # pattern `*` also matches `/`, so without this a target like versions/v1/../../../x/sling
    # would count as managed and let the installer replace a link it promised never to touch.
    id_mid="${LINK_TARGET#"$INSTALL_ROOT"/versions/}"
    id_mid="${id_mid%/sling}"
    case "$id_mid" in
      */* | . | .. | "") id_mid="" ;;
    esac
    case "$LINK_TARGET" in
      "$INSTALL_ROOT"/versions/*/sling)
        if [ -z "$id_mid" ]; then
          LINK_STATE=foreign-link
        elif [ -d "$LINK" ]; then
          LINK_STATE=foreign-dir
        elif [ -x "$LINK" ]; then
          PREV_TARGET="$LINK_TARGET"
          LINK_STATE=managed
        else
          # Managed shape, but the target is gone or not executable — a broken install. Repair
          # (a normal rerun) may replace it, so it still counts as the previous target for GC.
          PREV_TARGET="$LINK_TARGET"
          LINK_STATE=managed-broken
        fi
        ;;
      *) LINK_STATE=foreign-link ;;
    esac
  elif [ -e "$LINK" ]; then
    LINK_STATE=foreign-file
  fi
  return 0
}

validate_destination() {
  inspect_destination
  case "$LINK_STATE" in
    foreign-link) fail preflight "$LINK is a symlink to $LINK_TARGET, which this installer does not manage. Remove it yourself or pass a different --bin-dir." ;;
    foreign-dir) fail preflight "$LINK resolves to a directory, which this installer does not manage. Remove it yourself or pass a different --bin-dir." ;;
    foreign-file) fail preflight "$LINK exists and is not a managed symlink. Remove it yourself or pass a different --bin-dir." ;;
  esac

  # An installer older than the ACTIVE version refuses the implicit rollback: someone piping an
  # old pinned installer should not silently downgrade a machine. --allow-downgrade is the
  # deliberate path (an intentional control-plane rollback). An unreadable or non-x.y.z active
  # version skips the guard — that's a broken install this run exists to repair, and version_gt
  # must only ever see exactly three all-digit segments (anything else would crash the arithmetic).
  if [ "$LINK_STATE" = "managed" ]; then
    active_version="$("$LINK" --version 2>/dev/null || true)"
    case "$active_version" in
      *[!0-9.]*|"") : ;;     # non-numeric or empty — no comparison possible
      *..*|.*|*.) : ;;       # empty segments — malformed, no comparison
      *.*.*.*) : ;;          # more than three segments — not a release version
      # A segment of 10+ digits would overflow shell integer comparison, making [ -gt ] error and
      # the guard fail OPEN. Nine digits stays below every POSIX shell's integer limit.
      *[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*) : ;;
      *.*.*)
        if version_gt "$active_version" "$SLING_VERSION" && [ "$ALLOW_DOWNGRADE" != "1" ]; then
          fail preflight "active sling is v$active_version, newer than this installer's v$SLING_VERSION. Refusing the implicit downgrade; rerun with --allow-downgrade if this rollback is intentional."
        fi
        ;;
      *) : ;;                # fewer than three segments — no comparison
    esac
  fi
  return 0
}

# ---- conflict awareness: classify what the shell actually resolves, never touch it ----
classify_install() {
  ci_path="$1"
  # Package managers put a SYMLINK on PATH (/usr/local/bin/sling -> ../Cellar/...): the target is
  # what identifies the manager, so resolve one level before matching. Relative targets resolve
  # against the link's own directory; an unreadable link falls back to the original path.
  if [ -L "$ci_path" ]; then
    ci_target="$(readlink "$ci_path" 2>/dev/null || true)"
    case "$ci_target" in
      /*) ci_path="$ci_target" ;;
      ?*) ci_path="$(dirname "$ci_path")/$ci_target" ;;
    esac
  fi
  case "$ci_path" in
    */Cellar/*|*/homebrew/*|*/linuxbrew/*) echo "Homebrew" ;;
    */node_modules/*|*/npm/*|*/pnpm/*) echo "npm/pnpm" ;;
    */.asdf/*|*/mise/*) echo "mise/asdf" ;;
    /usr/local/bin/*|/usr/bin/*|/bin/*|/opt/*) echo "a system-wide install" ;;
    *) echo "an unrelated install" ;;
  esac
}

SHADOW_WARNING=""
note_shadowing() {
  SHADOW_WARNING=""
  # PATH is walked directly rather than asking `command -v`: shells skip a DANGLING symlink when
  # resolving, so a broken `sling` link would be invisible to command -v — but it is exactly the
  # stale leftover a user should hear about. The first executable candidate is what the shell
  # will run; a dangling candidate seen before BIN_DIR is reported as broken.
  ns_winner=""
  ns_broken=""
  ns_rest="$PATH:"
  while [ -n "$ns_rest" ]; do
    ns_dir="${ns_rest%%:*}"
    ns_rest="${ns_rest#*:}"
    [ -n "$ns_dir" ] || continue
    ns_cand="$ns_dir/sling"
    if [ -z "$ns_winner" ] && [ -f "$ns_cand" ] && [ -x "$ns_cand" ]; then ns_winner="$ns_cand"; fi
    if [ -z "$ns_broken" ] && [ "$ns_dir" != "$BIN_DIR" ] && [ -L "$ns_cand" ] && [ ! -e "$ns_cand" ]; then
      ns_broken="$ns_cand"
    fi
  done
  if [ -n "$ns_winner" ] && [ "$ns_winner" != "$LINK" ]; then
    SHADOW_WARNING="your shell resolves \`sling\` to $ns_winner ($(classify_install "$ns_winner")), earlier on PATH than $LINK. That copy is never touched by this installer — remove it or reorder PATH if you want the managed install to win."
  elif [ -n "$ns_broken" ]; then
    SHADOW_WARNING="a broken \`sling\` symlink sits on your PATH at $ns_broken. Shells skip it when executing, but it is a stale leftover — remove it to avoid confusion."
  fi
  return 0
}
note_shadowing
[ -n "$SHADOW_WARNING" ] && warn "$SHADOW_WARNING"
# Remembered for the final guidance: if a DIFFERENT sling resolved before this install, the
# PARENT shell may have cached that path — something this child process can never observe.
PRE_SHADOW="$SHADOW_WARNING"

# ---- --check: report the current state and stop — zero mutation, by position (before any mkdir
#      AND before validation, so foreign, broken, or newer states are REPORTED, never errors) ----
if [ "$CHECK_ONLY" = "1" ]; then
  inspect_destination
  echo "$PROG — check, nothing changed"
  echo "  detected target   $ASSET"
  case "$LINK_STATE" in
    managed) echo "  active link       $LINK -> $PREV_TARGET (managed)" ;;
    managed-broken) echo "  active link       $LINK -> $PREV_TARGET (managed, BROKEN: target missing or not executable)" ;;
    absent) echo "  active link       $LINK (absent)" ;;
    *) echo "  active link       $LINK (present, NOT managed by this installer)" ;;
  esac
  CHECK_MANIFEST="$INSTALL_ROOT/install-manifest.json"
  if [ -f "$CHECK_MANIFEST" ]; then
    m_schema="$(sed -n 's/^[[:space:]]*"schema": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    m_version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    m_at="$(sed -n 's/^[[:space:]]*"installedAt": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    m_target="$(sed -n 's/^[[:space:]]*"target": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    m_vpath="$(sed -n 's/^[[:space:]]*"versionPath": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    m_lpath="$(sed -n 's/^[[:space:]]*"linkPath": "\([^"]*\)".*/\1/p' "$CHECK_MANIFEST")"
    if [ "$m_schema" != "sling-install-manifest/1" ]; then
      echo "  install manifest  UNRECOGNIZED (schema '${m_schema:-missing}'; expected sling-install-manifest/1)"
    else
      echo "  install manifest  v${m_version:-?} installed ${m_at:-?}"
      # The manifest is authoritative for diagnostics — say so when it disagrees with reality
      # instead of presenting stale or wrong-platform state as truth.
      [ "$m_target" = "$ASSET" ] || echo "  manifest MISMATCH target is ${m_target:-?}, this machine needs $ASSET"
      [ "$m_lpath" = "$LINK" ] || echo "  manifest MISMATCH link path is ${m_lpath:-?}, checking $LINK"
      if [ -n "$PREV_TARGET" ] && [ "$m_vpath" != "${PREV_TARGET%/sling}" ]; then
        echo "  manifest MISMATCH version path is ${m_vpath:-?}, active link points at ${PREV_TARGET%/sling}"
      fi
    fi
  else
    echo "  install manifest  none"
  fi
  if [ -n "$SHADOW_WARNING" ]; then
    echo "  PATH resolution   shadowed (see warning above)"
  elif command -v sling >/dev/null 2>&1; then
    echo "  PATH resolution   sling -> $(command -v sling)"
  else
    # PATH membership is a separate fact from whether sling exists yet: a fresh machine with
    # BIN_DIR already on PATH must not be told to edit PATH.
    case ":$PATH:" in
      *":$BIN_DIR:"*) echo "  PATH resolution   $BIN_DIR is on PATH (sling not installed yet)" ;;
      *) echo "  PATH resolution   $BIN_DIR is not on PATH" ;;
    esac
  fi
  echo "  this installer    v$SLING_VERSION from $SLING_ORIGIN"
  exit 0
fi

validate_destination

# ---- required tools (install path only — --check above must work without curl or a hash tool) ----
command -v curl >/dev/null 2>&1 || fail preflight 'curl is required'
if command -v sha256sum >/dev/null 2>&1; then HASH_TOOL=sha256sum
elif command -v shasum >/dev/null 2>&1; then HASH_TOOL=shasum
elif command -v openssl >/dev/null 2>&1; then HASH_TOOL=openssl
else fail preflight 'need one of sha256sum, shasum, or openssl to verify the download'
fi
vlog "hash tool: $HASH_TOOL"

sha256_of() {
  case "$HASH_TOOL" in
    sha256sum) sha256sum "$1" | cut -d' ' -f1 ;;
    shasum) shasum -a 256 "$1" | cut -d' ' -f1 ;;
    openssl) openssl dgst -sha256 -r "$1" | cut -d' ' -f1 ;;
  esac
}

# HTTPS-only in production; plain http is permitted only for a loopback origin (hermetic tests).
case "$SLING_ORIGIN" in
  https://*) CURL_PROTO="=https" ;;
  http://127.0.0.1*|http://localhost*|http://\[::1\]*) CURL_PROTO="=http,https" ;;
  *) fail preflight "refusing non-https origin: $SLING_ORIGIN" ;;
esac

if [ "$DRY_RUN" = "1" ]; then
  cat <<EOF
$PROG — dry run, nothing changed
  target        $ASSET
  download      $ASSET_URL
  expect        $WANT_SIZE bytes, sha256 $WANT_SHA
  version dir   $VERSION_DIR
  active link   $LINK
  staging       under $INSTALL_ROOT (private mktemp; removed on exit)
  lock          $INSTALL_ROOT/.lock (held for the transaction)
  manifest      $INSTALL_ROOT/install-manifest.json (0600, written after activation)
EOF
  exit 0
fi

# ---- staging (same filesystem as the version dir, so the binary commit is an atomic rename;
#      the link swap gets its own PRIVATE mktemp dir NEXT TO the link — BIN_DIR may be another
#      mount, and a predictable temp name in a shared BIN_DIR would be swappable between ln and mv) ----
mkdir -p "$INSTALL_ROOT/versions" "$BIN_DIR"
STAGING=""
LINK_TMP_DIR=""
MANIFEST_TMP=""
LOCK_DIR="$INSTALL_ROOT/.lock"
LOCK_HELD=0
# Idempotent by construction: a signal trap runs cleanup and then EXITs, which runs it AGAIN — the
# second pass must find nothing to do, or it would remove a lock some other process acquired in
# between. Each field is cleared the moment its resource is released.
cleanup() {
  if [ -n "$STAGING" ]; then rm -rf "$STAGING"; STAGING=""; fi
  if [ -n "$LINK_TMP_DIR" ]; then rm -rf "$LINK_TMP_DIR"; LINK_TMP_DIR=""; fi
  if [ -n "$MANIFEST_TMP" ]; then rm -f "$MANIFEST_TMP"; MANIFEST_TMP=""; fi
  # Release only a lock THIS process acquired — never another owner's.
  if [ "$LOCK_HELD" = "1" ]; then rm -rf "$LOCK_DIR"; LOCK_HELD=0; fi
  return 0
}

# One installer at a time per install root (the contract `sling update` must reuse): `mkdir` is the
# atomic acquire; the owner file records pid + timestamp for diagnosis. Stale recovery: a lock whose
# owner pid is gone is TAKEN OVER BY RENAME — rename is atomic, so exactly one contender wins, and a
# fresh lock re-created by a new live owner after the rename is never touched. A live owner is
# waited on (bounded), then refused with its identity.
#
# Scope: the lock serializes per INSTALL ROOT. Two different roots pointed at one bin dir hold
# different locks, but cannot corrupt each other: ownership validation refuses a link owned by
# another root in every sequential case, the link swap is one atomic rename, and a racing loser
# detects the changed link at its post-activation smoke and fails WITHOUT touching the winner's
# verified install (see the rollback guard) — never a rollback or removal of someone else's link.
# Portable inode read: rename preserves the inode, so it is the identity that proves the takeover
# below renamed the very directory it classified. GNU/busybox (-c) is tried FIRST with its output
# captured — on GNU, a BSD-style `stat -f %i` half-fails yet still prints a filesystem-info block
# to stdout, which would corrupt the captured value; output is only emitted on a clean success.
inode_of() {
  if io_val="$(stat -c %i "$1" 2>/dev/null)"; then
    printf '%s\n' "$io_val"
    return 0
  fi
  stat -f %i "$1" 2>/dev/null
}

# How long a contending installer waits on a held lock, and for how many polls. The shipped values are
# 1 second and 10 attempts — ~10 seconds — and nothing but the test suite ever sets these.
#
# They are env-readable for the same reason SLING_INSTALL_ROOT is: the behaviour under test is that a
# contender genuinely BLOCKS on a live lock and proceeds the moment it is released, and proving that at
# the shipped cadence means a test that sleeps for seconds to observe a single poll. At a 100ms poll the
# same test observes several, which is a sharper assertion, not a weaker one. Neither value changes what
# the lock does — only how often it looks.
SLING_LOCK_POLL_S="${SLING_LOCK_POLL_S:-1}"
SLING_LOCK_ATTEMPTS="${SLING_LOCK_ATTEMPTS:-10}"

# Strips a leading run of zeros, keeping at least one digit. A zero-padded value is all digits and so
# clears the validation below, and `[ "$n" -ge 08 ]` compares it as decimal, so the poll loop behaves
# — but ARITHMETIC does not: `$((08))` is an octal literal with an illegal digit, and dash answers
# with a fatal `Illegal number: 08` that kills the script on the same path the leading-dot case below
# already cost us, the one whose entire job is to say WHO holds the lock. Normalised rather than
# rejected: `SLING_LOCK_ATTEMPTS=08` plainly means eight, and a padded value in someone's environment
# should install rather than warn. Non-numeric input is returned untouched, for the case below to
# catch.
decimal_of() {
  _decimal="$1"
  while [ "${#_decimal}" -gt 1 ] && [ "${_decimal#0}" != "$_decimal" ]; do
    _decimal="${_decimal#0}"
  done
  printf '%s' "$_decimal"
}

# How long the loop will have waited by the time it gives up, phrased the way a reader can check
# against the cadence in the same sentence.
#
# Whole seconds whenever the poll is at least a second — that product is already the right answer and
# stays inside the width the ceilings below are written for. Milliseconds only below a second, where
# the whole-seconds answer is 0 and says nothing; both operands are small there (under 1000ms, under
# a billion attempts), so the multiply cannot approach the int64 edge the validation guards.
lock_elapsed() {
  if [ "$SLING_LOCK_POLL_WHOLE_S" -ge 1 ]; then
    printf '~%ss' "$((SLING_LOCK_ATTEMPTS * SLING_LOCK_POLL_WHOLE_S))"
    return
  fi
  _elapsed_ms=$((SLING_LOCK_ATTEMPTS * SLING_LOCK_POLL_MS))
  if [ "$_elapsed_ms" -ge 1000 ]; then
    printf '~%ss' "$((_elapsed_ms / 1000))"
  else
    printf '~%sms' "$_elapsed_ms"
  fi
}

# Both are VALIDATED, not merely defaulted, because the failure of a bad value is unbounded rather
# than merely wrong. `[ "$n" -ge abc ]` errors "Illegal number" and returns non-zero — and because it
# sits in an `if`, `set -e` does not stop the script; the loop simply never reaches its ceiling and
# polls forever. A typo in an exported variable would hang an install with no output at all.
#
# A bad value falls back to the shipped default with a warning rather than refusing: this runs inside
# `curl | sh`, and a stray variable in someone's environment should not be the thing that stops them
# installing. The warning is what makes it noticeable.
#
# Normalise BEFORE validating, not after. `000` is all digits and is not the string `0`, so it clears
# a check written against the raw value — and then normalises to `0`, a ceiling the loop meets on its
# very first pass, refusing instantly instead of falling back to ten. Validating the normalised value
# collapses every all-zero spelling onto the one the check already rejects. The warning still quotes
# what the caller actually set, so `000` does not read back as `0`.
SLING_LOCK_ATTEMPTS_GIVEN="$SLING_LOCK_ATTEMPTS"
SLING_LOCK_ATTEMPTS="$(decimal_of "$SLING_LOCK_ATTEMPTS")"
case "$SLING_LOCK_ATTEMPTS" in
  ''|*[!0-9]*|0) warn "SLING_LOCK_ATTEMPTS=$SLING_LOCK_ATTEMPTS_GIVEN is not a positive whole number; using 10"; SLING_LOCK_ATTEMPTS=10 ;;
esac
# Width, not only shape. A number wider than the machine's is the SAME failure as `-ge abc`: dash's
# test builtin parses with strtoll and answers `Illegal number` above LLONG_MAX (9223372036854775807),
# returning non-zero from inside the `if` — which `set -e` does not stop, so the ceiling is never
# reached and the loop polls a held lock forever, one error line per pass. Nine digits is a billion
# attempts, past any cadence anyone means, and keeps the refusal message's multiply inside int64 even
# with both values at their limit.
if [ "${#SLING_LOCK_ATTEMPTS}" -gt 9 ]; then
  warn "SLING_LOCK_ATTEMPTS=$SLING_LOCK_ATTEMPTS_GIVEN is too large to count to; using 10"
  SLING_LOCK_ATTEMPTS=10
fi
# A poll may be fractional (`0.1`, `.5`), so digits and at most one dot — and not a lone dot. Zero is
# allowed here, unlike the attempt ceiling: a 0s poll is a busy-wait, not an instant refusal.
case "$SLING_LOCK_POLL_S" in
  ''|.|*[!0-9.]*|*.*.*) warn "SLING_LOCK_POLL_S=$SLING_LOCK_POLL_S is not a number of seconds; using 1"; SLING_LOCK_POLL_S=1 ;;
esac

# The whole-seconds part, for the refusal message's arithmetic — which is the only place the poll is
# ever multiplied. `${x%%.*}` is EMPTY for a leading-dot value like `.5`, and `$((n * ))` is a syntax
# error that would take the script down under `set -e` on the one path whose job is to explain that
# another installer holds the lock.
# Normalised too, for the octal reason above: `08.5` would otherwise multiply as `$((08))`.
SLING_LOCK_POLL_WHOLE_S="${SLING_LOCK_POLL_S%%.*}"
SLING_LOCK_POLL_WHOLE_S="$(decimal_of "${SLING_LOCK_POLL_WHOLE_S:-0}")"
# And the fractional part, in milliseconds, for the sub-second case the whole part cannot describe.
# A poll of `.5` has a whole part of 0, so a whole-seconds estimate reported ten attempts of it as
# "~0s (10 attempts, .5s apart)" — a duration its own sentence contradicts, and the suite's own 0.1s
# cadence hit exactly that. Three digits, zero-padded then truncated: `.5` → 500, `0.1` → 100,
# `0.0125` → 12. Truncation is right for an estimate, and the loop below only reaches for this when
# the poll is under a second, which keeps the multiply far inside the width the ceilings above assume.
SLING_LOCK_POLL_MS=0
case "$SLING_LOCK_POLL_S" in
  *.*)
    _poll_frac="${SLING_LOCK_POLL_S#*.}000"
    while [ "${#_poll_frac}" -gt 3 ]; do _poll_frac="${_poll_frac%?}"; done
    SLING_LOCK_POLL_MS="$(decimal_of "$_poll_frac")"
    ;;
esac
# Same ceiling, same reason: this is the other operand of that multiply, and a poll of a billion
# seconds is thirty years. Falls the POLL back too, not just the message's copy of it — a `sleep` that
# long is its own hang, and reporting "~1s apart" while sleeping for an age would be a lie.
if [ "${#SLING_LOCK_POLL_WHOLE_S}" -gt 9 ]; then
  warn "SLING_LOCK_POLL_S=$SLING_LOCK_POLL_S is too long to wait; using 1"
  SLING_LOCK_POLL_S=1
  SLING_LOCK_POLL_WHOLE_S=1
fi

acquire_lock() {
  lock_attempts=0
  while :; do
    if mkdir "$LOCK_DIR" 2>/dev/null; then
      # LOCK_HELD is set BEFORE the owner write: if that write fails or a signal lands in the
      # gap, the traps must still release this lock instead of leaving an ownerless corpse that
      # blocks reruns until the grace period expires.
      LOCK_HELD=1
      printf 'pid=%s\nacquired=%s\n' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$LOCK_DIR/owner" || true
      return 0
    fi
    lock_owner_pid="$(sed -n 's/^pid=//p' "$LOCK_DIR/owner" 2>/dev/null || true)"
    # The classified dir's IDENTITY (inode — rename preserves it), so the post-rename check below
    # can prove it renamed the very dir it classified. A pid comparison cannot: an ownerless
    # corpse and a fresh lock whose owner file isn't written yet both read as pid="".
    lock_ino="$(inode_of "$LOCK_DIR" || true)"
    lock_stale=0
    if [ -n "$lock_owner_pid" ]; then
      kill -0 "$lock_owner_pid" 2>/dev/null || lock_stale=1
    else
      # No owner file: either a lock mid-creation (wait) or a corpse from a death between mkdir
      # and the owner write. Only a lock that has sat ownerless for over a minute is a corpse.
      if [ -n "$(find "$LOCK_DIR" -maxdepth 0 -type d -mmin +1 2>/dev/null)" ]; then lock_stale=1; fi
    fi
    if [ "$lock_stale" = "1" ] && mv "$LOCK_DIR" "$LOCK_DIR.stale.$$" 2>/dev/null; then
      # Rename won the takeover — but between classification and rename, the stale lock may have
      # been replaced by a FRESH one we just stole. Verify BY INODE that the renamed dir is the
      # one classified; if not, put it back (its owner holds by path). If the path was re-taken
      # meanwhile, discard our copy — the mkdir winner's lock stands.
      renamed_ino="$(inode_of "$LOCK_DIR.stale.$$" || true)"
      if [ -n "$lock_ino" ] && [ "$renamed_ino" = "$lock_ino" ]; then
        rm -rf "$LOCK_DIR.stale.$$"
        continue
      fi
      mv "$LOCK_DIR.stale.$$" "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR.stale.$$"
    fi
    lock_attempts=$((lock_attempts + 1))
    if [ "$lock_attempts" -ge "$SLING_LOCK_ATTEMPTS" ]; then
      # The elapsed time is derived, not hardcoded: at the shipped values this still reads "~10s".
      fail lock "another installer holds $LOCK_DIR (owner pid ${lock_owner_pid:-unknown}) after $(lock_elapsed) ($SLING_LOCK_ATTEMPTS attempts, ${SLING_LOCK_POLL_S}s apart). If that process is truly gone, remove the lock directory and rerun."
    fi
    sleep "$SLING_LOCK_POLL_S"
  done
}
on_exit() {
  status=$?
  cleanup
  exit "$status"
}
trap on_exit EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
trap 'cleanup; exit 129' HUP
# Traps are installed BEFORE the lock is taken, so a death at any point after acquisition releases
# it — and the destination is re-validated AFTER acquisition, because the pre-lock pass was
# advisory: a concurrent installer may have changed the link (and thus PREV_TARGET or the
# downgrade verdict) before this process held the lock.
acquire_lock
vlog "lock acquired at $LOCK_DIR"
validate_destination
# Staging lives UNDER versions/ so the commit below is one same-directory atomic rename of the
# whole staged version dir — there is no window in which a partial VERSION_DIR can exist. The
# dot prefix keeps GC's glob from ever considering it a version.
STAGING="$(mktemp -d "$INSTALL_ROOT/versions/.staging.XXXXXX")" || fail staging 'mktemp failed'
LINK_TMP_DIR="$(mktemp -d "$BIN_DIR/.sling.tmp.XXXXXX")" || fail staging 'mktemp failed in bin dir'
LINK_TMP="$LINK_TMP_DIR/link"

# Repair path: this exact version+digest is already committed — revalidate instead of redownloading.
if [ -f "$VERSION_DIR/sling" ] && [ "$(sha256_of "$VERSION_DIR/sling")" = "$WANT_SHA" ]; then
  echo "$PROG: v$SLING_VERSION already installed and verified; repairing the active link only"
  DID_DOWNLOAD=0
else
  DID_DOWNLOAD=1
  echo "$PROG: downloading $ASSET ($WANT_SIZE bytes) from $SLING_ORIGIN"
  # --max-filesize aborts an oversized transfer early (mid-stream on curl >= 8.4; on known
  # Content-Length responses everywhere). The exact wc -c check below stays the authority.
  curl -fsSL --proto "$CURL_PROTO" --max-redirs 3 --connect-timeout 15 --max-time 600 \
    --max-filesize "$WANT_SIZE" \
    --retry 2 --retry-connrefused -o "$STAGING/sling" "$ASSET_URL" \
    || fail download "fetching $ASSET_URL failed; the previous installation (if any) is untouched. Retry, or check $SLING_ORIGIN."

  if [ ! -f "$STAGING/sling" ] || [ -L "$STAGING/sling" ]; then
    fail verify 'download is not a regular file'
  fi
  got_size="$(wc -c < "$STAGING/sling" | tr -d '[:space:]')"
  [ "$got_size" = "$WANT_SIZE" ] || fail verify "size mismatch: got $got_size bytes, expected $WANT_SIZE. Prior installation untouched — retry (a deploy may be mid-transition)."
  got_sha="$(sha256_of "$STAGING/sling")"
  [ "$got_sha" = "$WANT_SHA" ] || fail verify "sha256 mismatch: got $got_sha, expected $WANT_SHA. Prior installation untouched — retry (a deploy may be mid-transition)."

  chmod 0755 "$STAGING/sling"
  staged_version="$("$STAGING/sling" --version 2>/dev/null)" || fail smoke 'staged binary failed to run --version; prior installation untouched'
  [ "$staged_version" = "$SLING_VERSION" ] || fail smoke "staged binary reports '$staged_version', expected '$SLING_VERSION'; prior installation untouched"

  # Single-rename commit: the whole staged directory BECOMES the version dir in one atomic rename,
  # so an interruption, disk-full, or kill can never leave a partial VERSION_DIR behind. A dir
  # already present here therefore failed the verified-repair check above — refuse rather than
  # silently replace a directory this installer can't prove it created.
  if [ -e "$VERSION_DIR" ]; then
    fail commit "$VERSION_DIR exists but failed verification; remove it and rerun. The active installation is untouched."
  fi
  mv "$STAGING" "$VERSION_DIR" || fail commit "could not commit $VERSION_DIR"
  STAGING=""
fi

# ---- atomic activation: build the new link beside the old one, then rename over it ----
ln -s "$VERSION_DIR/sling" "$LINK_TMP" || fail activate 'could not create the new symlink'
mv -f "$LINK_TMP" "$LINK" || fail activate 'could not switch the active symlink'

active_version="$("$LINK" --version 2>/dev/null || true)"
if [ "$active_version" != "$SLING_VERSION" ]; then
  # Unwind ONLY a link that still points at OUR commit. A concurrent installer (another install
  # root sharing this bin dir) may have won a racing swap between our rename and this smoke —
  # its verified link must never be rolled back or removed as if it were our failure.
  now_target="$(readlink "$LINK" 2>/dev/null || true)"
  if [ "$now_target" != "$VERSION_DIR/sling" ]; then
    fail activate "the active link changed underneath this install (now -> ${now_target:-absent}); leaving it untouched"
  fi
  # Roll back: restore the previous target, or remove the link on a failed first install.
  if [ -n "$PREV_TARGET" ]; then
    ln -s "$PREV_TARGET" "$LINK_TMP" && mv -f "$LINK_TMP" "$LINK"
    fail activate "post-activation check failed (got '$active_version'); restored the previous link to $PREV_TARGET"
  else
    rm -f "$LINK"
    fail activate "post-activation check failed (got '$active_version'); removed the broken link"
  fi
fi

# ---- state + retention: only after the activated binary has proven itself ----
# From here on the install has SUCCEEDED — the link is switched and smoke-tested. Manifest and GC
# are post-commit maintenance: a failure in either is warned about, never allowed to turn a
# completed install into a reported failure (or to trip a rollback of a good activation).
#
# The install manifest is what repair, GC, --check, future uninstall, and support diagnostics read,
# instead of guessing from the filesystem. Written 0600, staged on the same filesystem, atomically
# renamed into place — a failed install never replaces the previous manifest. Every interpolated
# value is baked, derived, or a path that validate_dir_value already restricted to safe characters.
write_state_manifest() {
  # Own temp file (staging was consumed by the version-dir rename); same fs as the final path.
  MANIFEST_TMP="$(mktemp "$INSTALL_ROOT/.manifest.XXXXXX")" || return 1
  printf '{\n  "schema": "sling-install-manifest/1",\n  "version": "%s",\n  "target": "%s",\n  "sha256": "%s",\n  "bytes": %s,\n  "installedAt": "%s",\n  "sourceUrl": "%s",\n  "versionPath": "%s",\n  "linkPath": "%s"\n}\n' \
    "$SLING_VERSION" "$ASSET" "$WANT_SHA" "$WANT_SIZE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ASSET_URL" "$VERSION_DIR" "$LINK" > "$MANIFEST_TMP" &&
    chmod 600 "$MANIFEST_TMP" &&
    mv -f "$MANIFEST_TMP" "$INSTALL_ROOT/install-manifest.json" &&
    MANIFEST_TMP=""
}
if ! write_state_manifest; then
  printf '%s: warning: install succeeded but the install manifest could not be written\n' "$PROG" >&2
fi

# Retention: keep the active version and the PREVIOUSLY ACTIVE one (the validated rollback
# candidate, from the post-lock PREV_TARGET) — everything else goes, including never-activated
# strays. Runs only after activation + smoke, so a failed install deletes nothing.
gc_versions() {
  keep_prev_dir=""
  case "$PREV_TARGET" in
    "$INSTALL_ROOT"/versions/*/sling) keep_prev_dir="${PREV_TARGET%/sling}" ;;
  esac
  # A same-version repair (PREV_TARGET already points at this very version) must not GC at all:
  # the retained rollback candidate from the last real upgrade would otherwise be deleted by a
  # routine rerun. GC only runs when the active version actually CHANGED.
  if [ "$keep_prev_dir" = "$VERSION_DIR" ]; then return 0; fi
  for gc_d in "$INSTALL_ROOT/versions"/*; do
    [ -d "$gc_d" ] || continue
    [ "$gc_d" = "$VERSION_DIR" ] && continue
    if [ -n "$keep_prev_dir" ] && [ "$gc_d" = "$keep_prev_dir" ]; then continue; fi
    # Propagate rm failure explicitly: this function runs under `if !`, where errexit is
    # suppressed — without this the warning below could never fire.
    rm -rf "$gc_d" || return 1
  done
  return 0
}
if ! gc_versions; then
  printf '%s: warning: install succeeded but old-version cleanup failed\n' "$PROG" >&2
fi

# The decisions that were made, not shell commands as progress: integrity, smoke, where the bytes
# live, and what became active.
if [ "$DID_DOWNLOAD" = "1" ]; then
  echo "$PROG: verified sha256 ${SHA12}… and exact size; staged and activated binaries both passed the version smoke"
else
  echo "$PROG: revalidated committed sha256 ${SHA12}…; activated binary passed the version smoke"
fi
echo "$PROG: committed to $VERSION_DIR"
printf '%s%s: installed v%s → %s%s\n' "$c_green" "$PROG" "$SLING_VERSION" "$LINK" "$c_reset"
case ":$PATH:" in
  *:"$BIN_DIR":*) : ;;
  *)
    echo ""
    echo "$BIN_DIR is not on your PATH. For this session:"
    echo "  export PATH=\"$BIN_DIR:\$PATH\""
    echo "Add that line to your shell profile to make it permanent (this installer never edits your profile)."
    ;;
esac
# A shadowing warning that is still true after the install is repeated in the next steps.
note_shadowing
[ -n "$SHADOW_WARNING" ] && warn "$SHADOW_WARNING"
if [ -n "$PRE_SHADOW" ] && [ -z "$SHADOW_WARNING" ]; then
  # A different sling resolved BEFORE this install and the managed link wins now — but the PARENT
  # shell may still execute its cached copy: caches are per-shell and invisible from here.
  echo "A different \`sling\` resolved before this install; your shell may have cached it — run \`hash -r\` (bash/sh), \`rehash\` (zsh), or open a new shell."
fi
echo "Next: run \`sling login\`"
