#!/bin/sh
# aif installer — the public `curl | sh` endpoint served at
# https://aif.harden.run/install.sh (runtime/plan/
# 2026_07_17_external_release_pipeline_spike.md, ticket XR1). It generalizes
# the internal IR3 installer (runtime/plan/2026_07_14_internal_release_spike.md
# §4): by default it now resolves EXCLUSIVELY from the public endpoints — no
# GitHub credentials, no `gh`, no account — so a stranger on a clean machine
# can install with one line. The private GitHub-release paths remain available
# as explicit opt-ins (AIF_DOWNLOADER=gh|curl) for internal testing.
#
# Downloads a tagged `aif` build, verifies its sha256 against the release's
# sha256sums.txt, and DELEGATES the install to the verified binary itself
# (`aif install-self`), which lands it in the managed layout: the binary
# under ~/.aif/versions/<tag>/, an atomic symlink swap of the STABLE
# canonical path ~/.aif/bin/aif, and the version.json install record. That
# stable path is load-bearing: `aif configure` embeds it in every installed
# hook (internal/cli/root.go), and a moved/deleted binary breaks all hooks
# fail-closed — it survives every swap and rollback. The layout has ONE
# writer, the Go binary (SW-2, runtime/plan/2026_07_27_fix_updater_spike.md);
# this script only writes it via the legacy in-place rename when installing
# an old release that predates `install-self`. Re-running this script IS the
# update path for a manual install; the daemon-owned self-update (BB-B2)
# resolves from the same public pointer.
#
# Usage:
#   curl -fsSL https://aif.harden.run/install.sh | sh   # install/update to latest
#   AIF_VERSION=v0.1.0 bash install.sh               # pin an exact tag
#
# Public download path (the default): the version pointer at
# $AIF_BASE_URL/stable.json names the current release; tarball +
# sha256sums.txt come from the pointer's assets_base (or, absent one,
# $AIF_BASE_URL/releases/<tag>/). Serving layout + provisioning runbook:
# runtime/install/PUBLIC_ENDPOINTS.md.
#
# Environment knobs (all optional):
#   AIF_VERSION         exact tag to install (default: pointer's `latest`)
#   AIF_BASE_URL        public endpoint base (default https://aif.harden.run)
#   AIF_DOWNLOADER      auto|public|gh|curl (default auto -> public, the
#                       credential-free public endpoints; gh / curl are the
#                       private GitHub-release paths for internal testing)
#   AIF_LINK_DIR        directory for the convenience symlink; empty string
#                       skips symlinking (default: /usr/local/bin if
#                       writable, else ~/.local/bin)
#   AIF_CONFIGURE       when "1"/"true", run `aif configure` after install so
#                       the whole thing is a single command (opt-in: configure
#                       writes hooks into agent config files, a consent-
#                       sensitive change; `aif configure --dry-run` previews).
#   AIF_NO_SHOW         when non-empty, do NOT open the local browser viewer
#                       after a successful interactive install. The installer
#                       still prints the viewer URL.
#   AIF_NO_MODIFY_PATH  when non-empty, do NOT touch any shell rc file to put
#                       ~/.aif/bin on PATH (default when it is not already on
#                       PATH: write the sourceable ~/.aif/env and add ONE
#                       marker-guarded source line to your login shell's rc —
#                       see "PATH setup" below; opting out keeps the printed
#                       copy-paste hint).
#   AIF_NO_RESTART      when non-empty, do NOT auto-restart a daemon or the
#                       macOS menu-bar app after an update (default: restart
#                       them so the new binaries go live).
#   AIF_REPO            owner/repo slug for the gh/curl paths (default
#                       vizopsai/aif_enterprise; test seam)
#   AIF_GITHUB_API_URL  GitHub API base URL (default https://api.github.com;
#                       test seam so fixtures can stand in for the API)
#   AIF_POINTER_URL     override the version-pointer URL (default
#                       $AIF_BASE_URL/stable.json; test seam)
#   AIF_MINISIGN_PUBKEY release-signing public key (base64 minisign blob). When
#                       set, the release's sha256sums.txt.minisig is verified
#                       against it before any tarball is trusted (RD6 / XR2).
#                       Empty in internal/unsigned builds (checksum only). A
#                       signed public release ships this script with the key
#                       filled in (MINISIGN_PUBKEY below).
#   AIF_REQUIRE_SIGNATURE  when non-empty, refuse to install unless the
#                       signature is verified — fail rather than fall back to
#                       checksum-only when minisign is absent (default: warn
#                       and continue on sha256, since a clean machine may not
#                       have minisign; macOS first installs are additionally
#                       covered by Gatekeeper/notarization).
#   AIF_MINISIGN_BIN    minisign binary to verify with (default: minisign on
#                       PATH; test seam).
#
# Signing chain (RD6 / external-release spike XR2): releases are signed with a
# minisign (Ed25519) key that lives ONLY in the release pipeline. The detached
# signature sha256sums.txt.minisig signs the checksum manifest; because every
# tarball is sha256-verified against that manifest, one signature authenticates
# the whole release. The daemon self-update (internal/update) verifies the same
# signature with the key compiled into the binary; this installer verifies it
# with the `minisign` CLI when present. On macOS, Developer ID signing +
# notarization give Gatekeeper an OS-level origin check for first installs.
#
# Four targets exist, matching release.yml's build matrix and the public
# support matrix (runtime/install/legal/PLATFORM_SUPPORT.md): darwin_arm64,
# darwin_amd64 (Intel Mac — the x86_64 slice of the universal darwin build),
# linux_amd64, and linux_arm64.
#
# POSIX sh only — the documented invocation pipes into `sh`, which is dash on
# Debian/Ubuntu (a pipe ignores the shebang). No `pipefail` (dash rejects it;
# the one fetch that needed it captures curl's own exit status instead), no
# bashisms — the rig's install scenarios (e2e/scenarios/install_test.go) run
# this file under the runner's real `sh` and are the standing gate.
set -eu

