#!/usr/bin/env sh
# shellcheck shell=sh
#
# Hassle CLI — public installer.
#
# Served from https://hassle.dev/install (CloudFront -> S3
# `hassle-marketing-uk-390709476962`, object key `install`, served with
# Content-Type text/x-shellscript). The human-facing page is /install.html.
#
# CANONICAL SOURCE. This file is the single source of truth for the unix
# installer; it is the file deploy-marketing.yml publishes to the `install`
# S3 key and the file the "Verify install endpoint is live" probe checks.
# (There is no cli/dist/install.sh copy — it was removed to kill source drift.)
#
# Usage:
#   curl -fsSL hassle.dev/install | sh
#
# Optional env vars:
#   HASSLE_VERSION       pin to a specific tag, e.g. cli-v0.1.4 (default: latest)
#                        Tags below cli-v0.1.4 are withdrawn and refused (exit 2).
#   HASSLE_INSTALL_DIR   force the install directory (overrides auto-detection)
#   HASSLE_INSTALL_BASE  override the binary mirror base URL
#                        (default: https://hassle.dev/install/binaries)
#
# There is deliberately NO verification bypass variable. (HASSLE_NO_VERIFY was
# removed: its failure path advertised the bypass at the exact moment a blocked
# user was hunting an escape. HASSLE_REPO was removed: it fed the Cosign
# cert-identity pin, so a pasted one-liner could make this script print
# "signature verified" against an ATTACKER's workflow identity.)
#
# Exit codes (CLAUDE.md §5):
#   0  success
#   1  general error (incl. a cosign too old for this script's flags)
#   2  validation error (unsupported OS/arch, withdrawn/invalid version pin)
#   5  network error
#   6  unsafe (signature verification failed) — the script REFUSES to install
#
# Trust contract: this script downloads ONLY a tar.gz from the public Hassle
# binary mirror (CloudFront -> S3 in the Hassle AWS account; the GitHub repo is
# private, so binaries are NOT served from GitHub Releases) and NEVER executes
# the downloaded binary before Cosign signature verification. The signature is
# produced by the GitHub Actions release workflow via keyless OIDC, so the
# cert-identity check below is unchanged regardless of where the tarball is
# hosted. Verification prefers the offline `.bundle` (Rekor inclusion proof +
# signed timestamp — no live Sigstore dependency); releases without a bundle
# fall back to the legacy `.sig`/`.pem` online path. Verification is MANDATORY:
# if it fails, the script exits 6 without writing anything to the install
# directory. If cosign is absent, the OFFICIAL Sigstore cosign binary is
# fetched from github.com/sigstore/cosign, checked against a SHA-256 pinned in
# this script, cached at ~/.hassle/bin, and used. NOTE the bootstrap is a
# friction fix, NOT a security improvement: it does not add an independent
# trust root (a compromised installer could bootstrap anything) — it only
# guarantees the EXISTING verification always runs, on every machine. The
# `hassle --version` smoke-check below runs ONLY after a verified install.

set -eu

# ---------- ANSI helpers ----------
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then
  # tput calls fall back to empty string if a capability is absent (some BSD
  # tput builds lack `dim`); `|| true` keeps `set -e` happy.
  BOLD="$(tput bold 2>/dev/null || true)"
  GREEN="$(tput setaf 2 2>/dev/null || true)"
  YELLOW="$(tput setaf 3 2>/dev/null || true)"
  RED="$(tput setaf 1 2>/dev/null || true)"
  DIM="$(tput dim 2>/dev/null || true)"
  RESET="$(tput sgr0 2>/dev/null || true)"
else
  BOLD=""; GREEN=""; YELLOW=""; RED=""; DIM=""; RESET=""
fi

say()  { printf "%s%s%s\n" "$DIM" "$1" "$RESET"; }
ok()   { printf "%s ok%s %s\n" "$GREEN" "$RESET" "$1"; }
warn() { printf "%s !%s %s\n" "$YELLOW" "$RESET" "$1" >&2; }
fail() { printf "%s x%s %s\n" "$RED" "$RESET" "$1" >&2; }
die()  { fail "$1"; exit "${2:-1}"; }

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

has_tty() {
  # "Can we prompt the user?" Under the published `curl … | sh`, fd 0 is the
  # PIPE, so `[ -t 0 ]` is always false — that dead test used to disable the
  # sudo branch for every real install. sudo prompts on the controlling
  # terminal (/dev/tty), not stdin, so the right question is whether /dev/tty
  # opens.
  ( exec </dev/tty ) 2>/dev/null
}

