#!/bin/sh
# Virgil Mac installer (VRT-pef5.4 / P6.2).
#
# Usage: curl -fsSL https://virgil.coach/install.sh | sh
#
# Mirrors scripts/install.sh (gpu-cli) in shape. Diverges where the
# Virgil distribution model requires it: Apple-Silicon-only, signed
# manifest verification (Ed25519 detached over canonical bytes), per-
# version install tree under ~/Library/Application Support/Virgil/installs/,
# atomic installs/current promotion, and LaunchAgent registration via
# launchctl bootstrap.
#
# Environment variables:
#   VIRGIL_VERSION          Specific version to install (overrides channel).
#   VIRGIL_CHANNEL          Release channel (stable|beta|nightly). Default: stable.
#   VIRGIL_BUCKET           GCS bucket name (default: virgil-releases).
#   VIRGIL_INSTALL_TARBALL  Local tarball to install instead of downloading.
#                           When set the network fetch + sha256 step is skipped
#                           (the on-disk tarball is treated as authoritative).
#                           Manifest signature verification still runs.
#   VIRGIL_NO_OPEN          Set to 1 to skip auto-opening the browser SPA.
#   VIRGIL_NO_LAUNCHCTL     Set to 1 to skip launchctl bootstrap (testing).
#   VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE
#                           Path to a PEM file overriding the bundled
#                           current pubkey. Test/dev only.
#   VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE
#                           Path to a PEM file overriding the bundled
#                           next pubkey. Test/dev only.
#
# References:
#   crates/virgil/planning/vz-native-migration/distribution-and-updates.md
#     §11.1 (install path), §11.2 (on-disk layout), §11.3 (daemon
#     lifecycle), §11.6 (trust model — current+next pubkeys baked in).
#   crates/virgil/virgil-installer/src/manifest.rs (canonical bytes
#     contract; signature is over compact serde_json output).

set -eu

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

VIRGIL_BUCKET="${VIRGIL_BUCKET:-virgil-releases}"
BASE_URL="https://storage.googleapis.com/${VIRGIL_BUCKET}"
TARGET_TRIPLE="aarch64-apple-darwin"
ARCHIVE_NAME="virgil-${TARGET_TRIPLE}.tar.gz"

# Per-user install tree (matches install_paths.rs + paths.rs).
APP_SUPPORT="$HOME/Library/Application Support/Virgil"
INSTALLS_ROOT="$APP_SUPPORT/installs"
LOGS_DIR="$HOME/Library/Logs/Virgil"
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
LAUNCH_AGENT_LABEL="sh.virgil.desktop"
LAUNCH_AGENT_PLIST="$LAUNCH_AGENTS_DIR/${LAUNCH_AGENT_LABEL}.plist"
# VRT-060v — the HUD ships as a Helper bundle nested inside the
# parent Virgil.app at
#   installs/current/Virgil.app/Contents/Helpers/VirgilHelper.app/
# A second LaunchAgent supervises it independently of the daemon so
# either can crash + respawn without affecting the other. The HUD
# binary path stays in sync with the daemon binary path because both
# resolve through the same `installs/current` symlink — `virgil
# update` flips the symlink atomically and `launchctl kickstart -k`
# bounces both labels onto the new version.
HUD_LAUNCH_AGENT_LABEL="sh.virgil.hud"
HUD_LAUNCH_AGENT_PLIST="$LAUNCH_AGENTS_DIR/${HUD_LAUNCH_AGENT_LABEL}.plist"
LOCAL_BIN="$HOME/.local/bin"
DAEMON_SOCKET="$APP_SUPPORT/desktop.sock"

# Daemon-readiness poll. Mirror the bead's "200ms cadence, 5s budget"
# (~25 iterations).
DAEMON_POLL_INTERVAL_TENTHS=2
DAEMON_POLL_BUDGET_TENTHS=50

# ---------------------------------------------------------------------------
# Bundled release pubkeys (current + next)
# ---------------------------------------------------------------------------
#
# These are the verifying keys for the Virgil release signing pipeline,
# inlined from crates/virgil/virgil-installer/build/release-pubkey.{current,next}.pem.
# The body of each PEM is the base64 of the raw 32-byte Ed25519 public
# key. Either key validates a manifest signature; the verifier reports
# which slot matched (rotation tolerance window per
# distribution-and-updates.md §11.6).
#
# Rotation contract: when rolling pubkeys, generate a fresh `next` key
# and bake the new pubkey HERE (and in the matching Rust constants in
# virgil-installer/build/) one release before promoting it to `current`.
# A user installed N releases ago will accept signatures from either
# pubkey, so a single rotation cycle is non-breaking.

read_bundled_pubkey_current() {
    cat <<'PEM'
-----BEGIN VIRGIL ED25519 PUBLIC KEY-----
pcxINuxx7WsY63h5xvxtLAFaQ9Ya+nAstQzKtbqck9w=
-----END VIRGIL ED25519 PUBLIC KEY-----
PEM
}

