#!/usr/bin/env bash
#
# Olakai On-Prem (Tier 2) — one-command install script.
#
# Headline usage:
#   curl -fsSL https://get.olakai.ai | bash
#
# Review-first usage (recommended):
#   curl -fsSL https://get.olakai.ai -o get.sh
#   less get.sh
#   bash get.sh
#
# What this script does (audit summary):
#   1. Pre-flight: OS / RAM / disk / ports / Docker.
#   2. Bootstrap Sigstore cosign if missing (verifies its own SHA256).
#   3. Collect license key, domain, admin email (interactive, with --flag pre-fills).
#   4. POST /api/onprem/install-handshake to relay.olakai.ai with the license key
#      and the domain. Receives a 15-min signed download URL plus the bundle's
#      expected SHA256 + cosign signature + cert.
#   5. Download + sha256-verify + cosign-verify the bundle tarball.
#   6. Extract to /opt/olakai-onprem (configurable).
#   7. Materialize .env: secrets via openssl rand, OLAKAI_DOMAIN/AUTH_URL from
#      --domain, OLAKAI_EMAIL_RELAY_KEY from the handshake bearer.
#   8. Run the bundle's own install.sh, which verifies the four image
#      signatures and brings the stack up via docker compose.
#   9. Wait for app health; print the success banner with the dashboard URL +
#      magic-link delivery confirmation + (per D-022 Option B) the bootstrap-log
#      magic-link fallback URL.
#
# Source repo:    https://github.com/olakai-ai/olakai-installer
# Public verify:  see SECURITY.md in that repo
# Issues / docs:  https://docs.olakai.ai/on-prem/install
#
# This script supports many bundle versions. Pin one with --version=v1.0.0;
# default is the latest non-yanked release the relay knows about. Verification
# (sha256 + cosign keyless) is enforced regardless of which version is
# installed; --skip-verify is an air-gapped escape hatch with a loud warning.
#
# Trust model for image verification:
#   The default posture skips per-image cosign verification at install time —
#   the four image references in docker-compose.yml are digest-pinned (@sha256
#   immutable refs) inside the bundle tarball, and the bundle tarball itself
#   is cosign-verified above. Pass --verify-images (or set
#   OLAKAI_VERIFY_IMAGES=true) to additionally run per-image cosign verify in
#   the bundle's install.sh — strict mode for customers with policy
#   requirements. --skip-verify remains orthogonal: it disables ALL cosign
#   verification (bundle + images) for air-gapped installs.
#
# License: MIT (see LICENSE).

set -euo pipefail
IFS=$'\n\t'

# ──────────────────────────────────────────────────────────────────────────────
# Versioning + chain-of-trust constants
# ──────────────────────────────────────────────────────────────────────────────

GET_SH_VERSION="2026.06.04"

# Pinned cosign version + SHA256 sums for the cosign binaries we may bootstrap.
# Update procedure: bump COSIGN_VERSION and refresh both SHA256s from the
# matching release's cosign_checksums.txt:
#   https://github.com/sigstore/cosign/releases/download/<COSIGN_VERSION>/cosign_checksums.txt
# Last refreshed: 2026-05-06 (cosign v3.0.6).
COSIGN_VERSION="v3.0.6"
COSIGN_SHA256_LINUX_AMD64="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74"
COSIGN_SHA256_LINUX_ARM64="bedac92e8c3729864e13d4a17048007cfafa79d5deca993a43a90ffe018ef2b8"

# Bundle-tarball cosign identity. Set on every release by the
# onprem-publish.yml workflow's `cosign sign-blob` step (W2 / OLA-146).
# The regexp matches every stable release tag (vX.Y.Z); pre-release tags
# (-rc, -beta, etc) are intentionally excluded so customers can't be tricked
# into installing a non-stable build that happens to have a valid signature.
COSIGN_CERT_IDENTITY_REGEXP='^https://github\.com/olakai-ai/localnode-app/\.github/workflows/onprem-publish\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$'
COSIGN_OIDC_ISSUER='https://token.actions.githubusercontent.com'

# Endpoints. Override via env for staging/testing.
RELAY_HANDSHAKE_URL_DEFAULT="${OLAKAI_RELAY_HANDSHAKE_URL:-https://relay.olakai.ai/api/onprem/install-handshake}"

# OS support matrix (humans-readable copy used in error messages).
SUPPORTED_OSES_HUMAN="Ubuntu 22.04+, Debian 12+, RHEL 9+, Amazon Linux 2023"

# Resource thresholds.
MIN_RAM_GB=8
MIN_FREE_DISK_GB=80
REQUIRED_PORTS=(80 443)
MIN_DOCKER_MAJOR=24

# ──────────────────────────────────────────────────────────────────────────────
# Mutable globals (filled by parse_args / prompts / handshake)
# ──────────────────────────────────────────────────────────────────────────────

LICENSE_KEY=""
DOMAIN=""
ADMIN_EMAIL=""
INSTALL_DIR="/opt/olakai-onprem"
NO_TLS="false"
SKIP_VERIFY="false"
# VERIFY_IMAGES toggles per-image cosign verification inside the bundle's
# install.sh. Default false: the bundle is already cosign-verified above and
# images are digest-pinned. Set true via --verify-images or
# OLAKAI_VERIFY_IMAGES=<truthy> for strict mode (CLI flag wins over env).
#
# Truthy values normalized below: `true`, `1`, `yes`, `TRUE`, `True`. Anything
# else (including the common `OLAKAI_VERIFY_IMAGES=0` or unset) leaves
# VERIFY_IMAGES=false. We accept multiple truthy spellings because a silent
# disable on a security flag is the worst-possible failure mode — operators
# trying `=1` and getting silently ignored would think they were verifying.
case "${OLAKAI_VERIFY_IMAGES:-false}" in
  true|TRUE|True|1|yes|YES|Yes) VERIFY_IMAGES="true" ;;
  *)                            VERIFY_IMAGES="false" ;;
esac
REKOR_BUNDLE=""
VERSION_PIN=""
NON_INTERACTIVE="false"
QUIET="false"
AUTO_INSTALL_DOCKER="false"
RELAY_HANDSHAKE_URL="$RELAY_HANDSHAKE_URL_DEFAULT"