# ---------- 1. Detect OS + arch ----------
detect_os() {
  uname_s="$(uname -s 2>/dev/null || echo unknown)"
  case "$uname_s" in
    Linux)                echo "linux" ;;
    Darwin)               echo "darwin" ;;
    MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
    *)                    echo "unknown" ;;
  esac
}

detect_arch() {
  uname_m="$(uname -m 2>/dev/null || echo unknown)"
  case "$uname_m" in
    x86_64|amd64)  echo "x86_64" ;;
    arm64|aarch64) echo "aarch64" ;;
    *)             echo "unknown" ;;
  esac
}

OS="$(detect_os)"
ARCH="$(detect_arch)"

# Windows (Git Bash / MSYS / Cygwin): this POSIX script ships the unix
# tar.gz binaries, NOT the native windows .zip. Point the user at the native
# PowerShell installer (which downloads + Cosign-verifies the windows .zip)
# or, for the Linux binary, at WSL. Do this BEFORE any download so we never
# fetch a unix artifact onto a Windows host.
if [ "$OS" = "windows" ]; then
  warn "This installer ships the Linux/macOS binaries; Windows has a native installer."
  printf "\n%sInstall Hassle on Windows (PowerShell, recommended):%s\n" "$BOLD" "$RESET"
  printf "    %sirm hassle.dev/install.ps1 | iex%s\n" "$BOLD" "$RESET"
  say  "  (downloads + Cosign-verifies the native windows .zip)"
  printf "\n%sOr, to use the Linux binary:%s run this same command inside WSL:\n" "$BOLD" "$RESET"
  printf "    %scurl -fsSL hassle.dev/install | sh%s   (from a WSL shell)\n" "$BOLD" "$RESET"
  say  "  More: https://hassle.dev/install.html"
  exit 2
fi

if [ "$OS" = "unknown" ] || [ "$ARCH" = "unknown" ]; then
  fail "Unsupported platform: $(uname -sm 2>/dev/null || echo unknown)"
  say  "Supported: linux/x86_64, linux/aarch64, darwin/x86_64, darwin/aarch64"
  say  "Windows users: run  irm hassle.dev/install.ps1 | iex  (PowerShell)."
  exit 2
fi

# ---------- 2. Resolve version + URLs ----------
HASSLE_VERSION="${HASSLE_VERSION:-latest}"

# ---------- 2a. Version floor (enforced BEFORE any download) ----------
# cli-v0.1.3 and older are WITHDRAWN — v0.1.3 ships a credential-storage
# defect (the CLI cannot hold a login), and its signature is GENUINE, so
# without this floor a pinned install would print "signature verified" and
# hand the founder a broken CLI. `latest` is always >= the floor by
# construction and skips the parse.
HASSLE_MIN_VERSION="cli-v0.1.4"
FLOOR_MAJ=0; FLOOR_MIN=1; FLOOR_PAT=4

parse_pinned_version() {
  # parse_pinned_version cli-vX.Y.Z -> sets VMAJ/VMIN/VPAT; 1 if malformed.
  case "$1" in
    cli-v*) _v="${1#cli-v}" ;;
    *) return 1 ;;
  esac
  _oldifs="$IFS"; IFS=.
  # shellcheck disable=SC2086  # word-splitting on '.' is the point here
  set -- $_v
  IFS="$_oldifs"
  [ $# -eq 3 ] || return 1
  for _c in "$1" "$2" "$3"; do
    case "$_c" in ''|*[!0-9]*) return 1 ;; esac
  done
  VMAJ=$1; VMIN=$2; VPAT=$3
}

if [ "$HASSLE_VERSION" != "latest" ]; then
  if ! parse_pinned_version "$HASSLE_VERSION"; then
    fail "Invalid HASSLE_VERSION '$HASSLE_VERSION' — expected 'latest' or a tag like cli-v0.1.4."
    exit 2
  fi
  if [ "$VMAJ" -lt "$FLOOR_MAJ" ] || {
       [ "$VMAJ" -eq "$FLOOR_MAJ" ] && {
         [ "$VMIN" -lt "$FLOOR_MIN" ] || {
           [ "$VMIN" -eq "$FLOOR_MIN" ] && [ "$VPAT" -lt "$FLOOR_PAT" ]; }; }; }; then
    fail "$HASSLE_VERSION is withdrawn and cannot be installed."
    say  "Releases before $HASSLE_MIN_VERSION carry a credential-storage defect"
    say  "(the CLI cannot hold a login). Minimum installable version: $HASSLE_MIN_VERSION."
    say  "Run again without HASSLE_VERSION to get the latest release."
    exit 2
  fi
