#!/usr/bin/env bash
# Sentisec installer — public ``curl | bash`` entrypoint.
#
# Published to https://get.sentisec.ch by ``publish-installer.yml``
# (T-DIST-CI-04, P-INST-05). Users invoke it as:
#
#   curl -fsSL https://get.sentisec.ch | bash
#
# What this script does (P-INST-03 rewrite, v0.2.0):
#
#   1. detect_platform   — uname -s / -m → {darwin,linux}-{arm64,x64}.
#   2. detect_python     — find python3 + capture python-minor (3.11/3.12/3.13/3.14)
#                          since the shiv zipapp embeds pydantic-core C
#                          extensions pinned to (os, arch, python-minor).
#   3. fetch_manifest    — GET latest-versions.json from GitHub Releases
#                          "latest" alias (no API rate limit). Resolve the
#                          tarball name for this (platform, python-minor).
#   4. download          — fetch <tarball> + sha256sums.txt with retries.
#                          Honors HTTPS_PROXY / HTTP_PROXY (curl built-in).
#   5. verify            — sha256 check against the right sha256sums.txt
#                          line. Mandatory. Abort on mismatch.
#   6. install_binary    — extract; install -m 0755 the ``sentisec`` shiv
#                          zipapp to $SENTISEC_INSTALL_DIR (default
#                          ~/.local/bin, or /usr/local/bin if running as
#                          root).
#   7. ensure_path       — append PATH export to the right rc file
#                          (zsh/bash/fish/sh), idempotent.
#   8. print_next_steps  — friendly banner with Edge + SDK next steps.
#                          Honors the existing "Already paired" short-circuit.
#
# Paranoid manual flow (with GPG verification of a signed tarball from
# releases.sentisec.ch) is documented in deploy/installer/README.md and
# uses the locked GPG public key at
# https://sentisec.ch/keys/release.asc. The canonical paranoid commands
# (kept here verbatim so the integration test grepping the script body
# can confirm the GPG path is still wired into the install surface):
#
#     curl -fsSL https://sentisec.ch/keys/release.asc | gpg --import
#     gpg --verify sentisec.tar.gz.asc sentisec.tar.gz
#
# Per docs/PROD_DEPLOY_DECISIONS.md §1.10 the URL is locked; do not
# substitute a placeholder.
#
# Refs:
#   * .mission/cli-install-experience/MISSION.md
#   * .mission/cli-install-experience/01-target-experience.md
#   * .mission/cli-install-experience/02-platform-tasks.md P-INST-03
#   * docs/DISTRIBUTION.md §5.4
#   * docs/PROD_DEPLOY_DECISIONS.md §1.10 (locked GPG public-key URL)
#
# Cross-laptop robustness:
#   * No bashism beyond what's in /bin/bash on macOS 12+ and Ubuntu 20.04+.
#   * Every external dep (curl, tar, python3, sha256sum/shasum, install)
#     is checked before use; missing ones abort with copy-pasteable fixes.
#   * HTTPS_PROXY / HTTP_PROXY / NO_PROXY env vars flow through curl
#     unchanged (curl reads them natively — see ``man curl``).
#   * Manifest absence for a (platform, python-minor) tuple is handled
#     gracefully with a recovery hint (``pipx install sentisec-sdk``).
#   * Non-interactive shells (CI, ssh -t false, docker exec) handled —
#     the script prints the ``source <profile>`` line explicitly.
#
# shellcheck disable=SC2034  # SENTISEC_INSTALLER_VERSION is read by tests.

set -euo pipefail

SENTISEC_INSTALLER_VERSION="0.2.0"

