#!/usr/bin/env bash
# install.sultix.ai — one-line installer / updater for the sultix
# controller. Idempotent: every run brings the host to the latest
# released version. First run does the bootstrap (admin password,
# master.key, device CA, optional postgres, optional caddy).
# Subsequent runs detect the existing install and offer an update.
#
# Curl-pipe-bash entry:
#
#   curl -fsSL https://install.sultix.ai | bash
#
# Or with flags for unattended runs:
#
#   curl -fsSL https://install.sultix.ai | bash -s -- \
#       --yes --backend=postgres --proxy=caddy \
#       --proxy-domain=admin.example.com --admin-password=auto
#
# Targets v1: Ubuntu / Debian (apt) on amd64 + arm64. macOS and
# others are stubbed via dispatch functions (os_install_docker,
# os_install_unit, …) — adding a platform = filling those stubs.

set -euo pipefail

# ── presentation ──────────────────────────────────────────────────────
# Color codes degrade to plain when not a tty. Used by say/ok/warn/fail
# and the interactive prompts. Output is single-line so a curl-pipe
# rerun produces a clean log.
if [ -t 1 ]; then
    C_DIM='\033[2m'; C_RESET='\033[0m'
    C_CYAN='\033[1;36m'; C_GREEN='\033[1;32m'
    C_YELLOW='\033[1;33m'; C_RED='\033[1;31m'
else
    C_DIM=; C_RESET=; C_CYAN=; C_GREEN=; C_YELLOW=; C_RED=
fi

say()   { printf "${C_CYAN}▸${C_RESET} %s\n" "$*"; }
ok()    { printf "${C_GREEN}✓${C_RESET} %s\n" "$*"; }
warn()  { printf "${C_YELLOW}!${C_RESET} %s\n" "$*" >&2; }
fail()  { printf "${C_RED}✗${C_RESET} %s\n" "$*" >&2; exit 1; }
hr()    { printf "${C_DIM}────────────────────────────────────────────────${C_RESET}\n"; }

# ── flags + interactive defaults ──────────────────────────────────────
ASSUME_YES=0
FORCE=0
BACKEND="sqlite"          # sqlite | postgres
PROXY="none"              # none | caddy
PROXY_DOMAIN=""
PROXY_CERT=""
PROXY_KEY=""
ADMIN_USER="admin"
ADMIN_PASSWORD=""         # empty = auto-generate (interactive prompts)
MASTER_KEY_SOURCE="auto"  # auto | path:/foo/master.key | env
DATA_DIR=""               # filled in by os_default_data_dir
BIND="127.0.0.1:3000"
CLOUDFLARE_API_TOKEN_VAL=""

while [ $# -gt 0 ]; do
    case "$1" in
        --yes|-y)              ASSUME_YES=1 ;;
        --force)               FORCE=1 ;;
        --backend=*)           BACKEND="${1#*=}" ;;
        --proxy=*)             PROXY="${1#*=}" ;;
        --proxy-domain=*)      PROXY_DOMAIN="${1#*=}" ;;
        --proxy-cert=*)        PROXY_CERT="${1#*=}" ;;
        --proxy-key=*)         PROXY_KEY="${1#*=}" ;;
        --admin-user=*)        ADMIN_USER="${1#*=}" ;;
        --admin-password=*)    ADMIN_PASSWORD="${1#*=}" ;;
        --master-key=*)        MASTER_KEY_SOURCE="${1#*=}" ;;
        --data-dir=*)          DATA_DIR="${1#*=}" ;;
        --bind=*)              BIND="${1#*=}" ;;
        --cloudflare-api-token=*) CLOUDFLARE_API_TOKEN_VAL="${1#*=}" ;;
        --help|-h)
            cat <<HELP
sultix installer — fresh install + idempotent updates.

Usage:
    curl -fsSL https://install.sultix.ai | bash
    curl -fsSL https://install.sultix.ai | bash -s -- [flags]

