#!/usr/bin/env bash
# ==============================================================================
# Plexicus Evaluator Installer
#
#   curl -fsSL https://docs.plexicus.ai/install.sh -o install.sh
#   sudo -E bash install.sh
#
# Installs a complete single-node Plexicus evaluation environment on a fresh
# Ubuntu 22.04/24.04 (amd64) VM: k3s, ingress-nginx, TLS certificates from a
# local evaluation CA (Burp/ZAP friendly: proper CN + SANs, importable root),
# all Kubernetes Secrets with per-install random passwords, the Plexicus Helm
# chart with bundled infrastructure, and a verified admin account.
#
# Required inputs (env vars, or interactive prompt when a TTY is available):
#   PLEXICUS_GAR_KEY_FILE   Path to the registry service-account JSON key
#   PLEXICUS_LICENSE_FILE   Path to your evaluation license (license.jwt)
#   PLEXICUS_AI_KEY         OpenAI-compatible API key (AI enrichment/remediation
#                           + AI SAST). Normally supplied by Plexicus inside the
#                           plexicus-eval.env of your delivery package — see
#                           PLEXICUS_ENV_FILE below. Set it here to bring your
#                           own key instead; Plexicus uses DeepInfra by default,
#                           get a key at https://deepinfra.com.
#
# Optional — SCM connectors (GitHub, GitLab, Bitbucket Cloud):
#   NONE of these is required to install. The evaluator installs and runs its
#   first scan with no SCM at all: the Sandbox onboarding flow uses a synthetic
#   connector. Connect a real SCM afterwards from the UI, or pre-configure one
#   here by supplying its values.
#   A connector is written to the chart only when BOTH its client id and its
#   secret are present. A half-filled connector is worse than none: it renders
#   a "Connect" button that can only fail with a 400 at token exchange.
#   GitHub can be registered either as a plain OAuth App (client id + secret)
#   or as a full GitHub App (add app id, installation URL and private key —
#   that flow additionally enables webhooks and per-repository install/
#   uninstall). The platform has two distinct GitHub OAuth flows that each
#   need their own registered callback: /api/callback/github (sign-in) and
#   /vulnerability-tool/callback/github (the repo-scanning connector).
#   Self-hosted GitLab needs no separate installer input: register the app
#   against gitlab.com here, then connect a self-hosted instance's own client
#   id/secret later, per-connection, in the UI.
#   PLEXICUS_GITHUB_CLIENT_ID        Client id of a GitHub App or OAuth App.
#                                    Callback URLs: https://<PLEXICUS_DOMAIN>/api/callback/github
#                                    and https://api.<PLEXICUS_DOMAIN>/vulnerability-tool/callback/github
#   PLEXICUS_GITHUB_CLIENT_SECRET    Client secret of that App.
#   PLEXICUS_GITHUB_APP_ID           GitHub App id (numeric). GitHub App flow only.
#   PLEXICUS_GITHUB_APP_INSTALLATION_URL https://github.com/apps/<slug>/installations/new
#   PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE Path to the App's private key .pem
#   PLEXICUS_GITLAB_CLIENT_ID        Client id of a GitLab OAuth application (gitlab.com).
#                                    Callback URL: https://<PLEXICUS_DOMAIN>/api/callback/gitlab
#   PLEXICUS_GITLAB_CLIENT_SECRET    Client secret of that application.
#   PLEXICUS_BITBUCKET_CLIENT_ID     "Key" of a Bitbucket Cloud OAuth consumer.
#                                    Callback URL: https://<PLEXICUS_DOMAIN>/api/callback/bitbucket_cloud
#   PLEXICUS_BITBUCKET_CLIENT_SECRET "Secret" of that consumer.
#
# Optional — everything else:
#   PLEXICUS_ENV_FILE       Path to the plexicus-eval.env file shipped in your Plexicus
#                           delivery package (alongside keys.json and license.jwt). It
#                           carries the Plexicus-managed AI key and any pre-set install
#                           settings as plain PLEXICUS_*=value lines. Sourced before
#                           anything else, so PLEXICUS_CHART_VERSION / PLEXICUS_DOMAIN
#                           delivered in it take effect. Any PLEXICUS_* variable you
#                           export explicitly WINS over the file's value (bring your own
#                           AI key: PLEXICUS_AI_KEY=... overrides the delivered one).
#   PLEXICUS_AI_BASE_URL    OpenAI-compatible endpoint. Default: https://api.deepinfra.com/v1/openai
#                           (OpenAI: https://api.openai.com/v1, DeepSeek: https://api.deepseek.com)
#   PLEXICUS_AI_MODEL       Model id at that endpoint. Default: deepseek-ai/DeepSeek-V4-Flash
#                           (the DeepSeek flagship as served by DeepInfra)
#   PLEXICUS_AI_SCAN_API_KEY          API key for the AI SAST (SocratiCode) scan engine's
#                                     own LLM calls. Default: reuses PLEXICUS_AI_KEY.
#   PLEXICUS_AI_SCAN_BASE_URL         Default: reuses PLEXICUS_AI_BASE_URL.
#   PLEXICUS_AI_SCAN_MODEL            Default: reuses PLEXICUS_AI_MODEL.
#   PLEXICUS_AI_SCAN_EMBEDDING_API_KEY  API key for the AI SAST embedding endpoint
#                                     (model BAAI/bge-m3, 1024 dims). Every AI SAST scan
#                                     indexes the repo with this model first, and the
#                                     endpoint must resolve to a provider that serves it
#                                     (DeepInfra is the default; DeepSeek and OpenAI do
#                                     not serve BAAI/bge-m3).
#                                     Default: reuses PLEXICUS_AI_KEY while
#                                     PLEXICUS_AI_BASE_URL stays on DeepInfra (the
#                                     default); with any other chat endpoint you are
#                                     prompted for a separate DeepInfra key
#                                     (get one at https://deepinfra.com).
#   PLEXICUS_AI_SCAN_EMBEDDING_BASE_URL  Default: https://api.deepinfra.com/v1/openai
#   PLEXICUS_DOMAIN         Default: plexicus.local
#   PLEXICUS_ADMIN_EMAIL    Default: admin@example.com
#   PLEXICUS_ADMIN_PASSWORD Default: generated
#   PLEXICUS_CHART_VERSION  Default: 1.2.32
#
# Request registry credentials and an evaluation license at:
#   engineering@plexicus.ai
# ==============================================================================
set -euo pipefail

# -------------------------------------------------------------------- output
C_GREEN=$'\033[1;32m'; C_RED=$'\033[1;31m'; C_YELLOW=$'\033[1;33m'; C_BLUE=$'\033[1;34m'; C_OFF=$'\033[0m'
say()  { echo "${C_BLUE}==>${C_OFF} $*"; }
ok()   { echo "${C_GREEN}  [ok]${C_OFF} $*"; }
warn() { echo "${C_YELLOW}  [warn]${C_OFF} $*"; }
die()  { echo "${C_RED}ERROR:${C_OFF} $*" >&2; exit 1; }