read_bundled_pubkey_next() {
    cat <<'PEM'
-----BEGIN VIRGIL ED25519 PUBLIC KEY-----
e1YOHH+bN7qqVOiFgLTwVqCJUijh6iy2BOU/KQIrwGU=
-----END VIRGIL ED25519 PUBLIC KEY-----
PEM
}

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

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

info() {
    printf "${BLUE}==>${NC} ${BOLD}%s${NC}\n" "$1"
}

success() {
    printf "${GREEN}==>${NC} ${BOLD}%s${NC}\n" "$1"
}

warn() {
    printf "${YELLOW}Warning:${NC} %s\n" "$1"
}

error() {
    printf "${RED}Error:${NC} %s\n" "$1" >&2
    exit 1
}

# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------

TMP_DIR=""
cleanup() {
    if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
        rm -rf "$TMP_DIR"
    fi
}
trap cleanup EXIT INT TERM

# ---------------------------------------------------------------------------
# Platform refusal
# ---------------------------------------------------------------------------

require_apple_silicon_mac() {
    OS="$(uname -s)"
    if [ "$OS" != "Darwin" ]; then
        error "Virgil ships only for macOS (detected: $OS)."
    fi

    ARCH="$(uname -m)"
    case "$ARCH" in
        arm64|aarch64)
            ;;
        x86_64|amd64)
            error "Virgil requires an Apple Silicon Mac. Intel Mac support is not planned."
            ;;
        *)
            error "Unsupported macOS architecture: $ARCH"
            ;;
    esac
}

# ---------------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------------

require_dependencies() {
    for cmd in curl tar mkdir ln rm chmod codesign ruby; do
        if ! command -v "$cmd" >/dev/null 2>&1; then
            error "Required command not found: $cmd"
        fi
    done

    if command -v sha256sum >/dev/null 2>&1; then
        SHA256_CMD="sha256sum"
    elif command -v shasum >/dev/null 2>&1; then
        SHA256_CMD="shasum -a 256"
    else
        error "Required checksum tool not found (sha256sum or shasum)."
    fi

    # No openssl or python3 dep — manifest signature verification runs through
    # the staged `virgil` binary's `verify-manifest` subcommand
    # (native ed25519-dalek). The binary is codesign-verified against
    # APPLE_SIGNING_AUTHORITY before being trusted to do the check.
    # JSON reads use Ruby's stdlib parser because `/usr/bin/python3`
    # is a developer-tools shim on clean macOS installs.
}

# ---------------------------------------------------------------------------
# Channel + version resolution
# ---------------------------------------------------------------------------

validate_channel() {
    case "${VIRGIL_CHANNEL:-stable}" in
        stable|beta|nightly)
            ;;
        *)
            error "Invalid VIRGIL_CHANNEL: $VIRGIL_CHANNEL (use stable, beta, or nightly)"
            ;;
    esac
}

resolve_version() {
    if [ -n "${VIRGIL_VERSION:-}" ]; then
        VERSION="$VIRGIL_VERSION"
        info "Installing pinned version: $VERSION"
        return
    fi
    CHANNEL="${VIRGIL_CHANNEL:-stable}"
    info "Fetching latest $CHANNEL version from $BASE_URL/channels/$CHANNEL/version.txt"
    CACHE_BUST="$(date +%s)"
    VERSION_URL="${BASE_URL}/channels/${CHANNEL}/version.txt?t=${CACHE_BUST}"
    VERSION="$(curl -fsSL "$VERSION_URL" 2>/dev/null || true)"
    if [ -z "$VERSION" ]; then
        error "Failed to resolve $CHANNEL version from $VERSION_URL"
    fi
    info "Resolved $CHANNEL channel to $VERSION"
}

# ---------------------------------------------------------------------------
# Tarball acquisition + sha256
# ---------------------------------------------------------------------------

acquire_tarball() {
    TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/virgil-install.XXXXXX")"
    TARBALL_PATH="$TMP_DIR/$ARCHIVE_NAME"

    if [ -n "${VIRGIL_INSTALL_TARBALL:-}" ]; then
        if [ ! -f "$VIRGIL_INSTALL_TARBALL" ]; then
            error "VIRGIL_INSTALL_TARBALL=$VIRGIL_INSTALL_TARBALL: file not found"
        fi
        info "Using local tarball: $VIRGIL_INSTALL_TARBALL"
        cp "$VIRGIL_INSTALL_TARBALL" "$TARBALL_PATH"
        return
    fi

    ARCHIVE_URL="${BASE_URL}/v${VERSION}/${ARCHIVE_NAME}"
    SHA_URL="${ARCHIVE_URL}.sha256"

    info "Downloading $ARCHIVE_URL"
    if ! curl -fsSL "$ARCHIVE_URL" -o "$TARBALL_PATH"; then
        error "Failed to download tarball from $ARCHIVE_URL"
    fi

    info "Downloading $SHA_URL"
    SHA_FILE="$TMP_DIR/$ARCHIVE_NAME.sha256"
    if ! curl -fsSL "$SHA_URL" -o "$SHA_FILE"; then
        error "Failed to download sha256 from $SHA_URL"
    fi

    EXPECTED_SHA="$(awk '{print $1}' < "$SHA_FILE" | tr -d '[:space:]')"
    if [ -z "$EXPECTED_SHA" ]; then
        error "sha256 file $SHA_URL was empty"
    fi
    ACTUAL_SHA="$(cd "$TMP_DIR" && $SHA256_CMD "$ARCHIVE_NAME" | awk '{print $1}')"
    if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then
        error "tarball sha256 verification failed
Expected: $EXPECTED_SHA
Actual:   $ACTUAL_SHA"
    fi
    success "tarball sha256 verified"
}