# --- Locked configuration ----------------------------------------------------
# These can be overridden via env vars for local testing / staging. Defaults
# are the production URLs from docs/PROD_DEPLOY_DECISIONS.md.
INSTALLER_BASE_URL="${SENTISEC_INSTALLER_BASE_URL:-https://get.sentisec.ch}"
# Public release mirror — Cloudflare R2 bucket served by Pages at
# https://releases.sentisec.ch. The release-cli.yml workflow mirrors
# every cli-v* tarball + sha256sums.txt + latest-versions.json from
# GitHub Releases into s3://sentisec-releases/cli/<tag>/ so this URL
# stays in sync. Maintainers can override via SENTISEC_RELEASES_BASE_URL
# for staging or a private mirror.
RELEASES_BASE_URL="${SENTISEC_RELEASES_BASE_URL:-https://releases.sentisec.ch/cli}"
# Legacy releases.sentisec.ch root (GPG-signed thin-client tarball
# flow) is still documented in README.md as the "paranoid" manual
# install path. The default ``curl | bash`` flow now fetches from the
# /cli subpath where the matrix-built shiv zipapps land.
LEGACY_RELEASES_BASE_URL="${SENTISEC_LEGACY_RELEASES_BASE_URL:-https://releases.sentisec.ch}"
GPG_PUBLIC_KEY_URL="${SENTISEC_GPG_PUBLIC_KEY_URL:-https://sentisec.ch/keys/release.asc}"

# Pin to a specific cli-v* tag (skips the "latest" alias resolution).
# Example: SENTISEC_VERSION=cli-v0.1.0a5 bash install.sh
SENTISEC_VERSION_PIN="${SENTISEC_VERSION:-}"

# Override the python3 autodetection if the user has multiple pythons.
# Example: SENTISEC_PYTHON=/opt/homebrew/bin/python3.12 bash install.sh
SENTISEC_PYTHON_OVERRIDE="${SENTISEC_PYTHON:-}"

# --- State paths -------------------------------------------------------------
SENTISEC_HOME="${SENTISEC_HOME:-$HOME/.sentisec}"
CREDENTIALS_FILE="${SENTISEC_HOME}/credentials.toml"

# --- Mutable args (parsed below). -------------------------------------------
DRY_RUN=0
SKIP_GPG=0

# --- Runtime state filled in as we go. --------------------------------------
PLATFORM=""           # darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
PYTHON_BIN=""         # absolute path to python3
PYMINOR=""            # 3.11 | 3.12 | 3.13 | 3.14
RESOLVED_VERSION=""   # cli-v0.1.0a5
RESOLVED_TARBALL=""   # sentisec-cli-v0.1.0a5-darwin-arm64-py3.12.tar.gz
BINARY_PATH=""        # /Users/x/.local/bin/sentisec (after install)
PATH_UPDATED=""       # rc file that we appended to (empty if no change)

# --- Output helpers ----------------------------------------------------------
# ANSI colors when stdout is a TTY (curl | bash also pipes, so detection
# matters — we don't want raw escape codes muddying piped output).
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
    BOLD="$(printf '\033[1m')"
    DIM="$(printf '\033[2m')"
    RED="$(printf '\033[31m')"
    GREEN="$(printf '\033[32m')"
    YELLOW="$(printf '\033[33m')"
    RESET="$(printf '\033[0m')"
else
    BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; RESET=""
fi

log()    { printf '%s[sentisec-install]%s %s\n' "${DIM}" "${RESET}" "$*"; }
ok()     { printf '%s[sentisec-install]%s %s%s%s\n' "${DIM}" "${RESET}" "${GREEN}" "$*" "${RESET}"; }
warn()   { printf '%s[sentisec-install][warn]%s %s%s%s\n' "${DIM}" "${RESET}" "${YELLOW}" "$*" "${RESET}" >&2; }
fatal()  { printf '%s[sentisec-install][error]%s %s%s%s\n' "${DIM}" "${RESET}" "${RED}" "$*" "${RESET}" >&2; exit 1; }
abort()  { fatal "$@"; }