REPO="${AIF_REPO:-vizopsai/aif_enterprise}"
API_URL="${AIF_GITHUB_API_URL:-https://api.github.com}"
# DOMAIN: aif.harden.run — Q2 RESOLVED 2026-07-20. The Route 53 delegated zone
# exists; endpoint provisioning (registrar NS delegation + CDN) is go-live
# item 2, so this is not serving yet. Overridable via AIF_BASE_URL.
BASE_URL="${AIF_BASE_URL:-https://aif.harden.run}"
POINTER_URL="${AIF_POINTER_URL:-$BASE_URL/stable.json}"
# Release-signing public key (RD6 / XR2). EMPTY here: this branch has no
# Developer-ID/notarization credentials and no minisign key yet, so committed
# public releases are checksum-verified over HTTPS until the pipeline key is
# generated (see runtime/install/SIGNING.md). A signed release stamps the
# minisign public-key blob here (and AIF_MINISIGN_PUBKEY overrides it); once set
# the installer refuses a wrong-key or unsigned artifact.
MINISIGN_PUBKEY_BUILTIN="RWQK5m62p+1VjQdS7Se7WxZqdPjnvuMNAgHe7a4oPIGoBTIcpmlvW7/i"
MINISIGN_PUBKEY="${AIF_MINISIGN_PUBKEY:-$MINISIGN_PUBKEY_BUILTIN}"
AIF_HOME="$HOME/.aif"
BIN_DIR="$AIF_HOME/bin"
BIN_PATH="$BIN_DIR/aif"
MENUBAR_APP_DIR="$AIF_HOME/apps"
MENUBAR_APP_NAME="Harden Agent Integrity Foundation.app"
MENUBAR_APP_PATH="$MENUBAR_APP_DIR/$MENUBAR_APP_NAME"
MENUBAR_PREVIOUS_APP_PATH="$MENUBAR_APP_DIR/Harden AI Firewall.app"
MENUBAR_LEGACY_APP_PATH="$MENUBAR_APP_DIR/AIF.app"
MENUBAR_LEGACY_LABEL="run.harden.aif.menubar"
MENUBAR_LEGACY_PLIST="$HOME/Library/LaunchAgents/$MENUBAR_LEGACY_LABEL.plist"
ENTERPRISE_MARKER="/Library/Application Support/harden-run/aif/enterprise-managed"
case "$(uname -s)" in
Linux) ENTERPRISE_MARKER="/usr/lib/harden-run/aif/enterprise-managed" ;;
esac

status() { echo "aif-install: $*"; }
error() { echo "aif-install: ERROR: $*" >&2; }
die() {
  error "$@"
  exit 1
}

# A centrally managed Enterprise installation owns the machine-level AIF
# lifecycle. Refuse before platform detection, downloads, or user-state writes
# when the package-owned regular marker file is present.
# Missing paths, directories, valid symlinks, and broken symlinks do not count
# as the Enterprise marker.
if [ -f "$ENTERPRISE_MARKER" ] && [ ! -L "$ENTERPRISE_MARKER" ]; then
  die "AIF Enterprise is managed by your organization; AIF Free cannot be installed on this device"
fi

# --- platform detection ------------------------------------------------------

os="$(uname -s)"
arch="$(uname -m)"
case "$os/$arch" in
Darwin/arm64) platform="darwin_arm64" ;;
Darwin/x86_64) platform="darwin_amd64" ;;
Linux/x86_64 | Linux/amd64) platform="linux_amd64" ;;
Linux/aarch64 | Linux/arm64) platform="linux_arm64" ;;
*)
  die "unsupported platform: $os/$arch (aif releases target macOS arm64/x86_64 and Linux amd64/arm64)"
  ;;
esac
status "platform: $platform"

# --- checksum tool (linux: sha256sum; darwin: shasum -a 256) ------------------

if command -v sha256sum >/dev/null 2>&1; then
  sha256_check() { sha256sum -c "$1"; }
elif command -v shasum >/dev/null 2>&1; then
  sha256_check() { shasum -a 256 -c "$1"; }
else
  die "need sha256sum or shasum on PATH to verify the download"
fi

# --- release signature verification (RD6 / XR2) -------------------------------

# verify_signature FILE SIGFILE PUBKEY: verify SIGFILE is a valid minisign
# signature over FILE from PUBKEY. On a machine with `minisign`, a bad or
# wrong-key signature is fatal (refuse). Without minisign, honor
# AIF_REQUIRE_SIGNATURE — fail when set, otherwise warn and rely on sha256 over
# HTTPS (a clean box may lack minisign; the daemon self-update verifies the
# same signature with the compiled-in key, and macOS gets Gatekeeper).
verify_signature() {
  minisign_bin="${AIF_MINISIGN_BIN:-minisign}"
  if command -v "$minisign_bin" >/dev/null 2>&1; then
    if "$minisign_bin" -V -P "$3" -m "$1" -x "$2" >/dev/null 2>&1; then
      status "release signature verified (minisign, release key)"
      return 0
    fi
    die "minisign signature verification FAILED for $(basename "$1") — refusing to install a tampered or wrong-key release"
  fi
  if [ -n "${AIF_REQUIRE_SIGNATURE:-}" ]; then
    die "AIF_REQUIRE_SIGNATURE is set but minisign is not on PATH — cannot verify the release signature; install minisign and re-run"
  fi
  status "note: minisign not installed — release signature NOT verified"
  status "      (integrity is still enforced by sha256 over HTTPS; for origin"
  status "       verification install minisign and re-run, or verify per the trust page)"
}

# --- downloader selection -----------------------------------------------------

# Default (auto -> public): resolve EXCLUSIVELY from the public endpoints — the
# version pointer at $POINTER_URL and the tarball + sha256sums.txt under the
# release's assets_base — with NO credentials, NO gh, NO account, so a stranger
# on a clean machine installs with one line. The private GitHub-release paths
# (gh, curl+GITHUB_TOKEN) remain as explicit opt-ins for internal testing.
downloader="${AIF_DOWNLOADER:-auto}"
case "$downloader" in
auto | public)
  downloader="public"
  ;;