# ---------------------------------------------------------------------------
# Extract tarball + verify manifest signature + per-artifact sha256
# ---------------------------------------------------------------------------

extract_tarball() {
    EXTRACT_DIR="$TMP_DIR/extract"
    mkdir -p "$EXTRACT_DIR"
    info "Extracting tarball to $EXTRACT_DIR"
    if ! tar -xzf "$TARBALL_PATH" -C "$EXTRACT_DIR"; then
        error "tar extract failed for $TARBALL_PATH"
    fi

    # The Builder produces:
    #   <extract>/virgil-aarch64-apple-darwin/virgil-<version>/...
    ARCHIVE_ROOT="$EXTRACT_DIR/virgil-${TARGET_TRIPLE}"
    if [ ! -d "$ARCHIVE_ROOT" ]; then
        error "Tarball missing top-level directory $ARCHIVE_ROOT (corrupt archive?)"
    fi

    # Locate the single virgil-<version>/ child.
    INSTALL_TREE=""
    for child in "$ARCHIVE_ROOT"/*/; do
        # Strip trailing slash
        candidate="${child%/}"
        if [ -d "$candidate" ]; then
            if [ -n "$INSTALL_TREE" ]; then
                error "Tarball contains more than one install tree under $ARCHIVE_ROOT"
            fi
            INSTALL_TREE="$candidate"
        fi
    done
    if [ -z "$INSTALL_TREE" ]; then
        error "No install tree found inside $ARCHIVE_ROOT"
    fi

    MANIFEST_PATH="$INSTALL_TREE/manifest.json"
    SIG_PATH="$INSTALL_TREE/manifest.json.sig"
    if [ ! -f "$MANIFEST_PATH" ]; then
        error "manifest.json missing from $INSTALL_TREE"
    fi
    if [ ! -f "$SIG_PATH" ]; then
        error "manifest.json.sig missing from $INSTALL_TREE (is the release signed?)"
    fi
}

# Apple Team ID stamped on every Mach-O in the release tarball by the
# GHA workflow. Pre-trust check before we let the staged `virgil`
# binary verify the manifest signature.
#
# We match on Team ID (`TeamIdentifier=QSL4ZJ5R3J`) rather than the
# cert subject ("Developer ID Application: <Org>") because Apple
# reissues certificates with different organization names over the
# account's lifetime (e.g. legal entity rename) while the Team ID
# stays stable. As long as the binary is signed by this team's Dev
# ID Application cert, we trust it. Notarization additionally checks
# Apple's signature on the cert chain — that's enforced separately
# by `codesign --verify --strict` above.
APPLE_TEAM_ID="QSL4ZJ5R3J"

# Verify the staged `virgil` binary's codesign chain matches the
# bundled Apple Developer ID identity, then invoke
# `virgil verify-manifest` (native ed25519-dalek) to check the
# manifest's detached signature against the bundled pubkeys. Replaces
# the previous openssl-based path so we don't require Homebrew
# OpenSSL on user Macs (the system `/usr/bin/openssl` is LibreSSL,
# which rejects raw Ed25519 verification).
verify_manifest_signature() {
    info "Verifying manifest signature (native Ed25519 via virgil verify-manifest)"

    virgil_bin="$INSTALL_TREE/virgil"
    if [ ! -x "$virgil_bin" ]; then
        error "Staged virgil binary missing or not executable: $virgil_bin"
    fi

    # `--strict --verbose=2` rejects detached/invalid signatures and
    # prints "valid on disk" + "satisfies its Designated Requirement"
    # on success.
    if ! codesign --verify --strict --verbose=2 "$virgil_bin" >/dev/null 2>&1; then
        error "codesign chain on $virgil_bin is invalid — refusing to trust this binary to verify the manifest. Re-download the release."
    fi

    # `codesign -dvv` writes signing metadata to stderr — among them
    # `TeamIdentifier=<TEAM_ID>` for any Dev-ID-signed Mach-O, and
    # `Authority=Developer ID Application: <Org> (<TEAM_ID>)` for the
    # leaf cert. We assert both: the leaf cert MUST be a Dev ID
    # Application cert (rules out ad-hoc / unsigned), and the Team ID
    # MUST match ours (rules out other Dev IDs).
    sig_info="$(codesign -dvv "$virgil_bin" 2>&1)"
    if [ "${VIRGIL_INSTALL_SKIP_CODESIGN:-0}" = "1" ]; then
        warn "VIRGIL_INSTALL_SKIP_CODESIGN=1 — not asserting Apple signing identity on $virgil_bin (test mode)"
    else
        if ! printf "%s\n" "$sig_info" | grep -q "^TeamIdentifier=${APPLE_TEAM_ID}\$"; then
            error "$virgil_bin is not signed by Apple Team ID ${APPLE_TEAM_ID} — refusing to use it for manifest verification."
        fi
        if ! printf "%s\n" "$sig_info" | grep -q "^Authority=Developer ID Application: "; then
            error "$virgil_bin is not signed by a Developer ID Application certificate — refusing to use it for manifest verification."
        fi
    fi

    # Materialize bundled pubkeys to disk so virgil verify-manifest
    # can read them as files. The `*_OVERRIDE` env vars let the local
    # smoke point at the test-keypair fixtures without touching the
    # heredocs.
    current_pem_path="$TMP_DIR/release-pubkey.current.pem"
    next_pem_path="$TMP_DIR/release-pubkey.next.pem"

    if [ -n "${VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE:-}" ]; then
        if [ ! -f "$VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE" ]; then
            error "VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE points at missing file: $VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE"
        fi
        cp "$VIRGIL_RELEASE_PUBKEY_CURRENT_OVERRIDE" "$current_pem_path"
    else
        read_bundled_pubkey_current > "$current_pem_path"
    fi
    if [ -n "${VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE:-}" ]; then
        if [ ! -f "$VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE" ]; then
            error "VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE points at missing file: $VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE"
        fi
        cp "$VIRGIL_RELEASE_PUBKEY_NEXT_OVERRIDE" "$next_pem_path"
    else
        read_bundled_pubkey_next > "$next_pem_path"
    fi

    if ! verify_output="$("$virgil_bin" verify-manifest \
            --manifest "$MANIFEST_PATH" \
            --signature "$SIG_PATH" \
            --pubkey-current "$current_pem_path" \
            --pubkey-next "$next_pem_path" 2>&1)"; then
        error "manifest signature did not verify: $verify_output"
    fi

    # `virgil verify-manifest` writes `verified: <slot>` on success.
    matched_slot="$(printf "%s" "$verify_output" | awk -F': ' '/^verified:/{print $2; exit}')"
    success "manifest signature verified (${matched_slot:-unknown} pubkey)"
}

# Verify each artifact in manifest.artifacts matches its declared
# sha256. This is what the installer crate's verify-bundle does
# post-extract; we do the same here.
verify_artifact_shas() {
    info "Verifying per-artifact sha256 against manifest.json"

    # Emit "<sha>  <relpath>" lines, one per artifact.
    artifact_list="$TMP_DIR/artifacts.list"
    ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0))).fetch("artifacts", {}).each { |rel, sha| puts "#{sha}  #{rel}" }' "$MANIFEST_PATH" > "$artifact_list"

    # Walk each line, sha256 the file, compare.
    failed=0
    while IFS= read -r line; do
        [ -z "$line" ] && continue
        expected_sha="$(printf "%s" "$line" | awk '{print $1}')"
        rel_path="$(printf "%s" "$line" | awk '{$1=""; sub(/^  */,""); print}')"
        abs_path="$INSTALL_TREE/$rel_path"
        if [ ! -f "$abs_path" ]; then
            warn "manifest references missing artifact: $rel_path"
            failed=$((failed + 1))
            continue
        fi
        actual_sha="$(cd "$(dirname "$abs_path")" && $SHA256_CMD "$(basename "$abs_path")" | awk '{print $1}')"
        if [ "$expected_sha" != "$actual_sha" ]; then
            warn "sha256 mismatch for $rel_path
  expected: $expected_sha
  actual:   $actual_sha"
            failed=$((failed + 1))
        fi
    done < "$artifact_list"

    if [ "$failed" -gt 0 ]; then
        error "$failed artifact(s) failed sha256 verification — refusing to install"
    fi
    success "all artifacts verified"
}