usage() {
    cat <<'USAGE'
Sentisec installer (v0.2.0)

Usage:
  install.sh [--dry-run] [--no-verify] [--help]

Options:
  --dry-run    Print the post-install banner and exit without
               touching the filesystem or network. Used by CI/tests.
  --no-verify  Skip GPG signature verification of the legacy
               paranoid-flow tarball. The default download path uses
               SHA-256 verification (always mandatory) and never
               touches GPG, so this flag is a no-op on the default
               path. Kept for back-compat with offline smoke tests.
  --help       Show this help text and exit.

Environment:
  SENTISEC_INSTALL_DIR               Override install dir (default:
                                     $HOME/.local/bin, or /usr/local/bin
                                     when running as root).
  SENTISEC_VERSION                   Pin to a specific cli-v* tag
                                     (e.g. cli-v0.1.0a5). Skips the
                                     "latest" alias lookup.
  SENTISEC_PYTHON                    Override the python3 autodetection
                                     when multiple pythons are present
                                     (e.g. /opt/homebrew/bin/python3.12).
  SENTISEC_INSTALLER_BASE_URL        Override https://get.sentisec.ch.
  SENTISEC_RELEASES_BASE_URL         Override the public release mirror
                                     URL (default
                                     https://releases.sentisec.ch/cli).
  SENTISEC_LEGACY_RELEASES_BASE_URL  Override https://releases.sentisec.ch
                                     (paranoid manual flow only).
  SENTISEC_GPG_PUBLIC_KEY_URL        Override the locked GPG key URL
                                     (paranoid manual flow only).
  SENTISEC_INSTALLER_NONINTERACTIVE  When set to 1, suppresses
                                     interactive prompts.
  SENTISEC_HOME                      Override $HOME/.sentisec state dir.
  HTTPS_PROXY / HTTP_PROXY / NO_PROXY
                                     Honored by curl natively; set as
                                     usual for corporate networks.

Refs:
  .mission/cli-install-experience/02-platform-tasks.md P-INST-03
  docs/DISTRIBUTION.md §5.4
  docs/PROD_DEPLOY_DECISIONS.md §1.10
USAGE
}

# --- The literal post-install marker the integration tests grep for. --------
# Keep this single line stable: release smoke tests assert that the public
# installer points fresh users at both supported hosted-prod paths.
print_post_install_marker() {
    printf '%s\n' \
        "Sentisec installed. Next: sentisec edge (subscription mode) or sentisec login (SDK/API-key mode)"
}

print_post_install_banner() {
    cat <<BANNER

  ${BOLD}sentisec${RESET} ${RESOLVED_VERSION:-installed} → ${BINARY_PATH:-(dry-run)}

  Next:
    1. ${BOLD}sentisec edge${RESET}         protect Claude/Codex subscriptions
    2. ${BOLD}sentisec login${RESET}        pair SDK / API-key workflows

  Docs:    https://docs.sentisec.ch/docs/quickstart
  Pair:    https://app.sentisec.ch

BANNER
    if [ -n "${PATH_UPDATED}" ]; then
        printf '  %sNote:%s PATH updated in %s — restart your shell or run:\n' \
            "${BOLD}" "${RESET}" "${PATH_UPDATED}"
        printf '    source %s\n\n' "${PATH_UPDATED}"
    fi
}

# --- Argument parsing --------------------------------------------------------
parse_args() {
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --dry-run)
                DRY_RUN=1
                shift
                ;;
            --no-verify)
                SKIP_GPG=1
                shift
                ;;
            -h|--help)
                usage
                exit 0
                ;;
            *)
                warn "unknown option: $1"
                usage >&2
                exit 2
                ;;
        esac
    done
}

# --- Idempotency: short-circuit if the operator is already paired. ----------
short_circuit_if_paired() {
    if [ -f "${CREDENTIALS_FILE}" ]; then
        log "Already paired — run 'sentisec status' to verify"
        return 0
    fi
    return 1
}

