#!/bin/sh
set -eu

RELEASE_HOST="${BOWLINE_RELEASE_HOST:-https://install.bowline.sh}"
CANDIDATE_URL=""
AGENT_HOST="0"
RESET_INSTALL="0"
INSTALL_DIR="${BOWLINE_INSTALL_DIR:-$HOME/.local/bin}"
APP_DIR="${BOWLINE_APP_DIR:-$HOME/Applications}"
RELEASE_SIGNING_IDENTITY="bowline-release"
RELEASE_SIGNING_NAMESPACE="bowline-release"
RELEASE_SIGNING_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF4Nfjn9iT+NwvF2JpRj9GQAwkjv0Cpp16LXmA+AzBwP bowline-release-2026-07-23"

fail() { echo "bowline install failed: $*" >&2; exit 1; }
note() { echo "bowline install: $*" >&2; }
usage() {
  cat <<'EOF'
Usage: install.sh [--agent-host] [--candidate-url URL] [--reset]

Installs Bowline into a machine with no existing Bowline installation.
This installer does not upgrade, migrate, preserve, or replace an installation.
Agent-host mode installs the signed CLI and daemon without a GUI application.
Reset verifies a signed candidate first, then removes only the generated Bowline
installation and local Bowline state before performing a clean genesis install.
Workspace source files are never removed.
EOF
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --candidate-url) [ "$#" -ge 2 ] || fail "--candidate-url requires a value"; CANDIDATE_URL="${2%/}"; shift 2 ;;
    --candidate-url=*) CANDIDATE_URL="${1#*=}"; CANDIDATE_URL="${CANDIDATE_URL%/}"; shift ;;
    --agent-host) AGENT_HOST="1"; shift ;;
    --reset) RESET_INSTALL="1"; shift ;;
    -h|--help) usage; exit 0 ;;
    *) fail "unknown argument: $1" ;;
  esac
done

need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; }
need curl
need mktemp
need ssh-keygen

case "$(uname -s):$(uname -m)" in
  Darwin:arm64) PLATFORM="macos"; TARGET="aarch64-apple-darwin" ;;
  Linux:x86_64) PLATFORM="linux"; TARGET="x86_64-unknown-linux-gnu" ;;
  Linux:aarch64|Linux:arm64) PLATFORM="linux"; TARGET="aarch64-unknown-linux-gnu" ;;
  *) fail "unsupported platform $(uname -s)/$(uname -m)" ;;
esac

reject_existing_install() {
  for path in "$INSTALL_DIR/bowline" "$INSTALL_DIR/bowline-daemon"; do
    if [ -e "$path" ] || [ -L "$path" ]; then
      fail "existing Bowline installation at $path; remove it explicitly before a clean install"
    fi
  done
  if [ "$AGENT_HOST" != "1" ]; then
    if [ -e "$APP_DIR/Bowline.app" ] || [ -L "$APP_DIR/Bowline.app" ]; then
      fail "existing Bowline installation at $APP_DIR/Bowline.app; remove it explicitly before a clean install"
    fi
  fi
  if [ "$PLATFORM" = "macos" ] && launchctl print "gui/$(id -u)/io.bowline.daemon" >/dev/null 2>&1; then
    fail "existing Bowline daemon service is loaded; remove it explicitly before a clean install"
  fi
  if [ "$PLATFORM" = "linux" ]; then
    [ ! -e "$HOME/.config/systemd/user/bowline.service" ] || fail "existing Bowline daemon service definition; remove it explicitly before a clean install"
    if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active --quiet bowline.service 2>/dev/null; then
      fail "existing Bowline daemon service is loaded; remove it explicitly before a clean install"
    fi
  fi
}