gh)
  command -v gh >/dev/null 2>&1 || die "AIF_DOWNLOADER=gh but gh is not on PATH"
  gh auth status >/dev/null 2>&1 || die "AIF_DOWNLOADER=gh but gh is not authenticated (run: gh auth login)"
  ;;
curl)
  [ -n "${GITHUB_TOKEN:-}" ] || die "AIF_DOWNLOADER=curl needs GITHUB_TOKEN set"
  ;;
*)
  die "AIF_DOWNLOADER must be auto, public, gh, or curl (got: $downloader)"
  ;;
esac
command -v curl >/dev/null 2>&1 || [ "$downloader" = "gh" ] || die "curl not found on PATH"
status "downloader: $downloader"

# --- temp workspace -----------------------------------------------------------

workdir="$(mktemp -d)"
rollback_armed=""
rollback_bin_kind=""
rollback_bin_link=""
rollback_state_exists=""
rollback_app_exists=""
rollback_app_path=""
rollback_plist_exists=""
rollback_menubar_captured=""

restore_previous_binary() {
  mkdir -p "$BIN_DIR" || return 1
  if [ "$rollback_bin_kind" = "symlink" ]; then
    case "$rollback_bin_link" in
    /*) rollback_bin_target="$rollback_bin_link" ;;
    *) rollback_bin_target="$BIN_DIR/$rollback_bin_link" ;;
    esac
    if ! cmp -s "$workdir/previous-aif" "$rollback_bin_target"; then
      mkdir -p "$(dirname "$rollback_bin_target")" || return 1
      rollback_bin_stage="$(mktemp "$(dirname "$rollback_bin_target")/.aif.rollback.XXXXXX")" || return 1
      cp -p "$workdir/previous-aif" "$rollback_bin_stage" || return 1
      mv -f "$rollback_bin_stage" "$rollback_bin_target" || return 1
    fi
    rollback_link_stage="$BIN_DIR/.aif.rollback-link.$$"
    rm -f "$rollback_link_stage"
    ln -s "$rollback_bin_link" "$rollback_link_stage" || return 1
    mv -f "$rollback_link_stage" "$BIN_PATH" || return 1
  elif [ "$rollback_bin_kind" = "regular" ]; then
    rollback_bin_stage="$(mktemp "$BIN_DIR/.aif.rollback.XXXXXX")" || return 1
    cp -p "$workdir/previous-aif" "$rollback_bin_stage" || return 1
    mv -f "$rollback_bin_stage" "$BIN_PATH" || return 1
  fi
}

restore_previous_state() {
  if [ -n "$rollback_state_exists" ]; then
    rollback_state_stage="$(mktemp "$AIF_HOME/.version.rollback.XXXXXX")" || return 1
    cp -p "$workdir/previous-version.json" "$rollback_state_stage" || return 1
    mv -f "$rollback_state_stage" "$AIF_HOME/version.json" || return 1
  else
    rm -f "$AIF_HOME/version.json" || return 1
  fi
}

restore_previous_install() {
  status "install did not complete — restoring the previous installation"
  restore_failed=""
  restore_previous_binary || restore_failed=1
  restore_previous_state || restore_failed=1
  restore_previous_menubar || restore_failed=1
  [ -z "$restore_failed" ] || return 1
  status "restored the previous aif installation"
}

restore_previous_menubar() {
  [ -n "$rollback_menubar_captured" ] || return 0
  launchctl bootout "gui/$(id -u)/$MENUBAR_LEGACY_LABEL" >/dev/null 2>&1 || true
  /usr/bin/pkill -x AIFMenuBar >/dev/null 2>&1 || true
  for installed_app in "$MENUBAR_APP_PATH" "$MENUBAR_PREVIOUS_APP_PATH" "$MENUBAR_LEGACY_APP_PATH"; do
    if menubar_uses_native_login_item "$installed_app"; then
      menubar_login_item_command "$installed_app" --unregister-login-item >/dev/null 2>&1 || true
    fi
  done
  rm -rf "$MENUBAR_APP_PATH" "$MENUBAR_PREVIOUS_APP_PATH" "$MENUBAR_LEGACY_APP_PATH" || return 1
  if [ -n "$rollback_app_exists" ]; then
    ditto "$workdir/previous-menubar.app" "$rollback_app_path" || return 1
  fi
  if [ -n "$rollback_plist_exists" ]; then
    cp -p "$workdir/previous-menubar.plist" "$MENUBAR_LEGACY_PLIST" || return 1
    launchctl bootstrap "gui/$(id -u)" "$MENUBAR_LEGACY_PLIST" >/dev/null 2>&1 || true
  else
    rm -f "$MENUBAR_LEGACY_PLIST" || return 1
  fi
  if [ -n "$rollback_app_exists" ] && menubar_uses_native_login_item "$rollback_app_path"; then
    menubar_login_item_command "$rollback_app_path" --register-login-item >/dev/null 2>&1 || true
    if [ -z "${AIF_NO_RESTART:-}" ]; then
      /usr/bin/open -g "$rollback_app_path" >/dev/null 2>&1 || true
    fi
  fi
}

menubar_uses_native_login_item() {
  [ -d "$1" ] &&
    [ "$(/usr/libexec/PlistBuddy -c 'Print :AIFLoginItemRegistration' "$1/Contents/Info.plist" 2>/dev/null || true)" = "SMAppService" ]
}

menubar_login_item_command() {
  app_path="$1"
  command_name="$2"
  command_stdout="$workdir/menubar-login-item.out"
  command_stderr="$workdir/menubar-login-item.err"
  : >"$command_stdout"
  : >"$command_stderr"
  /usr/bin/open -W -n -g -o "$command_stdout" --stderr "$command_stderr" \
    "$app_path" --args "$command_name" || return 1
  menubar_command_status="$(tr -d '\r\n' <"$command_stdout")"
  case "$command_name/$menubar_command_status" in
  --register-login-item/enabled | --register-login-item/requires-approval | --unregister-login-item/not-registered | --unregister-login-item/not-found)
    return 0
    ;;
  esac
  error "menu-bar login item command returned '$menubar_command_status': $(cat "$command_stderr")"
  return 1
}

cleanup() {
  rc=$?
  trap - EXIT
  if [ "$rc" -ne 0 ] && [ -n "$rollback_armed" ]; then
    if ! restore_previous_install; then
      error "automatic rollback failed; recovery files remain in $workdir"
      exit "$rc"
    fi
  fi
  rm -rf "$workdir"
  exit "$rc"
}
trap cleanup EXIT

# --- GitHub API helpers (curl path) -------------------------------------------

api_get() {
  curl -fsSL \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    -H "Accept: application/vnd.github+json" \
    "$1"
}

# Extract a top-level string field ("tag_name") from release JSON on stdin.
json_field() {
  awk -v key="$1" '
    {
      s = $0
      pat = "\"" key "\"[[:space:]]*:[[:space:]]*\"[^\"]*\""
      while (match(s, pat)) {
        tok = substr(s, RSTART, RLENGTH)
        s = substr(s, RSTART + RLENGTH)
        sub(/^"[^"]*"[[:space:]]*:[[:space:]]*"/, "", tok)
        sub(/"$/, "", tok)
        print tok
        exit
      }
    }'
}

# Print each element of a top-level JSON string array ($1, e.g. "recalled")
# from stdin, one per line. Callers feed newline-flattened JSON so a pretty-
# printed pointer parses the same as a compact one.
json_string_array() {
  awk -v key="$1" '
    {
      pat = "\"" key "\"[[:space:]]*:[[:space:]]*\\["
      if (match($0, pat)) {
        rest = substr($0, RSTART + RLENGTH)
        if (match(rest, /\]/)) rest = substr(rest, 1, RSTART - 1)
        while (match(rest, /"[^"]*"/)) {
          print substr(rest, RSTART + 1, RLENGTH - 2)
          rest = substr(rest, RSTART + RLENGTH)
        }
      }
    }'
}

# version_gt A B — exit 0 iff version A > version B (plain vX.Y.Z SemVer, the
# IR0 tag scheme; a leading "v" is optional and missing trailing components are
# zero, so v0.2 == v0.2.0). Pure awk for portability — BSD `sort -V` on macOS is
# not dependable, and this is on the darwin install path.
version_gt() {
  awk -v a="$1" -v b="$2" '
    BEGIN {
      sub(/^v/, "", a); sub(/^v/, "", b)
      na = split(a, aa, "."); nb = split(b, bb, ".")
      m = (na > nb) ? na : nb
      for (i = 1; i <= m; i++) {
        x = (i <= na) ? aa[i] + 0 : 0
        y = (i <= nb) ? bb[i] + 0 : 0
        if (x > y) exit 0
        if (x < y) exit 1
      }
      exit 1
    }'
}

# Print the API url of the named asset from release JSON on stdin. Works on
# both compact and pretty-printed JSON: in every asset object the "url" field
# (".../releases/assets/<id>") precedes "name", so pair each name with the
# last-seen asset url.
asset_url() {
  awk -v want="$1" '
    BEGIN { lasturl = "" }
    {
      s = $0
      while (match(s, /"(url|name)"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
        tok = substr(s, RSTART, RLENGTH)
        s = substr(s, RSTART + RLENGTH)
        key = tok
        sub(/^"/, "", key)
        sub(/".*$/, "", key)
        val = tok
        sub(/^"[^"]*"[[:space:]]*:[[:space:]]*"/, "", val)
        sub(/"$/, "", val)
        if (key == "url" && val ~ /\/releases\/assets\/[0-9]+$/) {
          lasturl = val
        } else if (key == "name" && val == want && lasturl != "") {
          print lasturl
          exit
        }
      }
    }'
}

download_asset_curl() {
  # $1 = asset name, $2 = destination path, stdin unused;
  # uses $release_json resolved below.
  local url
  url="$(printf '%s' "$release_json" | asset_url "$1")"
  [ -n "$url" ] || die "release $version has no asset named $1"
  curl -fSL --progress-bar \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    -H "Accept: application/octet-stream" \
    -o "$2" "$url"
}

# --- resolve version ----------------------------------------------------------

version="${AIF_VERSION:-}"
release_json=""
assets_base=""
if [ "$downloader" = "public" ]; then
  # The version pointer names the current release (its `latest`) and where the
  # tarballs live (its `assets_base`). Flatten newlines so the awk parsers see
  # one logical line whether the pointer is compact or pretty-printed.
  pointer_json="$(curl -fsSL "$POINTER_URL")" ||
    die "could not fetch the version pointer from $POINTER_URL"
  pointer_json="$(printf '%s' "$pointer_json" | tr '\n' ' ')"
  [ -n "$pointer_json" ] || die "empty version pointer at $POINTER_URL"
  latest="$(printf '%s' "$pointer_json" | json_field latest)"
  if [ -z "$version" ]; then
    version="$latest"
    [ -n "$version" ] || die "version pointer at $POINTER_URL has no \"latest\" field"
    # Honor a max_version cap (RD5 staged rollout / holdback / recall lever):
    # the offer is min(latest, max_version). A max_version below `latest` means
    # the publisher is holding back — or has RECALLED — the newer build and is
    # steering fresh installs to the last-good version; install that, not
    # `latest`. This mirrors the self-update client's OfferedVersion
    # (internal/update/pointer.go), so `curl | sh` and `aif update` agree on the
    # capped offer. An explicit AIF_VERSION pin bypasses the cap (a deliberate
    # choice, like the client's explicit --version); the recall-list check below
    # still applies to a pin.
    max_version="$(printf '%s' "$pointer_json" | json_field max_version)"
    if [ -n "$max_version" ] && version_gt "$version" "$max_version"; then
      echo "note: the version pointer caps the offer at max_version $max_version (latest is $version) — installing the capped version." >&2
      version="$max_version"
    fi
  fi
  # Honor the recall list (RD5): never install a pulled release.
  if printf '%s' "$pointer_json" | json_string_array recalled | grep -qx "$version"; then
    die "release $version is RECALLED in the version pointer at $POINTER_URL — refusing to install"
  fi
  # The pointer's assets_base names LATEST's assets. Any other version — a
  # max_version-capped offer or an explicit AIF_VERSION pin — lives at the
  # conventional per-tag path instead, NOT under latest's assets_base.
  assets_base="$(printf '%s' "$pointer_json" | json_field assets_base)"
  if [ -z "$assets_base" ] || [ "$version" != "$latest" ]; then
    assets_base="$BASE_URL/releases/$version"
  fi
  assets_base="${assets_base%/}"
elif [ "$downloader" = "gh" ]; then
  if [ -z "$version" ]; then
    version="$(gh release view --repo "$REPO" --json tagName --jq .tagName)"
    [ -n "$version" ] || die "could not resolve the latest release tag via gh"
  fi
else
  if [ -z "$version" ]; then
    release_json="$(api_get "$API_URL/repos/$REPO/releases/latest")" ||
      die "could not fetch the latest release from $API_URL/repos/$REPO"
    version="$(printf '%s' "$release_json" | json_field tag_name)"
    [ -n "$version" ] || die "latest release JSON has no tag_name"
  else
    release_json="$(api_get "$API_URL/repos/$REPO/releases/tags/$version")" ||
      die "could not fetch release $version from $API_URL/repos/$REPO"
  fi
fi
status "installing aif $version"

# Asset names must match .github/workflows/release.yml exactly.
tarball="aif_${version}_${platform}.tar.gz"
sums="sha256sums.txt"
sig="$sums.minisig" # detached signature over the checksum manifest (RD6)

# --- download -----------------------------------------------------------------

if [ "$downloader" = "public" ]; then
  curl -fSL --progress-bar -o "$workdir/$tarball" "$assets_base/$tarball" ||
    die "could not download $assets_base/$tarball"
  curl -fSL -o "$workdir/$sums" "$assets_base/$sums" ||
    die "could not download $assets_base/$sums"
  if [ -n "$MINISIGN_PUBKEY" ]; then
    curl -fSL -o "$workdir/$sig" "$assets_base/$sig" ||
      die "signed release but could not download $assets_base/$sig — refusing to install unverified"
  fi
elif [ "$downloader" = "gh" ]; then
  gh release download "$version" --repo "$REPO" --dir "$workdir" \
    --pattern "$tarball" --pattern "$sums"
  if [ -n "$MINISIGN_PUBKEY" ]; then
    gh release download "$version" --repo "$REPO" --dir "$workdir" --pattern "$sig" ||
      die "signed release but $sig is not attached to $version — refusing to install unverified"
  fi
else
  download_asset_curl "$tarball" "$workdir/$tarball"
  download_asset_curl "$sums" "$workdir/$sums"
  if [ -n "$MINISIGN_PUBKEY" ]; then
    download_asset_curl "$sig" "$workdir/$sig"
  fi
fi
[ -s "$workdir/$tarball" ] || die "download produced no $tarball"
[ -s "$workdir/$sums" ] || die "download produced no $sums"

# --- verify the release signature over the checksum manifest (RD6) ------------

# Verify the signature BEFORE the manifest is trusted to vouch for the tarball:
# a valid signature over sha256sums.txt, combined with the sha256 check below,
# authenticates the tarball's origin, not just its integrity.
if [ -n "$MINISIGN_PUBKEY" ]; then
  [ -s "$workdir/$sig" ] || die "signed build expected $sig but it is missing — refusing to install"
  verify_signature "$workdir/$sums" "$workdir/$sig" "$MINISIGN_PUBKEY"
fi

# --- verify sha256 ------------------------------------------------------------

# sha256sums.txt covers EVERY platform tarball; check only the line for ours
# (exact filename match on the second column) so the absent other-platform
# tarballs cannot mask or fail the verification.
(
  cd "$workdir"
  awk -v f="$tarball" '$2 == f' "$sums" >"checksum.expected"
  [ -s checksum.expected ] || {
    error "$sums has no entry for $tarball"
    exit 1
  }
  sha256_check checksum.expected >/dev/null || {
    error "sha256 verification FAILED for $tarball — refusing to install"
    exit 1
  }
) || exit 1
status "sha256 verified"

# --- extract + smoke ----------------------------------------------------------

macos_supports_menubar() {
  macos_version="$1"
  macos_major="${macos_version%%.*}"
  case "$macos_major" in
  "" | *[!0-9]*) return 0 ;;
  esac
  [ "$macos_major" -ge 13 ]
}

prepare_menubar_archive() {
  command -v ditto >/dev/null 2>&1 || {
    error "macOS menu-bar install needs /usr/bin/ditto"
    return 1
  }
  tar -xzf "$workdir/$tarball" -C "$workdir" AIF.app.zip || {
    error "could not extract AIF.app.zip"
    return 1
  }
  mkdir -p "$workdir/menubar" || return 1
  ditto -x -k "$workdir/AIF.app.zip" "$workdir/menubar" || {
    error "could not unpack AIF.app.zip"
    return 1
  }
  menubar_source="$workdir/menubar/AIF.app"
  [ -x "$menubar_source/Contents/MacOS/AIFMenuBar" ] || {
    error "downloaded archive has an invalid AIF.app bundle"
    menubar_source=""
    return 1
  }
  codesign --verify --deep --strict "$menubar_source" >/dev/null 2>&1 || {
    error "downloaded AIF.app failed code-signature verification"
    menubar_source=""
    return 1
  }
  app_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$menubar_source/Contents/Info.plist" 2>/dev/null || true)"
  [ "$app_version" = "${version#v}" ] || {
    error "downloaded AIF.app reports version '$app_version', expected ${version#v}"
    menubar_source=""
    return 1
  }
}

menubar_source=""
tar -xzf "$workdir/$tarball" -C "$workdir" aif || die "could not extract the aif binary"
if [ "$os" = "Darwin" ]; then
  if tar -tzf "$workdir/$tarball" AIF.app.zip >/dev/null 2>&1; then
    macos_version="$(/usr/bin/sw_vers -productVersion 2>/dev/null || true)"
    if ! macos_supports_menubar "$macos_version"; then
      status "warning: menu-bar app requires macOS 13 or newer (found $macos_version); continuing with the AIF CLI install"
    elif ! prepare_menubar_archive; then
      status "warning: menu-bar app could not be prepared; continuing with the AIF CLI install"
    fi
  else
    status "note: $version predates the menu-bar app — installing the CLI only"
  fi
fi
chmod 0755 "$workdir/aif"
# Run the new binary BEFORE touching any existing install: a tarball that
# unpacks but cannot execute must not clobber a working ~/.aif/bin/aif.
smoke="$("$workdir/aif" --version)" || die "downloaded binary failed to run ($workdir/aif --version)"
case "$smoke" in
*"$version"*) ;;
*) die "downloaded binary reports '$smoke', expected version $version" ;;
esac

# --- atomic install to the stable path -----------------------------------------

mkdir -p "$BIN_DIR"
previous=""
if [ -x "$BIN_PATH" ]; then
  previous="$("$BIN_PATH" --version 2>/dev/null || true)"
  if [ -L "$BIN_PATH" ]; then
    rollback_bin_kind="symlink"
    rollback_bin_link="$(readlink "$BIN_PATH")"
  else
    rollback_bin_kind="regular"
  fi
  cp -p "$BIN_PATH" "$workdir/previous-aif"
  if [ -f "$AIF_HOME/version.json" ]; then
    cp -p "$AIF_HOME/version.json" "$workdir/previous-version.json"
    rollback_state_exists=1
  fi
  rollback_armed=1
fi
# Single-writer rule (SW-2, runtime/plan/2026_07_27_fix_updater_spike.md):
# the managed layout — the versions tree, the stable symlink, the
# version.json install record — has exactly ONE writer, the Go binary
# (`install-self` / `update` / `rollback`). This script downloads, verifies,
# and DELEGATES: the verified binary registers itself. The old unconditional
# in-place rename left the install record stale whenever it ran over an
# `aif update`-managed layout, and the daemon's idle restarter then bounced
# the daemon once a minute, forever (the 2026-07-27 incident). Releases that
# predate `install-self` fall back to the legacy rename below.
if "$workdir/aif" install-self --help >/dev/null 2>&1; then
  "$workdir/aif" install-self || die "the verified binary could not register itself into $AIF_HOME (aif install-self failed)"
else
  status "note: $version predates 'aif install-self' — installing via the legacy in-place rename"
  # Legacy fallback: stage inside BIN_DIR so the final mv is a
  # same-filesystem rename(2): atomic, and a daemon still running the old
  # binary keeps its inode.
  staged="$(mktemp "$BIN_DIR/.aif.new.XXXXXX")"
  cp "$workdir/aif" "$staged"
  chmod 0755 "$staged"
  mv -f "$staged" "$BIN_PATH"
fi
if [ -n "$previous" ]; then
  status "updated: $BIN_PATH ($previous -> $smoke)"
else
  status "installed: $BIN_PATH ($smoke)"
fi

# --- macOS menu-bar companion -------------------------------------------------

# New macOS release archives carry a signed, notarized AIF.app. Install the
# unchanged release artifact as Harden Agent Integrity Foundation.app so macOS uses the
# product name in Login Items and background-item notifications. It is an
# optional companion: a staging, registration, or launch failure must preserve
# the previous app and must never roll back the independently verified CLI.
# Historical pinned releases without AIF.app.zip remain CLI-only installs.
install_menubar() {
  mkdir -p "$MENUBAR_APP_DIR" || return 1
  menubar_stage="$(mktemp -d "$MENUBAR_APP_DIR/.install.XXXXXX")" || return 1
  ditto "$menubar_source" "$menubar_stage/$MENUBAR_APP_NAME" || {
    error "could not stage $MENUBAR_APP_NAME"
    return 1
  }
  codesign --verify --deep --strict "$menubar_stage/$MENUBAR_APP_NAME" >/dev/null 2>&1 || {
    error "staged $MENUBAR_APP_NAME failed code-signature verification"
    return 1
  }

  rollback_app_path="$MENUBAR_APP_PATH"
  if [ ! -d "$rollback_app_path" ] && [ -d "$MENUBAR_PREVIOUS_APP_PATH" ]; then
    rollback_app_path="$MENUBAR_PREVIOUS_APP_PATH"
  elif [ ! -d "$rollback_app_path" ] && [ -d "$MENUBAR_LEGACY_APP_PATH" ]; then
    rollback_app_path="$MENUBAR_LEGACY_APP_PATH"
  fi
  if [ -d "$rollback_app_path" ]; then
    ditto "$rollback_app_path" "$workdir/previous-menubar.app" || return 1
    rollback_app_exists=1
  fi
  if [ -f "$MENUBAR_LEGACY_PLIST" ]; then
    cp -p "$MENUBAR_LEGACY_PLIST" "$workdir/previous-menubar.plist" || return 1
    rollback_plist_exists=1
  fi
  rollback_menubar_captured=1

  if [ -z "${AIF_NO_RESTART:-}" ]; then
    launchctl bootout "gui/$(id -u)/$MENUBAR_LEGACY_LABEL" >/dev/null 2>&1 || true
    /usr/bin/pkill -x AIFMenuBar >/dev/null 2>&1 || true
    if [ -d "$rollback_app_path" ] && menubar_uses_native_login_item "$rollback_app_path"; then
      menubar_login_item_command "$rollback_app_path" --unregister-login-item || return 1
    fi
  fi
  rm -rf "$MENUBAR_APP_PATH" "$MENUBAR_PREVIOUS_APP_PATH" "$MENUBAR_LEGACY_APP_PATH" || return 1
  mv "$menubar_stage/$MENUBAR_APP_NAME" "$MENUBAR_APP_PATH" || return 1
  rmdir "$menubar_stage" || return 1
  rm -f "$MENUBAR_LEGACY_PLIST" || return 1

  if [ -n "${AIF_NO_RESTART:-}" ]; then
    status "menu bar: installed $MENUBAR_APP_NAME $app_version (not started or registered; open it once to enable start at login)"
    return 0
  fi

  gui_domain="gui/$(id -u)"
  if ! launchctl print "$gui_domain" >/dev/null 2>&1; then
    status "menu bar: installed $MENUBAR_APP_NAME $app_version (open it once to enable start at login)"
    return 0
  fi
  menubar_login_item_command "$MENUBAR_APP_PATH" --register-login-item || {
    error "could not register the AIF menu-bar app as a Login Item"
    return 1
  }
  if [ "$menubar_command_status" = "requires-approval" ]; then
    status "menu bar: Login Item requires approval in System Settings > General > Login Items"
  fi
  /usr/bin/open -g "$MENUBAR_APP_PATH" || {
    error "could not start the AIF menu-bar app"
    return 1
  }
  status "menu bar: installed and started $MENUBAR_APP_NAME $app_version"
}

if [ -n "$menubar_source" ] && ! install_menubar; then
  if [ -n "$rollback_menubar_captured" ] && ! restore_previous_menubar; then
    error "could not restore the previous menu-bar app"
  fi
  if [ -n "${menubar_stage:-}" ] && [ -d "$menubar_stage" ]; then
    rm -rf "$menubar_stage"
  fi
  rollback_menubar_captured=""
  rollback_app_exists=""
  status "warning: menu-bar app was not installed; the AIF CLI remains installed and functional"
fi

# --- optional: configure now (opt-in true one-liner) --------------------------
# Keep rollback armed until configuration succeeds. `aif configure` writes its
# own files transactionally; if it fails, its edits and the binary/menu-bar
# replacement are both restored before this installer exits.
configured=""
case "${AIF_CONFIGURE:-}" in
1 | true | yes)
  echo
  status "AIF_CONFIGURE set — configuring the firewall hooks now:"
  # The installer owns the one final browser open. Suppress configure's
  # separate first-run open so AIF_CONFIGURE=1 never opens two windows.
  if "$BIN_PATH" configure --no-show; then
    configured=1
    status "aif configure done."
  else
    die "'aif configure' did not finish; restoring the previous installation"
  fi
  ;;
esac

# A daemon that started before the swap keeps the OLD binary's code in memory
# until it is restarted. Do this only after the installation has committed, so
# a failed configure never leaves a new daemon serving an otherwise-old install.
if [ -n "$previous" ]; then
  if [ -n "${AIF_NO_RESTART:-}" ]; then
    status "a running daemon still serves $previous; run '$BIN_PATH stop' to pick up $version (AIF_NO_RESTART set)"
  elif "$BIN_PATH" status 2>/dev/null | grep -q 'version mismatch'; then
    status "restarting the daemon to run $version (hooked agents briefly fail closed, then resume)…"
    "$BIN_PATH" stop >/dev/null 2>&1 || true
    if "$BIN_PATH" start >/dev/null 2>&1; then
      status "daemon now running $version"
    else
      status "note: could not auto-restart — run '$BIN_PATH stop' then '$BIN_PATH start' to pick up $version"
    fi
  fi
fi
rollback_armed=""

# --- best-effort convenience symlink -------------------------------------------

# Hooks embed the symlink-RESOLVED path, so this link is PURE convenience
# for interactive `aif ...` — nothing breaks if it is absent or removed.
link_dir="${AIF_LINK_DIR-__auto__}"
if [ "$link_dir" = "__auto__" ]; then
  if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
    link_dir="/usr/local/bin"
  else
    link_dir="$HOME/.local/bin"
  fi
fi
linked=""
if [ -n "$link_dir" ]; then
  if mkdir -p "$link_dir" 2>/dev/null; then
    if [ -e "$link_dir/aif" ] && [ ! -L "$link_dir/aif" ]; then
      status "note: $link_dir/aif exists and is not a symlink — leaving it alone"
    elif ln -sf "$BIN_PATH" "$link_dir/aif" 2>/dev/null; then
      linked="$link_dir/aif"
      status "symlinked: $linked -> $BIN_PATH"
    else
      status "note: could not symlink into $link_dir"
    fi
  else
    status "note: could not create $link_dir"
  fi
fi

# --- PATH setup (issues #34, #66) ----------------------------------------------
# A child process can never change its parent shell's environment, so an
# installer that only PRINTS `export PATH=...` leaves `aif` "command not
# found" in every new shell. Do what rustup/uv do instead: write a sourceable
# env snippet at ~/.aif/env and add ONE marker-guarded source line to the rc
# file of the user's login shell ($SHELL — zsh, bash, a POSIX sh; fish gets
# the conf.d drop-in it documents for exactly this). The line is guarded by
# the ".aif/env" marker, so re-running the installer never duplicates it.
# Always the STABLE canonical directory ~/.aif/bin (the one path every hook
# embeds), never the convenience-symlink dir. AIF_NO_MODIFY_PATH=1 opts out;
# a sudo run never edits the invoking user's rc files; when no rc file can be
# identified the old copy-paste hint still prints.

ENV_FILE="$AIF_HOME/env"
RC_MARKER=".aif/env"

# The sourceable PATH snippet. Idempotent to source (no-op when ~/.aif/bin is
# already on PATH) and rewritten wholesale on every install.
write_env_file() {
  cat >"$ENV_FILE" <<'EOF'
#!/bin/sh
# aif shell setup — written by the aif installer; safe to source repeatedly.
case ":${PATH}:" in
*:"$HOME/.aif/bin":*) ;;
*) export PATH="$HOME/.aif/bin:$PATH" ;;
esac
EOF
}

# The rc file(s) to receive the source line, one per line. Prefer the rc of
# the user's OWN login shell ($SHELL), creating it if absent; an unknown or
# unset $SHELL only ever appends to rc files that already exist.
path_rc_targets() {
  case "${SHELL:-}" in
  */zsh)
    echo "${ZDOTDIR:-$HOME}/.zshrc"
    ;;
  */bash)
    echo "$HOME/.bashrc"
    # macOS terminals run LOGIN shells, which read ~/.bash_profile (or, only
    # if that is absent, ~/.profile) and SKIP ~/.bashrc — cover the login
    # file too or the edit is invisible in every new Terminal tab.
    if [ "$os" = "Darwin" ]; then
      if [ -f "$HOME/.profile" ] && [ ! -f "$HOME/.bash_profile" ]; then
        echo "$HOME/.profile"
      else
        echo "$HOME/.bash_profile"
      fi
    fi
    ;;
  */fish) ;; # handled via conf.d below, not an rc append
  */sh | */dash | */ksh)
    echo "$HOME/.profile"
    ;;
  *)
    for f in "${ZDOTDIR:-$HOME}/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do
      if [ -f "$f" ]; then echo "$f"; fi
    done
    ;;
  esac
}