Flags (all optional; interactive prompts ask for unset values):
    --yes, -y               run unattended; never prompt
    --force                 reinstall even if version matches latest
    --backend=sqlite|postgres
                            sqlite (default) or pgvector-in-docker
    --proxy=none|caddy      reverse proxy (default: none)
    --proxy-domain=DOMAIN   public domain that points at this host
                            (caddy + Let's Encrypt)
    --proxy-cert=PATH       BYO TLS cert (caddy + your cert)
    --proxy-key=PATH        BYO TLS key  (paired with --proxy-cert)
    --admin-user=NAME       initial admin username (default: admin)
    --admin-password=auto|<literal>
                            auto = generate 16-char password and print it
    --master-key=auto|path:/path|env
                            auto = first install creates one;
                            path: = import existing master.key;
                            env  = read SULTIX_MASTER_KEY at runtime
    --data-dir=PATH         override default data dir
    --bind=HOST:PORT        admin UI bind address (default 127.0.0.1:3000)
    --cloudflare-api-token=TOKEN
                            for caddy DNS-01 ACME (NAT'd hosts)

Interactive walkthrough is the default when stdin is a tty.
    curl ... | bash         interactive (asks every choice)
    curl ... | bash -s -- --yes
                            unattended (uses defaults; --backend etc.
                            override individual choices)
HELP
            exit 0
            ;;
        *)
            fail "unknown flag: $1 (try --help)"
            ;;
    esac
    shift
done

# Detect "we have a tty so we can prompt." curl-pipe-bash typically
# runs without a tty on stdin, but stdout may still be a tty. We use
# /dev/tty for prompts so --yes can flip the flow without losing the
# ability to print colored output.
INTERACTIVE=1
if [ "$ASSUME_YES" = "1" ] || [ ! -e /dev/tty ]; then
    INTERACTIVE=0
fi

confirm() {
    # confirm "Continue?" [Y/n]
    local prompt="$1"
    if [ "$INTERACTIVE" != "1" ]; then
        return 0
    fi
    local ans
    printf "%s [Y/n] " "$prompt" > /dev/tty
    read -r ans < /dev/tty || ans=""
    case "${ans:-Y}" in
        Y|y|YES|yes|"") return 0 ;;
        *) return 1 ;;
    esac
}

ask() {
    # ask "Prompt" "default-value"
    if [ "$INTERACTIVE" != "1" ]; then
        printf "%s\n" "$2"
        return
    fi
    local ans
    printf "%s [%s] " "$1" "$2" > /dev/tty
    read -r ans < /dev/tty || ans=""
    printf "%s\n" "${ans:-$2}"
}

ask_secret() {
    # ask_secret "Prompt"
    # Echoes the entered string. No default. Empty = caller decides.
    if [ "$INTERACTIVE" != "1" ]; then
        printf ""
        return
    fi
    local ans
    printf "%s: " "$1" > /dev/tty
    stty -echo < /dev/tty
    read -r ans < /dev/tty || ans=""
    stty echo < /dev/tty
    printf "\n" > /dev/tty
    printf "%s\n" "$ans"
}

# ── OS detection + abstraction ────────────────────────────────────────
# Each os_* function dispatches to a per-platform implementation. v1
# fills the linux_* family; darwin_* / windows_* return a clear
# "not supported yet" so adding a platform is finding the four
# unimplemented stubs.
detect_os() {
    case "$(uname -s)" in
        Linux)  OS="linux" ;;
        Darwin) OS="darwin" ;;
        MINGW*|MSYS*|CYGWIN*) OS="windows" ;;
        *) fail "unsupported OS: $(uname -s)" ;;
    esac
    case "$(uname -m)" in
        x86_64|amd64) ARCH="amd64" ;;
        aarch64|arm64) ARCH="arm64" ;;
        *) fail "unsupported architecture: $(uname -m)" ;;
    esac
    if [ "$OS" = "linux" ] && [ -r /etc/os-release ]; then
        # shellcheck disable=SC1091
        . /etc/os-release
        DISTRO="${ID:-unknown}"
        DISTRO_VERSION="${VERSION_ID:-unknown}"
        case "$DISTRO" in
            ubuntu|debian) PKG="apt" ;;
            *) PKG="unknown" ;;
        esac
    else
        DISTRO="${OS}"
        DISTRO_VERSION="$(uname -r)"
        PKG="unknown"
    fi
}