# ------------------------------------------------- delivery package env file
# plexicus-eval.env from the Plexicus delivery package: PLEXICUS_*=value lines
# carrying the Plexicus-managed AI key and any pre-set install settings.
#
# Sourced HERE, above the constants block, on purpose. CHART_VERSION, DOMAIN and
# the API_DOMAIN derived from DOMAIN are resolved once, below; sourcing the file
# after that point would leave PLEXICUS_CHART_VERSION and PLEXICUS_DOMAIN from
# the file silently ignored — the built-in defaults still work, so the bug would
# only ever surface as "the version we shipped you is not the one installed".
if [ -n "${PLEXICUS_ENV_FILE:-}" ]; then
  [ -f "$PLEXICUS_ENV_FILE" ] || die "PLEXICUS_ENV_FILE not found: $PLEXICUS_ENV_FILE (it ships as plexicus-eval.env in your Plexicus delivery package)"
  [ -r "$PLEXICUS_ENV_FILE" ] || die "PLEXICUS_ENV_FILE is not readable: $PLEXICUS_ENV_FILE"
  # scp and most unzip paths land files at 0644. Tighten and continue — refusing
  # here would turn every delivery into a guaranteed first-run failure.
  ENV_FILE_MODE=$(stat -c '%a' "$PLEXICUS_ENV_FILE" 2>/dev/null || echo "")
  case "$ENV_FILE_MODE" in
    ''|*00) ;;
    *) warn "$PLEXICUS_ENV_FILE is mode ${ENV_FILE_MODE} (world/group readable) — tightening to 600" ;;
  esac
  chmod 600 "$PLEXICUS_ENV_FILE" 2>/dev/null || warn "could not chmod 600 $PLEXICUS_ENV_FILE — continuing"
  # Same save/restore contract as credentials.env below: a plain `VAR=value` in
  # the file always overwrites, so an explicitly-exported value (bring your own
  # AI key at install time) would be clobbered back to the delivered one. Every
  # PLEXICUS_* variable already in the environment is saved here and restored
  # after sourcing, so explicit input always wins over the file.
  ENV_FILE_VARS=$(compgen -v | grep '^PLEXICUS_' || true)
  for v in $ENV_FILE_VARS; do
    # [ -z ] cannot tell "unset" from "explicitly set to empty" — an operator
    # exporting PLEXICUS_AI_KEY= (empty, to force the later prompt) would be
    # indistinguishable from not having set it at all, and the file's value
    # would win. Record presence separately via indirect ${!v+set}.
    [ "${!v+set}" = set ] && printf -v "_ENVFILE_SET_$v" '1'
    printf -v "_ENVFILE_PRESET_$v" '%s' "${!v:-}"
  done
  # shellcheck disable=SC1090
  . "$PLEXICUS_ENV_FILE"
  for v in $ENV_FILE_VARS; do
    s="_ENVFILE_SET_$v"
    p="_ENVFILE_PRESET_$v"
    [ "${!s:-}" = 1 ] && printf -v "$v" '%s' "${!p}"
  done
  ok "settings loaded from $PLEXICUS_ENV_FILE (explicitly-set PLEXICUS_* variables take precedence)"
fi

# ------------------------------------------------------------------ constants
REGISTRY_HOST="europe-west3-docker.pkg.dev"
CHART_OCI="oci://${REGISTRY_HOST}/plexicus-registry/charts/plexicus"
CHART_VERSION="${PLEXICUS_CHART_VERSION:-1.2.32}"
DOMAIN="${PLEXICUS_DOMAIN:-plexicus.local}"
API_DOMAIN="api.${DOMAIN}"
NAMESPACE="plexicus"
STATE_DIR="/root/plexicus-eval"
KUBECONFIG_PATH="/etc/rancher/k3s/k3s.yaml"
INGRESS_NGINX_VERSION="4.11.3"

# --------------------------------------------------------------- input helper
# Works when the script is piped into bash: prompts read from /dev/tty.
ask() { # ask <var-name> <prompt> [secret]
  local var="$1" prompt="$2" secret="${3:-}" value=""
  if [ -e /dev/tty ]; then
    if [ -n "$secret" ]; then
      read -r -s -p "$prompt: " value < /dev/tty; echo
    else
      read -r -p "$prompt: " value < /dev/tty
    fi
    printf -v "$var" '%s' "$value"
  else
    die "$var is not set and no TTY is available for prompting. Set it as an environment variable."
  fi
}

# ------------------------------------------------------------------ preflight
say "Preflight checks"
[ "$(id -u)" -eq 0 ] || die "Run as root (sudo)."
[ "$(uname -m)" = "x86_64" ] || die "amd64/x86_64 required (found $(uname -m))."
grep -qiE 'ubuntu' /etc/os-release || warn "This installer is validated on Ubuntu 22.04/24.04; continuing anyway."

CPUS=$(nproc)
RAM_GB=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') / 1024 / 1024 ))
DISK_GB=$(df -BG --output=avail / | tail -1 | tr -dc '0-9')
[ "$CPUS" -ge 4 ]    || die "Need >= 4 vCPU (found ${CPUS})."
[ "$RAM_GB" -ge 7 ]  || die "Need >= 7 GB RAM (found ${RAM_GB} GB)."
[ "$DISK_GB" -ge 40 ] || die "Need >= 40 GB free disk on / (found ${DISK_GB} GB)."
ok "resources: ${CPUS} vCPU, ${RAM_GB} GB RAM, ${DISK_GB} GB free disk"

for port in 80 443 6443; do
  if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}$"; then
    # k3s/ingress already listening is fine on re-run; anything else is not.
    if ! systemctl is-active --quiet k3s 2>/dev/null; then
      die "Port ${port} is already in use and k3s is not the owner."
    fi
  fi
done
ok "ports 80/443/6443 available (or already owned by k3s)"

# --------------------------------------------------------------------- inputs
say "Collecting inputs"
# Sourced early so a prior run's SCM OAuth values below are already in the
# environment on a re-run — otherwise a re-run for an unrelated fix would
# silently drop a connector that the first run configured, since none of these
# values is prompted for any more.
mkdir -p "$STATE_DIR" && chmod 700 "$STATE_DIR"
ENVF="$STATE_DIR/credentials.env"
SCM_VARS="PLEXICUS_GITHUB_CLIENT_ID PLEXICUS_GITHUB_CLIENT_SECRET
  PLEXICUS_GITHUB_APP_ID PLEXICUS_GITHUB_APP_INSTALLATION_URL
  PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE
  PLEXICUS_GITLAB_CLIENT_ID PLEXICUS_GITLAB_CLIENT_SECRET
  PLEXICUS_BITBUCKET_CLIENT_ID PLEXICUS_BITBUCKET_CLIENT_SECRET"