fi

# Asset names match release-cli.yml (.github/workflows/release-cli.yml):
#   "Strip + package tarball" step: ASSET="hassle-${os}-${arch}.tar.gz"
#   The mirror job uploads each tarball plus its Cosign offline .bundle and
#   the legacy .sig (Cosign) + .pem (Fulcio cert) pair to the public
#   S3+CloudFront mirror. (Releases up to cli-v0.1.4 predate bundles.)
ASSET="hassle-${OS}-${ARCH}.tar.gz"
SIG_ASSET="${ASSET}.sig"
CERT_ASSET="${ASSET}.pem"
BUNDLE_ASSET="${ASSET}.bundle"

# 1b distribution: binaries are served from the public CloudFront mirror in the
# Hassle AWS account (S3 hassle-marketing-uk-390709476962, fronted by
# hassle.dev) — NOT GitHub Releases (private repo => anonymous 404). Mirror
# layout, per release-cli.yml's "Mirror tarballs to S3" step:
#   <base>/<tag>/hassle-<os>-<arch>.tar.gz(.sig|.pem)     (immutable, pinned)
#   <base>/latest/hassle-<os>-<arch>.tar.gz(.sig|.pem)    (short-TTL, newest)
HASSLE_INSTALL_BASE="${HASSLE_INSTALL_BASE:-https://hassle.dev/install/binaries}"

if [ "$HASSLE_VERSION" = "latest" ]; then
  RELEASE_BASE="${HASSLE_INSTALL_BASE}/latest"
else
  RELEASE_BASE="${HASSLE_INSTALL_BASE}/${HASSLE_VERSION}"
fi

TARBALL_URL="${RELEASE_BASE}/${ASSET}"
SIG_URL="${RELEASE_BASE}/${SIG_ASSET}"
CERT_URL="${RELEASE_BASE}/${CERT_ASSET}"
BUNDLE_URL="${RELEASE_BASE}/${BUNDLE_ASSET}"

# Cosign keyless verification pins both the OIDC issuer and the workflow SAN.
# Keep COSIGN_CERT_IDENTITY_REGEX in sync with release-cli.yml's path:
# `.github/workflows/release-cli.yml@refs/tags/`.
# HARD CONSTANTS — deliberately not overridable. When the repo half of the
# identity was env-tunable (HASSLE_REPO), a pasted one-liner could point the
# pin at an attacker's fork and this script would happily print
# "signature verified" for the attacker's binary.
COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com"
COSIGN_CERT_IDENTITY_REGEX="^https://github.com/hassledev/hassle/.github/workflows/release-cli.yml@refs/tags/"

# ---------- 2b. Pinned verifier (used only when cosign is absent) ----------
# The exact cosign version release-cli.yml pins for signing, so installer-side
# behaviour matches CI-side behaviour by construction. SHA-256 pins are from
# the official cosign_checksums.txt for v2.4.1 (independently re-verified).
# Bumping this pin = new version + new hashes + new cache filename, deployed
# as a change to this script.
COSIGN_PIN_VERSION="v2.4.1"
COSIGN_PIN_BASE_URL="https://github.com/sigstore/cosign/releases/download/${COSIGN_PIN_VERSION}"

# ---------- 3. Downloader ----------
if have curl; then
  DOWNLOADER="curl"
elif have wget; then
  DOWNLOADER="wget"
else
  die "Need curl or wget to download the CLI." 5
fi

download() {
  # download URL OUT_PATH  -> 0 on success, 1 on failure
  _url="$1"; _out="$2"
  if [ "$DOWNLOADER" = "curl" ]; then
    curl -fsSL --proto '=https' --tlsv1.2 -o "$_out" "$_url" || return 1
  else
    wget --quiet --https-only -O "$_out" "$_url" || return 1
  fi
}

download_progress() {
  # Like download(), but shows a progress bar when stderr is a TTY (used for
  # the ~104 MB cosign bootstrap fetch; a silent multi-minute stall reads as
  # a hung installer). Same HTTPS/TLS floor as download().
  _url="$1"; _out="$2"
  if [ ! -t 2 ]; then
    download "$_url" "$_out"
    return
  fi
  if [ "$DOWNLOADER" = "curl" ]; then
    curl -f -# -L --proto '=https' --tlsv1.2 -o "$_out" "$_url" || return 1
  else
    # Plain (non --quiet) wget shows its default progress meter; the
    # --show-progress flag is skipped as it doesn't exist before wget 1.16.
    wget --https-only -O "$_out" "$_url" || return 1
  fi
}