# add_rc_source_line RC: append the marker-guarded source block to RC unless
# the marker is already there. Status 0 = appended, 1 = left alone.
add_rc_source_line() {
  if grep -qsF "$RC_MARKER" "$1"; then
    return 1
  fi
  mkdir -p "$(dirname "$1")" 2>/dev/null || return 1
  # $HOME must land LITERALLY in the rc file, hence the single quotes:
  # shellcheck disable=SC2016
  printf '\n# aif — adds the aif firewall CLI to PATH (installer-managed; AIF_NO_MODIFY_PATH=1 opts out)\n[ -f "$HOME/.aif/env" ] && . "$HOME/.aif/env"\n' >>"$1" 2>/dev/null || return 1
}

# fish reads every ~/.config/fish/conf.d/*.fish — a drop-in file, not an rc
# append, is the fish idiom for third-party PATH entries.
write_fish_conf() {
  mkdir -p "$fish_conf_dir" 2>/dev/null || return 1
  cat >"$fish_conf_dir/aif.fish" <<'EOF' || return 1
# aif — adds the aif firewall CLI to PATH (installer-managed; AIF_NO_MODIFY_PATH=1 opts out)
if not contains -- "$HOME/.aif/bin" $PATH
    set -gx PATH "$HOME/.aif/bin" $PATH
end
EOF
}