# Filled by handshake.
HANDSHAKE_DEPLOYMENT_BEARER=""
HANDSHAKE_DOWNLOAD_URL=""
HANDSHAKE_EXPECTED_SHA256=""
HANDSHAKE_EXPECTED_SIG_B64=""
HANDSHAKE_EXPECTED_CERT_PEM=""
HANDSHAKE_VERSION=""

# Workspace dir (mktemp); cleaned up on exit.
WORK_DIR=""

# ──────────────────────────────────────────────────────────────────────────────
# Print helpers (color-aware; degrade when not a TTY)
# ──────────────────────────────────────────────────────────────────────────────

if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
  C_RESET=$'\033[0m'
  C_BOLD=$'\033[1m'
  C_DIM=$'\033[2m'
  C_GREEN=$'\033[32m'
  C_YELLOW=$'\033[33m'
  C_RED=$'\033[31m'
  C_CYAN=$'\033[36m'
else
  C_RESET=""
  C_BOLD=""
  C_DIM=""
  C_GREEN=""
  C_YELLOW=""
  C_RED=""
  C_CYAN=""
fi

print_step()  { printf '%s\n' "${C_BOLD}${C_CYAN}==>${C_RESET} ${C_BOLD}$*${C_RESET}"; }
print_info()  { printf '    %s\n' "$*"; }
print_dim()   { printf '    %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
print_warn()  { printf '%swarn:%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*" >&2; }
print_error() { printf '%serror:%s %s\n' "${C_RED}" "${C_RESET}" "$*" >&2; }
print_ok()    { printf '    %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }

die() {
  print_error "$*"
  exit 1
}

# ──────────────────────────────────────────────────────────────────────────────
# Cleanup
# ──────────────────────────────────────────────────────────────────────────────

cleanup() {
  local rc=$?
  if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then
    rm -rf "$WORK_DIR"
  fi
  # Tell the operator about preserved partial state so they don't have to
  # guess why a re-run hits the "non-empty install dir" prompt or finds an
  # existing .env. We only print this on non-zero exits past the point
  # where we may have created files in INSTALL_DIR.
  if (( rc != 0 )) && [[ -n "$INSTALL_DIR" ]] && [[ -d "$INSTALL_DIR" ]] \
      && [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
    printf '\n%snote:%s partial install state at %s is preserved. Re-run get.sh to retry; existing .env values (if any) will not be overwritten.\n' \
      "${C_DIM}" "${C_RESET}" "${INSTALL_DIR}" >&2
  fi
  exit "$rc"
}
trap cleanup EXIT INT TERM

# ──────────────────────────────────────────────────────────────────────────────
# TTY binding for interactive prompts (D-024 / OLA-169).
#
# Under `curl … | bash`, bash reads the SCRIPT ITSELF from fd 0 (the pipe).
# The old idiom `exec < /dev/tty` rebound fd 0 to the terminal — which made
# bash read the REST of the script (every function definition and the final
# `main` call) from the now-idle terminal, hanging silently with zero output
# even in --non-interactive mode (OLA-301). Running from a file never hit this
# because bash reads the script from the file, never from fd 0.
#
# Fix: never touch fd 0. Bind the controlling terminal to a dedicated
# descriptor (fd 3) and read every prompt from it. TTY_FD records where
# interactive reads should come from:
#   - fd 0  when stdin is already a terminal (review-first `bash get.sh` path)
#   - fd 3  when we successfully borrowed /dev/tty (the `curl | bash` path)
#   - empty when no terminal is available (true CI, `docker exec` without -t,
#           a pipe with no controlling tty) → the script runs strictly
#           non-interactive and every prompt hard-fails on missing input.
#
# The probe tolerates hosts where /dev/tty exists but has no controlling
# terminal: `exec 3</dev/tty` fails, 2>/dev/null swallows bash's "cannot open
# /dev/tty" diagnostic, and TTY_FD stays empty. The `-t 3` guard rejects the
# case where the open succeeds but doesn't yield an actual terminal.
# ──────────────────────────────────────────────────────────────────────────────

TTY_FD=""
if [[ -t 0 ]]; then
  TTY_FD=0
elif { exec 3</dev/tty; } 2>/dev/null && [[ -t 3 ]]; then
  TTY_FD=3
fi

# ──────────────────────────────────────────────────────────────────────────────
# Help / usage
# ──────────────────────────────────────────────────────────────────────────────

print_help() {
  cat <<'EOF'
Olakai on-prem one-command install.

Usage:
  curl -fsSL https://get.olakai.ai | bash
  curl -fsSL https://get.olakai.ai | bash -s -- [flags]

Flags (all optional in interactive mode; required with --non-interactive):
  --license-key=KEY        License from Olakai sales (silent prompt if unset).
  --domain=DOMAIN          Customer-facing domain (becomes appUrl + AUTH_URL).
  --admin-email=EMAIL      First admin user; receives a magic-link login.
  --install-dir=DIR        Where to extract the bundle. Default: /opt/olakai-onprem
  --no-tls                 Skip Caddy + Let's Encrypt (use behind a load balancer).
  --skip-verify            Skip cosign verify (air-gapped escape hatch — UNSAFE).
  --verify-images          Run per-image cosign verification at install (strict mode).
                           Default: skip (images are digest-pinned in the verified bundle).
                           Equivalent env var: OLAKAI_VERIFY_IMAGES=true
  --rekor-bundle=PATH      Use an offline cosign --bundle for verification.
  --version=VERSION        Pin to a specific bundle version (e.g. v1.0.0).
  --non-interactive        Hard-fail on missing required flags. Implied by CI=true.
  --auto-install-docker    Allow get.sh to install Docker via get.docker.com when
                           missing. Default in interactive mode is to prompt;
                           in non-interactive mode this flag is REQUIRED to opt in
                           (otherwise we fail with manual-install instructions —
                           the get.docker.com installer is not signature-verified).
  --quiet                  Suppress the magic-link fallback URL on the success banner.
  -h, --help               This text.

Failure modes (each prints an actionable message before exiting non-zero):
  License invalid / already consumed   relay returns 401/409 → re-confirm with sales.
  Tarball SHA256 mismatch              corruption or MITM → re-run; if persistent, bail.
  Cosign verify failure                signature mismatch → DO NOT continue; report it.
  Docker not present / too old         install Docker 24+ from get.docker.com.
  Compose v2 missing                   ensure 'docker compose version' works.
  Ports 80/443 in use                  free the ports (or use --no-tls behind an LB).
  RAM < 8 GB or free disk < 80 GB      provision a larger VM and re-run.
  Stack didn't go healthy in 5 min     run /opt/olakai-onprem/support-bundle.sh.

Exit code is non-zero on any failure. Re-running this script is safe: secrets
in .env are never overwritten on a second pass.

Source: https://github.com/olakai-ai/olakai-installer
EOF
}

# ──────────────────────────────────────────────────────────────────────────────
# Argument parsing
# ──────────────────────────────────────────────────────────────────────────────

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --license-key=*)   LICENSE_KEY="${1#*=}"; shift ;;
      --license-key)     LICENSE_KEY="${2:-}"; shift 2 ;;
      --domain=*)        DOMAIN="${1#*=}"; shift ;;
      --domain)          DOMAIN="${2:-}"; shift 2 ;;
      --admin-email=*)   ADMIN_EMAIL="${1#*=}"; shift ;;
      --admin-email)     ADMIN_EMAIL="${2:-}"; shift 2 ;;
      --install-dir=*)   INSTALL_DIR="${1#*=}"; shift ;;
      --install-dir)     INSTALL_DIR="${2:-}"; shift 2 ;;
      --no-tls)          NO_TLS="true"; shift ;;
      --skip-verify)     SKIP_VERIFY="true"; shift ;;
      --verify-images)   VERIFY_IMAGES="true"; shift ;;
      --rekor-bundle=*)  REKOR_BUNDLE="${1#*=}"; shift ;;
      --rekor-bundle)    REKOR_BUNDLE="${2:-}"; shift 2 ;;
      --version=*)       VERSION_PIN="${1#*=}"; shift ;;
      --version)         VERSION_PIN="${2:-}"; shift 2 ;;
      --non-interactive) NON_INTERACTIVE="true"; shift ;;
      --auto-install-docker) AUTO_INSTALL_DOCKER="true"; shift ;;
      --quiet)           QUIET="true"; shift ;;
      -h|--help)         print_help; exit 0 ;;
      --)                shift; break ;;
      *)                 die "unknown flag: $1 (run with --help)" ;;
    esac
  done

  # No usable terminal (true CI, `docker exec` without -t, or a pipe with no
  # controlling tty) flips us into non-interactive mode; CI=true does too.
  # TTY_FD is set above and is empty exactly when no terminal could be bound.
  if [[ "${CI:-}" == "true" || -z "$TTY_FD" ]]; then
    NON_INTERACTIVE="true"
  fi
}