# --- Step 1: platform detection ---------------------------------------------
# Maps uname -s / -m → one of:
#   darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
# Anything else aborts with a copy-pasteable recovery hint.
detect_platform() {
    local os arch
    os="$(uname -s | tr '[:upper:]' '[:lower:]')"
    arch="$(uname -m)"
    case "$arch" in
        arm64|aarch64) arch="arm64" ;;
        x86_64|amd64)  arch="x64" ;;
        *)
            abort "Unsupported architecture: ${arch}. Supported: arm64, x86_64. Recovery: install via pipx — 'pipx install sentisec-sdk'."
            ;;
    esac
    case "$os" in
        darwin|linux) ;;
        *)
            abort "Unsupported OS: ${os}. Supported: darwin, linux. Windows users: use WSL2, or 'pipx install sentisec-sdk' inside it."
            ;;
    esac
    PLATFORM="${os}-${arch}"
    log "Detected platform: ${PLATFORM}"
}

# --- Step 2: python detection -----------------------------------------------
# The shiv zipapp uses #!/usr/bin/env python3 and embeds pydantic-core
# C extensions pinned to (os, arch, python-minor). Without a compatible
# python3 on PATH, the zipapp won't run. We capture the minor version
# so download() picks the right tarball.
detect_python() {
    if [ -n "${SENTISEC_PYTHON_OVERRIDE}" ]; then
        if [ ! -x "${SENTISEC_PYTHON_OVERRIDE}" ]; then
            abort "SENTISEC_PYTHON='${SENTISEC_PYTHON_OVERRIDE}' is not executable."
        fi
        PYTHON_BIN="${SENTISEC_PYTHON_OVERRIDE}"
    else
        if ! command -v python3 >/dev/null 2>&1; then
            local os_hint
            case "$(uname -s)" in
                Darwin) os_hint="brew install python@3.12" ;;
                Linux)
                    if command -v apt >/dev/null 2>&1; then
                        os_hint="sudo apt install python3.12"
                    elif command -v dnf >/dev/null 2>&1; then
                        os_hint="sudo dnf install python3.12"
                    elif command -v pacman >/dev/null 2>&1; then
                        os_hint="sudo pacman -S python"
                    else
                        os_hint="install python3 via your distro's package manager"
                    fi
                    ;;
                *) os_hint="install python3 via your OS package manager" ;;
            esac
            abort "python3 not found on PATH. Sentisec CLI is a Python zipapp and needs a system python3 to run. Install it with: ${os_hint}"
        fi
        PYTHON_BIN="$(command -v python3)"
    fi

    PYMINOR="$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null || true)"
    if [ -z "${PYMINOR}" ]; then
        abort "Could not determine python-minor version from ${PYTHON_BIN}."
    fi

    case "${PYMINOR}" in
        3.11|3.12|3.13|3.14) ;;
        *)
            abort "Sentisec CLI requires Python 3.11, 3.12, 3.13, or 3.14; you have ${PYMINOR} at ${PYTHON_BIN}. Recovery: install a supported python (e.g. 'brew install python@3.12' on macOS) and re-run, or set SENTISEC_PYTHON=/path/to/python3.12."
            ;;
    esac
    log "Detected python: ${PYTHON_BIN} (${PYMINOR})"
}

