#!/usr/bin/env bash
# Ortaic customer install + update + uninstall script.
# Track 1c40b221 — M9 fresh path + M10 update/uninstall/reinstall/version/changelog.
#
# Served from https://install.ortaic.com (Caddy -> nginx -> install-server).
# Spec: KB be5ef360 §6 (single source of truth).
#
# Usage (fresh install):
#   curl -fsSL https://install.ortaic.com | sudo bash -s -- \
#       --domain example.com --license-key XXXX-XXXX-XXXX-XXXX
#
# Usage (update existing install):
#   curl -fsSL https://install.ortaic.com | sudo bash -s -- --update
#
# Usage (uninstall, data preserved):
#   curl -fsSL https://install.ortaic.com | sudo bash -s -- --uninstall

set -euo pipefail

# --- defaults --------------------------------------------------------------
INSTALL_DIR="/opt/ortaic"
DOMAIN=""
LICENSE_KEY=""
TARGET_VERSION="latest"
MOTHERSHIP_URL="${ORTAIC_MOTHERSHIP_URL:-https://ms.ortaic.com}"
IMAGE_REPO="${ORTAIC_IMAGE_REPO:-reg.ortaic.com/ortaic-core}"
REGISTRY_URL="${ORTAIC_REGISTRY_URL:-reg.ortaic.com}"
REGISTRY_LOGIN_USER="customer"
SKIP_DOCKER_INSTALL=false
DRY_RUN=false
MODE="fresh"          # fresh | update | uninstall | reinstall | version | changelog
KEEP_DATA=true        # default for --uninstall and --reinstall
SHOW_CHANGELOG=false  # --update prints changelog before applying when true
CONTAINER_NAME="ortaic-core"
HEALTH_TIMEOUT=60

# --- colors ----------------------------------------------------------------
if [[ -t 1 ]]; then
  GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; RED=$'\033[0;31m'; BLUE=$'\033[0;34m'; NC=$'\033[0m'
else
  GREEN=""; YELLOW=""; RED=""; BLUE=""; NC=""
fi

log()   { printf '%s[ortaic]%s %s\n' "$GREEN" "$NC" "$*"; }
warn()  { printf '%s[warn]%s %s\n'   "$YELLOW" "$NC" "$*" >&2; }
err()   { printf '%s[error]%s %s\n'  "$RED" "$NC" "$*" >&2; }
die()   { err "$*"; exit 1; }
step()  { printf '%s→%s %s\n'    "$BLUE" "$NC" "$*"; }

# --- usage -----------------------------------------------------------------
usage() {
  cat <<'USAGE'
Ortaic install / update / uninstall script.

Fresh install:
  sudo bash install.sh --domain DOMAIN --license-key KEY [--install-dir DIR]

Update existing install:
  sudo bash install.sh --update [--target-version VER] [--changelog]

Version info:
  sudo bash install.sh --version       Show installed + latest available
  sudo bash install.sh --changelog     Show changelog for pending versions

Uninstall / reinstall:
  sudo bash install.sh --uninstall     Tear down (data preserved by --keep-data)
  sudo bash install.sh --reinstall     Wipe code, redo fresh install (data kept if --keep-data)

Flags:
  --install-dir DIR        Install directory (default: /opt/ortaic)
  --target-version VER     Image tag to install/update to (default: latest)
  --skip-docker-install    Don't auto-install Docker / compose
  --keep-data              Keep ./data volume on uninstall/reinstall (default: true)
  --no-keep-data           Wipe ./data on uninstall/reinstall (destructive)
  --dry-run                Print the steps but do not mutate the system
  --mothership-url URL     Override mothership base URL (default: https://ms.ortaic.com)
  --image-repo REPO        Override image repo (default: reg.ortaic.com/ortaic-core)
  --registry-url URL       Override private registry hostname (default: reg.ortaic.com)
  -h, --help               Show this help
USAGE
}

# --- arg parsing -----------------------------------------------------------
parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --domain)              DOMAIN="$2"; shift 2 ;;
      --license-key)         LICENSE_KEY="$2"; shift 2 ;;
      --install-dir)         INSTALL_DIR="$2"; shift 2 ;;
      --target-version)      TARGET_VERSION="$2"; shift 2 ;;
      --mothership-url)      MOTHERSHIP_URL="$2"; shift 2 ;;
      --image-repo)          IMAGE_REPO="$2"; shift 2 ;;
      --registry-url)        REGISTRY_URL="$2"; shift 2 ;;
      --skip-docker-install) SKIP_DOCKER_INSTALL=true; shift ;;
      --update)              MODE="update"; shift ;;
      --uninstall)           MODE="uninstall"; shift ;;
      --reinstall)           MODE="reinstall"; shift ;;
      --version)             MODE="version"; shift ;;
      --changelog)           if [[ "$MODE" == "update" ]]; then SHOW_CHANGELOG=true; else MODE="changelog"; fi; shift ;;
      --keep-data)           KEEP_DATA=true; shift ;;
      --no-keep-data)        KEEP_DATA=false; shift ;;
      --dry-run)             DRY_RUN=true; shift ;;
      -h|--help)             usage; exit 0 ;;
      *)                     die "Unknown option: $1 (try --help)" ;;
    esac
  done
}