# ──────────────────────────────────────────────────────────────────────────────
# Pre-flight: OS / privileges / hardware / Docker
# ──────────────────────────────────────────────────────────────────────────────

detect_os() {
  if [[ ! -r /etc/os-release ]]; then
    die "cannot detect OS (no /etc/os-release). Supported: ${SUPPORTED_OSES_HUMAN}."
  fi
  # shellcheck disable=SC1091
  . /etc/os-release

  local id="${ID:-}"
  local id_like="${ID_LIKE:-}"
  local version_id="${VERSION_ID:-}"
  local human="${PRETTY_NAME:-${id} ${version_id}}"

  case "$id" in
    ubuntu)
      _semver_ge "$version_id" "22.04" \
        || die "Ubuntu ${version_id} is too old (need 22.04+). Detected: ${human}."
      ;;
    debian)
      _semver_ge "$version_id" "12" \
        || die "Debian ${version_id} is too old (need 12+). Detected: ${human}."
      ;;
    rhel|rocky|almalinux|centos)
      _semver_ge "$version_id" "9" \
        || die "RHEL-family ${version_id} is too old (need 9+). Detected: ${human}."
      ;;
    amzn)
      [[ "$version_id" == "2023" ]] \
        || die "Amazon Linux ${version_id} is not supported (need 2023). Detected: ${human}."
      ;;
    *)
      # Some distros (e.g. Oracle Linux) report ID="ol" but ID_LIKE="rhel fedora".
      if [[ "$id_like" == *"rhel"* || "$id_like" == *"debian"* ]]; then
        print_warn "Unsupported distro ${human}; ID_LIKE suggests it may work. Proceeding."
      else
        die "unsupported OS '${human}'. Supported: ${SUPPORTED_OSES_HUMAN}."
      fi
      ;;
  esac

  print_ok "OS: ${human}"
}

# Compares two dotted version strings: returns success iff $1 >= $2.
# Pure-bash; no `bc`, `python`, or `dpkg --compare-versions` required.
# Major.minor only — adequate for the OS-version gate (we never compare
# sub-minor like "9.5 vs 9.10"). If a future caller needs three components,
# extend this rather than assuming the existing semantics.
_semver_ge() {
  local lhs="$1" rhs="$2"
  local lhs_major lhs_minor rhs_major rhs_minor
  lhs_major="${lhs%%.*}"; lhs_minor="${lhs#*.}"; [[ "$lhs_minor" == "$lhs" ]] && lhs_minor="0"
  rhs_major="${rhs%%.*}"; rhs_minor="${rhs#*.}"; [[ "$rhs_minor" == "$rhs" ]] && rhs_minor="0"
  # Strip any trailing minor noise ("22.04.1" → "04").
  lhs_minor="${lhs_minor%%.*}"
  rhs_minor="${rhs_minor%%.*}"
  if (( lhs_major > rhs_major )); then return 0; fi
  if (( lhs_major < rhs_major )); then return 1; fi
  (( lhs_minor >= rhs_minor ))
}

check_root() {
  if [[ "$(id -u)" -ne 0 ]]; then
    die "must run as root (use 'sudo bash get.sh' or curl … | sudo bash)."
  fi
  print_ok "Running as root"
}