reset_existing_install() {
  if [ -x "$INSTALL_DIR/bowline" ]; then
    "$INSTALL_DIR/bowline" daemon uninstall --json >/dev/null 2>&1 || true
  fi
  case "$PLATFORM" in
    macos)
      pkill -x Bowline >/dev/null 2>&1 || true
      launchctl bootout "gui/$(id -u)/io.bowline.daemon" >/dev/null 2>&1 || true
      if launchctl print "gui/$(id -u)/io.bowline.daemon" >/dev/null 2>&1; then
        fail "could not stop the existing Bowline daemon service"
      fi
      rm -f "$HOME/Library/LaunchAgents/io.bowline.daemon.plist"
      STATE_ROOT="$HOME/Library/Application Support/bowline"
      ;;
    linux)
      if command -v systemctl >/dev/null 2>&1; then
        systemctl --user disable --now bowline.service >/dev/null 2>&1 || true
        if systemctl --user is-active --quiet bowline.service 2>/dev/null; then
          fail "could not stop the existing Bowline daemon service"
        fi
      fi
      rm -f "$HOME/.config/systemd/user/bowline.service"
      STATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/bowline"
      ;;
    *) STATE_ROOT="$HOME/.bowline" ;;
  esac
  rm -f "$INSTALL_DIR/bowline" "$INSTALL_DIR/bowline-daemon"
  rm -rf "$APP_DIR/Bowline.app"
  rm -rf "$STATE_ROOT"
}