sha256_of() {
  # sha256_of FILE -> prints the hex digest. Portable: sha256sum on Linux,
  # shasum on stock macOS (which ships LibreSSL, not GNU coreutils).
  if have sha256sum; then
    sha256sum "$1" | cut -d' ' -f1
  else
    shasum -a 256 "$1" | cut -d' ' -f1
  fi
}

# ---------- 3b. Verifier bootstrap (cosign) ----------
# If cosign is on PATH, use it. If not, fetch the OFFICIAL Sigstore cosign
# release binary and cache it at ~/.hassle/bin so the ~104 MB cost is paid
# once per machine, not once per install.
#
# What this buys, stated honestly: it is NOT an independent trust root — a
# compromised copy of THIS script could bootstrap any "verifier" or skip the
# check entirely. It removes a prerequisite so the existing guarantee (no
# binary is installed or executed before verification against the pinned
# release-workflow identity) always runs, including on a clean machine. The
# fetch comes from github.com/sigstore/cosign (a different origin than
# hassle.dev) and is checked against the SHA-256 pins below, so compromising
# the hassle.dev mirror ALONE cannot supply the verifier.
#
# Cache policy (each line is a real failure mode):
#   - reuse:      the cached binary is re-hashed against the pin before EVERY
#                 use; mismatch (poisoned, corrupt, half-written) => deleted
#                 and re-fetched. An unverified binary is never executed.
#   - partial:    downloads land in a mktemp file in the same directory, are
#                 hash-verified, then atomically renamed into place. The final
#                 path never holds unverified bytes.
#   - races:      concurrent installs each verify their own temp file; the
#                 last atomic rename wins with byte-identical content.
#   - staleness:  the pin is (version, sha256) and the version is in the
#                 filename; bumping the pin is a deploy of this script and
#                 old cache entries are simply never referenced again.
#   - local attacker with write access to $HOME: detected by the per-use hash
#                 check (barring hash-to-exec TOCTOU — but that attacker owns
#                 your shell rc and the whole account already).
COSIGN_BIN=""
BOOTSTRAP_TMP=""

ensure_verifier() {
  if have cosign; then
    COSIGN_BIN="cosign"
    return 0
  fi

  case "${OS}-${ARCH}" in
    linux-x86_64)   _cs_asset="cosign-linux-amd64"
                    _cs_sha="8b24b946dd5809c6bd93de08033bcf6bc0ed7d336b7785787c080f574b89249b" ;;
    linux-aarch64)  _cs_asset="cosign-linux-arm64"
                    _cs_sha="3b2e2e3854d0356c45fe6607047526ccd04742d20bd44afb5be91fa2a6e7cb4a" ;;
    darwin-x86_64)  _cs_asset="cosign-darwin-amd64"
                    _cs_sha="666032ca283da92b6f7953965688fd51200fdc891a86c19e05c98b898ea0af4e" ;;
    darwin-aarch64) _cs_asset="cosign-darwin-arm64"
                    _cs_sha="13343856b69f70388c4fe0b986a31dde5958e444b41be22d785d3dc5e1a9cc62" ;;
    *)
      # Unreachable today (the platform gate above already exited), kept so a
      # future platform addition fails loudly here instead of skipping ahead.
      die "No pinned cosign build for ${OS}-${ARCH}; install cosign and re-run." 2 ;;
  esac

  _cache_dir="$HOME/.hassle/bin"
  _cached="$_cache_dir/cosign-${COSIGN_PIN_VERSION}"

  if [ -f "$_cached" ]; then
    if [ "$(sha256_of "$_cached")" = "$_cs_sha" ]; then
      COSIGN_BIN="$_cached"
      return 0
    fi
    warn "cached cosign at $_cached failed its integrity check — re-fetching."
    rm -f "$_cached"
  fi

  say "  cosign not found — fetching the official Sigstore verifier ${COSIGN_PIN_VERSION}"
  say "  (~104 MB, one-time per machine, cached at $_cache_dir,"
  say "   checked against a SHA-256 pinned in this script)"

  mkdir -p "$_cache_dir" 2>/dev/null \
    || die "Could not create $_cache_dir. Install cosign yourself and re-run." 1
  chmod 700 "$_cache_dir" 2>/dev/null || true

  BOOTSTRAP_TMP="$(mktemp "$_cache_dir/.cosign-download.XXXXXX")" \
    || die "Could not create a temp file in $_cache_dir." 1

  if ! download_progress "${COSIGN_PIN_BASE_URL}/${_cs_asset}" "$BOOTSTRAP_TMP"; then
    rm -f "$BOOTSTRAP_TMP"; BOOTSTRAP_TMP=""
    fail "Could not download cosign from ${COSIGN_PIN_BASE_URL}."
    say  "Check your network, or install cosign yourself and re-run."
    exit 5
  fi

  _got="$(sha256_of "$BOOTSTRAP_TMP")"
  if [ "$_got" != "$_cs_sha" ]; then
    rm -f "$BOOTSTRAP_TMP"; BOOTSTRAP_TMP=""
    fail "Downloaded cosign does not match its pinned SHA-256. Refusing to execute it."
    say  "  expected: $_cs_sha"
    say  "  got:      $_got"
    say  "Re-run to retry, or install cosign yourself. Report persistent"
    say  "mismatches to security@hassle.dev."
    exit 6
  fi

  chmod 0755 "$BOOTSTRAP_TMP" 2>/dev/null || true
  if ! mv -f "$BOOTSTRAP_TMP" "$_cached"; then
    rm -f "$BOOTSTRAP_TMP"; BOOTSTRAP_TMP=""
    die "Could not move the verified cosign into $_cached." 1
  fi
  BOOTSTRAP_TMP=""
  ok "cosign ${COSIGN_PIN_VERSION} ready (verified against pinned checksum)"
  COSIGN_BIN="$_cached"
}