os_default_data_dir() {
    case "$OS" in
        linux)  printf '/var/lib/sultix\n' ;;
        darwin) printf '%s/Library/Application Support/sultix\n' "$HOME" ;;
        *)      fail "os_default_data_dir: $OS not yet supported" ;;
    esac
}

os_install_docker() {
    case "$OS" in
        linux)  linux_install_docker "$@" ;;
        darwin) darwin_install_docker_stub "$@" ;;
        *)      fail "os_install_docker: $OS not yet supported" ;;
    esac
}

os_install_unit() {
    case "$OS" in
        linux)  linux_install_systemd "$@" ;;
        darwin) darwin_install_launchd_stub "$@" ;;
        *)      fail "os_install_unit: $OS not yet supported" ;;
    esac
}

os_restart_service() {
    case "$OS" in
        linux)  sudo systemctl restart sultix-controller ;;
        darwin) darwin_restart_stub ;;
        *)      fail "os_restart_service: $OS not yet supported" ;;
    esac
}

os_service_status() {
    # Returns 0 if running, 1 otherwise. No output.
    case "$OS" in
        linux)  systemctl is-active --quiet sultix-controller ;;
        darwin) return 1 ;;  # stub
        *)      return 1 ;;
    esac
}

# ── Linux implementations ─────────────────────────────────────────────
linux_install_docker() {
    if command -v docker >/dev/null 2>&1; then
        ok "docker already installed ($(docker --version 2>/dev/null | head -1))"
        return 0
    fi
    say "installing docker (apt)"
    sudo apt-get update -qq
    sudo apt-get install -y -qq ca-certificates curl gnupg
    sudo install -m 0755 -d /etc/apt/keyrings
    # --batch + --yes so gpg never tries to open /dev/tty (the
    # script runs non-interactively when piped from curl).
    sudo rm -f /etc/apt/keyrings/docker.gpg
    curl -fsSL https://download.docker.com/linux/${DISTRO}/gpg | \
        sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
    sudo chmod a+r /etc/apt/keyrings/docker.gpg
    local codename
    codename="$(. /etc/os-release && echo "$VERSION_CODENAME")"
    echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DISTRO} ${codename} stable" \
        | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt-get update -qq
    sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
    sudo systemctl enable --now docker
    ok "docker installed"
}

linux_install_systemd() {
    # The controller's `sultix install` subcommand writes the
    # systemd unit + creates sultix-ctrl user + dirs. We just
    # invoke it; idempotent.
    say "running 'sultix install' (writes systemd unit, creates sultix-ctrl user)"
    sudo /usr/local/bin/sultix install
    ok "systemd unit + service user ready"
}

# ── Darwin / Windows stubs (fail cleanly until implemented) ───────────
darwin_install_docker_stub() {
    fail "darwin: install Docker Desktop or colima manually (https://www.docker.com/products/docker-desktop), then re-run this script"
}
darwin_install_launchd_stub() {
    fail "darwin: launchd unit not yet implemented; service install pending. Linux is the v1 target."
}
darwin_restart_stub() {
    fail "darwin: launchctl reload pending; restart manually for now"
}

# ── version + binary fetch ────────────────────────────────────────────
RELEASES_BASE="https://releases.sultix.ai"
LATEST_JSON_URL="${RELEASES_BASE}/latest.json"

current_version() {
    if command -v sultix >/dev/null 2>&1; then
        sultix --version 2>/dev/null | awk '{print $NF}' || echo "unknown"
    else
        echo ""
    fi
}

latest_version() {
    # latest.json published by the release pipeline:
    #   {"version":"0.4.133","released_at":"2026-04-26T..."}
    curl -fsSL --max-time 10 "$LATEST_JSON_URL" \
        | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
        | head -1 \
        | sed 's/.*"\([^"]*\)"$/\1/'
}