if [ -f "$ENVF" ]; then
  # A plain `VAR=value` in $ENVF always overwrites, so an explicitly-passed
  # value (e.g. rotating a secret on re-run) would otherwise be silently
  # clobbered back to the cached one. Save it here, restore it after sourcing.
  for v in $SCM_VARS; do printf -v "_PRESET_$v" '%s' "${!v:-}"; done
  # shellcheck disable=SC1090
  . "$ENVF"
  for v in $SCM_VARS; do
    p="_PRESET_$v"
    [ -z "${!p}" ] || printf -v "$v" '%s' "${!p}"
  done
  ok "reusing credentials from $ENVF (re-run detected)"
fi
[ -n "${PLEXICUS_GAR_KEY_FILE:-}" ]  || ask PLEXICUS_GAR_KEY_FILE  "Path to registry key JSON (from engineering@plexicus.ai)"
[ -n "${PLEXICUS_LICENSE_FILE:-}" ]  || ask PLEXICUS_LICENSE_FILE  "Path to evaluation license (license.jwt)"
[ -n "${PLEXICUS_AI_KEY:-}" ]        || ask PLEXICUS_AI_KEY        "DeepInfra (or OpenAI-compatible) API key" secret

[ -f "$PLEXICUS_GAR_KEY_FILE" ] || die "Registry key file not found: $PLEXICUS_GAR_KEY_FILE"
[ -f "$PLEXICUS_LICENSE_FILE" ] || die "License file not found: $PLEXICUS_LICENSE_FILE"
grep -q '"private_key"' "$PLEXICUS_GAR_KEY_FILE" || die "$PLEXICUS_GAR_KEY_FILE does not look like a service-account JSON key."
ADMIN_EMAIL="${PLEXICUS_ADMIN_EMAIL:-admin@example.com}"
AI_BASE_URL="${PLEXICUS_AI_BASE_URL:-https://api.deepinfra.com/v1/openai}"
AI_MODEL="${PLEXICUS_AI_MODEL:-deepseek-ai/DeepSeek-V4-Flash}"

# AI SAST (SocratiCode) scan engine: its own chat credentials, reused from the
# enrichment/remediation key+endpoint above unless overridden.
AI_SCAN_KEY="${PLEXICUS_AI_SCAN_API_KEY:-$PLEXICUS_AI_KEY}"
AI_SCAN_BASE_URL="${PLEXICUS_AI_SCAN_BASE_URL:-$AI_BASE_URL}"
AI_SCAN_MODEL="${PLEXICUS_AI_SCAN_MODEL:-$AI_MODEL}"

# AI SAST embedding endpoint (model BAAI/bge-m3, 1024 dims) — a HARD dependency of
# every AI SAST scan (SocratiCode indexing runs before any scan). Only DeepInfra
# among the supported providers serves bge-m3, so the chat key above is reused
# while the chat endpoint stays on the DeepInfra default; any other chat endpoint
# (DeepSeek, OpenAI, Azure) needs its own DeepInfra key here.
AI_SCAN_EMBEDDING_BASE_URL="${PLEXICUS_AI_SCAN_EMBEDDING_BASE_URL:-https://api.deepinfra.com/v1/openai}"
if [ -n "${PLEXICUS_AI_SCAN_EMBEDDING_API_KEY:-}" ]; then
  AI_SCAN_EMBEDDING_KEY="$PLEXICUS_AI_SCAN_EMBEDDING_API_KEY"
elif [ "$AI_BASE_URL" = "$AI_SCAN_EMBEDDING_BASE_URL" ]; then
  AI_SCAN_EMBEDDING_KEY="$PLEXICUS_AI_KEY"
else
  ask PLEXICUS_AI_SCAN_EMBEDDING_API_KEY "DeepInfra API key for AI SAST embeddings, model BAAI/bge-m3 (https://deepinfra.com) — required, your chat provider serves no embedding model" secret
  AI_SCAN_EMBEDDING_KEY="$PLEXICUS_AI_SCAN_EMBEDDING_API_KEY"
fi

# SCM connectors — ALL OPTIONAL, never prompted for. The evaluator installs and
# runs its first scan without any of them (the Sandbox onboarding flow uses a
# synthetic connector), and a customer working from a delivery package has no
# way to supply them: the package carries only keys.json, license.jwt and
# plexicus-eval.env. Requiring them killed non-interactive installs outright
# and stalled interactive ones on values the operator does not have.
#
# Supply a connector's values via the environment to configure it at install
# time; whatever is absent stays absent and is connected later from the UI.
# A connector needs BOTH id and secret to be usable — see the conditional
# emission in the overrides block below for what a partial one would cost.
#
# Self-hosted GitLab needs no separate input here — customers add their own
# instance's client id/secret later, per-connection, in the UI (the hosted_url
# override in routes/vulnerability_tool); this only registers the gitlab.com app.
GH_CLIENT_ID="${PLEXICUS_GITHUB_CLIENT_ID:-}"
GH_CLIENT_SECRET="${PLEXICUS_GITHUB_CLIENT_SECRET:-}"
GH_APP_ID="${PLEXICUS_GITHUB_APP_ID:-}"
GH_APP_INSTALLATION_URL="${PLEXICUS_GITHUB_APP_INSTALLATION_URL:-}"
GH_APP_PRIVATE_KEY=""
if [ -n "${PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE:-}" ]; then
  # A path that was given but does not resolve is an operator mistake, not an
  # opt-out: fail loudly rather than installing a silently keyless GitHub App.
  [ -f "$PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE" ] || die "GitHub App private key not found: $PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE"
  GH_APP_PRIVATE_KEY="$(cat "$PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE")"
fi
GL_CLIENT_ID="${PLEXICUS_GITLAB_CLIENT_ID:-}"
GL_CLIENT_SECRET="${PLEXICUS_GITLAB_CLIENT_SECRET:-}"
BB_CLIENT_ID="${PLEXICUS_BITBUCKET_CLIENT_ID:-}"
BB_CLIENT_SECRET="${PLEXICUS_BITBUCKET_CLIENT_SECRET:-}"