on_path=""
case ":$PATH:" in
*":$BIN_DIR:"*) on_path="$BIN_DIR" ;;
esac
if [ -z "$on_path" ] && [ -n "$linked" ]; then
  case ":$PATH:" in
  *":$link_dir:"*) on_path="$link_dir" ;;
  esac
fi

echo
status "aif $version is installed."
if [ -z "$on_path" ]; then
  path_added=""   # rc files this run modified
  path_present="" # rc files that already carry the block (await a new shell)
  case "${SHELL:-}" in
  */fish) now_line="  fish_add_path \"$BIN_DIR\"" ;;
  *) now_line="  export PATH=\"$BIN_DIR:\$PATH\"" ;;
  esac
  if [ -n "${AIF_NO_MODIFY_PATH:-}" ]; then
    status "AIF_NO_MODIFY_PATH set — leaving your shell configuration alone."
  elif [ -n "${SUDO_USER:-}" ]; then
    status "note: running under sudo — not editing $SUDO_USER's shell rc files; re-run without sudo or add the PATH line yourself."
  else
    case "${SHELL:-}" in
    */fish)
      fish_conf_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d"
      write_env_file
      if [ -f "$fish_conf_dir/aif.fish" ]; then
        path_present="$fish_conf_dir/aif.fish"
      elif write_fish_conf; then
        path_added="$fish_conf_dir/aif.fish"
      fi
      ;;
    *)
      rc_targets="$(path_rc_targets)"
      if [ -n "$rc_targets" ]; then
        write_env_file
        save_ifs=$IFS
        IFS='