fetch_agent_image() {
    # fetch_agent_image <version>
    # Pulls the per-arch gzipped tarball, sha256-verifies, docker-loads.
    # Idempotent: skips if a same-version sultix-agent image is already
    # local (re-run of install.sh is a no-op for the agent image).
    local v="$1"
    local tag="sultix-agent:$v"
    local key="sultix-agent-${ARCH}.tar.gz"
    local url="${RELEASES_BASE}/v${v}/${key}"
    local sums_url="${RELEASES_BASE}/v${v}/SHA256SUMS"

    if sudo docker image inspect "$tag" >/dev/null 2>&1; then
        ok "agent image $tag already loaded"
        return 0
    fi

    say "downloading $key (may take a minute — 200-400 MiB)"
    local out=/tmp/sultix-agent.tar.gz
    local sums=/tmp/sultix-agent.sums
    curl -fL --progress-bar -o "$out" "$url" \
        || fail "download failed: $url"
    curl -fsSL -o "$sums" "$sums_url" \
        || fail "sha256sums fetch failed"

    say "verifying sha256"
    local expected got
    expected="$(grep "  ${key}\$" "$sums" | awk '{print $1}')"
    [ -n "$expected" ] || fail "no sha256 entry for $key in SHA256SUMS"
    got="$(shasum -a 256 "$out" | awk '{print $1}')"
    [ "$expected" = "$got" ] || fail "sha256 mismatch on agent image"
    ok "sha256 verified"

    say "loading agent image into docker"
    gzip -dc "$out" | sudo docker load
    rm -f "$out" "$sums"
    # Ensure :latest also points at this version, since the controller
    # spawns agent containers with the implicit :latest tag.
    sudo docker tag "$tag" "sultix-agent:latest"
    ok "agent image loaded as $tag (also tagged latest)"
}

fetch_binary() {
    # fetch_binary <version> → /tmp/sultix.new
    local v="$1"
    local url="${RELEASES_BASE}/v${v}/sultix-${OS}-${ARCH}"
    local sums_url="${RELEASES_BASE}/v${v}/SHA256SUMS"
    local out=/tmp/sultix.new
    local sums=/tmp/sultix.sums

    say "downloading sultix-${OS}-${ARCH} v${v}"
    curl -fL --progress-bar -o "$out" "$url" \
        || fail "download failed: $url"
    curl -fsSL -o "$sums" "$sums_url" \
        || fail "sha256sums download failed: $sums_url"

    say "verifying sha256"
    local expected got
    expected="$(grep "  sultix-${OS}-${ARCH}\$" "$sums" | awk '{print $1}')"
    [ -n "$expected" ] || fail "no sha256 entry for sultix-${OS}-${ARCH} in SHA256SUMS"
    got="$(shasum -a 256 "$out" | awk '{print $1}')"
    [ "$expected" = "$got" ] || fail "sha256 mismatch (expected=$expected got=$got)"
    ok "sha256 verified"

    chmod +x "$out"
    sudo mv "$out" /usr/local/bin/sultix
    rm -f "$sums"
    ok "installed /usr/local/bin/sultix"
}

# ── bootstrap helpers ─────────────────────────────────────────────────
gen_password() {
    # 16 chars, alnum, no shell-special. Sourced from /dev/urandom.
    #
    # The tr | head -c 16 pattern is the obvious-looking shape but
    # it explodes under `set -euo pipefail`: head exits after 16
    # bytes, tr gets a SIGPIPE, the pipeline reports the SIGPIPE as
    # a non-zero exit, pipefail propagates it, set -e exits the
    # script — silently, mid-function. Wrap in a subshell so the
    # SIGPIPE doesn't escape to the outer shell's pipefail check.
    ( set +o pipefail
      LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 )
}

# ── argument validation ───────────────────────────────────────────────
validate_args() {
    case "$BACKEND" in
        sqlite|postgres) ;;
        *) fail "--backend must be sqlite or postgres (got: $BACKEND)" ;;
    esac
    case "$PROXY" in
        none|caddy) ;;
        *) fail "--proxy must be none or caddy (got: $PROXY)" ;;
    esac
    if [ "$PROXY" = "caddy" ]; then
        if [ -n "$PROXY_CERT" ] && [ -z "$PROXY_KEY" ]; then
            fail "--proxy-cert requires --proxy-key"
        fi
        if [ -n "$PROXY_KEY" ] && [ -z "$PROXY_CERT" ]; then
            fail "--proxy-key requires --proxy-cert"
        fi
        if [ -z "$PROXY_DOMAIN" ] && [ -z "$PROXY_CERT" ]; then
            # Interactive will ask; unattended needs one or the other.
            if [ "$INTERACTIVE" != "1" ]; then
                fail "--proxy=caddy needs --proxy-domain (ACME) or --proxy-cert/--proxy-key (BYO)"
            fi
        fi
    fi
}