check_resources() {
  local mem_kb mem_gb disk_gb
  mem_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
  mem_gb=$(( mem_kb / 1024 / 1024 ))
  if (( mem_gb < MIN_RAM_GB )); then
    die "RAM too low (${mem_gb} GB; need ${MIN_RAM_GB} GB)."
  fi
  print_ok "RAM: ${mem_gb} GB"

  # df -BG → "<size>G" rows; strip the G.
  disk_gb="$(df -BG --output=avail / | tail -n 1 | tr -dc '0-9')"
  if (( disk_gb < MIN_FREE_DISK_GB )); then
    die "free disk too low on / (${disk_gb} GB; need ${MIN_FREE_DISK_GB} GB)."
  fi
  print_ok "Free disk on /: ${disk_gb} GB"

  if [[ "$NO_TLS" == "true" ]]; then
    print_dim "Skipping port checks (--no-tls)"
    return 0
  fi
  if ! command -v ss >/dev/null 2>&1; then
    print_warn "'ss' not found; skipping port-conflict check (install iproute2)."
    return 0
  fi
  local p
  for p in "${REQUIRED_PORTS[@]}"; do
    if ss -lnt "sport = :${p}" 2>/dev/null | tail -n +2 | grep -q .; then
      die "port ${p} is already in use; free it or pass --no-tls."
    fi
  done
  print_ok "Ports ${REQUIRED_PORTS[*]} are free"
}

check_or_install_docker() {
  local need_install="false" docker_major
  if ! command -v docker >/dev/null 2>&1; then
    need_install="true"
  else
    docker_major="$(docker --version | awk '{print $3}' | cut -d. -f1)"
    if [[ -z "$docker_major" ]] || (( docker_major < MIN_DOCKER_MAJOR )); then
      print_warn "Docker is too old (need ${MIN_DOCKER_MAJOR}+; have ${docker_major:-?}). Will reinstall."
      need_install="true"
    fi
  fi
  if [[ "$need_install" == "false" ]] && ! docker compose version >/dev/null 2>&1; then
    print_warn "docker compose v2 not detected; will reinstall via the official Docker repo."
    need_install="true"
  fi

  if [[ "$need_install" == "false" ]]; then
    print_ok "Docker $(docker --version | awk '{print $3}' | tr -d ',') + Compose v2 present"
    return 0
  fi

  # Docker auto-install runs an arbitrary upstream script (`get.docker.com`)
  # as root with no signature verification. We treat opting into that as a
  # conscious choice: prompt in interactive mode, REQUIRE --auto-install-docker
  # in non-interactive mode. Otherwise direct the operator to the signed
  # apt/dnf repos via Docker's documented install page.
  if [[ "$AUTO_INSTALL_DOCKER" != "true" ]]; then
    if [[ "$NON_INTERACTIVE" == "true" ]]; then
      die "Docker 24+ with Compose v2 is required; auto-install is opt-in via --auto-install-docker. Install Docker per https://docs.docker.com/engine/install/ (signed apt/dnf repos) and re-run."
    fi
    print_warn "get.sh can install Docker via https://get.docker.com — note that the upstream installer script is NOT signature-verified, so this extends the install's trust boundary to docker.com's HTTPS-served script."
    if ! _confirm "Install Docker now via the official get.docker.com installer?" "y"; then
      die "aborted: install Docker per https://docs.docker.com/engine/install/ and re-run."
    fi
  fi

  print_info "Installing Docker via https://get.docker.com (may take a few minutes)…"
  if ! curl -fsSL https://get.docker.com -o /tmp/get-docker.sh; then
    die "failed to download https://get.docker.com — check network."
  fi
  # Defense-in-depth (OLA-301): run the upstream installer with stdin detached
  # from any pipe/terminal (< /dev/null) so it can never block on an
  # interactive read, and force apt/dnf into non-interactive mode so a
  # configuration prompt deep in the dependency chain can't stall the install.
  if ! DEBIAN_FRONTEND=noninteractive sh /tmp/get-docker.sh < /dev/null 3<&-; then
    rm -f /tmp/get-docker.sh
    die "Docker installation failed; install manually per https://docs.docker.com/engine/install/ and re-run."
  fi
  rm -f /tmp/get-docker.sh

  systemctl enable --now docker >/dev/null 2>&1 || true
  print_ok "Docker installed: $(docker --version | awk '{print $3}' | tr -d ',')"
}

# ──────────────────────────────────────────────────────────────────────────────
# Cosign bootstrap (W2 / OLA-146)
# ──────────────────────────────────────────────────────────────────────────────

ensure_cosign() {
  if [[ "$SKIP_VERIFY" == "true" ]]; then
    print_warn "--skip-verify is set: ALL cosign verification is disabled — both the bundle tarball blob signature AND the four image signatures performed by the bundle's install.sh. Only use this for air-gapped installs where you have already verified out-of-band."
    return 0
  fi

  if command -v cosign >/dev/null 2>&1; then
    print_ok "cosign present: $(cosign version --json 2>/dev/null | grep -oE '"GitVersion":"[^"]+"' | head -1 | cut -d'"' -f4 || echo unknown)"
    return 0
  fi

  local arch expected_sha url tmp_path target_path
  case "$(uname -m)" in
    x86_64|amd64) arch="amd64"; expected_sha="$COSIGN_SHA256_LINUX_AMD64" ;;
    aarch64|arm64) arch="arm64"; expected_sha="$COSIGN_SHA256_LINUX_ARM64" ;;
    *) die "unsupported CPU arch '$(uname -m)'; cosign builds for amd64 / arm64 only." ;;
  esac
  url="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${arch}"
  tmp_path="$(mktemp)"
  target_path="/usr/local/bin/cosign"

  print_info "Downloading cosign ${COSIGN_VERSION} (${arch})…"
  if ! curl -fsSL -o "$tmp_path" "$url"; then
    rm -f "$tmp_path"
    die "failed to download cosign from ${url}"
  fi

  local actual_sha
  actual_sha="$(sha256sum "$tmp_path" | awk '{print $1}')"
  if [[ "$actual_sha" != "$expected_sha" ]]; then
    rm -f "$tmp_path"
    die "cosign SHA256 mismatch (expected ${expected_sha}, got ${actual_sha}). Refusing to install — possible MITM."
  fi
  print_ok "cosign SHA256 matches embedded constant"

  install -m 0755 "$tmp_path" "$target_path"
  rm -f "$tmp_path"
  print_ok "cosign installed at ${target_path}"
}

# ──────────────────────────────────────────────────────────────────────────────
# Interactive collection (D-024 / OLA-169)
# ──────────────────────────────────────────────────────────────────────────────