# A connector is only configured when it can complete a token exchange, i.e.
# when both halves of the credential pair are present.
GH_CONFIGURED=""; [ -n "$GH_CLIENT_ID" ] && [ -n "$GH_CLIENT_SECRET" ] && GH_CONFIGURED=1
GL_CONFIGURED=""; [ -n "$GL_CLIENT_ID" ] && [ -n "$GL_CLIENT_SECRET" ] && GL_CONFIGURED=1
BB_CONFIGURED=""; [ -n "$BB_CLIENT_ID" ] && [ -n "$BB_CLIENT_SECRET" ] && BB_CONFIGURED=1
for _scm in "GitHub:$GH_CLIENT_ID:$GH_CLIENT_SECRET" "GitLab:$GL_CLIENT_ID:$GL_CLIENT_SECRET" "Bitbucket Cloud:$BB_CLIENT_ID:$BB_CLIENT_SECRET"; do
  _name="${_scm%%:*}"; _rest="${_scm#*:}"; _id="${_rest%%:*}"; _secret="${_rest#*:}"
  if { [ -n "$_id" ] && [ -z "$_secret" ]; } || { [ -z "$_id" ] && [ -n "$_secret" ]; }; then
    warn "${_name}: only half of the client id/secret pair was supplied — the connector is left unconfigured"
  fi
done
unset _scm _name _rest _id _secret

# AI provider label. The platform's AI connection-test endpoints dispatch on
# this label with per-provider request shapes — a label that does not match
# the endpoint the key belongs to sends the key to the WRONG provider's API,
# the test 401s, and the UI blocks AI remediation. With the DeepInfra default
# this matches the chart default ("deepinfra"), but it must still be pinned for
# operators overriding PLEXICUS_AI_BASE_URL. Mirrors the platform's own URL
# inference: azure -> azure, deepinfra -> deepinfra, anything else -> deepseek
# (generic OpenAI-compatible at runtime).
case "$AI_BASE_URL" in
  *azure*)     AI_PROVIDER_TYPE="azure" ;;
  *deepinfra*) AI_PROVIDER_TYPE="deepinfra" ;;
  *)           AI_PROVIDER_TYPE="deepseek" ;;
esac
ok "inputs collected (AI endpoint: ${AI_BASE_URL}, model: ${AI_MODEL}; AI SAST embedding endpoint: ${AI_SCAN_EMBEDDING_BASE_URL})"

# ------------------------------------------------------ secrets (per-install)
say "Generating per-install credentials"
rand() { openssl rand -hex 16; }
# Generate whatever is still unset — everything on a fresh install, and only
# the variables introduced by a newer installer version on a re-run.
: "${DB_PASS:=$(rand)}"; : "${REDIS_PASS:=$(rand)}"; : "${MINIO_PASS:=$(rand)}"
: "${MINIO_USER:=$(rand)}"
: "${PG_PASS:=$(rand)}"; : "${SECRET_KEY:=$(rand)}"; : "${PLEXALYZER_SECRET:=$(rand)}"
: "${NUXT_SECRET:=$(rand)}"; : "${BREAK_GLASS:=$(rand)}"; : "${SSO_RELAY:=$(rand)}"
: "${SSO_ENCRYPT:=$(rand)}"
# Internal service-to-service auth tokens (staging/prod parity): fastapi <->
# worker control plane, worker <-> codex-remedium, fastapi <-> exporter.
# Setting them turns hardening on where the code supports it and keeps the
# install working if a future chart makes them startup-required.
: "${WORKER_CP_SECRET:=$(rand)}"; : "${CODEX_SHARED_SECRET:=$(rand)}"
: "${EXPORTER_TOKEN:=$(rand)}"
: "${ADMIN_PASSWORD:=${PLEXICUS_ADMIN_PASSWORD:-Eval-$(openssl rand -hex 6)!}}"
cat > "$ENVF" <<EOF
DB_PASS=$DB_PASS
REDIS_PASS=$REDIS_PASS
MINIO_PASS=$MINIO_PASS
MINIO_USER=$MINIO_USER
PG_PASS=$PG_PASS
SECRET_KEY=$SECRET_KEY
PLEXALYZER_SECRET=$PLEXALYZER_SECRET
NUXT_SECRET=$NUXT_SECRET
BREAK_GLASS=$BREAK_GLASS
SSO_RELAY=$SSO_RELAY
SSO_ENCRYPT=$SSO_ENCRYPT
WORKER_CP_SECRET=$WORKER_CP_SECRET
CODEX_SHARED_SECRET=$CODEX_SHARED_SECRET
EXPORTER_TOKEN=$EXPORTER_TOKEN
ADMIN_PASSWORD=$ADMIN_PASSWORD
PLEXICUS_GITHUB_CLIENT_ID=$GH_CLIENT_ID
PLEXICUS_GITHUB_CLIENT_SECRET=$GH_CLIENT_SECRET
PLEXICUS_GITHUB_APP_ID=$GH_APP_ID
PLEXICUS_GITHUB_APP_INSTALLATION_URL=$GH_APP_INSTALLATION_URL
PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE=${PLEXICUS_GITHUB_APP_PRIVATE_KEY_FILE:-}
PLEXICUS_GITLAB_CLIENT_ID=$GL_CLIENT_ID
PLEXICUS_GITLAB_CLIENT_SECRET=$GL_CLIENT_SECRET
PLEXICUS_BITBUCKET_CLIENT_ID=$BB_CLIENT_ID
PLEXICUS_BITBUCKET_CLIENT_SECRET=$BB_CLIENT_SECRET
EOF
chmod 600 "$ENVF"
ok "credentials present in $ENVF"

# ----------------------------------------------------------------------- k3s
say "Installing k3s (without traefik)"
if systemctl is-active --quiet k3s 2>/dev/null; then
  ok "k3s already running"
else
  curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik --write-kubeconfig-mode=644" sh - >/dev/null
  ok "k3s installed"
fi
export KUBECONFIG="$KUBECONFIG_PATH"
for i in $(seq 1 30); do kubectl get nodes >/dev/null 2>&1 && break; sleep 2; done
kubectl wait node --all --for=condition=Ready --timeout=120s >/dev/null
ok "node Ready"

# ---------------------------------------------------------------------- helm
say "Installing Helm"
if ! command -v helm >/dev/null 2>&1; then
  curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash >/dev/null
fi
ok "helm $(helm version --short 2>/dev/null)"

# --------------------------------------------------------------- ingress-nginx
say "Installing ingress-nginx"
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 || true
helm repo update ingress-nginx >/dev/null 2>&1 || helm repo update >/dev/null
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
  --version "$INGRESS_NGINX_VERSION" \
  --namespace ingress-nginx --create-namespace \
  --set controller.service.type=LoadBalancer \
  --wait --timeout 5m >/dev/null
ok "ingress-nginx ready"

# ------------------------------------------------- TLS: evaluation CA + leafs
# Burp Suite and browsers require certificates with a non-empty CN and proper
# SANs. We mint a local evaluation CA, sign one leaf per hostname, and give the
# evaluator the CA file to import into Burp / the OS trust store.
say "Generating TLS certificates (evaluation CA, Burp-friendly)"
CA_KEY="$STATE_DIR/ca.key"; CA_CRT="$STATE_DIR/plexicus-eval-ca.crt"
if [ ! -f "$CA_CRT" ]; then
  openssl req -x509 -newkey rsa:2048 -nodes -days 1095 -keyout "$CA_KEY" -out "$CA_CRT" \
    -subj "/CN=Plexicus Evaluation CA/O=Plexicus" \
    -addext "basicConstraints=critical,CA:true" -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