# Read VERSION from the manifest. We trust manifest.version more than
# the channel version.txt because the manifest was signed.
read_manifest_version() {
    if [ -z "${VIRGIL_INSTALL_TARBALL:-}" ]; then
        # Already resolved in resolve_version(); make sure the tarball
        # matches what we asked for.
        manifest_version="$(ruby -rjson -e 'puts JSON.parse(File.read(ARGV.fetch(0))).fetch("version")' "$MANIFEST_PATH")"
        if [ "$manifest_version" != "$VERSION" ]; then
            error "manifest version ($manifest_version) does not match requested version ($VERSION)"
        fi
    else
        VERSION="$(ruby -rjson -e 'puts JSON.parse(File.read(ARGV.fetch(0))).fetch("version")' "$MANIFEST_PATH")"
        info "Local tarball declares version: $VERSION"
    fi
}

# ---------------------------------------------------------------------------
# Promote install tree to ~/Library/Application Support/Virgil/installs/<v>/
# ---------------------------------------------------------------------------

# Compare two semver strings via dotted integer parts.
# Returns: 0 if a<b, 1 if a==b, 2 if a>b.
semver_compare() {
    a="$1"
    b="$2"
    if [ "$a" = "$b" ]; then
        return 1
    fi
    # Strip pre-release suffix for the triplet compare; treat a tagged
    # version as equal to the same triplet for refusal purposes.
    a_core="${a%%-*}"
    b_core="${b%%-*}"
    a_major="$(printf "%s" "$a_core" | awk -F. '{print $1+0}')"
    a_minor="$(printf "%s" "$a_core" | awk -F. '{print $2+0}')"
    a_patch="$(printf "%s" "$a_core" | awk -F. '{print $3+0}')"
    b_major="$(printf "%s" "$b_core" | awk -F. '{print $1+0}')"
    b_minor="$(printf "%s" "$b_core" | awk -F. '{print $2+0}')"
    b_patch="$(printf "%s" "$b_core" | awk -F. '{print $3+0}')"

    if [ "$a_major" -lt "$b_major" ]; then return 0; fi
    if [ "$a_major" -gt "$b_major" ]; then return 2; fi
    if [ "$a_minor" -lt "$b_minor" ]; then return 0; fi
    if [ "$a_minor" -gt "$b_minor" ]; then return 2; fi
    if [ "$a_patch" -lt "$b_patch" ]; then return 0; fi
    if [ "$a_patch" -gt "$b_patch" ]; then return 2; fi
    return 1
}