# Prompt with optional default, returning value via stdout. Hard-fails in
# non-interactive mode if the var is empty.
_prompt() {
  local var_name="$1" prompt_text="$2" default_value="${3:-}" silent="${4:-false}"
  local current="${!var_name}" reply

  if [[ -n "$current" ]]; then
    return 0
  fi

  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    # Optional inputs (those with a default) fall back silently in
    # non-interactive mode; required inputs hard-fail with a flag hint.
    if [[ -n "$default_value" ]]; then
      printf -v "$var_name" '%s' "$default_value"
      return 0
    fi
    die "missing required input '${var_name}' (pass --${var_name//_/-} or run interactively)."
  fi

  # Read from TTY_FD (fd 0 in the review-first path, fd 3 under `curl | bash`).
  # This branch is only reached when NON_INTERACTIVE is false, which the
  # parse_args guard guarantees implies TTY_FD is non-empty.
  if [[ "$silent" == "true" ]]; then
    printf '    %s%s%s ' "$C_BOLD" "$prompt_text" "$C_RESET"
    read -rs -u "$TTY_FD" reply
    printf '\n'
  else
    if [[ -n "$default_value" ]]; then
      printf '    %s%s%s [%s]: ' "$C_BOLD" "$prompt_text" "$C_RESET" "$default_value"
    else
      printf '    %s%s%s: ' "$C_BOLD" "$prompt_text" "$C_RESET"
    fi
    read -r -u "$TTY_FD" reply
  fi
  if [[ -z "$reply" ]]; then
    reply="$default_value"
  fi
  printf -v "$var_name" '%s' "$reply"
}

_confirm() {
  local prompt_text="$1" default="${2:-y}"
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    [[ "$default" == "y" ]]
    return $?
  fi
  local reply
  printf '    %s%s%s [%s/n]: ' "$C_BOLD" "$prompt_text" "$C_RESET" "$default"
  read -r -u "$TTY_FD" reply
  reply="${reply:-$default}"
  [[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}

_validate_domain() {
  local d="$1"
  # Reject empty, embedded scheme, or whitespace.
  [[ -n "$d" ]]                                              || return 1
  [[ "$d" != *"://"* ]]                                      || return 1
  [[ "$d" != *" "* ]]                                        || return 1
  # Permissive DNS-name shape; relay does the authoritative check.
  [[ "$d" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]]    || return 1
}

_validate_email() {
  [[ "$1" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]
}

collect_inputs() {
  _prompt LICENSE_KEY "License key" "" "true"
  [[ -n "$LICENSE_KEY" ]] || die "license key cannot be empty."

  if [[ -z "$DOMAIN" && "$NON_INTERACTIVE" == "false" ]]; then
    print_dim "DNS for the domain should already point at this VM. If it doesn't yet, that's OK — set it now and we'll wait for propagation before TLS issuance."
  fi
  _prompt DOMAIN "Customer-facing domain (e.g. olakai.example.com)"
  _validate_domain "$DOMAIN" || die "invalid domain '${DOMAIN}'."

  _prompt ADMIN_EMAIL "First admin email (will receive a magic-link login)"
  _validate_email "$ADMIN_EMAIL" || die "invalid email '${ADMIN_EMAIL}'."

  _prompt INSTALL_DIR "Install directory" "/opt/olakai-onprem"

  if [[ "$NO_TLS" != "true" && "$NON_INTERACTIVE" == "false" ]]; then
    if ! _confirm "Issue TLS certificates via Let's Encrypt (recommended)?" "y"; then
      NO_TLS="true"
      print_warn "TLS issuance disabled — make sure you terminate TLS upstream."
    fi
  fi

  print_ok "Inputs collected"
}

# ──────────────────────────────────────────────────────────────────────────────
# Helper: parse a JSON field via Python (every supported distro ships python3).
# Stdin is the JSON body; field is a top-level key. Empty string if missing.
# ──────────────────────────────────────────────────────────────────────────────

_json_field() {
  local field="$1"
  python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(0)
v = d.get('${field}')
if v is None:
    sys.exit(0)
sys.stdout.write(str(v))
"
}

# ──────────────────────────────────────────────────────────────────────────────
# Handshake (W5 / OLA-156, with D-021 appUrl handling)
# ──────────────────────────────────────────────────────────────────────────────

do_handshake() {
  if ! command -v python3 >/dev/null 2>&1; then
    die "python3 not found; required for parsing the handshake response."
  fi

  local app_url body http_status response_file curl_config body_file
  app_url="https://${DOMAIN}"
  response_file="${WORK_DIR}/handshake.json"
  curl_config="${WORK_DIR}/curl.cfg"
  body_file="${WORK_DIR}/handshake-body.json"

  # Build the request body via Python's json.dumps so the result is
  # guaranteed to be a well-formed JSON value regardless of what's in
  # DOMAIN / VERSION_PIN. The validators upstream of here narrow these
  # inputs further, but defense-in-depth here costs nothing.
  body="$(VERSION_PIN="$VERSION_PIN" APP_URL="$app_url" python3 -c '
import json, os
v = os.environ.get("VERSION_PIN", "")
body = {"instanceMetadata": {"appUrl": os.environ["APP_URL"]}}
if v:
    body["version"] = v.lstrip("v")
print(json.dumps(body))
')"
  printf '%s' "$body" > "$body_file"

  # Pass auth via curl --config so the license key never appears on the
  # command line (and therefore never in /proc/<pid>/cmdline or `ps auxww`).
  # The config file is created mode 0600 inside an mktemp work dir.
  umask 077
  cat > "$curl_config" <<EOF
header = "Authorization: Bearer ${LICENSE_KEY}"
header = "Content-Type: application/json"
EOF
  umask 022

  print_info "POST ${RELAY_HANDSHAKE_URL}"
  http_status="$(curl -sS -o "$response_file" -w '%{http_code}' \
    -X POST \
    -K "$curl_config" \
    --data-binary "@${body_file}" \
    "$RELAY_HANDSHAKE_URL" || echo "000")"

  case "$http_status" in
    200) ;;
    400) die "handshake rejected (400): malformed request. Re-check --domain / --version." ;;
    401) die "handshake rejected (401): license invalid. Confirm the key with Olakai sales." ;;
    409) die "handshake rejected (409): license already consumed by a previous install. Contact Olakai sales for a fresh key." ;;
    410) die "handshake rejected (410): requested release version is yanked or unknown. Try without --version, or pin to the value 'latestAvailable' from the response: $(cat "$response_file")" ;;
    503) die "handshake rejected (503): no release available yet. Olakai is still publishing the bundle — try again later or contact support." ;;
    000) die "handshake transport failure: could not reach ${RELAY_HANDSHAKE_URL}. Check outbound network access." ;;
    *) die "handshake failed (HTTP ${http_status}): $(cat "$response_file")" ;;
  esac

  HANDSHAKE_DEPLOYMENT_BEARER="$(_json_field deploymentBearer < "$response_file")"
  HANDSHAKE_DOWNLOAD_URL="$(_json_field downloadUrl < "$response_file")"
  HANDSHAKE_EXPECTED_SHA256="$(_json_field expectedSha256 < "$response_file")"
  HANDSHAKE_EXPECTED_SIG_B64="$(_json_field expectedSignature < "$response_file")"
  HANDSHAKE_EXPECTED_CERT_PEM="$(_json_field expectedCertificate < "$response_file")"
  HANDSHAKE_VERSION="$(_json_field version < "$response_file")"

  [[ -n "$HANDSHAKE_DEPLOYMENT_BEARER" ]] \
    || die "handshake response missing deploymentBearer (relay bug; contact support)."
  [[ -n "$HANDSHAKE_DOWNLOAD_URL" ]] \
    || die "handshake response has no downloadUrl. The release bucket may not be provisioned yet (OLA-154); contact support."
  [[ -n "$HANDSHAKE_EXPECTED_SHA256" ]] \
    || die "handshake response missing expectedSha256."
  [[ -n "$HANDSHAKE_VERSION" ]] \
    || die "handshake response missing version."

  # Sig + cert are required for cosign verify-blob unless verification is
  # explicitly skipped or running in offline-bundle mode.
  if [[ "$SKIP_VERIFY" != "true" && -z "$REKOR_BUNDLE" ]]; then
    [[ -n "$HANDSHAKE_EXPECTED_SIG_B64" ]] \
      || die "handshake response missing expectedSignature. Pass --skip-verify to bypass (UNSAFE) or wait for the relay to be updated."
    [[ -n "$HANDSHAKE_EXPECTED_CERT_PEM" ]] \
      || die "handshake response missing expectedCertificate. Pass --skip-verify to bypass (UNSAFE) or wait for the relay to be updated."
  fi

  print_ok "License accepted; bundle version ${HANDSHAKE_VERSION} resolved"
}