# ── postgres (linux) ──────────────────────────────────────────────────
# Brings up pgvector/pgvector:pg16 in a docker container bound to
# 127.0.0.1:5432, named volume sultix-postgres-data, random
# password persisted to /home/sultix-ctrl/.sultix-postgres.env.
# Idempotent: existing container/volume reused; existing creds file
# wins so reruns don't rotate the password under a running install.
# Writes db.json into the controller's data dir so OpenSmart picks
# postgres on next restart.
linux_setup_postgres() {
    local env_file="/home/sultix-ctrl/.sultix-postgres.env"
    say "postgres: pgvector/pgvector:pg16 in docker"

    if sudo test -f "$env_file"; then
        ok "postgres: existing creds at $env_file (reusing)"
    else
        local pass
        pass="$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48)"
        sudo install -d -m 0700 -o sultix-ctrl -g sultix-ctrl /home/sultix-ctrl
        sudo tee "$env_file" >/dev/null <<EOF
PG_USER=sultix
PG_DB=sultix
PG_PASS=$pass
PG_HOST=127.0.0.1
PG_PORT=5432
PG_CONTAINER=sultix-postgres
PG_VOLUME=sultix-postgres-data
PG_IMAGE=pgvector/pgvector:pg16
EOF
        sudo chmod 600 "$env_file"
        sudo chown sultix-ctrl:sultix-ctrl "$env_file"
        ok "postgres: generated creds → $env_file"
    fi

    # Source via sudo so the file's mode 0600 doesn't block us
    local pg_user pg_db pg_pass pg_port
    pg_user=$(sudo grep '^PG_USER=' "$env_file" | cut -d= -f2)
    pg_db=$(sudo grep '^PG_DB=' "$env_file" | cut -d= -f2)
    pg_pass=$(sudo grep '^PG_PASS=' "$env_file" | cut -d= -f2)
    pg_port=$(sudo grep '^PG_PORT=' "$env_file" | cut -d= -f2)

    say "postgres: pulling image"
    sudo -u sultix-ctrl docker pull -q pgvector/pgvector:pg16

    if sudo -u sultix-ctrl docker volume inspect sultix-postgres-data >/dev/null 2>&1; then
        ok "postgres: volume sultix-postgres-data already exists"
    else
        sudo -u sultix-ctrl docker volume create sultix-postgres-data >/dev/null
        ok "postgres: volume sultix-postgres-data created"
    fi

    if sudo -u sultix-ctrl docker ps -a --filter name='^sultix-postgres$' --format '{{.Names}}' \
            | grep -qx sultix-postgres; then
        ok "postgres: container sultix-postgres exists (leaving alone)"
    else
        sudo -u sultix-ctrl docker run -d \
            --name sultix-postgres \
            --restart unless-stopped \
            -p "127.0.0.1:${pg_port}:5432" \
            -v sultix-postgres-data:/var/lib/postgresql/data \
            -e POSTGRES_USER="$pg_user" \
            -e POSTGRES_DB="$pg_db" \
            -e POSTGRES_PASSWORD="$pg_pass" \
            pgvector/pgvector:pg16 >/dev/null
        ok "postgres: container started"
    fi

    say "postgres: waiting for pg_isready"
    local i=0
    while [ $i -lt 60 ]; do
        if sudo -u sultix-ctrl docker exec sultix-postgres \
                pg_isready -U "$pg_user" -d "$pg_db" >/dev/null 2>&1; then
            break
        fi
        i=$((i+1)); sleep 1
    done
    [ $i -lt 60 ] || fail "postgres: pg_isready timeout after 60s"
    ok "postgres: ready (after ${i}s)"

    sudo -u sultix-ctrl docker exec -e PGPASSWORD="$pg_pass" sultix-postgres \
        psql -U "$pg_user" -d "$pg_db" -c "CREATE EXTENSION IF NOT EXISTS vector;" >/dev/null
    ok "postgres: vector extension enabled"

    # Write db.json so OpenSmart picks postgres on next boot.
    local db_dir="$DATA_DIR/db"
    sudo install -d -m 0700 -o sultix-ctrl -g sultix-ctrl "$db_dir"
    sudo tee "$db_dir/db.json" >/dev/null <<EOF
{"backend":"postgres","postgres_url":"postgres://${pg_user}:${pg_pass}@127.0.0.1:${pg_port}/${pg_db}?sslmode=disable"}
EOF
    sudo chmod 600 "$db_dir/db.json"
    sudo chown sultix-ctrl:sultix-ctrl "$db_dir/db.json"
    ok "postgres: db.json written → $db_dir/db.json"

    say "postgres: restarting controller to pick up backend swap"
    sudo systemctl restart sultix-controller
    sleep 2
    if sudo journalctl -u sultix-controller -n 30 --no-pager 2>/dev/null \
            | grep -q "backend=postgres"; then
        ok "postgres: controller is now on postgres"
    else
        warn "postgres: controller didn't log backend=postgres yet — check 'journalctl -u sultix-controller'"
    fi
}