refuse_downgrade() {
    if [ -L "$INSTALLS_ROOT/current" ]; then
        existing_target="$(readlink "$INSTALLS_ROOT/current" || true)"
        existing_version="${existing_target##*/}"
        if [ -n "$existing_version" ] && [ "$existing_version" != "$VERSION" ]; then
            if semver_compare "$VERSION" "$existing_version"; then
                # VERSION < existing — this is a downgrade.
                error "Refusing to downgrade: requested $VERSION is older than installed $existing_version. Use \`virgil rollback\` to revert to a previous install."
            fi
        fi
    fi
}

promote_install_tree() {
    # Per-user, no sudo. mkdir -p across ASCII-only paths is safe.
    mkdir -p "$INSTALLS_ROOT" "$LOGS_DIR" "$LAUNCH_AGENTS_DIR" "$LOCAL_BIN" "$APP_SUPPORT"

    target_dir="$INSTALLS_ROOT/$VERSION"

    if [ -d "$target_dir" ]; then
        info "installs/$VERSION already exists — skipping extract (idempotent reinstall)"
    else
        # Stage to <target>.staging-<pid> then atomically rename. Avoids
        # a half-extracted directory ever being reachable as
        # installs/<version>/.
        staging_dir="$INSTALLS_ROOT/.staging-${VERSION}-$$"
        rm -rf "$staging_dir"
        info "Promoting install tree to $target_dir"
        mkdir -p "$staging_dir"
        # cp -R preserves modes; INSTALL_TREE is a verified directory.
        # Use -P to avoid following symlinks inside (defense in depth).
        if ! cp -RP "$INSTALL_TREE/." "$staging_dir/"; then
            rm -rf "$staging_dir"
            error "Failed to copy install tree into $staging_dir"
        fi
        if ! mv "$staging_dir" "$target_dir"; then
            rm -rf "$staging_dir"
            error "Failed to rename $staging_dir to $target_dir"
        fi
    fi

    # Mark binaries executable defensively (cp -R should already preserve).
    [ -f "$target_dir/virgil-desktop" ] && chmod +x "$target_dir/virgil-desktop"
    [ -f "$target_dir/virgil" ] && chmod +x "$target_dir/virgil"

    # Atomically flip installs/current via a sibling symlink + mv -h.
    # mv(1) on macOS supports -h ("if the target operand is a symbolic
    # link to a directory, do not follow it"), which gives us the
    # rename(2)-on-the-symlink semantic the bead asks for.
    current_link="$INSTALLS_ROOT/current"
    new_link="$INSTALLS_ROOT/current.new-$$"
    rm -f "$new_link"
    ln -s "$VERSION" "$new_link"
    if mv -fh "$new_link" "$current_link" 2>/dev/null; then
        :
    else
        # Fallback if mv -h is unavailable: brief race window between
        # rm and ln, but acceptable on first install.
        rm -f "$current_link"
        ln -s "$VERSION" "$current_link"
        rm -f "$new_link"
    fi
    success "installs/current -> $VERSION"

    # Symlink ~/.local/bin/virgil into installs/current/virgil. Use
    # `ln -sf` so a previous install's symlink is replaced cleanly.
    rm -f "$LOCAL_BIN/virgil"
    ln -s "$INSTALLS_ROOT/current/virgil" "$LOCAL_BIN/virgil"
    success "$LOCAL_BIN/virgil -> installs/current/virgil"
}