# ──────────────────────────────────────────────────────────────────────────────
# Download + verify (W2 + W5)
# ──────────────────────────────────────────────────────────────────────────────

download_and_verify() {
  local tarball="${WORK_DIR}/olakai-bundle.tar.gz"
  local sig_path="${WORK_DIR}/olakai-bundle.tar.gz.sig"
  local cert_path="${WORK_DIR}/olakai-bundle.tar.gz.cert"

  print_info "Downloading bundle (signed URL, 15-min TTL)…"
  if ! curl -fsSL -o "$tarball" "$HANDSHAKE_DOWNLOAD_URL"; then
    die "tarball download failed. The signed URL may have expired (15 min TTL); re-run get.sh to refresh."
  fi

  local actual_sha
  actual_sha="$(sha256sum "$tarball" | awk '{print $1}')"
  if [[ "$actual_sha" != "$HANDSHAKE_EXPECTED_SHA256" ]]; then
    die "tarball SHA256 mismatch (expected ${HANDSHAKE_EXPECTED_SHA256}, got ${actual_sha}). Possible corruption or MITM — DO NOT proceed; contact Olakai support."
  fi
  print_ok "Tarball SHA256 verified"

  if [[ "$SKIP_VERIFY" == "true" ]]; then
    print_warn "Skipping cosign verify-blob (--skip-verify)."
    TARBALL_PATH_OUT="$tarball"
    return 0
  fi

  if [[ -n "$REKOR_BUNDLE" ]]; then
    [[ -f "$REKOR_BUNDLE" ]] || die "--rekor-bundle path '${REKOR_BUNDLE}' does not exist."
    print_info "Verifying with offline Rekor bundle: ${REKOR_BUNDLE}"
    if ! cosign verify-blob \
      --certificate-identity-regexp "$COSIGN_CERT_IDENTITY_REGEXP" \
      --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \
      --bundle "$REKOR_BUNDLE" \
      --offline \
      "$tarball" >/dev/null 2>&1; then
      die "cosign verify-blob (offline) FAILED. The bundle may not match the published signature; refusing to install."
    fi
  else
    printf '%s' "$HANDSHAKE_EXPECTED_SIG_B64" > "$sig_path"
    printf '%s' "$HANDSHAKE_EXPECTED_CERT_PEM" > "$cert_path"

    # Defense-in-depth: catch a malformed handshake response BEFORE running
    # cosign so the operator gets a "relay returned a malformed cert" error
    # instead of an opaque cosign-internal failure.
    local first_cert_line
    first_cert_line="$(head -n 1 "$cert_path" 2>/dev/null || true)"
    if [[ "$first_cert_line" != "-----BEGIN CERTIFICATE-----" ]]; then
      die "handshake's expectedCertificate is not a PEM certificate (got '${first_cert_line:0:80}'). Relay response is malformed; contact Olakai support."
    fi
    if [[ ! "$HANDSHAKE_EXPECTED_SIG_B64" =~ ^[A-Za-z0-9+/=[:space:]]+$ ]]; then
      die "handshake's expectedSignature is not valid base64. Relay response is malformed; contact Olakai support."
    fi

    print_info "Verifying cosign keyless signature against published GitHub workflow identity…"
    if ! cosign verify-blob \
      --certificate-identity-regexp "$COSIGN_CERT_IDENTITY_REGEXP" \
      --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \
      --signature "$sig_path" \
      --certificate "$cert_path" \
      "$tarball" >/dev/null; then
      die "cosign verify-blob FAILED. Signature/identity mismatch — refusing to install."
    fi
  fi
  print_ok "cosign signature verified (signer: olakai-ai/localnode-app onprem-publish.yml)"

  TARBALL_PATH_OUT="$tarball"
}

# ──────────────────────────────────────────────────────────────────────────────
# Extract
# ──────────────────────────────────────────────────────────────────────────────