fi
mkleaf() { # mkleaf <host> <outprefix>
  local host="$1" out="$2"
  [ -f "${out}.crt" ] && return 0
  openssl req -newkey rsa:2048 -nodes -keyout "${out}.key" -out "${out}.csr" \
    -subj "/CN=${host}/O=Plexicus Evaluation" 2>/dev/null
  openssl x509 -req -in "${out}.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" -CAcreateserial \
    -days 365 -out "${out}.crt" \
    -extfile <(printf "subjectAltName=DNS:%s\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n" "$host") 2>/dev/null
  rm -f "${out}.csr"
}
mkleaf "$DOMAIN"     "$STATE_DIR/frontend"
mkleaf "$API_DOMAIN" "$STATE_DIR/fastapi"
chmod 600 "$STATE_DIR"/*.key
ok "CA + leaf certificates in $STATE_DIR (import $CA_CRT into Burp/your browser)"

# --------------------------------------------------------- kubernetes secrets
# Secret keys:
#   AI_VALIDATION_API_KEY  (fastapi + worker)
#   AI_REMEDIATION_API_KEY (fastapi + worker)
#   AI_ENRICHMENT_API_KEY  (exporter)
#   OBJECT_STORAGE_SECRET_KEY (fastapi + worker)
# analysis-scheduler, codex-remedium, and plexalyzer services no longer need AI keys in
# their Secrets (they receive AI config via non-secret Helm values / worker env inheritance).
# $PLEXICUS_AI_KEY continues to be accepted as installer input for all three channels.
#
# AI SAST (SocratiCode) scan engine keys, worker only — a separate credential surface
# from the AI_VALIDATION/AI_REMEDIATION channels above:
#   PLEXICUS_AI_SCAN_API_KEY           chat LLM the scan engine itself uses
#   PLEXICUS_AI_SCAN_EMBEDDING_API_KEY embedding endpoint (BAAI/bge-m3) — hard
#                                       dependency of every AI SAST scan since chart 1.2.27
say "Creating Kubernetes secrets"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null

kapply() { kubectl -n "$NAMESPACE" create "$@" --dry-run=client -o yaml | kubectl apply -f - >/dev/null; }

kapply secret docker-registry gar-secret \
  --docker-server="$REGISTRY_HOST" \
  --docker-username=_json_key \
  --docker-password="$(cat "$PLEXICUS_GAR_KEY_FILE")"

kapply secret generic plexicus-license --from-file=license.jwt="$PLEXICUS_LICENSE_FILE"

kapply secret tls plexicus-frontend-tls --cert="$STATE_DIR/frontend.crt" --key="$STATE_DIR/frontend.key"
kapply secret tls plexicus-fastapi-tls  --cert="$STATE_DIR/fastapi.crt"  --key="$STATE_DIR/fastapi.key"

kapply secret generic plexicus-fastapi \
  --from-literal=DATABASE_PASSWORD="$DB_PASS" \
  --from-literal=REDIS_PASSWORD="$REDIS_PASS" \
  --from-literal=SECRET_KEY="$SECRET_KEY" \
  --from-literal=PLEXALYZER_SECRET_KEY="$PLEXALYZER_SECRET" \
  --from-literal=GITHUB_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
  --from-literal=GITHUB_OAUTH_CLIENT_SECRET="$GH_CLIENT_SECRET" \
  --from-literal=GITLAB_OAUTH_CLIENT_SECRET="$GL_CLIENT_SECRET" \
  --from-literal=BITBUCKET_OAUTH_CLIENT_SECRET="$BB_CLIENT_SECRET" \
  --from-literal=BITBUCKET_ACCESS_TOKEN="" \
  --from-literal=OBJECT_STORAGE_SECRET_KEY="$MINIO_PASS" \
  --from-literal=EMAIL_PASSWORD="" \
  --from-literal=WORKER_CONTROL_PLANE_SECRET="$WORKER_CP_SECRET" \
  --from-literal=EXPORTER_INTERNAL_TOKEN="$EXPORTER_TOKEN" \
  --from-literal=BREAK_GLASS_SECRET_KEY="$BREAK_GLASS" \
  --from-literal=SSO_RELAY_STATE_SECRET="$SSO_RELAY" \
  --from-literal=SSO_ENCRYPTION_KEY="$SSO_ENCRYPT" \
  --from-literal=AI_VALIDATION_API_KEY="$PLEXICUS_AI_KEY" \
  --from-literal=AI_REMEDIATION_API_KEY="$PLEXICUS_AI_KEY" \
  --from-literal=STRIPE_API_KEY="" \
  --from-literal=STRIPE_WEBHOOK_SECRET="" \
  --from-literal=CANNY_PRIVATE_TOKEN="" \
  --from-literal=POSTHOG_API_KEY="" \
  --from-literal=POSTHOG_PERSONAL_API_KEY="" \
  --from-literal=MAUTIC_AUTH="" \
  --from-literal=MICROSOFT_MARKETPLACE_CLIENT_SECRET="" \
  --from-literal=MICROSOFT_INSTRUMENTATION_KEY=""

kapply secret generic plexicus-worker \
  --from-literal=DATABASE_PASSWORD="$DB_PASS" \
  --from-literal=REDIS_PASSWORD="$REDIS_PASS" \
  --from-literal=SECRET_KEY="$SECRET_KEY" \
  --from-literal=PLEXALYZER_SECRET_KEY="$PLEXALYZER_SECRET" \
  --from-literal=GITHUB_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
  --from-literal=OBJECT_STORAGE_SECRET_KEY="$MINIO_PASS" \
  --from-literal=PLEXALYZER_TOKEN="$PLEXALYZER_SECRET" \
  --from-literal=AZURE_RESOURCE_PASSWORD="" \
  --from-literal=AZURE_RESOURCE_USERNAME="" \
  --from-literal=AI_REMEDIATION_API_KEY="$PLEXICUS_AI_KEY" \
  --from-literal=AI_VALIDATION_API_KEY="$PLEXICUS_AI_KEY" \
  --from-literal=WORKER_CONTROL_PLANE_SECRET="$WORKER_CP_SECRET" \
  --from-literal=CODEX_REMEDIUM_SHARED_SECRET="$CODEX_SHARED_SECRET" \
  --from-literal=EXPORTER_INTERNAL_TOKEN="$EXPORTER_TOKEN" \
  --from-literal=EMAIL_PASSWORD="" \
  --from-literal=PLEXICUS_AI_SCAN_API_KEY="$AI_SCAN_KEY" \
  --from-literal=PLEXICUS_AI_SCAN_EMBEDDING_API_KEY="$AI_SCAN_EMBEDDING_KEY"

kapply secret generic plexicus-frontend \
  --from-literal=NUXT_SECRET_KEY="$NUXT_SECRET" \
  --from-literal=NUXT_GITHUB_SECRET_KEY="$GH_CLIENT_SECRET" \
  --from-literal=NUXT_GITLAB_SECRET_KEY="$GL_CLIENT_SECRET" \
  --from-literal=NUXT_BITBUCKET_CLOUD_SECRET="$BB_CLIENT_SECRET" \
  --from-literal=NUXT_GOOGLE_CLIENT_SECRET="" \
  --from-literal=NUXT_STRIPE_KEY="" \
  --from-literal=NUXT_PILOTING_KEY="" \
  --from-literal=NUXT_PILOTING_REMEDIATOR_KEY="" \
  --from-literal=NUXT_TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"

kapply secret generic plexicus-analysis-scheduler \
  --from-literal=DATABASE_PASSWORD="$DB_PASS"

kapply secret generic plexicus-codex-remedium \
  --from-literal=DATABASE_PASSWORD="$DB_PASS" \
  --from-literal=REDIS_PASSWORD="$REDIS_PASS" \
  --from-literal=CODEX_REMEDIUM_SHARED_SECRET="$CODEX_SHARED_SECRET" \
  --from-literal=PLEXALYZER_SECRET_KEY="$PLEXALYZER_SECRET"

kapply secret generic plexicus-exporter \
  --from-literal=DATABASE_PASSWORD="$DB_PASS" \
  --from-literal=EXPORTER_INTERNAL_TOKEN="$EXPORTER_TOKEN" \
  --from-literal=AI_ENRICHMENT_API_KEY="$PLEXICUS_AI_KEY"

kapply secret generic plexicus-plexalyzer-code \
  --from-literal=PLEXALYZER_SECRET_KEY="$PLEXALYZER_SECRET"

kapply secret generic plexicus-plexalyzer-prov \
  --from-literal=PLEXALYZER_SECRET_KEY="$PLEXALYZER_SECRET"

ok "all secrets created"

# ------------------------------------------------------------ fetch the chart
say "Fetching Plexicus chart ${CHART_VERSION}"
helm registry login "$REGISTRY_HOST" --username _json_key --password-stdin \
  < "$PLEXICUS_GAR_KEY_FILE" >/dev/null 2>&1
rm -rf "$STATE_DIR/chart" && mkdir -p "$STATE_DIR/chart"
helm pull "$CHART_OCI" --version "$CHART_VERSION" --untar -d "$STATE_DIR/chart" >/dev/null
INFRA_VALUES="$STATE_DIR/chart/plexicus/values-bundled-infra.yaml"
EVAL_VALUES="$STATE_DIR/chart/plexicus/values-evaluation.yaml"
[ -f "$INFRA_VALUES" ] && [ -f "$EVAL_VALUES" ] || die "values-bundled-infra.yaml / values-evaluation.yaml missing from chart ${CHART_VERSION} — use chart >= 1.2.32 (chart >= 1.2.19 and < 1.2.32 shipped the single values-evaluator.yaml this installer no longer uses). Note: AI SAST scanning needs chart >= 1.2.27 (SocratiCode indexing env-forwarding fixes)."
ok "chart pulled ($STATE_DIR/chart/plexicus)"

# --------------------------------------------------------- installer override
# Random per-install passwords replace the well-known defaults shipped in
# values-evaluation.yaml, and TLS moves from cert-manager to the local eval CA.
say "Writing installer overrides"
OVR="$STATE_DIR/overrides.yaml"

# --- oauth block, assembled per connector -----------------------------------
# Emitted only for connectors whose credentials are actually present. A block
# written from empty shell variables is what produces a "Connect" button that
# can only fail with a 400 at token exchange, so a half-configured connector
# must never reach the chart.
#
# The GitHub block is emitted explicitly even though the chart now defaults
# clientId, appId and appInstallationUrl to "" — values.schema.json rejects
# both Plexicus's own App id and its own OAuth client id by value, so neither
# can be inherited by accident any more. Emitting the block keeps the generated
# overrides self-describing and keeps the configured/unconfigured branches
# symmetrical.
#
# appId is emitted as "" (not "0") when unset: the chart's schema types it as a
# STRING and "" is its documented "no GitHub App" value, which makes
# GithubPlugin report NOT_CONFIGURED. A bare 0 would fail schema validation,
# and a quoted "0" is a non-empty id that matches no App.
#
# appId is quoted for a second reason: Helm round-trips values through JSON,
# where numbers become float64, and Go prints a 7-digit round number in
# scientific notation ("3.004825e+06"). Never emit it unquoted.
#
# GitLab and Bitbucket do default to "" in the chart, so omitting them is
# equivalent to emitting empties, and omitting keeps the file honest.
#
# freeSastToolUrl is — despite its name — what fastapi receives as
# GITHUB_OAUTH_REDIRECT_URI. The chart derives <scheme>://<domain>/api/callback/
# github when it is empty, which is exactly what we want here; it is set
# explicitly so the value stays visible in the generated overrides file.
OAUTH_BLOCK="    oauth:"
if [ -n "$GH_CONFIGURED" ]; then
  OAUTH_BLOCK="${OAUTH_BLOCK}
      github:
        appId: \"${GH_APP_ID:-}\"
        appInstallationUrl: \"${GH_APP_INSTALLATION_URL}\"
        clientId: \"${GH_CLIENT_ID}\"
        freeSastToolUrl: \"https://${DOMAIN}/api/callback/github\""
else
  OAUTH_BLOCK="${OAUTH_BLOCK}
      # No GitHub credentials supplied — emit the empties explicitly so the
      # generated file states the connector is off rather than implying it.
      github:
        appId: \"\"
        appInstallationUrl: \"\"
        clientId: \"\"
        freeSastToolUrl: \"https://${DOMAIN}/api/callback/github\""
fi
if [ -n "$GL_CONFIGURED" ]; then
  OAUTH_BLOCK="${OAUTH_BLOCK}
      gitlab:
        clientId: \"${GL_CLIENT_ID}\""
fi
if [ -n "$BB_CONFIGURED" ]; then
  OAUTH_BLOCK="${OAUTH_BLOCK}
      bitbucket:
        key: \"${BB_CLIENT_ID}\""
fi

cat > "$OVR" <<EOF
# Generated by install.sh — per-install values layered on
# values-bundled-infra.yaml + values-evaluation.yaml
global:
  domain: ${DOMAIN}
  certManager:
    enabled: false          # TLS secrets are pre-created from the local eval CA
  required:
    database:
      password: "${DB_PASS}"
    redis:
      password: "${REDIS_PASS}"
    minio:
      # rootUser/rootPassword here feed fastapi/worker's OBJECT_STORAGE_ACCESS_KEY
      # (a non-secret Helm value — see values.yaml's fastapi/worker envs block).
      # The secret side (OBJECT_STORAGE_SECRET_KEY) is delivered separately via
      # the plexicus-fastapi/plexicus-worker Kubernetes Secrets below, since
      # sensitive values must never go through envs:. Both rootUser and
      # rootPassword are ALSO repeated verbatim under the top-level minio.auth
      # block further down for the bundled MinIO server subchart itself:
      # values-bundled-infra.yaml hardcodes minio.auth.rootUser and
      # values-evaluation.yaml hardcodes minio.auth.rootPassword to their own
      # well-known defaults independently of these global.required.minio
      # values (separate values files, so no implicit propagation), so both
      # must be set here, with identical values, or the client (fastapi/
      # worker) and the bundled MinIO server end up with mismatched
      # credentials and object storage breaks on every evaluator install.
      rootUser: "${MINIO_USER}"
      rootPassword: "${MINIO_PASS}"
    postgresql:
      password: "${PG_PASS}"
    ai:
      # Key alone is not enough: the chart defaults the deployment/model name
      # to a REPLACE_WITH placeholder that breaks AI enrichment if left as-is.
      openAiBaseUrlFree: "${AI_BASE_URL}"
      openAiDeploymentNameFree: "${AI_MODEL}"
      openAiKeyFree: "${PLEXICUS_AI_KEY}"
      openAiBaseUrlSwe: "${AI_BASE_URL}"
      openAiDeploymentSwe: "${AI_MODEL}"
      openAiKeySwe: "${PLEXICUS_AI_KEY}"
      # Must match the endpoint above — see AI provider label note in the
      # inputs section. Pinned so non-DeepInfra overrides (e.g. a DeepSeek
      # key) don't 401 against the chart default ("deepinfra").
      freeAiRemediationProviderType: "${AI_PROVIDER_TYPE}"
      freeAiValidationProviderType: "${AI_PROVIDER_TYPE}"
${OAUTH_BLOCK}

mongodb:
  auth:
    rootPassword: "${DB_PASS}"
redis:
  auth:
    password: "${REDIS_PASS}"
minio:
  auth:
    # Must equal global.required.minio.rootUser/rootPassword above exactly —
    # see the comment there for why both are needed.
    rootUser: "${MINIO_USER}"
    rootPassword: "${MINIO_PASS}"
temporal-postgresql:
  auth:
    postgresPassword: "${PG_PASS}"
temporal:
  # The overlay pins Temporal's SQL client passwords too — keep them in sync
  # with temporal-postgresql.auth.postgresPassword or the schema Job fails.
  server:
    config:
      persistence:
        default:
          sql:
            password: "${PG_PASS}"
        visibility:
          sql:
            password: "${PG_PASS}"

fastapi:
  envs:
    WEBAUTHN_RP_ID: "${DOMAIN}"
    WEBAUTHN_ORIGIN: "https://${DOMAIN}"
  ingress:
    hosts:
      - host: ${API_DOMAIN}
        paths:
          - {path: /, pathType: Prefix}
    tls:
      - secretName: plexicus-fastapi-tls
        hosts: [${API_DOMAIN}]
worker:
  envs:
    # AI SAST (SocratiCode) scan engine — no packaged-chart default for BASE_URL/MODEL
    # or the embedding endpoint, so an install without these silently fails every scan.
    PLEXICUS_AI_SCAN_BASE_URL: "${AI_SCAN_BASE_URL}"
    PLEXICUS_AI_SCAN_MODEL: "${AI_SCAN_MODEL}"
    PLEXICUS_AI_SCAN_EMBEDDING_BASE_URL: "${AI_SCAN_EMBEDDING_BASE_URL}"
frontend:
  ingress:
    hosts:
      - host: ${DOMAIN}
        paths:
          - {path: /, pathType: Prefix}
    tls:
      - secretName: plexicus-frontend-tls
        hosts: [${DOMAIN}]
EOF
chmod 600 "$OVR"
ok "overrides written ($OVR)"

# -------------------------------------------------------------------- install
say "Installing Plexicus (this pulls several GB of images — 5-15 minutes)"
# An interrupted previous run (Ctrl-C, reboot) leaves the release in a
# pending-* state that blocks every future upgrade. Reset it; PVCs and the
# script-managed secrets survive, so no data or credentials are lost.
if helm -n "$NAMESPACE" status plexicus 2>/dev/null | grep -q "STATUS: pending"; then
  warn "previous install was interrupted — resetting the Helm release first"
  pkill -x helm 2>/dev/null || true
  helm -n "$NAMESPACE" uninstall plexicus --wait --timeout 5m >/dev/null 2>&1 || true
fi
helm upgrade --install plexicus "$STATE_DIR/chart/plexicus" \
  --namespace "$NAMESPACE" \
  -f "$INFRA_VALUES" -f "$EVAL_VALUES" -f "$OVR" \
  --timeout 20m >/dev/null
ok "helm release applied"

# ----------------------------------------------- post-install chart hotfixes
# Chart <= 1.2.32 ships two NetworkPolicy defects that brick AI remediation
# (worker cannot reach the remediation job's API on 7200, and the job cannot
# reach MongoDB to fetch its findings), plus a worker HPA floor (minReplicas 3)
# that leaves too little memory for the 3Gi remediation pod on the minimum
# 7 GB VM. Patch all three here; every patch is idempotent and becomes a
# harmless no-op once a fixed chart ships (platform #851 / #853).
say "Applying evaluator hotfixes for chart ${CHART_VERSION}"
NP="plexicus-remediation-job-egress"
if kubectl -n "$NAMESPACE" get networkpolicy "$NP" >/dev/null 2>&1; then
  kubectl -n "$NAMESPACE" patch networkpolicy "$NP" --type=merge -p \
    '{"spec":{"ingress":[{"from":[{"podSelector":{"matchLabels":{"app.kubernetes.io/name":"worker"}}}],"ports":[{"port":7200,"protocol":"TCP"}]}]}}' >/dev/null
  if ! kubectl -n "$NAMESPACE" get networkpolicy "$NP" \
      -o jsonpath='{.spec.egress[*].ports[*].port}' 2>/dev/null | grep -q 27017; then
    kubectl -n "$NAMESPACE" patch networkpolicy "$NP" --type=json -p \
      '[{"op":"add","path":"/spec/egress/-","value":{"to":[{"podSelector":{"matchLabels":{"app.kubernetes.io/name":"mongodb"}}}],"ports":[{"port":27017,"protocol":"TCP"}]}}]' >/dev/null
  fi
fi
kubectl -n "$NAMESPACE" patch hpa worker --type=merge -p '{"spec":{"minReplicas":1}}' >/dev/null 2>&1 || true
kubectl -n "$NAMESPACE" scale deploy/worker --replicas=1 >/dev/null 2>&1 || true
# Debug/console UIs the evaluation flow never needs. On a minimum-spec VM
# their requests are exactly the difference between the 3Gi remediation pod
# scheduling and FailedScheduling: without this, free memory lands ~100Mi
# short of the pod's request and remediation hangs at Pending forever.
kubectl -n "$NAMESPACE" scale deploy/temporal-admintools deploy/minio-console deploy/temporal-web --replicas=0 >/dev/null 2>&1 || true
ok "remediation network rules + memory headroom hotfixes applied"

say "Waiting for the platform to come up"
# Infra first (temporal crash-loops ~2 min while its schema Job runs — normal).
# Sequential on purpose: a bare `wait` swallows rollout failures.
for d in fastapi frontend worker; do
  kubectl -n "$NAMESPACE" rollout status "deploy/$d" --timeout=20m >/dev/null \
    || die "deploy/$d did not become ready — inspect: kubectl -n $NAMESPACE describe pod -l app.kubernetes.io/name=$d"
done
ok "fastapi, frontend and worker are Running"

# ------------------------------------------------------------- local DNS + CA
say "Configuring local DNS on this VM"
sed -i "/ ${DOMAIN}\$/d; / ${API_DOMAIN}\$/d" /etc/hosts
printf "127.0.0.1 %s\n127.0.0.1 %s\n" "$DOMAIN" "$API_DOMAIN" >> /etc/hosts
ok "/etc/hosts updated on the VM"

# ----------------------------------------------------------------- smoke test
say "Smoke tests"
# rollout status returns as soon as the pod is Ready, but fastapi keeps
# initializing (DB/Redis/license) for a few more seconds and the ingress
# needs to pick up the endpoint — so poll rather than check once.
poll_200() { # poll_200 <host> <path> <timeout-seconds>
  local host="$1" path="$2" deadline=$(( SECONDS + ${3:-120} )) code=""
  while [ "$SECONDS" -lt "$deadline" ]; do
    code=$(curl -sk -o /dev/null -w "%{http_code}" --resolve "${host}:443:127.0.0.1" "https://${host}${path}")
    [ "$code" = "200" ] && { echo "$code"; return 0; }
    sleep 5
  done
  echo "$code"; return 1
}
API_CODE=$(poll_200 "$API_DOMAIN" "/health" 180)   || die "API /health never returned 200 (last: ${API_CODE}). Check: kubectl -n ${NAMESPACE} logs deploy/fastapi"
FRONT_CODE=$(poll_200 "$DOMAIN" "/" 60)             || die "Frontend never returned 200 (last: ${FRONT_CODE})."
CERT_SUBJECT=$(echo | openssl s_client -connect 127.0.0.1:443 -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -subject)
echo "$CERT_SUBJECT" | grep -q "CN *= *${DOMAIN}" || warn "Unexpected certificate subject: ${CERT_SUBJECT}"
ok "frontend 200, api/health 200, certificate CN=${DOMAIN}"

# -------------------------------------------------------------- admin account
say "Creating admin account (${ADMIN_EMAIL})"
REG_CODE=$(curl -sk -o "$STATE_DIR/register.out" -w "%{http_code}" \
  --resolve "${API_DOMAIN}:443:127.0.0.1" \
  -X POST -H "Content-Type: application/json" \
  -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\",\"confirm_password\":\"${ADMIN_PASSWORD}\"}" \
  "https://${API_DOMAIN}/registrations")
case "$REG_CODE" in
  2*) ok "account registered" ;;
  409|400) warn "registration returned HTTP ${REG_CODE} (account may already exist) — continuing" ;;
  *) die "registration failed with HTTP ${REG_CODE}: $(cat "$STATE_DIR/register.out")" ;;
esac

# No SMTP in evaluation. values-evaluation.yaml declares
# global.required.smtp.deliveryMode: "disabled", so registration above returns
# 200 with email_sent:false instead of the 503 an undeclared-but-unconfigured
# deployment now gets, and nothing is silently dropped.
#
# That declaration does NOT verify the account, and must not be expected to:
# POST /registrations writes is_verified:false in every delivery mode, because
# it is a public unauthenticated endpoint and auto-verifying there would turn
# "no mail server" into open signup on a tenant carrying trial AI credits. The
# supported no-email path is admin-mediated — an authenticated admin invites a
# user and the invite response carries the verification link — but that
# presupposes an admin, and this is the call that creates the first one.
# Registration returns no token, so there is nothing to redeem.
#
# Writing the flag here is therefore still the only way to bootstrap account
# one. Every subsequent user should be invited from inside the platform.
kubectl -n "$NAMESPACE" exec deploy/mongodb -- mongosh \
  "mongodb://root:${DB_PASS}@localhost:27017/plexicus?authSource=admin" \
  --quiet --eval "db.Users.updateOne({email:\"${ADMIN_EMAIL}\"},{\$set:{is_verified:true,role:\"admin\"}})" >/dev/null
ok "account verified and promoted to admin"

# -------------------------------------------------------------------- summary
VM_IP=$(hostname -I | awk '{print $1}')
SCM_SUMMARY=""
[ -n "$GH_CONFIGURED" ] && SCM_SUMMARY="${SCM_SUMMARY}GitHub (client id ${GH_CLIENT_ID}) "
[ -n "$GL_CONFIGURED" ] && SCM_SUMMARY="${SCM_SUMMARY}GitLab (client id ${GL_CLIENT_ID}) "
[ -n "$BB_CONFIGURED" ] && SCM_SUMMARY="${SCM_SUMMARY}Bitbucket Cloud (key ${BB_CLIENT_ID}) "
[ -n "$SCM_SUMMARY" ] || SCM_SUMMARY="none connected — nothing to configure, nothing broken"
cat <<EOF

${C_GREEN}==============================================================================
  Plexicus evaluation environment is ready
==============================================================================${C_OFF}

  Frontend   https://${DOMAIN}
  API        https://${API_DOMAIN}

  Login      ${ADMIN_EMAIL}
  Password   ${ADMIN_PASSWORD}

  From your workstation, add to /etc/hosts (or your DNS):

      ${VM_IP} ${DOMAIN} ${API_DOMAIN}

  TLS: certificates are signed by a local evaluation CA with proper CN/SANs.
  To avoid warnings in your browser or Burp Suite, import:

      ${CA_CRT}
      (scp root@${VM_IP}:${CA_CRT} . )

  Credentials and generated passwords:  ${ENVF}
  SCM connectors: ${SCM_SUMMARY}
  First scan: log in and use the Sandbox onboarding flow — it needs no SCM at all.
  To scan your own repositories, connect GitHub afterwards from Settings ->
  Source Control: https://docs.plexicus.ai/docs/self-hosted/local-evaluation
  Uninstall:  /usr/local/bin/k3s-uninstall.sh

EOF