# ---------------------------------------------------------------------------
# LaunchAgent
# ---------------------------------------------------------------------------

write_launch_agent_plist() {
    template_path="$INSTALLS_ROOT/$VERSION/sh.virgil.desktop.plist"
    if [ ! -f "$template_path" ]; then
        error "LaunchAgent template not found at $template_path (is the release tree complete?)"
    fi

    desktop_bin="$INSTALLS_ROOT/current/virgil-desktop"
    desktop_log="$LOGS_DIR/virgil-desktop.log"
    desktop_err="$LOGS_DIR/virgil-desktop.err"
    spa_root="$INSTALLS_ROOT/current/spa"

    # sed substitution: paths must not contain "|" — none of ours do.
    info "Writing LaunchAgent plist to $LAUNCH_AGENT_PLIST"
    sed \
        -e "s|__VIRGIL_DESKTOP_BIN__|$desktop_bin|g" \
        -e "s|__VIRGIL_DESKTOP_LOG__|$desktop_log|g" \
        -e "s|__VIRGIL_DESKTOP_ERR__|$desktop_err|g" \
        -e "s|__VIRGIL_SPA_ROOT__|$spa_root|g" \
        < "$template_path" > "$LAUNCH_AGENT_PLIST"
}

# VRT-060v — write the HUD's LaunchAgent plist if the install tree
# ships the helper bundle. The two predicates we check (the staged
# template plist + the helper-bundle binary) match the assembler's
# all-or-nothing contract on BuildSpec.{hud_binary, hud_info_plist,
# hud_entitlements, hud_launch_agent_plist}. If either is missing we
# log + skip — older releases without the HUD continue to install
# cleanly, and the daemon's own plist registration is unaffected.
write_hud_launch_agent_plist() {
    template_path="$INSTALLS_ROOT/$VERSION/sh.virgil.hud.plist"
    # Helper-bundle binary path — must stay in sync with virgil-installer's
    # build_release::HUD_* constants. When the installer is regenerated
    # with new helper-bundle topology, update both ends together (the
    # codepath that produces the path and the codepath that registers
    # the plist).
    hud_bin="$INSTALLS_ROOT/current/Virgil.app/Contents/Helpers/VirgilHelper.app/Contents/MacOS/Virgil"
    hud_log="$LOGS_DIR/virgil-hud.log"
    hud_err="$LOGS_DIR/virgil-hud.err"

    if [ ! -f "$template_path" ]; then
        info "no HUD plist template at $template_path — skipping HUD LaunchAgent registration (older release without HUD?)"
        HUD_INSTALLED=0
        return
    fi
    if [ ! -x "$hud_bin" ]; then
        warn "HUD plist template present but VirgilHUD binary missing or not executable at $hud_bin — skipping HUD LaunchAgent registration"
        HUD_INSTALLED=0
        return
    fi

    info "Writing HUD LaunchAgent plist to $HUD_LAUNCH_AGENT_PLIST"
    sed \
        -e "s|__VIRGIL_HUD_BIN__|$hud_bin|g" \
        -e "s|__VIRGIL_HUD_LOG__|$hud_log|g" \
        -e "s|__VIRGIL_HUD_ERR__|$hud_err|g" \
        < "$template_path" > "$HUD_LAUNCH_AGENT_PLIST"
    HUD_INSTALLED=1
}

kill_orphan_daemons() {
    # cli-fxe: any virgil-desktop process the user launched outside
    # launchd (Tauri bundle, raw `./virgil-desktop`, leftover from a
    # crashed prior run) survives `launchctl bootout` because launchd
    # never knew about it. The freshly-bootstrapped service then
    # races on the same desktop.sock and can't bind, so the user sees
    # a working-but-stale daemon AND a logged-but-not-running one.
    # Fire SIGTERM at every PID owning a binary under our installs/
    # tree, then bootstrap. The launchd-managed PID gets respawned by
    # KeepAlive immediately; orphans stay dead.
    if ! command -v pkill >/dev/null 2>&1; then
        return
    fi
    bin_pattern="${INSTALLS_ROOT}/.*/virgil-desktop"
    if pgrep -f "$bin_pattern" >/dev/null 2>&1; then
        info "killing orphan virgil-desktop processes (matched: $bin_pattern)"
        pkill -TERM -f "$bin_pattern" >/dev/null 2>&1 || true
        # Give them ~2s to exit before SIGKILL fallback. Without this
        # bootstrap can race the dying orphan on bind().
        i=0
        while [ "$i" -lt 20 ] && pgrep -f "$bin_pattern" >/dev/null 2>&1; do
            sleep 0.1
            i=$((i + 1))
        done
        if pgrep -f "$bin_pattern" >/dev/null 2>&1; then
            warn "virgil-desktop did not exit after SIGTERM; sending SIGKILL"
            pkill -KILL -f "$bin_pattern" >/dev/null 2>&1 || true
        fi
    fi
}