# ── caddy reverse proxy (linux) ───────────────────────────────────────
# Three modes:
#   - --proxy-domain=DOMAIN  → ACME HTTP-01 (or DNS-01 if NAT'd and
#     CLOUDFLARE_API_TOKEN is set)
#   - --proxy-cert + --proxy-key → BYO cert; caddy doesn't talk ACME
#   - (no domain, no cert) → interactive mode prompts; --yes errors
#     out earlier in validate_args
linux_setup_caddy() {
    if ! command -v caddy >/dev/null 2>&1; then
        say "caddy: installing (apt)"
        sudo apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https \
            >/dev/null 2>&1 || true
        sudo rm -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
            | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
            | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
        sudo apt-get update -qq
        sudo apt-get install -y -qq caddy
        ok "caddy: installed"
    else
        ok "caddy: already installed ($(caddy version 2>&1 | head -1))"
    fi

    # Caddyfile: we own a single sites-available snippet that we
    # import into the master Caddyfile. The user owns everything
    # else; uninstall touches only our file.
    sudo install -d -m 0755 /etc/caddy/sites-available
    local snippet=/etc/caddy/sites-available/sultix.caddyfile

    if [ -n "$PROXY_CERT" ]; then
        # BYO cert mode. The user-supplied PEMs are referenced in
        # place; we don't copy them so a key rotation by replacing
        # the source file just works after `systemctl reload caddy`.
        sudo tee "$snippet" >/dev/null <<EOF
# generated by install.sultix.ai — sultix admin reverse proxy
# BYO TLS cert mode: serves on the address caddy listens at by
# default (whichever site_addresses you've set globally).
:443 {
    tls $PROXY_CERT $PROXY_KEY
    reverse_proxy localhost:${BIND##*:}
}
EOF
        ok "caddy: BYO cert config written ($snippet)"
    else
        # ACME mode. Caddy auto-issues from Let's Encrypt; the
        # cloudflare token (if set) goes in via the dns01 module
        # for the tls config. Without DNS-01 module support
        # bundled into the standard caddy package, DNS-01 needs
        # `caddy-dns/cloudflare` plugin — we'll print a warning
        # if the user passed a token but the bundled caddy doesn't
        # speak DNS-01, so they know to install the plugin.
        if [ -n "$CLOUDFLARE_API_TOKEN_VAL" ]; then
            sudo tee "$snippet" >/dev/null <<EOF
# generated by install.sultix.ai — sultix admin reverse proxy
# ACME via DNS-01 (Cloudflare). Requires the caddy-dns/cloudflare
# plugin; falls back to HTTP-01 if the plugin is missing.
$PROXY_DOMAIN {
    tls {
        dns cloudflare $CLOUDFLARE_API_TOKEN_VAL
    }
    reverse_proxy localhost:${BIND##*:}
}
EOF
            warn "caddy: DNS-01 config written. If Caddy reload fails with"
            warn "       'unknown directive: dns cloudflare', install the"
            warn "       cloudflare plugin: https://caddyserver.com/docs/build"
        else
            sudo tee "$snippet" >/dev/null <<EOF
# generated by install.sultix.ai — sultix admin reverse proxy
# ACME via HTTP-01. Caddy retries indefinitely if the challenge
# fails, so a NAT'd host stays online (just without TLS) until
# the operator either forwards :80 or switches to DNS-01.
$PROXY_DOMAIN {
    reverse_proxy localhost:${BIND##*:}
}
EOF
        fi
        ok "caddy: ACME config written for $PROXY_DOMAIN ($snippet)"
    fi

    # Master Caddyfile: ensure it imports sites-available/*.
    if ! sudo grep -q 'import sites-available' /etc/caddy/Caddyfile 2>/dev/null; then
        sudo tee -a /etc/caddy/Caddyfile >/dev/null <<EOF

# Added by install.sultix.ai — pull in per-site snippets.
import sites-available/*
EOF
        ok "caddy: imported sites-available/* in /etc/caddy/Caddyfile"
    fi

    sudo systemctl enable --now caddy
    sudo systemctl reload caddy
    ok "caddy: running + reloaded"
}

# ── admin password bootstrap ──────────────────────────────────────────
# Calls `sultix admin reset-password <pw>` once the controller is
# up. Either:
#   --admin-password=<literal>  set to that value
#   --admin-password=auto       generate a 16-char password
#   (interactive; no flag)      prompt
#   ASSUME_YES + no flag        leave at default sultix00 + warn
bootstrap_admin_password() {
    local pw="$ADMIN_PASSWORD"

    if [ "$pw" = "auto" ]; then
        pw="$(gen_password)"
    elif [ -z "$pw" ] && [ "$INTERACTIVE" = "1" ]; then
        local p1 p2
        printf "\n${C_CYAN}Initial admin password${C_RESET} (leave blank to auto-generate)\n" > /dev/tty
        p1="$(ask_secret "  password")"
        if [ -z "$p1" ]; then
            pw="$(gen_password)"
            ok "auto-generated admin password (will print at end)"
        else
            p2="$(ask_secret "  confirm ")"
            if [ "$p1" != "$p2" ]; then
                fail "passwords don't match"
            fi
            pw="$p1"
        fi
    fi

    if [ -z "$pw" ]; then
        # Unattended without --admin-password: leave the default
        # in place, warn loudly, fix-it instructions in the summary.
        warn "no admin password set — controller booted with the default"
        warn "  username: admin   password: sultix00"
        warn "Set immediately:  sudo sultix admin reset-password"
        ADMIN_PASSWORD_DISPLAY="sultix00 (DEFAULT — change immediately)"
        return 0
    fi

    say "setting admin password (sultix admin reset-password)"
    # The admin CLI needs to find the data dir. systemd unit sets
    # SULTIX_DATA_DIR=/var/lib/sultix; we use the same here so the
    # CLI opens the same store the controller is actively using.
    if sudo SULTIX_DATA_DIR="$DATA_DIR" /usr/local/bin/sultix admin reset-password "$pw" \
            >/dev/null 2>&1; then
        ok "admin password set"
        ADMIN_PASSWORD_DISPLAY="$pw"
    else
        warn "admin password reset failed — falling back to default sultix00"
        ADMIN_PASSWORD_DISPLAY="sultix00 (DEFAULT — reset failed; try 'sudo sultix admin reset-password')"
    fi
}

# ── flow ──────────────────────────────────────────────────────────────
main() {
    detect_os
    validate_args

    [ -z "$DATA_DIR" ] && DATA_DIR="$(os_default_data_dir)"

    hr
    printf "${C_CYAN}sultix installer${C_RESET}\n"
    printf "  os         %s/%s (%s %s)\n" "$OS" "$ARCH" "$DISTRO" "$DISTRO_VERSION"
    printf "  data dir   %s\n" "$DATA_DIR"
    printf "  bind       %s\n" "$BIND"
    printf "  backend    %s\n" "$BACKEND"
    printf "  proxy      %s%s\n" "$PROXY" \
        "$([ -n "$PROXY_DOMAIN" ] && printf " (%s)" "$PROXY_DOMAIN")"
    hr

    if [ "$OS" != "linux" ]; then
        fail "v1 supports Linux (Ubuntu/Debian) only — $OS coming soon"
    fi
    if [ "$PKG" != "apt" ]; then
        fail "v1 supports apt-based distros (Ubuntu/Debian) only — $DISTRO coming soon"
    fi

    # ── version branch: fresh install / update / current / dev ──────
    local cur lat
    cur="$(current_version)"
    lat="$(latest_version)" || fail "could not fetch $LATEST_JSON_URL — check network"
    [ -n "$lat" ] || fail "latest version empty in $LATEST_JSON_URL"

    if [ -z "$cur" ]; then
        say "no existing install — fresh install of v$lat"
    elif [ "$cur" = "$lat" ] && [ "$FORCE" != "1" ]; then
        ok "sultix v$cur is current — nothing to do (rerun with --force to reinstall)"
        exit 0
    elif [ "$cur" = "$lat" ] && [ "$FORCE" = "1" ]; then
        say "v$cur is current; --force given, will reinstall"
    elif [ "$cur" \> "$lat" ]; then
        warn "installed v$cur is NEWER than latest v$lat (dev build?). Skipping."
        exit 0
    else
        say "updating sultix v$cur → v$lat"
    fi

    confirm "Proceed?" || { say "aborted"; exit 0; }

    # ── docker (always — agent containers need it) ──────────────────
    os_install_docker

    # ── fetch + install binary ──────────────────────────────────────
    fetch_binary "$lat"

    # ── fetch + load agent docker image ─────────────────────────────
    # Without this, the controller can't spawn agents — image-build
    # fails with "no Dockerfile at /opt/sultix-agent". The release
    # pipeline ships per-arch gzipped tarballs alongside binaries.
    fetch_agent_image "$lat"

    # ── master.key import (must happen BEFORE first boot) ───────────
    case "$MASTER_KEY_SOURCE" in
        auto)
            say "master.key will be auto-generated on first boot"
            ;;
        path:*)
            local src="${MASTER_KEY_SOURCE#path:}"
            [ -r "$src" ] || fail "master.key source not readable: $src"
            sudo install -d -m 0700 "$DATA_DIR"
            sudo install -m 0600 "$src" "$DATA_DIR/master.key"
            ok "imported master.key from $src"
            ;;
        env)
            ok "controller will read SULTIX_MASTER_KEY at runtime"
            ;;
        *)
            fail "--master-key must be auto, path:/foo, or env"
            ;;
    esac

    # ── service install (writes systemd unit, creates user, starts) ─
    os_install_unit

    # ── postgres if requested ───────────────────────────────────────
    if [ "$BACKEND" = "postgres" ]; then
        linux_setup_postgres
    fi

    # ── caddy if requested ──────────────────────────────────────────
    if [ "$PROXY" = "caddy" ]; then
        linux_setup_caddy
    fi

    # ── admin password ──────────────────────────────────────────────
    bootstrap_admin_password

    # ── final summary ───────────────────────────────────────────────
    local ui_url="http://$BIND"
    if [ "$PROXY" = "caddy" ] && [ -n "$PROXY_DOMAIN" ]; then
        ui_url="https://$PROXY_DOMAIN"
    fi
    hr
    ok "sultix installed"
    printf "  admin UI         %s\n" "$ui_url"
    printf "  admin user       %s\n" "$ADMIN_USER"
    printf "  admin password   %s\n" "${ADMIN_PASSWORD_DISPLAY:-sultix00 (DEFAULT — change immediately in Settings)}"
    printf "  data dir         %s\n" "$DATA_DIR"
    printf "  master key       %s/master.key\n" "$DATA_DIR"
    printf "  device CA        %s/device-ca/\n" "$DATA_DIR"
    printf "  backend          %s\n" "$BACKEND"
    [ "$PROXY" = "caddy" ] && printf "  caddy            /etc/caddy/sites-available/sultix.caddyfile\n"
    printf "  logs             journalctl -u sultix-controller -f\n"
    printf "  uninstall        curl -fsSL https://uninstall.sultix.ai | bash\n"
    hr
    warn "master.key + device-ca/ are NOT in DB backups. Copy them"
    warn "alongside any .sultixdb file when migrating to a new host."
}

main "$@"