TMPDIR="$(mktemp -d 2>/dev/null || mktemp -d -t bowline-install)"
CREATED_APP="0"
CREATED_CLI="0"
CREATED_DAEMON="0"
CREATED_SERVICE="0"
INSTALL_COMMITTED="0"
cleanup() {
  if [ "$INSTALL_COMMITTED" != "1" ]; then
    [ "$CREATED_SERVICE" != "1" ] || "$INSTALL_DIR/bowline" daemon uninstall --json >/dev/null 2>&1 || true
    [ "$CREATED_CLI" != "1" ] || rm -f "$INSTALL_DIR/bowline"
    [ "$CREATED_DAEMON" != "1" ] || rm -f "$INSTALL_DIR/bowline-daemon"
    [ "$CREATED_APP" != "1" ] || rm -rf "$APP_DIR/Bowline.app"
  fi
  rm -rf "$TMPDIR"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

download() { note "download $(basename "$2")"; curl -fL --retry 3 --retry-delay 1 -o "$2" "$1"; }
verify_signature() {
  printf '%s %s\n' "$RELEASE_SIGNING_IDENTITY" "$RELEASE_SIGNING_PUBKEY" >"$TMPDIR/allowed-signers"
  ssh-keygen -Y verify -f "$TMPDIR/allowed-signers" -I "$RELEASE_SIGNING_IDENTITY" -n "$RELEASE_SIGNING_NAMESPACE" -s "$2" <"$1" >/dev/null 2>&1 || fail "signature verification failed for $(basename "$1")"
}
sha256() {
  if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}';
  elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
  else fail "shasum or sha256sum is required"; fi
}
json_string() { sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\1/p" "$1" | head -n 1; }
artifact_digest() {
  tr '{' '\n' <"$1" | awk -v key="\"key\":\"$2\"" '
    index($0, key) {
      value = $0
      sub(/.*"digest":"/, "", value)
      sub(/".*/, "", value)
      print value
      found = 1
      exit
    }
    END { if (!found) exit 2 }
  ' || fail "release root is missing $2"
}
verify_digest() {
  actual="sha256:$(sha256 "$1")"
  [ "$actual" = "$2" ] || fail "digest mismatch for $(basename "$1")"
}

[ "$RESET_INSTALL" = "1" ] || reject_existing_install

ROOT="$TMPDIR/release-root.json"
if [ -n "$CANDIDATE_URL" ]; then
  BASE="$CANDIDATE_URL"
else
  SIGNED_POINTER="$TMPDIR/release-pointer.signed"
  POINTER="$TMPDIR/release-pointer.json"
  download "$RELEASE_HOST/latest/release-pointer" "$SIGNED_POINTER"
  sed -n '1p' "$SIGNED_POINTER" >"$POINTER"
  sed -n '2,$p' "$SIGNED_POINTER" >"$POINTER.sig"
  if [ ! -s "$POINTER" ] || [ ! -s "$POINTER.sig" ]; then
    fail "signed release pointer is malformed"
  fi
  verify_signature "$POINTER" "$POINTER.sig"
  ROOT_KEY="$(json_string "$POINTER" releaseRootKey)"
  [ -n "$ROOT_KEY" ] || fail "release pointer is missing releaseRootKey"
  case "$ROOT_KEY" in
    releases/v*/release-root.json) ;;
    *) fail "release pointer contains an invalid release root key" ;;
  esac
  ROOT_DIGEST="$(json_string "$POINTER" releaseRootDigest)"
  [ -n "$ROOT_DIGEST" ] || fail "release pointer is missing releaseRootDigest"
  BASE="$RELEASE_HOST/$(dirname "$ROOT_KEY")"
fi
download "$BASE/release-root.json" "$ROOT"
download "$BASE/release-root.json.sig" "$ROOT.sig"
verify_signature "$ROOT" "$ROOT.sig"
[ -z "${ROOT_DIGEST:-}" ] || verify_digest "$ROOT" "$ROOT_DIGEST"
VERSION="$(json_string "$ROOT" version)"
echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || fail "release root version is invalid"

mkdir -p "$TMPDIR/stage"
if [ "$PLATFORM" = "macos" ]; then
  need ditto
  need codesign
  need unzip
  KEY="releases/v$(json_string "$ROOT" version)/Bowline-$TARGET.app.zip"
  ZIP="$TMPDIR/Bowline-$TARGET.app.zip"
  download "$BASE/Bowline-$TARGET.app.zip" "$ZIP"
  verify_digest "$ZIP" "$(artifact_digest "$ROOT" "$KEY")"
  unzip -Z1 "$ZIP" >"$TMPDIR/zip-members"
  awk '
    !length($0) || $0 ~ /^\// || $0 ~ /(^|\/)\.\.($|\/)/ || $0 !~ /^Bowline\.app\// { exit 1 }
    seen[$0]++ { exit 1 }
    END { if (NR == 0) exit 1 }
  ' "$TMPDIR/zip-members" || fail "candidate app archive has an unsafe inventory"
  ditto -x -k "$ZIP" "$TMPDIR/stage"
  [ -d "$TMPDIR/stage/Bowline.app/Contents" ] || fail "candidate has no Bowline.app"
  [ -z "$(find "$TMPDIR/stage/Bowline.app" -type l -print -quit)" ] || fail "candidate app archive contains a symbolic link"
  codesign --verify --deep --strict "$TMPDIR/stage/Bowline.app" >/dev/null 2>&1 || fail "candidate app signature is invalid"
  CLI="$TMPDIR/stage/Bowline.app/Contents/Resources/bin/bowline"
  DAEMON="$TMPDIR/stage/Bowline.app/Contents/Resources/bin/bowline-daemon"
else
  need tar
  KEY="releases/v$(json_string "$ROOT" version)/bowline-$TARGET.tar.gz"
  ARCHIVE="$TMPDIR/bowline-$TARGET.tar.gz"
  download "$BASE/bowline-$TARGET.tar.gz" "$ARCHIVE"
  verify_digest "$ARCHIVE" "$(artifact_digest "$ROOT" "$KEY")"
  tar -tzf "$ARCHIVE" >"$TMPDIR/tar-members"
  awk '
    $0 == "bowline" { cli++ }
    $0 == "bowline-daemon" { daemon++ }
    $0 != "bowline" && $0 != "bowline-daemon" { exit 1 }
    END { if (NR != 2 || cli != 1 || daemon != 1) exit 1 }
  ' "$TMPDIR/tar-members" || fail "candidate CLI archive has an unsafe inventory"
  tar -tvzf "$ARCHIVE" >"$TMPDIR/tar-details"
  awk 'substr($1, 1, 1) != "-" { exit 1 } END { if (NR != 2) exit 1 }' "$TMPDIR/tar-details" || fail "candidate CLI archive contains a link or non-file member"
  tar -xzf "$ARCHIVE" -C "$TMPDIR/stage" -- bowline bowline-daemon
  CLI="$TMPDIR/stage/bowline"
  DAEMON="$TMPDIR/stage/bowline-daemon"
fi
[ -x "$CLI" ] || fail "candidate is missing executable bowline"
[ -x "$DAEMON" ] || fail "candidate is missing executable bowline-daemon"
CLI_VERSION="$("$CLI" version --json 2>/dev/null | sed -nE 's/.*"cliVersion"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
DAEMON_VERSION="$("$DAEMON" --version 2>/dev/null | awk 'NR == 1 { print $2 }')"
[ "$CLI_VERSION" = "$VERSION" ] || fail "candidate CLI identity does not match its release root"
[ "$DAEMON_VERSION" = "$VERSION" ] || fail "candidate daemon identity does not match its release root"

# Destructive reset happens only after every candidate byte and identity has
# passed signature, digest, inventory, and executable checks.
[ "$RESET_INSTALL" != "1" ] || reset_existing_install
reject_existing_install

mkdir -p "$INSTALL_DIR"
if [ "$PLATFORM" = "macos" ] && [ "$AGENT_HOST" != "1" ]; then
  mkdir -p "$APP_DIR"
  mv -n "$TMPDIR/stage/Bowline.app" "$APP_DIR/Bowline.app"
  [ ! -e "$TMPDIR/stage/Bowline.app" ] || fail "another Bowline installation appeared during install"
  CREATED_APP="1"
  ln -s "$APP_DIR/Bowline.app/Contents/Resources/bin/bowline" "$INSTALL_DIR/bowline"
  CREATED_CLI="1"
  ln -s "$APP_DIR/Bowline.app/Contents/Resources/bin/bowline-daemon" "$INSTALL_DIR/bowline-daemon"
  CREATED_DAEMON="1"
else
  mv -n "$CLI" "$INSTALL_DIR/bowline"
  [ ! -e "$CLI" ] || fail "another Bowline CLI appeared during install"
  CREATED_CLI="1"
  mv -n "$DAEMON" "$INSTALL_DIR/bowline-daemon"
  [ ! -e "$DAEMON" ] || fail "another Bowline daemon appeared during install"
  CREATED_DAEMON="1"
fi

CREATED_SERVICE="1"
"$INSTALL_DIR/bowline" daemon install --json >/dev/null
DAEMON_STATE=""
DAEMON_SERVICE_STATE=""
RUNNING_DAEMON_VERSION=""
STARTUP_DEADLINE=$(( $(date +%s) + 30 ))
while [ "$(date +%s)" -lt "$STARTUP_DEADLINE" ]; do
  STATUS_JSON="$("$INSTALL_DIR/bowline" daemon status --json 2>/dev/null || true)"
  DAEMON_STATE="$(printf '%s\n' "$STATUS_JSON" | sed -nE 's/.*"daemon":\{[^}]*"state":"([^"]+)".*/\1/p' | head -n 1)"
  RUNNING_DAEMON_VERSION="$(printf '%s\n' "$STATUS_JSON" | sed -nE 's/.*"daemon":\{[^}]*"daemonVersion":"([^"]+)".*/\1/p' | head -n 1)"
  DAEMON_SERVICE_STATE="$(printf '%s\n' "$STATUS_JSON" | sed -nE 's/.*"service":\{[^}]*"state":"([^"]+)".*/\1/p' | head -n 1)"
  if [ "$DAEMON_STATE" = "running" ] && [ "$RUNNING_DAEMON_VERSION" = "$VERSION" ] && [ "$DAEMON_SERVICE_STATE" = "active" ]; then
    break
  fi
  sleep 0.25
done
[ "$DAEMON_STATE" = "running" ] || fail "installed daemon did not become reachable"
[ "$RUNNING_DAEMON_VERSION" = "$VERSION" ] || fail "running daemon identity does not match its release root"
[ "$DAEMON_SERVICE_STATE" = "active" ] || fail "installed daemon service did not become healthy"
if [ "$PLATFORM" = "macos" ] && [ "$AGENT_HOST" != "1" ]; then
  open "$APP_DIR/Bowline.app"
fi

INSTALL_COMMITTED="1"
note "installed clean candidate successfully"