bootstrap_launch_agent() {
    if [ "${VIRGIL_NO_LAUNCHCTL:-0}" = "1" ]; then
        warn "VIRGIL_NO_LAUNCHCTL=1 — skipping launchctl bootstrap"
        return
    fi

    if ! command -v launchctl >/dev/null 2>&1; then
        warn "launchctl not on PATH — skipping LaunchAgent bootstrap"
        return
    fi

    target="gui/$(id -u)"

    # macOS 12+ supports `launchctl bootstrap <domain> <plist>` and
    # `launchctl bootout <domain>/<label>`. Fall back to the deprecated
    # `launchctl load` / `launchctl unload` on older macOS.
    macos_version="$(sw_vers -productVersion 2>/dev/null || echo "0.0.0")"
    macos_major="$(printf "%s" "$macos_version" | awk -F. '{print $1+0}')"

    if [ "$macos_major" -ge 12 ]; then
        # Idempotent: bootout if already loaded, then bootstrap.
        launchctl bootout "${target}/${LAUNCH_AGENT_LABEL}" >/dev/null 2>&1 || true
        # cli-fxe: kill any orphan daemon launchctl bootout couldn't
        # see (started outside launchd) so the new bootstrap doesn't
        # collide on desktop.sock / port binds.
        kill_orphan_daemons
        info "launchctl bootstrap $target $LAUNCH_AGENT_PLIST"
        if ! launchctl bootstrap "$target" "$LAUNCH_AGENT_PLIST"; then
            error "launchctl bootstrap failed for $LAUNCH_AGENT_PLIST"
        fi
        # kickstart -k forces a clean restart so a stale daemon picks
        # up the new plist. -k = kill if running first.
        launchctl kickstart -k "${target}/${LAUNCH_AGENT_LABEL}" >/dev/null 2>&1 || true
    else
        launchctl unload "$LAUNCH_AGENT_PLIST" >/dev/null 2>&1 || true
        # cli-fxe: same orphan-kill on macOS <12 — `launchctl unload`
        # only knows about agents it loaded itself.
        kill_orphan_daemons
        info "launchctl load $LAUNCH_AGENT_PLIST"
        if ! launchctl load "$LAUNCH_AGENT_PLIST"; then
            error "launchctl load failed for $LAUNCH_AGENT_PLIST"
        fi
    fi
    success "LaunchAgent registered: $LAUNCH_AGENT_LABEL"
}

# VRT-060v — bootstrap the HUD's LaunchAgent independently of the
# daemon's. Skipped when write_hud_launch_agent_plist set
# HUD_INSTALLED=0 (older release without the HUD, or a partial
# install missing the helper-bundle binary).
bootstrap_hud_launch_agent() {
    if [ "${HUD_INSTALLED:-0}" != "1" ]; then
        return
    fi
    if [ "${VIRGIL_NO_LAUNCHCTL:-0}" = "1" ]; then
        warn "VIRGIL_NO_LAUNCHCTL=1 — skipping HUD launchctl bootstrap"
        return
    fi
    if ! command -v launchctl >/dev/null 2>&1; then
        warn "launchctl not on PATH — skipping HUD LaunchAgent bootstrap"
        return
    fi

    target="gui/$(id -u)"
    macos_version="$(sw_vers -productVersion 2>/dev/null || echo "0.0.0")"
    macos_major="$(printf "%s" "$macos_version" | awk -F. '{print $1+0}')"

    if [ "$macos_major" -ge 12 ]; then
        launchctl bootout "${target}/${HUD_LAUNCH_AGENT_LABEL}" >/dev/null 2>&1 || true
        info "launchctl bootstrap $target $HUD_LAUNCH_AGENT_PLIST"
        if ! launchctl bootstrap "$target" "$HUD_LAUNCH_AGENT_PLIST"; then
            error "launchctl bootstrap failed for $HUD_LAUNCH_AGENT_PLIST"
        fi
        launchctl kickstart -k "${target}/${HUD_LAUNCH_AGENT_LABEL}" >/dev/null 2>&1 || true
    else
        launchctl unload "$HUD_LAUNCH_AGENT_PLIST" >/dev/null 2>&1 || true
        info "launchctl load $HUD_LAUNCH_AGENT_PLIST"
        if ! launchctl load "$HUD_LAUNCH_AGENT_PLIST"; then
            error "launchctl load failed for $HUD_LAUNCH_AGENT_PLIST"
        fi
    fi
    success "LaunchAgent registered: $HUD_LAUNCH_AGENT_LABEL"
}

# ---------------------------------------------------------------------------
# Wait for daemon control socket
# ---------------------------------------------------------------------------