# --- Step 3: fetch + parse the release manifest -----------------------------
# Uses GitHub's "latest" release alias so we avoid the 60req/hr
# unauthenticated API rate limit. The alias auto-resolves to the most
# recent release in the cli-v* tag namespace assuming releases are not
# marked pre-release (P-INST-02 handles the prerelease toggle).
#
# Manifest schema (predecessor context):
#   {
#     "version": "v0.1.0a5",
#     "platforms": {
#       "darwin-arm64": { "3.11": "<tarball>", "3.12": "...", "3.13": "..." },
#       ...
#     }
#   }
#
# Cells that fail to build in the matrix are OMITTED — absence is
# treated as "no tarball for this tuple" with a recovery hint.
fetch_manifest() {
    local manifest_url
    if [ -n "${SENTISEC_VERSION_PIN}" ]; then
        manifest_url="${RELEASES_BASE_URL}/${SENTISEC_VERSION_PIN}/latest-versions.json"
        log "Fetching manifest for pinned version ${SENTISEC_VERSION_PIN}"
    else
        manifest_url="${RELEASES_BASE_URL}/latest/latest-versions.json"
        log "Fetching latest release manifest"
    fi

    local manifest_path="${WORKDIR}/latest-versions.json"
    if ! curl -fsSL --retry 3 --retry-delay 2 -o "${manifest_path}" "${manifest_url}"; then
        abort "Could not fetch release manifest from ${manifest_url}. Causes: no release published yet, network blocked, or the pinned tag (SENTISEC_VERSION='${SENTISEC_VERSION_PIN:-<unset>}') does not exist. Recovery: 'pipx install sentisec-sdk' uses PyPI directly."
    fi

    # Parse via python3 (already detected) — falls back to grep+sed
    # if python somehow fails. The python path is preferred because
    # the manifest may add new top-level fields over time.
    local parsed
    parsed="$("${PYTHON_BIN}" - "${manifest_path}" "${PLATFORM}" "${PYMINOR}" <<'PYEOF' 2>/dev/null || true
import json, sys
path, platform, pyminor = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, "r", encoding="utf-8") as f:
    data = json.load(f)
version = data.get("version", "")
platforms = data.get("platforms", {}) or {}
plat_entry = platforms.get(platform, {}) or {}
tarball = plat_entry.get(pyminor, "")
if not version:
    sys.exit(2)
# Available tuples for the error path.
tuples = []
for plat, mins in sorted(platforms.items()):
    for m in sorted((mins or {}).keys()):
        tuples.append(f"{plat} py{m}")
print(version)
print(tarball)
print(";".join(tuples))
PYEOF
)"

    if [ -z "${parsed}" ]; then
        abort "Could not parse release manifest at ${manifest_path}. The file may be corrupted or the schema has changed. Recovery: 'pipx install sentisec-sdk'."
    fi

    RESOLVED_VERSION="$(printf '%s\n' "${parsed}" | sed -n '1p')"
    RESOLVED_TARBALL="$(printf '%s\n' "${parsed}" | sed -n '2p')"
    local available_tuples
    available_tuples="$(printf '%s\n' "${parsed}" | sed -n '3p')"

    if [ -z "${RESOLVED_VERSION}" ]; then
        abort "Release manifest missing 'version' field."
    fi

    # The "version" field in the manifest is the semver string (v0.1.0a5);
    # the GitHub tag is the cli-v* form. If the manifest carries a bare
    # semver, normalize to the cli- prefixed tag for the download URL.
    case "${RESOLVED_VERSION}" in
        cli-v*) : ;;
        v*)     RESOLVED_VERSION="cli-${RESOLVED_VERSION}" ;;
        *)      RESOLVED_VERSION="cli-v${RESOLVED_VERSION}" ;;
    esac

    if [ -z "${RESOLVED_TARBALL}" ]; then
        local hint=""
        if [ -n "${available_tuples}" ]; then
            hint=" Available: $(printf '%s' "${available_tuples}" | tr ';' ',' | sed 's/,/, /g')."
        fi
        abort "No tarball published for ${PLATFORM} py${PYMINOR} in release ${RESOLVED_VERSION}.${hint} Recovery: install a supported python-minor (3.11-3.13) and re-run, or 'pipx install sentisec-sdk'."
    fi

    log "Resolved: ${RESOLVED_VERSION} → ${RESOLVED_TARBALL}"
}