run() {
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s %s\n' "$YELLOW" "$NC" "$*"
  else
    eval "$@"
  fi
}

# --- preflight -------------------------------------------------------------
require_linux() {
  step "Verifying Linux host"
  local kernel
  kernel="$(uname -s)"
  if [[ "$kernel" != "Linux" ]]; then
    die "Ortaic install supports Linux only in v1. Detected: $kernel."
  fi
  if [[ ! -f /etc/os-release ]]; then
    die "Cannot determine distribution (/etc/os-release missing)."
  fi
  # shellcheck source=/dev/null
  source /etc/os-release
  local distro="${ID:-unknown}"
  case "$distro" in
    ubuntu|debian|centos|rhel|fedora|rocky|almalinux) log "Distro: ${PRETTY_NAME:-$distro}" ;;
    *) warn "Distro $distro is untested. Continuing." ;;
  esac
}

require_root() {
  step "Verifying sudo / root"
  if [[ $EUID -ne 0 ]]; then
    die "install.sh must run as root (use sudo)."
  fi
}

require_network() {
  step "Verifying network reachability of $MOTHERSHIP_URL"
  if ! curl --silent --show-error --fail --max-time 10 \
        --output /dev/null "$MOTHERSHIP_URL/api/license/health"; then
    die "Cannot reach mothership at $MOTHERSHIP_URL/api/license/health. Check DNS / firewall."
  fi
  log "Mothership reachable."
}