# ---------- 4. Stage in a temp dir ----------
TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t hassle-install)"
trap 'rm -rf "$TMP_DIR"; [ -n "${BOOTSTRAP_TMP:-}" ] && rm -f "$BOOTSTRAP_TMP"' EXIT INT TERM

printf "\n%sInstalling Hassle CLI%s (%s/%s, %s)\n" "$BOLD" "$RESET" "$OS" "$ARCH" "$HASSLE_VERSION"
say "  source: $RELEASE_BASE"

# Make sure a verifier exists BEFORE downloading the tarball. Verification is
# mandatory and has no bypass; if neither PATH cosign nor the bootstrap can
# provide one, we stop here having installed nothing.
ensure_verifier

say "  downloading $ASSET ..."
if ! download "$TARBALL_URL" "$TMP_DIR/$ASSET"; then
  fail "Could not download $TARBALL_URL"
  say  "Check your network, or that this version exists. Available builds:"
  say  "  https://hassle.dev/install.html"
  exit 5
fi
ok "downloaded tarball"

# ---------- 5. Verify signature (Cosign keyless / GitHub OIDC) ----------
# Bundle-first, legacy-fallback; absence-tolerant, FAILURE-FATAL:
#   - the offline .bundle is preferred (embeds the Rekor inclusion proof +
#     signed timestamp, so verification needs no live Sigstore — the Fulcio
#     leaf cert is only valid ~10 minutes, so the legacy path depends on a
#     live Rekor query for every verification);
#   - a release WITHOUT a bundle (anything <= cli-v0.1.4, or a blocked
#     bundle URL) falls back to the legacy .sig/.pem ONLINE path — which is
#     just as mandatory;
#   - once ANY verification runs and FAILS, the script ABORTS. There is no
#     fallback past a failed verification and no bypass.
COSIGN_LOG="$TMP_DIR/cosign-verify.log"

cosign_failed() {
  # Distinguish "cosign did not understand the command" from "the artifact
  # failed verification". Without this, an upstream flag removal in a future
  # cosign would be reported to the user as tampering (today's cosign v3
  # merely deprecates flags — warnings on SUCCESS are never scanned; only a
  # non-zero exit lands here).
  if grep -qE 'unknown flag|unknown shorthand flag|flag provided but not defined|unknown command|accepts at most' "$COSIGN_LOG" 2>/dev/null; then
    fail "cosign rejected the verification COMMAND, not the artifact."
    say  "Your installed cosign does not support the flags this installer uses."
    say  "Fix: upgrade cosign, or uninstall it — this installer then fetches a"
    say  "known-good pinned cosign (${COSIGN_PIN_VERSION}) automatically."
    say  "cosign said:"
    tail -n 3 "$COSIGN_LOG" 2>/dev/null | while IFS= read -r _line; do say "    $_line"; done
    exit 1
  fi
  fail "Cosign signature verification FAILED. Refusing to install."
  say  "The artifact does not verify against the official Hassle release"
  say  "workflow identity. Report to security@hassle.dev."
  say  "cosign said:"
  tail -n 5 "$COSIGN_LOG" 2>/dev/null | while IFS= read -r _line; do say "    $_line"; done
  exit 6
}