extract_bundle() {
  if [[ -e "$INSTALL_DIR" && ! -d "$INSTALL_DIR" ]]; then
    die "${INSTALL_DIR} exists and is not a directory."
  fi
  if [[ -d "$INSTALL_DIR" ]] && [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
    if ! _confirm "${INSTALL_DIR} is non-empty; overlay anyway? Existing .env (if any) will be preserved." "n"; then
      die "aborted: install directory is non-empty."
    fi
  fi

  mkdir -p "$INSTALL_DIR"
  print_info "Extracting bundle to ${INSTALL_DIR}…"
  # Bundle tarball convention (per W1 / OLA-145): top-level dir is
  # `onprem-bundle/`. Strip the leading component so files land directly
  # in INSTALL_DIR (matches today's manual scp+tar flow).
  if ! tar -xzf "$TARBALL_PATH_OUT" --strip-components=1 -C "$INSTALL_DIR"; then
    die "failed to extract bundle to ${INSTALL_DIR}."
  fi
  print_ok "Extracted to ${INSTALL_DIR}"
}

# ──────────────────────────────────────────────────────────────────────────────
# Materialize .env
# ──────────────────────────────────────────────────────────────────────────────

# Replace `^KEY=$` with `KEY=<generated>` only when the value is empty.
# Mirrors the helper in localnode-app/onprem-bundle/install.sh so behavior is
# identical: URL-safe base64, idempotent, no overwrite when re-running.
_fill_secret_if_empty() {
  local key="$1" bytes="$2" env_file="$3" encoding="${4:-urlsafe}"
  local value escaped
  if grep -qE "^${key}=$" "$env_file"; then
    if [[ "$encoding" == "standard" ]]; then
      # NEXT_SERVER_ACTIONS_ENCRYPTION_KEY round-trips through Next.js's
      # atob() decrypt path (OLA-132); a URL-safe value fails the boot
      # validator (it requires the standard alphabet A-Za-z0-9+/=). Emit
      # standard base64 with padding for this key. The sed replacement
      # below uses `|` as the delimiter and escapes \ & |, so the +/=
      # characters in standard base64 are inserted safely.
      value="$(openssl rand -base64 "$bytes")"
    else
      value="$(openssl rand -base64 "$bytes" | tr '+/' '-_' | tr -d '=')"
    fi
    escaped="$(printf '%s\n' "$value" | sed -e 's/[\\&|]/\\&/g')"
    sed -i.bak -e "s|^${key}=$|${key}=${escaped}|" "$env_file"
    rm -f "${env_file}.bak"
  fi
}

# Set KEY=<value> in env file. Handles the "key is present, possibly with a
# value" case by replacing in place; otherwise appends. Value is sed-escaped.
_set_env_var() {
  local key="$1" value="$2" env_file="$3" escaped
  escaped="$(printf '%s\n' "$value" | sed -e 's/[\\&|]/\\&/g')"
  if grep -qE "^${key}=" "$env_file"; then
    sed -i.bak -e "s|^${key}=.*|${key}=${escaped}|" "$env_file"
    rm -f "${env_file}.bak"
  else
    printf '\n%s=%s\n' "$key" "$value" >> "$env_file"
  fi
}

materialize_env() {
  local env_file="${INSTALL_DIR}/.env"
  local env_template="${INSTALL_DIR}/.env.example"

  if [[ -f "$env_file" ]]; then
    print_warn ".env already exists — preserving existing values (re-run safety)."
  else
    [[ -f "$env_template" ]] || die "bundle is incomplete: ${env_template} missing."
    cp "$env_template" "$env_file"
  fi

  _fill_secret_if_empty NEXTAUTH_SECRET 32 "$env_file"
  _fill_secret_if_empty AUTH_SECRET 32 "$env_file"
  _fill_secret_if_empty DEFAULT_ENCRYPTION_KEY 32 "$env_file"
  # Standard base64 (NOT url-safe) — see OLA-132 note in _fill_secret_if_empty.
  _fill_secret_if_empty NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 32 "$env_file" standard
  _fill_secret_if_empty ENCRYPTION_SALT 16 "$env_file"
  _fill_secret_if_empty POSTGRES_PASSWORD 24 "$env_file"
  _fill_secret_if_empty MINIO_ROOT_PASSWORD 24 "$env_file"
  _fill_secret_if_empty REDIS_PASSWORD 24 "$env_file"
  # Shared secret between main-app and the support-bundle sidecar (OLA-177).
  # Required by the on-prem env validator; without it the bootstrap container
  # exits 1 on first boot. install.sh seeds this on its own first-run path,
  # but get.sh writes a fully-populated .env so that path never runs here.
  _fill_secret_if_empty OLAKAI_SUPPORT_BUNDLE_SECRET 32 "$env_file"

  _set_env_var OLAKAI_DOMAIN "$DOMAIN" "$env_file"
  if [[ "$NO_TLS" == "true" ]]; then
    _set_env_var AUTH_URL "http://${DOMAIN}" "$env_file"
  else
    _set_env_var AUTH_URL "https://${DOMAIN}" "$env_file"
  fi
  _set_env_var OLAKAI_ADMIN_EMAIL "$ADMIN_EMAIL" "$env_file"
  _set_env_var OLAKAI_EMAIL_RELAY_KEY "$HANDSHAKE_DEPLOYMENT_BEARER" "$env_file"
  _set_env_var OLAKAI_VERSION "$HANDSHAKE_VERSION" "$env_file"

  chmod 600 "$env_file"
  print_ok ".env materialized at ${env_file} (mode 0600)"
}

# ──────────────────────────────────────────────────────────────────────────────
# Bring-up: hand off to the bundle's install.sh.
# install.sh sees that .env is fully populated, runs cosign verify on the four
# images, then `docker compose up -d` and waits for app health (5 min).
# ──────────────────────────────────────────────────────────────────────────────

bring_up() {
  local installer="${INSTALL_DIR}/install.sh"
  [[ -x "$installer" ]] || die "bundle is incomplete: ${installer} missing or not executable."

  # Activate the bundled Caddy TLS sidecar unless the operator opted out with
  # --no-tls. The bundle ships docker-compose.caddy.yml.example; install.sh
  # auto-detects the (non-.example) docker-compose.caddy.yml and includes it
  # via -f. Without this copy the stack comes up HTTP-only, so the https
  # AUTH_URL and the magic-link setup URL are unreachable and first-login
  # silently breaks. Idempotent: skip if the operator already created one.
  if [[ "$NO_TLS" != "true" ]]; then
    local caddy_example="${INSTALL_DIR}/docker-compose.caddy.yml.example"
    local caddy_active="${INSTALL_DIR}/docker-compose.caddy.yml"
    if [[ -f "$caddy_example" && ! -f "$caddy_active" ]]; then
      cp "$caddy_example" "$caddy_active"
      print_ok "Activated Caddy TLS sidecar (docker-compose.caddy.yml)."
    fi
  fi

  if [[ "$SKIP_VERIFY" == "true" ]]; then
    export OLAKAI_SKIP_VERIFY=true
    print_warn "Propagating --skip-verify to bundle install.sh (image cosign verify will be skipped)."
  fi

  # Resolve --skip-verify + --verify-images conflict at the user-facing layer
  # rather than punting it to the bundle. --skip-verify is the air-gapped
  # escape hatch that disables ALL verification; --verify-images opts INTO
  # extra per-image cosign. Setting both is contradictory, so honor the
  # safer-looking-but-stricter --skip-verify (matches the existing trust-model
  # header) and warn so the operator notices the contradiction.
  if [[ "$SKIP_VERIFY" == "true" && "$VERIFY_IMAGES" == "true" ]]; then
    print_warn "--skip-verify overrides --verify-images; per-image cosign verify will not run."
    VERIFY_IMAGES="false"
  fi

  if [[ "$VERIFY_IMAGES" == "true" ]]; then
    export OLAKAI_VERIFY_IMAGES=true
    print_info "Propagating --verify-images to bundle install.sh (per-image cosign verify enabled on top of bundle verification)."
  fi

  # Close our borrowed /dev/tty (fd 3, only open under `curl | bash`) before
  # handing off so the bundle's install.sh starts with a clean fd table.
  # `3<&-` is a harmless no-op when fd 3 was never opened (TTY_FD=0 / "").
  print_info "Handing off to ${installer}…"
  ( cd "$INSTALL_DIR" && bash install.sh 3<&- )
}

# ──────────────────────────────────────────────────────────────────────────────
# Success banner (D-022 Option B — always print both magic-link sent +
# bootstrap-log fallback URL, unless --quiet)
# ──────────────────────────────────────────────────────────────────────────────

_extract_setup_url() {
  # The bootstrap container always logs `Setup URL: …` on first boot
  # (per OLA-120). docker compose's project-namespaced container is
  # `olakai-bootstrap` (not `olakai-bootstrap-1`) under v2 with
  # COMPOSE_PROJECT_NAME=olakai. We use `docker compose logs` so the
  # call is project-aware regardless of container naming.
  #
  # The bundle's install.sh returns when the *app* is healthy, but the
  # bootstrap container may still be minting the magic-link token at that
  # moment. Retry with a short timeout so the success banner reliably
  # contains the fallback URL (per D-022 Option B). 30s is well within
  # human attention span and gives bootstrap ample time after app-healthy.
  local url="" i
  for i in 1 2 3 4 5 6; do
    url="$( ( cd "$INSTALL_DIR" \
        && docker compose logs --tail 200 olakai-bootstrap 2>/dev/null \
        | grep -oE 'https?://[^[:space:]]+/setup/admin\?token=[^[:space:]]+' \
        | tail -n 1 ) )"
    if [[ -n "$url" ]]; then
      printf '%s' "$url"
      return 0
    fi
    # 5s × 6 = 30s total budget. The bundle's install.sh waits for app
    # health before returning; bootstrap is typically just behind it.
    if (( i < 6 )); then sleep 5; fi
  done
  return 1
}

print_success_banner() {
  local proto="https"
  [[ "$NO_TLS" == "true" ]] && proto="http"
  local dashboard_url="${proto}://${DOMAIN}"
  local fallback_url=""
  if [[ "$QUIET" != "true" ]]; then
    fallback_url="$(_extract_setup_url || true)"
  fi

  printf '\n'
  printf '%s%s✓ Olakai is up at %s%s\n' "$C_BOLD" "$C_GREEN" "$dashboard_url" "$C_RESET"
  printf '\n'
  printf '  Magic link sent to %s%s%s.\n' "$C_BOLD" "$ADMIN_EMAIL" "$C_RESET"
  if [[ -n "$fallback_url" ]]; then
    printf "  If it doesn't arrive within a minute, you can also use:\n"
    printf '    %s\n' "$fallback_url"
    printf '\n'
    printf '  This link expires in 24 hours.\n'
  elif [[ "$QUIET" != "true" ]]; then
    # Per D-022 Option B we promise both lines by default. If the grep
    # genuinely found nothing (rare race or non-default bootstrap
    # configuration), tell the operator how to retrieve it manually
    # rather than silently dropping the contract.
    printf "  If the magic link doesn't arrive, retrieve the fallback URL with:\n"
    printf '    cd %s && docker compose logs olakai-bootstrap | grep "Setup URL"\n' "$INSTALL_DIR"
  fi
  printf '\n'
  printf '  Docs:    https://docs.olakai.ai/on-prem/install\n'
  printf '  Support: support@olakai.ai\n'
  printf '\n'
}

# ──────────────────────────────────────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────────────────────────────────────

main() {
  parse_args "$@"

  printf '%solakai on-prem installer%s (script v%s, cosign %s)\n\n' \
    "$C_BOLD" "$C_RESET" "$GET_SH_VERSION" "$COSIGN_VERSION"

  WORK_DIR="$(mktemp -d -t olakai-install.XXXXXX)"

  print_step "1/8 Pre-flight"
  detect_os
  check_root
  check_resources
  check_or_install_docker

  print_step "2/8 Cosign bootstrap"
  ensure_cosign

  print_step "3/8 Inputs"
  collect_inputs

  print_step "4/8 License handshake"
  do_handshake

  print_step "5/8 Download + verify"
  download_and_verify

  print_step "6/8 Extract"
  extract_bundle

  print_step "7/8 .env"
  materialize_env

  print_step "8/8 Bring up"
  bring_up

  print_success_banner
}

main "$@"