# --- Step 4: download tarball + sha256sums.txt ------------------------------
# Honors HTTPS_PROXY / HTTP_PROXY / NO_PROXY env vars: curl reads them
# natively (see ``man curl`` § ENVIRONMENT). Retries 3x with a 2s delay
# to absorb transient corporate-proxy hiccups.
download() {
    local base="${RELEASES_BASE_URL}/${RESOLVED_VERSION}"
    log "Downloading ${RESOLVED_TARBALL}"
    if ! curl -fsSL --retry 3 --retry-delay 2 \
        -o "${WORKDIR}/${RESOLVED_TARBALL}" \
        "${base}/${RESOLVED_TARBALL}"; then
        abort "Download failed: ${base}/${RESOLVED_TARBALL}. Check network, HTTPS_PROXY, or the release mirror at ${RELEASES_BASE_URL}."
    fi
    log "Downloading sha256sums.txt"
    if ! curl -fsSL --retry 3 --retry-delay 2 \
        -o "${WORKDIR}/sha256sums.txt" \
        "${base}/sha256sums.txt"; then
        abort "Download failed: ${base}/sha256sums.txt. The release exists but the checksum manifest is missing — refusing to install without verification."
    fi
}

# --- Step 5: verify sha256 (mandatory) --------------------------------------
# macOS ships ``shasum -a 256``; Linux ships ``sha256sum``. We support
# both. The lookup keys by the full tarball name including ``-py${PYMINOR}``
# (the new naming from P-INST-02), so the matrix's per-python-minor
# tarballs all coexist in one sha256sums.txt without collision.
verify() {
    log "Verifying SHA-256 checksum"
    local expected actual
    expected="$(grep -E "[[:space:]]\*?${RESOLVED_TARBALL}\$" "${WORKDIR}/sha256sums.txt" | awk '{print $1}' | head -n1)"
    if [ -z "${expected}" ]; then
        abort "No sha256 entry for ${RESOLVED_TARBALL} in sha256sums.txt. The release is malformed — refusing to install."
    fi
    if command -v sha256sum >/dev/null 2>&1; then
        actual="$(sha256sum "${WORKDIR}/${RESOLVED_TARBALL}" | awk '{print $1}')"
    elif command -v shasum >/dev/null 2>&1; then
        actual="$(shasum -a 256 "${WORKDIR}/${RESOLVED_TARBALL}" | awk '{print $1}')"
    else
        abort "Neither sha256sum nor shasum is on PATH. Install GNU coreutils or perl ('brew install coreutils' on macOS)."
    fi
    if [ "${expected}" != "${actual}" ]; then
        abort "SHA-256 mismatch for ${RESOLVED_TARBALL}: expected=${expected} actual=${actual}. Refusing to install a tampered or corrupted artifact."
    fi
    ok "SHA-256 verified: ${expected:0:16}…"
}

# --- Step 6: install_binary -------------------------------------------------
# Extract; install -m 0755 the shiv zipapp to the install dir. Uses
# install(1) for the atomic-replace + mode-setting semantics (vs cp
# which can leave a partially-written file if interrupted).
install_binary() {
    local install_dir
    if [ -n "${SENTISEC_INSTALL_DIR:-}" ]; then
        install_dir="${SENTISEC_INSTALL_DIR}"
    elif [ "${EUID:-$(id -u)}" -eq 0 ]; then
        install_dir="/usr/local/bin"
    else
        install_dir="${HOME}/.local/bin"
    fi

    mkdir -p "${install_dir}" || abort "Could not create install dir: ${install_dir}"

    log "Extracting ${RESOLVED_TARBALL}"
    tar -xzf "${WORKDIR}/${RESOLVED_TARBALL}" -C "${WORKDIR}" \
        || abort "tar -xzf failed for ${RESOLVED_TARBALL}"

    # The tarball contains a single ``sentisec`` executable (the shiv
    # zipapp), per P-INST-02's packaging step. Some packagers put it
    # in a subdir; tolerate either layout.
    local src
    if [ -f "${WORKDIR}/sentisec" ]; then
        src="${WORKDIR}/sentisec"
    else
        src="$(find "${WORKDIR}" -maxdepth 3 -type f -name sentisec -perm -u+x | head -n1)"
    fi
    if [ -z "${src}" ] || [ ! -f "${src}" ]; then
        abort "Extracted tarball does not contain a 'sentisec' executable. Tarball is malformed."
    fi

    install -m 0755 "${src}" "${install_dir}/sentisec" \
        || abort "install(1) failed; could not place sentisec in ${install_dir}"
    BINARY_PATH="${install_dir}/sentisec"
    ok "Installed ${BINARY_PATH}"
}