'
        for rc in $rc_targets; do
          if add_rc_source_line "$rc"; then
            path_added="${path_added:+$path_added, }$rc"
          elif grep -qsF "$RC_MARKER" "$rc"; then
            path_present="${path_present:+$path_present, }$rc"
          fi
        done
        IFS=$save_ifs
      fi
      ;;
    esac
  fi
  if [ -n "$path_added" ]; then
    status "added $BIN_DIR to PATH in: $path_added"
    status "open a new shell to pick it up, or for THIS shell run:"
    status "$now_line"
  elif [ -n "$path_present" ]; then
    status "PATH already set up in: $path_present"
    status "open a new shell to pick it up, or for THIS shell run:"
    status "$now_line"
  else
    status "PATH hint: no installed location is on your PATH; add one, e.g.:"
    status "$now_line"
  fi
  # This process CAN fix its own PATH for any remaining installer work.
  PATH="$BIN_DIR:$PATH"
  export PATH
fi

if [ -z "$configured" ]; then
  status "next steps:"
  status "  aif configure --dry-run   # preview which agents would be configured"
  status "  aif configure             # install the firewall hooks"
  status "  (or re-run this installer with AIF_CONFIGURE=1 to configure automatically)"
fi
status "docs: runtime/install/README.md (per-agent write surface, daemon lifecycle, uninstall)"
status "file a bug: https://github.com/hardenrun/aif ('aif issue' opens it; attach 'aif doctor --bundle' output)"

# The verified FINAL binary owns the lifecycle auto-open policy: only a real
# terminal with an OS browser opener starts the daemon and opens the viewer.
# Headless installs and AIF_NO_SHOW print the URL without opening anything.
# Keep old AIF_VERSION pins installable: releases predating `show --auto`
# reject the hidden integration flag, so fall back to a manual URL instead of
# turning a successful install into a failure.
if ! "$BIN_PATH" show --auto 2>/dev/null; then
  status "viewer: automatic opening is unavailable in $version; run '$BIN_PATH show'"
  status "viewer URL: http://127.0.0.1:47391/"
fi