# Is a downloaded body actually a Sigstore bundle, or a captive-portal login
# page / an empty object / a CDN error body served with HTTP 200?
#
# WHY THIS EXISTS, and why it runs BEFORE cosign rather than interpreting
# cosign's result afterwards. Measured 2026-08-04 against cosign v3.1.1: a
# malformed bundle (HTML), an empty bundle, a wrong-shape JSON bundle, random
# bytes, a TAMPERED archive and a WRONG signing identity ALL exit 1. There is
# no exit code and no machine-readable diagnostic that separates "this is not
# a bundle" from "this is an attack", so classifying on cosign's output would
# mean matching its stderr text — unstable strings an on-path attacker could
# influence, i.e. a downgrade surface.
#
# So we never ask cosign that question. A body that is not shaped like a
# bundle is classified as ABSENT (fall through to the legacy .sig/.pem path,
# which is equally mandatory and pinned to the same identity). A body that
# passes this check is handed to cosign, and ANY cosign failure from there is
# FATAL. This weakens nothing: it cannot turn a failed verification into a
# skipped one, because the two paths are decided before verification starts.
#
# POSIX only, deliberately: `jq` is NOT guaranteed on a clean macOS, and
# depending on a tool the target machine may lack is the original defect this
# whole branch exists to fix.
bundle_is_wellformed() {
  # bundle_is_wellformed FILE -> 0 when it looks like a Sigstore bundle
  _b="$1"
  [ -s "$_b" ] || return 1                      # empty / zero-byte object
  # First non-whitespace byte must be '{'. Catches HTML, XML, plain text.
  _first="$(tr -d '[:space:]' < "$_b" 2>/dev/null | cut -c1)"
  [ "$_first" = "{" ] || return 1
  # Must carry the fields cosign needs. A wrong-shape JSON body fails here.
  grep -q '"base64Signature"' "$_b" 2>/dev/null || return 1
  grep -q '"cert"\|"certificate"' "$_b" 2>/dev/null || return 1
  return 0
}

# The bundle is a PROBE: on every release that predates bundles it 404s, and
# that is the normal, handled path (fall through to legacy). curl's `-S` prints
# "curl: (22) ... 404" to stderr even under `-s`, so without this redirect
# EVERY install today shows a raw HTTP error immediately before "signature
# verified" — corrosive on an installer whose whole pitch is that it tells you
# exactly what it does. The tarball download below is NOT silenced: a failure
# there is genuine and the user should see it.
if download "$BUNDLE_URL" "$TMP_DIR/$BUNDLE_ASSET" 2>/dev/null && bundle_is_wellformed "$TMP_DIR/$BUNDLE_ASSET"; then
  say "  verifying with cosign (keyless OIDC, offline bundle) ..."
  if "$COSIGN_BIN" verify-blob \
       --bundle "$TMP_DIR/$BUNDLE_ASSET" \
       --offline \
       --certificate-identity-regexp "$COSIGN_CERT_IDENTITY_REGEX" \
       --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \
       "$TMP_DIR/$ASSET" >"$COSIGN_LOG" 2>&1; then
    ok "signature verified (cosign keyless OIDC, offline bundle)"
  else
    # FAILURE-FATAL: evidence was present and did not verify. Never fall
    # back to the legacy pair after a failed verification.
    cosign_failed
  fi
else
  # ABSENCE-tolerant: no usable bundle — use the legacy online pair. This arm
  # is reached when the bundle 404s, when the network failed, OR when a body
  # arrived that is not a bundle. Deliberately NOT claiming which: the old
  # wording said "no offline bundle for this release", which is literally
  # false when the release HAS one and the download was blocked or corrupted.
  say "  no usable offline bundle — verifying via transparency log ..."

  say "  downloading signature ..."
  if ! download "$SIG_URL" "$TMP_DIR/$SIG_ASSET"; then
    fail "Could not download $SIG_URL"
    say  "Check your network, or that this version exists."
    exit 5
  fi

  say "  downloading certificate ..."
  if ! download "$CERT_URL" "$TMP_DIR/$CERT_ASSET"; then
    fail "Could not download $CERT_URL"
    say  "Check your network, or that this version exists."
    exit 5
  fi

  say "  verifying with cosign (keyless OIDC) ..."
  if "$COSIGN_BIN" verify-blob \
       --certificate "$TMP_DIR/$CERT_ASSET" \
       --signature "$TMP_DIR/$SIG_ASSET" \
       --certificate-identity-regexp "$COSIGN_CERT_IDENTITY_REGEX" \
       --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \
       "$TMP_DIR/$ASSET" >"$COSIGN_LOG" 2>&1; then
    ok "signature verified (cosign keyless OIDC)"
  else
    cosign_failed
  fi