require_fresh_args() {
  [[ -n "$DOMAIN" ]]      || die "--domain is required for a fresh install."
  [[ -n "$LICENSE_KEY" ]] || die "--license-key is required for a fresh install."
  if ! [[ "$DOMAIN" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
    die "--domain '$DOMAIN' does not look like a valid FQDN."
  fi
  if ! [[ "$LICENSE_KEY" =~ ^[A-Za-z0-9-]{8,}$ ]]; then
    die "--license-key '$LICENSE_KEY' must be 8+ alphanumeric/dash characters."
  fi
}

require_install_dir() {
  [[ -d "$INSTALL_DIR" ]] || die "$INSTALL_DIR does not exist. Run a fresh install first."
  [[ -f "$INSTALL_DIR/docker-compose.yml" ]] || die "$INSTALL_DIR is not a valid Ortaic install (missing docker-compose.yml)."
}

# --- docker bootstrap ------------------------------------------------------
ensure_docker() {
  step "Detecting Docker engine"
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    log "Docker present: $(docker --version)"
    return 0
  fi
  if [[ "$SKIP_DOCKER_INSTALL" == "true" ]]; then
    die "Docker not installed and --skip-docker-install set. Install Docker manually and re-run."
  fi
  warn "Docker missing — installing via https://get.docker.com"
  run "curl -fsSL https://get.docker.com | sh"
  run "systemctl enable --now docker"
  if ! docker info >/dev/null 2>&1; then
    die "Docker installed but daemon is not running. Investigate 'systemctl status docker'."
  fi
  log "Docker installed: $(docker --version)"
}

ensure_compose() {
  step "Detecting Docker Compose plugin"
  if docker compose version >/dev/null 2>&1; then
    log "Compose plugin present: $(docker compose version | head -n1)"
    return 0
  fi
  if [[ "$SKIP_DOCKER_INSTALL" == "true" ]]; then
    die "Compose plugin missing and --skip-docker-install set."
  fi
  warn "Compose plugin missing — installing via OS package manager"
  if command -v apt-get >/dev/null 2>&1; then
    run "apt-get update -qq"
    run "apt-get install -y docker-compose-plugin"
  elif command -v dnf >/dev/null 2>&1; then
    run "dnf install -y docker-compose-plugin"
  elif command -v yum >/dev/null 2>&1; then
    run "yum install -y docker-compose-plugin"
  else
    die "No supported package manager found. Install docker-compose-plugin manually."
  fi
  if ! docker compose version >/dev/null 2>&1; then
    die "docker compose still unavailable after install attempt."
  fi
  log "Compose plugin installed."
}

# --- templating ------------------------------------------------------------
# write_compose_file OUT IMAGE_TAG DOMAIN
# License key + per-customer vars live in .env (env_file) at runtime,
# NOT in the compose YAML itself. Defense-in-depth (bd901055 §5).
write_compose_file() {
  local out="$1"
  local image_tag="$2"
  local domain="$3"
  cat > "$out" <<COMPOSE
# Generated by install.sh for ${domain} on $(date -u +%FT%TZ).
# Edit at your own risk; install.sh --update regenerates this file.
services:
  caddy:
    image: caddy:2.10-alpine
    container_name: ortaic-caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy-data:/data
      - ./caddy-config:/config
    networks:
      - ortaic

  ortaic:
    image: ${IMAGE_REPO}:${image_tag}
    container_name: ${CONTAINER_NAME}
    restart: unless-stopped
    env_file:
      - .env
    environment:
      - PORT=6915
    volumes:
      - ./data:/data
      - ./logs:/logs
    expose:
      - "6915"
      - "6916"
    networks:
      - ortaic

networks:
  ortaic:
    driver: bridge
COMPOSE
}

write_caddyfile() {
  local out="$1"
  local domain="$2"
  cat > "$out" <<CADDY
# Generated by install.sh for ${domain} on $(date -u +%FT%TZ).
# Caddy obtains Let's Encrypt certificates automatically on first request.
${domain} {
    encode gzip
    reverse_proxy ortaic:6915
}

www.${domain} {
    redir https://${domain}{uri} permanent
}
CADDY
}

# --- health + state ---------------------------------------------------------
wait_for_health() {
  local timeout="${1:-$HEALTH_TIMEOUT}"
  step "Waiting for ${CONTAINER_NAME} health (max ${timeout}s)"
  if [[ "$DRY_RUN" == "true" ]]; then return 0; fi
  local deadline=$(( $(date +%s) + timeout ))
  while (( $(date +%s) < deadline )); do
    if docker exec "$CONTAINER_NAME" curl --silent --fail --max-time 3 \
        http://localhost:6915/health >/dev/null 2>&1; then
      log "${CONTAINER_NAME} is healthy."
      return 0
    fi
    sleep 2
  done
  return 1
}

read_installed_version() {
  if [[ -f "$INSTALL_DIR/.version" ]]; then
    tr -d '\n' < "$INSTALL_DIR/.version"
  else
    echo "unknown"
  fi
}

cache_signing_pub() {
  # Copy the bundled signing.pub out of the running container so updates can
  # verify manifests without re-trusting the mothership response.
  if [[ "$DRY_RUN" == "true" ]]; then return 0; fi
  if docker exec "$CONTAINER_NAME" test -f /opt/ortaic/signing.pub 2>/dev/null; then
    docker cp "${CONTAINER_NAME}:/opt/ortaic/signing.pub" "$INSTALL_DIR/.signing.pub" >/dev/null 2>&1 || true
  fi
}

record_version() {
  local ver="$1"
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s would write %s/.version <- %s\n' "$YELLOW" "$NC" "$INSTALL_DIR" "$ver"
    return 0
  fi
  # Keep previous version for rollback fallback.
  if [[ -f "$INSTALL_DIR/.version" ]]; then
    cp "$INSTALL_DIR/.version" "$INSTALL_DIR/.version.prev"
  fi
  printf '%s\n' "$ver" > "$INSTALL_DIR/.version"
  chmod 0644 "$INSTALL_DIR/.version"
}

restore_previous_version() {
  [[ -f "$INSTALL_DIR/.version.prev" ]] || return 1
  mv "$INSTALL_DIR/.version.prev" "$INSTALL_DIR/.version"
}

# --- env file reader -------------------------------------------------------
# Read a KEY=VALUE line from $INSTALL_DIR/.env. Returns empty string if
# the key is absent. Used by update_install + reinstall_install so we can
# regenerate compose without needing --license-key on the CLI again.
read_env_var() {
  local key="$1"
  local envfile="$INSTALL_DIR/.env"
  [[ -f "$envfile" ]] || return 0
  grep -E "^${key}=" "$envfile" 2>/dev/null | head -n1 | sed -E "s/^${key}=//"
}

# --- manifest fetch + verify -----------------------------------------------
fetch_manifest() {
  local out="$1"
  step "Fetching update manifest from $MOTHERSHIP_URL/api/updates/manifest"
  if ! curl --silent --show-error --fail --max-time 15 \
        "$MOTHERSHIP_URL/api/updates/manifest" -o "$out"; then
    die "Failed to fetch manifest from $MOTHERSHIP_URL/api/updates/manifest"
  fi
}

# verify_manifest_entry MANIFEST_JSON_PATH TARGET_VERSION
# Prints JSON {version, image, summary, changelog_url} for the verified entry.
# Exits non-zero on missing entry or signature failure.
verify_manifest_entry() {
  local manifest_path="$1"
  local target="$2"
  step "Verifying manifest signature via ${CONTAINER_NAME}"
  if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
    die "${CONTAINER_NAME} is not running; cannot verify manifest. Start the stack first."
  fi
  docker exec -i -e ORTAIC_TARGET_VERSION="$target" "$CONTAINER_NAME" python3 -c '
import json, os, sys
from lib.manifest_signing import verify_entry
try:
    pub = open("/opt/ortaic/signing.pub", "rb").read()
except FileNotFoundError:
    sys.stderr.write("signing.pub missing from image\n"); sys.exit(10)
manifest = json.load(sys.stdin)
target = os.environ.get("ORTAIC_TARGET_VERSION", "latest")
if target in ("latest", "", None):
    target = manifest.get("latest_version")
for v in manifest.get("versions", []):
    if v.get("version") != target:
        continue
    if not verify_entry(v, pub):
        sys.stderr.write("signature verification FAILED for {}\n".format(target)); sys.exit(11)
    print(json.dumps({
        "version": v["version"],
        "image": v.get("image"),
        "summary": v.get("summary", ""),
        "changelog_url": v.get("changelog_url", ""),
    }))
    sys.exit(0)
sys.stderr.write("target version {} not found in manifest\n".format(target)); sys.exit(12)
' < "$manifest_path"
}

manifest_field() {
  # Extract a top-level scalar field from raw JSON without jq. Used for
  # latest_version when verify_manifest_entry isn't applicable.
  local manifest_path="$1"
  local key="$2"
  python3 -c '
import json, sys
data = json.load(open(sys.argv[1]))
val = data.get(sys.argv[2], "")
print(val if isinstance(val, str) else json.dumps(val))
' "$manifest_path" "$key"
}

manifest_summaries() {
  # Print one line per version newer than $current (or all if current="unknown"),
  # most-recent first: "VERSION\tRELEASED\tSUMMARY\tCHANGELOG_URL".
  local manifest_path="$1"
  local current="$2"
  python3 -c '
import json, sys
m = json.load(open(sys.argv[1]))
current = sys.argv[2]
for v in m.get("versions", []):
    if current != "unknown" and v.get("version") == current:
        break
    print("\t".join([
        v.get("version", "?"),
        v.get("released", "?"),
        v.get("summary", ""),
        v.get("changelog_url", ""),
    ]))
' "$manifest_path" "$current"
}

# --- registry auth ---------------------------------------------------------
# Customer authenticates to the private registry with their license_key.
# The docker daemon performs the realm dance itself: it Basic-auths to
# $MOTHERSHIP_URL/api/registry/token with -u customer -p $LICENSE_KEY,
# receives a short-lived (300s) RS256 pull-scope JWT, and uses that JWT
# to authenticate subsequent /v2/* calls against $REGISTRY_URL. No manual
# JWT vend step is needed in install.sh.
#
# License must be ACTIVE; revoked/suspended/unknown licenses fail at the
# token vend with HTTP 401. The Bearer/Basic-admin paths exist but are not
# customer-facing (used by CI release.yml only).
#
# We logout immediately after pull so cached creds in ~/.docker/config.json
# do not linger. The JWT is short-lived anyway, but clearing creds matches
# the spec's defense-in-depth posture (bd901055 §2 + §4).
registry_login() {
  local license_key="$1"
  step "Logging into $REGISTRY_URL"
  if [[ "$DRY_RUN" == "true" ]]; then return 0; fi
  if ! printf '%s' "$license_key" | docker login "$REGISTRY_URL" -u "$REGISTRY_LOGIN_USER" --password-stdin >/dev/null 2>&1; then
    die "docker login $REGISTRY_URL failed. License may be invalid, suspended, or revoked."
  fi
}

registry_logout() {
  if [[ "$DRY_RUN" == "true" ]]; then return 0; fi
  docker logout "$REGISTRY_URL" >/dev/null 2>&1 || true
}

# --- env file --------------------------------------------------------------
# .env is the single source of customer-secret material on disk. Mode 0600
# (root-only). docker-compose reads it automatically for ${VAR} substitution
# inside docker-compose.yml. License key is NEVER baked into compose YAML.
write_env_file() {
  local out="$1"
  local license_key="$2"
  local domain="$3"
  local version="$4"
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s would write %s (mode 0600)\n' "$YELLOW" "$NC" "$out"
    return 0
  fi
  cat > "$out" <<ENVFILE
# Generated by install.sh for ${domain} on $(date -u +%FT%TZ).
# DO NOT commit this file. Mode 0600. Re-generated on every install/update.
LICENSE_KEY=${license_key}
DOMAIN=${domain}
MOTHERSHIP_URL=${MOTHERSHIP_URL}
REGISTRY_URL=${REGISTRY_URL}
ORTAIC_VERSION=${version}
ENVFILE
  chmod 0600 "$out"
}

# --- fresh install ---------------------------------------------------------
fresh_install() {
  require_fresh_args
  require_network
  ensure_docker
  ensure_compose

  step "Preparing $INSTALL_DIR"
  if [[ -d "$INSTALL_DIR" ]] && [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]]; then
    die "$INSTALL_DIR exists and is non-empty. Use --reinstall to overwrite or pick another --install-dir."
  fi
  run "mkdir -p '$INSTALL_DIR' '$INSTALL_DIR/data' '$INSTALL_DIR/logs' '$INSTALL_DIR/caddy-data' '$INSTALL_DIR/caddy-config'"

  registry_login "$LICENSE_KEY"
  step "Pulling image ${IMAGE_REPO}:${TARGET_VERSION}"
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s would docker pull %s:%s\n' "$YELLOW" "$NC" "$IMAGE_REPO" "$TARGET_VERSION"
  else
    if ! docker pull "${IMAGE_REPO}:${TARGET_VERSION}"; then
      registry_logout
      die "docker pull failed for ${IMAGE_REPO}:${TARGET_VERSION}. License may be revoked or version may not exist in registry."
    fi
  fi
  registry_logout

  step "Writing .env, docker-compose.yml + Caddyfile"
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s would write %s/.env (mode 0600)\n'    "$YELLOW" "$NC" "$INSTALL_DIR"
    printf '%s[dry-run]%s would write %s/docker-compose.yml\n' "$YELLOW" "$NC" "$INSTALL_DIR"
    printf '%s[dry-run]%s would write %s/Caddyfile\n'         "$YELLOW" "$NC" "$INSTALL_DIR"
  else
    write_env_file     "$INSTALL_DIR/.env"                 "$LICENSE_KEY" "$DOMAIN" "$TARGET_VERSION"
    write_compose_file "$INSTALL_DIR/docker-compose.yml" "$TARGET_VERSION" "$DOMAIN"
    write_caddyfile    "$INSTALL_DIR/Caddyfile"           "$DOMAIN"
  fi

  step "Booting stack via docker compose"
  run "cd '$INSTALL_DIR' && docker compose up -d"

  if ! wait_for_health 60; then
    die "${CONTAINER_NAME} did not become healthy within 60s. Check 'docker compose logs ortaic'."
  fi

  cache_signing_pub
  record_version "$TARGET_VERSION"

  printf '\n'
  log "✓ Ortaic installed at $INSTALL_DIR."
  log "✓ Domain: https://$DOMAIN (Caddy is provisioning a Let's Encrypt cert)."
  log "✓ Image: ${IMAGE_REPO}:${TARGET_VERSION}"
  log "Next: visit https://$DOMAIN to create the admin account and configure API keys."
}

# --- update -----------------------------------------------------------------
update_install() {
  require_install_dir
  require_network
  ensure_docker
  ensure_compose

  local current
  current="$(read_installed_version)"
  log "Currently installed: $current"

  local manifest_tmp
  manifest_tmp="$(mktemp -t ortaic-manifest.XXXXXX.json)"
  trap 'rm -f "$manifest_tmp"' RETURN
  fetch_manifest "$manifest_tmp"

  local latest
  latest="$(manifest_field "$manifest_tmp" latest_version)"
  log "Latest available: $latest"

  local target="$TARGET_VERSION"
  if [[ "$target" == "latest" ]]; then target="$latest"; fi

  if [[ "$current" == "$target" ]]; then
    log "Already at $current. Nothing to do."
    return 0
  fi

  local verified_entry
  if ! verified_entry="$(verify_manifest_entry "$manifest_tmp" "$target")"; then
    die "Manifest entry for $target did not verify. Refusing to update."
  fi

  local new_image
  new_image="$(printf '%s' "$verified_entry" | python3 -c 'import json,sys; print(json.load(sys.stdin)["image"])')"
  local new_summary
  new_summary="$(printf '%s' "$verified_entry" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("summary",""))')"

  if [[ "$SHOW_CHANGELOG" == "true" ]] || [[ -n "$new_summary" ]]; then
    log "Update summary for $target: $new_summary"
  fi

  # Read license_key + domain from .env (single source of truth post-pivot).
  local domain license_key
  domain="$(read_env_var DOMAIN)"
  license_key="$(read_env_var LICENSE_KEY)"
  [[ -n "$domain" ]]      || die "Could not read DOMAIN from $INSTALL_DIR/.env; cannot continue update."
  [[ -n "$license_key" ]] || die "Could not read LICENSE_KEY from $INSTALL_DIR/.env; cannot continue update."

  registry_login "$license_key"
  step "Pulling new image $new_image"
  if [[ "$DRY_RUN" == "true" ]]; then
    printf '%s[dry-run]%s would docker pull %s\n' "$YELLOW" "$NC" "$new_image"
  else
    if ! docker pull "$new_image"; then
      registry_logout
      die "docker pull failed for $new_image. License may have been revoked or registry is unreachable."
    fi
  fi
  registry_logout

  # Capture the image tag we'll roll back to if the new one is unhealthy.
  local prev_image
  prev_image="${IMAGE_REPO}:${current}"

  step "Rewriting .env + docker-compose.yml for new image tag"
  if [[ "$DRY_RUN" != "true" ]]; then
    write_env_file     "$INSTALL_DIR/.env"                 "$license_key" "$domain" "$target"
    write_compose_file "$INSTALL_DIR/docker-compose.yml" "$target" "$domain"
  fi

  step "Swapping ortaic container"
  run "cd '$INSTALL_DIR' && docker compose up -d ortaic"

  if ! wait_for_health 90; then
    warn "New image $new_image failed health check — rolling back to $prev_image"
    if [[ "$DRY_RUN" != "true" ]]; then
      write_env_file     "$INSTALL_DIR/.env"                 "$license_key" "$domain" "$current"
      write_compose_file "$INSTALL_DIR/docker-compose.yml" "$current" "$domain"
      (cd "$INSTALL_DIR" && docker compose up -d ortaic)
    fi
    if ! wait_for_health 90; then
      die "Rollback also failed. Site is down. Inspect 'docker compose logs ortaic'."
    fi
    die "Update to $target failed; rolled back to $current."
  fi

  cache_signing_pub
  record_version "$target"

  printf '\n'
  log "✓ Ortaic updated $current → $target"
  log "✓ Image: $new_image"
  [[ -n "$new_summary" ]] && log "Notes: $new_summary"
}

# --- version + changelog ---------------------------------------------------
show_version() {
  require_install_dir
  require_network
  local current
  current="$(read_installed_version)"
  local manifest_tmp
  manifest_tmp="$(mktemp -t ortaic-manifest.XXXXXX.json)"
  trap 'rm -f "$manifest_tmp"' RETURN
  fetch_manifest "$manifest_tmp"
  local latest
  latest="$(manifest_field "$manifest_tmp" latest_version)"
  log "Installed: $current"
  log "Latest:    $latest"
  if [[ "$current" != "$latest" ]]; then
    log "Run 'sudo bash install.sh --update' to upgrade."
  else
    log "You are on the latest version."
  fi
}

show_changelog() {
  require_install_dir
  require_network
  local current
  current="$(read_installed_version)"
  local manifest_tmp
  manifest_tmp="$(mktemp -t ortaic-manifest.XXXXXX.json)"
  trap 'rm -f "$manifest_tmp"' RETURN
  fetch_manifest "$manifest_tmp"
  log "Changelog vs installed ($current):"
  local printed=0
  while IFS=$'\t' read -r ver released summary url; do
    [[ -z "$ver" ]] && continue
    printed=1
    printf '  • %s (%s) — %s\n' "$ver" "$released" "$summary"
    [[ -n "$url" ]] && printf '      details: %s\n' "$url"
  done < <(manifest_summaries "$manifest_tmp" "$current")
  if [[ "$printed" == "0" ]]; then
    log "No pending versions — you are up to date."
  fi
}

# --- uninstall + reinstall -------------------------------------------------
uninstall_install() {
  require_install_dir
  ensure_docker
  step "Stopping ortaic stack"
  run "cd '$INSTALL_DIR' && docker compose down"
  if [[ "$KEEP_DATA" == "true" ]]; then
    log "Code stopped. Data preserved at $INSTALL_DIR/data (--keep-data default)."
    log "Re-run with --no-keep-data to wipe."
  else
    warn "--no-keep-data set — wiping data, logs, compose files, .env."
    run "rm -rf '$INSTALL_DIR/data' '$INSTALL_DIR/logs' '$INSTALL_DIR/docker-compose.yml' '$INSTALL_DIR/Caddyfile' '$INSTALL_DIR/.env' '$INSTALL_DIR/.version' '$INSTALL_DIR/.version.prev' '$INSTALL_DIR/.signing.pub' '$INSTALL_DIR/caddy-data' '$INSTALL_DIR/caddy-config'"
    log "$INSTALL_DIR purged. Run a fresh install to reactivate."
  fi
}

reinstall_install() {
  # Stop + (optionally) wipe non-data code; then redo fresh install.
  step "Reinstall: stopping existing stack"
  if [[ -d "$INSTALL_DIR" ]] && [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then
    run "cd '$INSTALL_DIR' && docker compose down"
  fi
  step "Reinstall: clearing code files (data preserved=$KEEP_DATA)"
  if [[ "$DRY_RUN" != "true" ]]; then
    rm -f "$INSTALL_DIR/docker-compose.yml" "$INSTALL_DIR/Caddyfile" \
          "$INSTALL_DIR/.version" "$INSTALL_DIR/.version.prev" "$INSTALL_DIR/.signing.pub"
    rm -rf "$INSTALL_DIR/caddy-data" "$INSTALL_DIR/caddy-config"
    if [[ "$KEEP_DATA" != "true" ]]; then
      rm -rf "$INSTALL_DIR/data" "$INSTALL_DIR/logs"
    fi
  fi
  # If --domain / --license-key not supplied, try to recover from .env first
  # (post-pivot source of truth), then container env as legacy fallback.
  if [[ -z "$DOMAIN" || -z "$LICENSE_KEY" ]]; then
    local recovered_domain recovered_key
    recovered_domain="$(read_env_var DOMAIN)"
    recovered_key="$(read_env_var LICENSE_KEY)"
    if [[ -z "$recovered_domain" ]]; then
      recovered_domain="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER_NAME" 2>/dev/null | sed -n 's/^DOMAIN=//p' | head -n1 || true)"
    fi
    if [[ -z "$recovered_key" ]]; then
      recovered_key="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER_NAME" 2>/dev/null | sed -n 's/^LICENSE_KEY=//p' | head -n1 || true)"
    fi
    [[ -z "$DOMAIN"      && -n "$recovered_domain" ]] && DOMAIN="$recovered_domain"
    [[ -z "$LICENSE_KEY" && -n "$recovered_key"    ]] && LICENSE_KEY="$recovered_key"
  fi
  fresh_install
}

# --- main ------------------------------------------------------------------
main() {
  parse_args "$@"
  require_linux
  require_root

  case "$MODE" in
    fresh)     fresh_install ;;
    update)    update_install ;;
    uninstall) uninstall_install ;;
    reinstall) reinstall_install ;;
    version)   show_version ;;
    changelog) show_changelog ;;
    *)         die "Unknown mode: $MODE" ;;
  esac
}

main "$@"