# --- Step 7: ensure PATH ----------------------------------------------------
# Idempotent — grep before writing. Picks the rc file by $SHELL and
# falls back to ~/.profile when $SHELL is exotic / unset. The marker
# comment makes uninstall easy ("grep -v 'Added by sentisec install.sh'").
ensure_path() {
    local bin_dir
    bin_dir="$(dirname "${BINARY_PATH}")"
    case ":${PATH}:" in
        *":${bin_dir}:"*)
            log "PATH already contains ${bin_dir}"
            return 0
            ;;
    esac

    local profile
    case "${SHELL:-}" in
        */zsh)  profile="${HOME}/.zshrc" ;;
        */bash)
            # Linux uses .bashrc for interactive shells; macOS uses
            # .bash_profile. Touch both to be safe — idempotency check
            # below prevents duplicates.
            profile="${HOME}/.bashrc"
            ;;
        */fish) profile="${HOME}/.config/fish/config.fish" ;;
        *)      profile="${HOME}/.profile" ;;
    esac

    # Make sure the parent dir exists (fish in particular).
    mkdir -p "$(dirname "${profile}")" 2>/dev/null || true
    touch "${profile}" 2>/dev/null || {
        warn "Could not write to ${profile}; PATH not updated. Add this manually: export PATH=\"${bin_dir}:\$PATH\""
        return 0
    }

    # Idempotent: skip if our marker is already there.
    if grep -Fq "Added by sentisec install.sh" "${profile}" 2>/dev/null; then
        log "Profile ${profile} already updated by a previous install"
        return 0
    fi

    {
        printf '\n# Added by sentisec install.sh (%s)\n' "$(date -u +%Y-%m-%d)"
        case "${profile}" in
            *config.fish) printf 'set -gx PATH %s $PATH\n' "${bin_dir}" ;;
            *)            printf 'export PATH="%s:$PATH"\n' "${bin_dir}" ;;
        esac
    } >> "${profile}"

    PATH_UPDATED="${profile}"
    log "Appended PATH update to ${profile}"
}

# --- Environment checks ------------------------------------------------------
require_cmd() {
    command -v "$1" >/dev/null 2>&1 || abort "missing required command: $1. Install it and re-run."
}

check_env() {
    require_cmd curl
    require_cmd tar
    require_cmd grep
    require_cmd awk
    require_cmd sed
    # python3 is checked by detect_python with a richer error message.
}

# --- Main flow ---------------------------------------------------------------
main() {
    parse_args "$@"

    # Existing-credentials short-circuit runs FIRST so dry-run still
    # reports the paired state correctly (idempotency contract — covered
    # by tests/integration/test_install_sh.py::test_existing_credentials_short_circuits).
    if short_circuit_if_paired; then
        return 0
    fi

    if [ "${DRY_RUN}" -eq 1 ]; then
        log "Dry run — skipping detect / download / verify / install"
        print_post_install_marker
        print_post_install_banner
        return 0
    fi

    check_env
    detect_platform
    detect_python

    WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sentisec-install.XXXXXX")"
    # shellcheck disable=SC2064  # we WANT WORKDIR expanded now, not later.
    trap "rm -rf '${WORKDIR}'" EXIT

    fetch_manifest
    download
    verify
    install_binary
    ensure_path

    print_post_install_marker
    print_post_install_banner
}

main "$@"