fi

# ---------- 6. Extract ----------
say "  extracting ..."
tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR" || die "Failed to extract tarball." 1

# release-cli.yml packages a single `hassle` binary at the tarball root.
if [ ! -f "$TMP_DIR/hassle" ]; then
  found="$(find "$TMP_DIR" -maxdepth 2 -name hassle -type f 2>/dev/null | head -1)"
  [ -n "$found" ] || die "Tarball did not contain a hassle binary." 1
  mv "$found" "$TMP_DIR/hassle"
fi
chmod +x "$TMP_DIR/hassle"

# ---------- 6a. Anti-rollback: bind the RECEIVED bytes to the version floor ----------
# The floor at step 2a checks the REQUESTED label. That is not enough. cosign
# authenticates "signed by this repo's release workflow" for ANY tag — the
# identity regex ends at `@refs/tags/` with no specific tag — so a genuinely
# signed OLD release (cli-v0.1.3, credential-broken) verifies "OK". A mutable
# `latest/` object, a re-mirror mistake, or HASSLE_INSTALL_BASE pointed
# elsewhere could therefore serve v0.1.3 bytes that pass verification, and the
# label check never sees them. Measured 2026-08-04: the live v0.1.3 tarball
# passes `cosign verify-blob` with this script's exact identity pin.
#
# cosign proves ORIGIN, not FITNESS. So after verification — the binary is now
# trusted to RUN — ask it its own version and re-apply the floor to what we
# ACTUALLY GOT, not to what we asked for. This is the only check that binds the
# received bytes, and it closes the swapped-`latest/` downgrade the label floor
# cannot see. Running it here is safe precisely because cosign already passed.
GOT_VER="$("$TMP_DIR/hassle" --version 2>/dev/null | tr -cd '0-9.' )"
if [ -n "$GOT_VER" ]; then
  _oldifs="$IFS"; IFS=.
  # shellcheck disable=SC2086
  set -- $GOT_VER
  IFS="$_oldifs"
  GMAJ="${1:-0}"; GMIN="${2:-0}"; GPAT="${3:-0}"
  case "$GMAJ$GMIN$GPAT" in
    *[!0-9]*|'') : ;;  # unparseable — fall through, smoke check still runs
    *)
      if [ "$GMAJ" -lt "$FLOOR_MAJ" ] || {
           [ "$GMAJ" -eq "$FLOOR_MAJ" ] && {
             [ "$GMIN" -lt "$FLOOR_MIN" ] || {
               [ "$GMIN" -eq "$FLOOR_MIN" ] && [ "$GPAT" -lt "$FLOOR_PAT" ]; }; }; }; then
        fail "The downloaded binary reports version $GMAJ.$GMIN.$GPAT, which is"
        fail "below the minimum $FLOOR_MAJ.$FLOOR_MIN.$FLOOR_PAT. Refusing to install."
        say  "Its signature is genuine, but that release carries a"
        say  "credential-storage defect (the CLI cannot hold a login). This can"
        say  "happen if a mirror served a stale artifact. Report to security@hassle.dev."
        exit 6
      fi
      ;;
  esac
fi

# ---------- 7. Choose install dir + write (idempotent: overwrites = upgrade) ----------
# Precedence:
#   1. $HASSLE_INSTALL_DIR (explicit override)
#   2. /usr/local/bin if writable (no sudo)
#   3. /usr/local/bin via sudo if available and /dev/tty can prompt
#      (NOT `[ -t 0 ]` — stdin is the curl pipe, sudo prompts on /dev/tty)
#   4. ~/.local/bin (per-user fallback, no privileges)
USED_SUDO=0
INSTALL_DIR=""

place_binary() {
  # place_binary DIR  -> 0 on success
  _dir="$1"
  mkdir -p "$_dir" 2>/dev/null || return 1
  mv "$TMP_DIR/hassle" "$_dir/hassle" 2>/dev/null || cp "$TMP_DIR/hassle" "$_dir/hassle" 2>/dev/null || return 1
  chmod +x "$_dir/hassle" 2>/dev/null || true
  INSTALL_DIR="$_dir"
}