wait_for_daemon() {
    if [ "${VIRGIL_NO_LAUNCHCTL:-0}" = "1" ]; then
        warn "VIRGIL_NO_LAUNCHCTL=1 — skipping daemon socket probe"
        return
    fi

    info "Waiting for daemon control socket at $DAEMON_SOCKET (up to 5s)"
    i=0
    while [ "$i" -lt "$DAEMON_POLL_BUDGET_TENTHS" ]; do
        if [ -S "$DAEMON_SOCKET" ] || [ -e "$DAEMON_SOCKET" ]; then
            success "daemon socket reachable: $DAEMON_SOCKET"
            return
        fi
        # POSIX sleep doesn't always accept fractional seconds; Ruby is
        # already required above and does not trigger the Xcode/CLT shim.
        ruby -e "sleep 0.${DAEMON_POLL_INTERVAL_TENTHS}"
        i=$((i + DAEMON_POLL_INTERVAL_TENTHS))
    done
    error "daemon control socket did not appear at $DAEMON_SOCKET within 5s — run \`virgil doctor\` to diagnose"
}

# ---------------------------------------------------------------------------
# Browser open + PATH warning
# ---------------------------------------------------------------------------

maybe_open_setup_ui() {
    if [ "${VIRGIL_NO_OPEN:-0}" = "1" ]; then
        info "VIRGIL_NO_OPEN=1 — skipping browser open"
        return
    fi
    if [ ! -x "$LOCAL_BIN/virgil" ]; then
        warn "$LOCAL_BIN/virgil not executable — cannot open setup UI"
        return
    fi
    info "Opening browser setup UI via virgil open"
    if "$LOCAL_BIN/virgil" open >/dev/null 2>&1; then
        return
    fi
    # virgil open failed — fall back to printing the URL the daemon
    # wrote to ui.url so the user can paste it.
    ui_url_file="$APP_SUPPORT/ui.url"
    if [ -s "$ui_url_file" ]; then
        url="$(cat "$ui_url_file")"
        warn "virgil open returned non-zero. Open this URL manually:"
        printf "    %s\n" "$url"
    else
        warn "virgil open returned non-zero and ui.url has not been written yet. Run \`virgil open\` after the daemon finishes initializing."
    fi
}

emit_path_warning() {
    case ":$PATH:" in
        *":$LOCAL_BIN:"*)
            ;;
        *)
            echo ""
            warn "$LOCAL_BIN is not in your PATH"
            echo ""
            echo "Add the following to your shell profile (~/.bashrc, ~/.zshrc, etc.):"
            echo ""
            echo "    export PATH=\"\$HOME/.local/bin:\$PATH\""
            echo ""
            ;;
    esac
}

# Register the staged install tree via the Rust single-source-of-truth
# (`virgil register`), which flips installs/current, symlinks the CLI,
# and writes + bootstraps the daemon (and HUD) LaunchAgents. This is the
# convergence point: the DMG first-run bootstrap and this curl|sh path
# now share crates/virgil/virgil-installer/src/register.rs instead of
# duplicating the plist-substitution + launchctl logic in bash.
#
# Supersedes write_launch_agent_plist / write_hud_launch_agent_plist /
# bootstrap_launch_agent / bootstrap_hud_launch_agent (kept defined for
# one release as a rollback hedge; delete after this path is verified on
# a real install).
register_install_tree() {
    register_bin="$INSTALLS_ROOT/$VERSION/virgil"
    if [ ! -x "$register_bin" ]; then
        error "virgil CLI not found at $register_bin — cannot register install tree"
    fi
    args="register --version $VERSION"
    if [ "${VIRGIL_NO_LAUNCHCTL:-0}" = "1" ]; then
        args="$args --no-launchctl"
    fi
    info "Registering install tree via virgil register"
    # shellcheck disable=SC2086
    if ! "$register_bin" $args; then
        error "virgil register failed"
    fi
    success "Registered installs/current -> $VERSION"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

main() {
    echo ""
    printf "${BOLD}Virgil Installer${NC}\n"
    echo ""

    require_apple_silicon_mac
    require_dependencies
    validate_channel
    if [ -z "${VIRGIL_INSTALL_TARBALL:-}" ]; then
        resolve_version
    else
        # VERSION resolved post-extract from manifest.
        VERSION=""
    fi
    acquire_tarball
    extract_tarball
    read_manifest_version
    refuse_downgrade
    verify_manifest_signature
    verify_artifact_shas
    promote_install_tree
    register_install_tree
    wait_for_daemon
    emit_path_warning
    maybe_open_setup_ui

    success "Virgil v${VERSION} installed."
    echo ""
    echo "  CLI:        $LOCAL_BIN/virgil"
    echo "  Install:    $INSTALLS_ROOT/$VERSION"
    echo "  LaunchAgent: $LAUNCH_AGENT_PLIST"
    echo ""
    echo "Next steps:"
    echo "  virgil setup    # connect channels (Slack, Discord, Telegram, …)"
    echo "  virgil doctor   # diagnose any problems"
    echo ""
}

main "$@"