place_binary_sudo() {
  # place_binary_sudo DIR  -> 0 on success (uses sudo for both mkdir + install)
  _dir="$1"
  sudo mkdir -p "$_dir" 2>/dev/null || return 1
  sudo install -m 0755 "$TMP_DIR/hassle" "$_dir/hassle" 2>/dev/null || return 1
  INSTALL_DIR="$_dir"
  USED_SUDO=1
}

if [ -n "${HASSLE_INSTALL_DIR:-}" ]; then
  place_binary "$HASSLE_INSTALL_DIR" \
    || die "Could not write to HASSLE_INSTALL_DIR=$HASSLE_INSTALL_DIR" 1
elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
  place_binary /usr/local/bin || die "Could not write to /usr/local/bin" 1
elif have sudo && has_tty; then
  warn "/usr/local/bin needs elevated permissions; using sudo (you may be prompted)."
  if ! place_binary_sudo /usr/local/bin; then
    warn "sudo install to /usr/local/bin failed; falling back to ~/.local/bin."
    place_binary "$HOME/.local/bin" || die "Could not create $HOME/.local/bin" 1
  fi
else
  # No write access to /usr/local/bin and no interactive sudo: per-user install.
  if [ ! -w /usr/local/bin ] 2>/dev/null && ! have sudo; then
    say "/usr/local/bin is not writable and sudo is unavailable — installing per-user."
  fi
  place_binary "$HOME/.local/bin" || die "Could not create $HOME/.local/bin" 1
fi

INSTALL_PATH="$INSTALL_DIR/hassle"
if [ "$USED_SUDO" -eq 1 ]; then
  ok "installed to $INSTALL_PATH (via sudo)"
else
  ok "installed to $INSTALL_PATH"
fi

# ---------- 8. PATH hint ----------
case ":$PATH:" in
  *":$INSTALL_DIR:"*) ON_PATH=1 ;;
  *)                  ON_PATH=0 ;;
esac

if [ "$ON_PATH" -eq 0 ]; then
  warn "$INSTALL_DIR is not on your PATH yet."
  say  "Add this to your shell rc (~/.zshrc, ~/.bashrc, ~/.profile):"
  printf "    %sexport PATH=\"%s:\$PATH\"%s\n" "$BOLD" "$INSTALL_DIR" "$RESET"
fi

# ---------- 9. Smoke-check: the binary actually runs on this machine ----------
say "  verifying the binary runs ..."
if VER_OUT="$("$INSTALL_PATH" --version 2>/dev/null)"; then
  ok "hassle is runnable: $VER_OUT"
else
  warn "Installed, but '$INSTALL_PATH --version' did not run cleanly."
  warn "If this is a per-user install, open a NEW shell (so PATH refreshes) and retry."
fi

# ---------- 10. Next steps ----------
printf "\n%sHassle CLI installed.%s\n" "$BOLD" "$RESET"
printf "Next:\n"

# If the install dir is not on PATH, `hassle login` CANNOT run yet. Printing it
# as the next step produces "command not found" and strands the user, so the
# PATH step is printed first and the rest are numbered after it.
if [ "$ON_PATH" -eq 0 ]; then
  case "${SHELL:-}" in
    */zsh)  SHELL_RC="$HOME/.zshrc" ;;
    */bash) SHELL_RC="$HOME/.bashrc" ;;
    *)      SHELL_RC="$HOME/.profile" ;;
  esac
  printf "  %s1.%s Put hassle on your PATH (this shell and future ones):\n" "$BOLD" "$RESET"
  printf "     %secho 'export PATH=\"%s:\$PATH\"' >> %s && . %s%s\n" \
    "$BOLD" "$INSTALL_DIR" "$SHELL_RC" "$SHELL_RC" "$RESET"
  printf "  %s2.%s %shassle login%s          authenticate this device (approve the code at app.hassle.dev/device)\n" "$BOLD" "$RESET" "$BOLD" "$RESET"
  printf "  %s3.%s %shassle setup guided%s   print the guided proof-scan workflow\n" "$BOLD" "$RESET" "$BOLD" "$RESET"
else
  printf "  %shassle login%s          authenticate this device (approve the code at app.hassle.dev/device)\n" "$BOLD" "$RESET"
  printf "  %shassle setup guided%s   print the guided proof-scan workflow\n" "$BOLD" "$RESET"
fi
printf "Docs: %shttps://hassle.dev/docs.html%s\n" "$BOLD" "$RESET"

exit 0
