#!/usr/bin/env bash

set -euo pipefail

DEFAULT_RELEASE="falkordb-enterprise"
DEFAULT_NAMESPACE="falkordb-system"
DEFAULT_KUBEBLOCKS_NAMESPACE="kb-system"
DEFAULT_KUBEBLOCKS_RELEASE="kubeblocks"
DEFAULT_KUBEBLOCKS_CHART="kubeblocks/kubeblocks"
DEFAULT_KUBEBLOCKS_REPO_NAME="kubeblocks"
DEFAULT_KUBEBLOCKS_REPO_URL="https://apecloud.github.io/helm-charts"
DEFAULT_SNAPSHOT_CONTROLLER_RELEASE="snapshot-controller"
DEFAULT_SNAPSHOT_CONTROLLER_CHART="piraeus/snapshot-controller"
DEFAULT_SNAPSHOT_CONTROLLER_REPO_NAME="piraeus"
DEFAULT_SNAPSHOT_CONTROLLER_REPO_URL="https://piraeus.io/helm-charts"
DEFAULT_SNAPSHOT_CONTROLLER_VERSION="5.0.3"
DEFAULT_INGRESS_NGINX_RELEASE="ingress-nginx"
DEFAULT_INGRESS_NGINX_CHART="ingress-nginx/ingress-nginx"
DEFAULT_INGRESS_NGINX_REPO_NAME="ingress-nginx"
DEFAULT_INGRESS_NGINX_REPO_URL="https://kubernetes.github.io/ingress-nginx"
DEFAULT_INGRESS_NGINX_NAMESPACE="ingress-nginx"
DEFAULT_CHART="oci://registry.falkordb.cloud/falkordb/falkordb-enterprise"
DEFAULT_TIMEOUT="15m"
DEFAULT_SNAPSHOTTER_VERSION="v6.3.3"
DEFAULT_IMAGE_PULL_SECRET_NAME="falkordb-registry-pull-secret"
# No DEFAULT_IMAGE_REGISTRY on purpose. The chart's own global.imageRegistry
# already defaults to registry.falkordb.cloud/falkordb, and injecting a --set
# here would silently outrank any registry the user supplied through -f.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P || pwd)"
LOCAL_CHART="$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd -P || pwd)/helm/falkordb-enterprise"
# The published chart, kept separate from DEFAULT_CHART so an explicit version
# request can fall back to it when the local checkout would otherwise win.
REMOTE_CHART="$DEFAULT_CHART"
CHART_FROM_LOCAL_CHECKOUT="false"
if [[ -z "${CHART:-}" && -f "$LOCAL_CHART/Chart.yaml" ]]; then
    DEFAULT_CHART="$LOCAL_CHART"
    CHART_FROM_LOCAL_CHECKOUT="true"
fi

RELEASE="${RELEASE:-$DEFAULT_RELEASE}"
NAMESPACE="${NAMESPACE:-$DEFAULT_NAMESPACE}"
KUBEBLOCKS_NAMESPACE="${KUBEBLOCKS_NAMESPACE:-$DEFAULT_KUBEBLOCKS_NAMESPACE}"
KUBEBLOCKS_RELEASE="${KUBEBLOCKS_RELEASE:-$DEFAULT_KUBEBLOCKS_RELEASE}"
KUBEBLOCKS_CHART="${KUBEBLOCKS_CHART:-$DEFAULT_KUBEBLOCKS_CHART}"
KUBEBLOCKS_REPO_NAME="${KUBEBLOCKS_REPO_NAME:-$DEFAULT_KUBEBLOCKS_REPO_NAME}"
KUBEBLOCKS_REPO_URL="${KUBEBLOCKS_REPO_URL:-$DEFAULT_KUBEBLOCKS_REPO_URL}"
SNAPSHOT_CONTROLLER_RELEASE="${SNAPSHOT_CONTROLLER_RELEASE:-$DEFAULT_SNAPSHOT_CONTROLLER_RELEASE}"
SNAPSHOT_CONTROLLER_CHART="${SNAPSHOT_CONTROLLER_CHART:-$DEFAULT_SNAPSHOT_CONTROLLER_CHART}"
SNAPSHOT_CONTROLLER_REPO_NAME="${SNAPSHOT_CONTROLLER_REPO_NAME:-$DEFAULT_SNAPSHOT_CONTROLLER_REPO_NAME}"
SNAPSHOT_CONTROLLER_REPO_URL="${SNAPSHOT_CONTROLLER_REPO_URL:-$DEFAULT_SNAPSHOT_CONTROLLER_REPO_URL}"
SNAPSHOT_CONTROLLER_VERSION="${SNAPSHOT_CONTROLLER_VERSION:-$DEFAULT_SNAPSHOT_CONTROLLER_VERSION}"
INGRESS_NGINX_RELEASE="${INGRESS_NGINX_RELEASE:-$DEFAULT_INGRESS_NGINX_RELEASE}"
INGRESS_NGINX_CHART="${INGRESS_NGINX_CHART:-$DEFAULT_INGRESS_NGINX_CHART}"
INGRESS_NGINX_REPO_NAME="${INGRESS_NGINX_REPO_NAME:-$DEFAULT_INGRESS_NGINX_REPO_NAME}"
INGRESS_NGINX_REPO_URL="${INGRESS_NGINX_REPO_URL:-$DEFAULT_INGRESS_NGINX_REPO_URL}"
INGRESS_NGINX_NAMESPACE="${INGRESS_NGINX_NAMESPACE:-$DEFAULT_INGRESS_NGINX_NAMESPACE}"
CHART="${CHART:-$DEFAULT_CHART}"
CHART_VERSION="${CHART_VERSION:-}"
# Release channel to resolve an unpinned chart from. Helm already hides
# prerelease versions from its "latest" resolution, so beta releases are
# published as prerelease semver and only surface with --devel.
CHART_CHANNEL="${CHART_CHANNEL:-stable}"
HELM_DEVEL_ARGS=()
KUBE_CONTEXT="${KUBE_CONTEXT:-}"
KUBEBLOCKS_VERSION="${KUBEBLOCKS_VERSION:-}"
# Ref used for the ComponentDefinition CRD override in apply_kubeblocks_crds.
# Must stay pinned: the KubeBlocks main branch drops fields (for example
# systemAccounts[].passwordGenerationPolicy) that supported addon versions still set.
KUBEBLOCKS_CRD_REF="${KUBEBLOCKS_CRD_REF:-v1.2.0-alpha.1}"
SNAPSHOTTER_VERSION="${SNAPSHOTTER_VERSION:-$DEFAULT_SNAPSHOTTER_VERSION}"
TIMEOUT="${TIMEOUT:-$DEFAULT_TIMEOUT}"
JWT_SECRET="${JWT_SECRET:-}"
WAIT="true"
ENTERPRISE_WAIT="true"
INSTALL_CRDS="true"
INSTALL_SNAPSHOT_CRDS="false"
INSTALL_KUBEBLOCKS="true"
INSTALL_SNAPSHOT_CONTROLLER="true"
UPDATE_DEPS="true"
DRY_RUN="false"
DEBUG="false"
YES="${YES:-false}"
SKIP_AUTH_CHECK="${SKIP_AUTH_CHECK:-false}"
AUTH_CHECK_REQUEST_TIMEOUT="${AUTH_CHECK_REQUEST_TIMEOUT:-10s}"
HELM_PROGRESS_INTERVAL="${HELM_PROGRESS_INTERVAL:-60}"
IMAGE_PULL_SECRET_NAME="${IMAGE_PULL_SECRET_NAME:-${GITEA_PULL_SECRET_NAME:-${GHCR_PULL_SECRET_NAME:-}}}"
IMAGE_PULL_SECRET_SERVER="${IMAGE_PULL_SECRET_SERVER:-${GITEA_REGISTRY_SERVER:-${GHCR_REGISTRY_SERVER:-registry.falkordb.cloud}}}"
IMAGE_PULL_SECRET_USERNAME="${IMAGE_PULL_SECRET_USERNAME:-${GITEA_USERNAME:-${GHCR_USERNAME:-}}}"
IMAGE_PULL_SECRET_PASSWORD="${IMAGE_PULL_SECRET_PASSWORD:-${GITEA_TOKEN:-${GHCR_TOKEN:-}}}"
IMAGE_REGISTRY="${IMAGE_REGISTRY:-}"
KUBERNETES_SERVICE_HOST_OVERRIDE="${KUBERNETES_SERVICE_HOST_OVERRIDE:-}"
KUBERNETES_SERVICE_PORT_OVERRIDE="${KUBERNETES_SERVICE_PORT_OVERRIDE:-}"

VALUES_ARGS=()
SET_ARGS=()
SET_STRING_ARGS=()
HELM_EXTRA_ARGS=()
SNAPSHOT_CLASS_HELM_ARGS=()
ADMIN_SERVER_PERSISTENCE_HELM_ARGS=()
QUICK_ACCESS_HELM_ARGS=()
APISERVER_HOST_HELM_ARGS=()
REUSE_VALUES_ARGS=()
HELM_SERVER_SIDE_ARGS=()
PREPARED_CHART_DIR=""
GENERATED_VALUES_DIR=""
QUICK_ACCESS_ENABLED="false"
QUICK_ACCESS_INGRESS="${QUICK_ACCESS_INGRESS:-true}"
QUICK_ACCESS_MODE=""
QUICK_ACCESS_INGRESS_CLASS=""
QUICK_ACCESS_INSTALL_INGRESS_NGINX="false"

usage() {
    cat <<'USAGE'
Install or update FalkorDB Enterprise on any Kubernetes cluster reachable by kubectl.

One-line install:
    curl -fsSL https://falkordb.github.io/FalkorDB-Enterprise/install.sh | bash

Examples:
    curl -fsSL https://falkordb.github.io/FalkorDB-Enterprise/install.sh | \
    bash -s -- --kube-context my-gke-context --namespace falkordb-system --kubeblocks-namespace kb-system

  JWT_SECRET='replace-with-a-strong-32-character-secret' \
    bash scripts/install.sh --chart helm/falkordb-enterprise --set adminUi.service.type=LoadBalancer

Options:
  --release NAME                 Helm release name (default: falkordb-enterprise)
    --namespace NAME               FalkorDB Enterprise namespace (default: falkordb-system)
    --kubeblocks-namespace NAME    KubeBlocks namespace (default: kb-system)
    --kubeblocks-release NAME      KubeBlocks Helm release name (default: kubeblocks)
    --kubeblocks-chart REF         KubeBlocks Helm chart ref (default: kubeblocks/kubeblocks)
  --kube-context NAME            kubeconfig context to target
    --chart REF                    Helm chart path or ref (default: local checkout chart, otherwise
                                      oci://registry.falkordb.cloud/falkordb/falkordb-enterprise)
  --version VERSION              FalkorDB Enterprise chart version to install, for example 0.4.1
                                   (a leading "v" is accepted). Defaults to the latest published
                                   version. Re-running with a different value upgrades or
                                   downgrades the release. Overrides a local checkout chart.
  --chart-version VERSION        Alias for --version
  --channel CHANNEL              Release channel to install the latest version from: stable
                                   (default) or beta. Beta releases are prerelease versions and
                                   are never picked up by stable. Ignored when --version is given.
  --kubeblocks-version VERSION   KubeBlocks CRD version; auto-detected from the chart when omitted
  --snapshotter-version VERSION  External snapshotter CRD version (default: v6.3.3)
  -f, --values FILE              Helm values file; repeatable
  --set KEY=VALUE                Helm --set override; repeatable
  --set-string KEY=VALUE         Helm --set-string override; repeatable
  --jwt-secret VALUE             Admin server JWT secret; generated on first install when omitted
    --image-pull-secret NAME       Create/use this image pull Secret for Enterprise images
                                      (default when credentials are provided: falkordb-registry-pull-secret)
      --github-username NAME         Registry username for creating the image pull Secret (alias)
      --github-token TOKEN           Registry token for creating the image pull Secret (alias)
    --registry-server URL          Registry server for the image pull Secret (default: registry.falkordb.cloud)
    --registry-username NAME       Registry username for creating the image pull Secret
    --registry-password VALUE      Registry password/token for creating the image pull Secret
    --image-registry PREFIX        Registry prefix, including the namespace, for
                                     the images this chart templates and the
                                     FalkorDB addon. Sets global.imageRegistry.
                                     Vendored subcharts (KubeBlocks, CSI, the
                                     browser) do not read it -- retarget those
                                     with examples/mirror-registry-values.yaml
                                     (default: unset, so the chart's own
                                     registry.falkordb.cloud/falkordb applies)
    --kubernetes-service-host HOST Point every in-cluster Kubernetes client at this
                                     API server address instead of the ClusterIP the
                                     kubelet injects. Only needed where the ClusterIP
                                     route fails -- a public API endpoint that requires
                                     SNI, or a firewall that rejects the ClusterIP.
                                     Use a DNS name covered by the API server
                                     certificate, e.g. kubernetes.default.svc.cluster.local
    --kubernetes-service-port PORT Port to pair with --kubernetes-service-host
                                     (default: leave the kubelet's port)
  --timeout DURATION             Helm/kubectl wait timeout (default: 15m)
  --skip-crds                    Do not apply KubeBlocks CRDs
    --install-snapshot-crds        Pre-apply VolumeSnapshot CRDs and disable chart CRD ownership
    --skip-snapshot-crds           Deprecated no-op; snapshot CRDs are skipped by default
    --skip-kubeblocks              Do not install or upgrade the KubeBlocks Helm release
    --skip-snapshot-controller     Do not install or upgrade the Snapshot Controller Helm release
    --no-ingress                   Quick access: expose Admin UI and Admin Server with two
                                      LoadBalancer services instead of a single gateway Ingress
    --skip-auth-check              Do not verify Kubernetes RBAC permissions before installing
  --skip-dependency-update       Do not run helm dependency update for local chart directories
  --no-wait                      Do not wait for Helm resources
    --no-enterprise-wait           Do not wait for the FalkorDB Enterprise Helm release
  --dry-run                      Render and validate Helm output without changing cluster resources
    -y, --yes                      Skip confirmation prompts
  --debug                        Enable shell tracing and Helm debug output
    --helm-arg VALUE               Extra raw argument passed to the Enterprise helm install/upgrade command; repeatable
  -h, --help                     Show this help

Environment variables mirror the long option names in uppercase, for example RELEASE,
NAMESPACE, KUBEBLOCKS_NAMESPACE, KUBE_CONTEXT, CHART, CHART_VERSION,
KUBEBLOCKS_VERSION, SNAPSHOT_CONTROLLER_VERSION, JWT_SECRET,
IMAGE_PULL_SECRET_NAME, GITEA_USERNAME, GITEA_TOKEN, IMAGE_REGISTRY, TIMEOUT, YES,
and SKIP_AUTH_CHECK. GHCR_USERNAME/GHCR_TOKEN remain supported as aliases.
--kubernetes-service-host/--kubernetes-service-port are the exception: they read
KUBERNETES_SERVICE_HOST_OVERRIDE/KUBERNETES_SERVICE_PORT_OVERRIDE, so running the
installer from inside a pod does not silently pick up the kubelet's own values.

The installer prints an install plan and asks for confirmation before changing the cluster.
USAGE
}

log() {
    printf '[falkordb-enterprise] %s\n' "$*"
}

fail() {
    printf '[falkordb-enterprise] ERROR: %s\n' "$*" >&2
    exit 1
}

require_command() {
    command -v "$1" >/dev/null 2>&1 || fail "$1 is required but was not found in PATH"
}

cleanup_prepared_chart_dir() {
    if [[ -n "$PREPARED_CHART_DIR" ]]; then
        rm -rf "$PREPARED_CHART_DIR"
    fi
    if [[ -n "$GENERATED_VALUES_DIR" ]]; then
        rm -rf "$GENERATED_VALUES_DIR"
    fi
}

# Prints the path of a fresh, empty values file for the caller to write to.
#
# Never override a list-valued setting with `--set 'list[0].field=x'`: Helm
# replaces the whole element rather than merging into it, so every sibling key
# the chart default supplied is silently dropped and the chart renders against a
# half-built object. Lists have to be written out in full, which means a values
# file.
generated_values_file() {
    if [[ -z "$GENERATED_VALUES_DIR" ]]; then
        GENERATED_VALUES_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t falkordb-enterprise-values)"
    fi
    mktemp "$GENERATED_VALUES_DIR/values-XXXXXX.yaml"
}

helm_registry_login_for_chart() {
    local chart_ref="$1"
    local registry_host

    [[ "$chart_ref" == oci://* ]] || return 0
    [[ -n "$IMAGE_PULL_SECRET_USERNAME" && -n "$IMAGE_PULL_SECRET_PASSWORD" ]] || return 0

    registry_host="${chart_ref#oci://}"
    registry_host="${registry_host%%/*}"

    [[ "$registry_host" == "$IMAGE_PULL_SECRET_SERVER" ]] || return 0

    log "Logging in to Helm registry ${registry_host}"
    printf '%s' "$IMAGE_PULL_SECRET_PASSWORD" | \
        helm registry login "$registry_host" --username "$IMAGE_PULL_SECRET_USERNAME" --password-stdin
}

prepare_chart_for_install() {
    local source_chart="$CHART"
    local pulled_chart
    local pull_args

    if ! is_local_chart_dir; then
        helm_registry_login_for_chart "$source_chart"
        PREPARED_CHART_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t falkordb-enterprise-chart)"
        pull_args=(pull "$source_chart" --untar --untardir "$PREPARED_CHART_DIR")
        if [[ -n "$CHART_VERSION" ]]; then
            pull_args+=(--version "$CHART_VERSION")
        fi
        if ((${#HELM_DEVEL_ARGS[@]})); then
            pull_args+=("${HELM_DEVEL_ARGS[@]}")
        fi

        log "Preparing Helm chart ${source_chart}${CHART_VERSION:+@$CHART_VERSION}"
        helm "${pull_args[@]}"
        pulled_chart="$(find "$PREPARED_CHART_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
        [[ -n "$pulled_chart" && -f "$pulled_chart/Chart.yaml" ]] || fail "Unable to prepare Helm chart ${source_chart}"
        CHART="$pulled_chart"
        CHART_VERSION=""
        log "Resolved chart version $(chart_dir_version "$CHART")"
    fi

    if [[ "$UPDATE_DEPS" == "true" ]]; then
        log "Updating Helm chart dependencies"
        helm dependency update "$CHART"
    fi
}

confirm_or_exit() {
    local prompt="$1"
    local answer=""

    if [[ "$YES" == "true" || "$DRY_RUN" == "true" ]]; then
        return
    fi

    if [[ ! -r /dev/tty ]]; then
        fail "Confirmation requires an interactive terminal. Re-run with --yes to continue non-interactively."
    fi

    printf '%s [y/N]: ' "$prompt" > /dev/tty
    read -r answer < /dev/tty || true
    case "$answer" in
        y|Y|yes|YES)
            ;;
        *)
            fail "Aborted by user"
            ;;
    esac
}

print_plan() {
    local target_version
    local current_version

    target_version="$(chart_dir_version "$CHART")"
    current_version="$(deployed_chart_version)"

    log "Install plan:"
    log "  kube context: ${KUBE_CONTEXT:-current context}"
    log "  KubeBlocks release: ${KUBEBLOCKS_RELEASE} in namespace ${KUBEBLOCKS_NAMESPACE}"
    if [[ "$INSTALL_SNAPSHOT_CONTROLLER" == "true" ]]; then
        log "  Snapshot Controller chart: ${SNAPSHOT_CONTROLLER_CHART}${SNAPSHOT_CONTROLLER_VERSION:+@$SNAPSHOT_CONTROLLER_VERSION}"
    else
        log "  Snapshot Controller chart: skipped"
    fi
    log "  Enterprise release: ${RELEASE} in namespace ${NAMESPACE}"
    log "  Enterprise chart: ${CHART}${CHART_VERSION:+@$CHART_VERSION}"
    if [[ -n "$target_version" ]]; then
        if [[ -z "$current_version" ]]; then
            log "  Enterprise chart version: ${target_version} (new install)"
        elif [[ "$current_version" == "$target_version" ]]; then
            log "  Enterprise chart version: ${target_version} (unchanged)"
        else
            log "  Enterprise chart version: ${current_version} -> ${target_version}"
        fi
    fi
    log "  FalkorDB addon: managed by the Enterprise Helm release"
    log "  Snapshot controller: managed as a separate platform Helm release"
    log "  KubeBlocks embedded subchart: disabled"
    if [[ "$QUICK_ACCESS_ENABLED" == "true" ]]; then
        if [[ "$QUICK_ACCESS_MODE" == "ingress" ]]; then
            log "  Quick access: expose Admin UI and Admin Server API on one origin via the gateway Ingress (class: ${QUICK_ACCESS_INGRESS_CLASS})"
            if [[ "$QUICK_ACCESS_INSTALL_INGRESS_NGINX" == "true" ]]; then
                log "  Quick access ingress controller: install ingress-nginx in ${INGRESS_NGINX_NAMESPACE}"
            else
                log "  Quick access ingress controller: use existing IngressClass ${QUICK_ACCESS_INGRESS_CLASS}"
            fi
            log "  Quick access cookies: COOKIE_SECURE=false (plain HTTP); SameSite stays lax on a single origin"
        else
            log "  Quick access: expose Admin UI and Admin Server with LoadBalancer services"
            log "  Quick access cookies: COOKIE_SECURE=false and COOKIE_SAME_SITE=none for cross-origin HTTP endpoints"
        fi
    else
        log "  Quick access: using chart/user-provided access settings"
    fi
    if [[ -n "$IMAGE_PULL_SECRET_NAME" ]]; then
        if [[ -n "$IMAGE_PULL_SECRET_USERNAME" || -n "$IMAGE_PULL_SECRET_PASSWORD" ]]; then
            log "  Image pull secret: create/update ${IMAGE_PULL_SECRET_NAME} in ${NAMESPACE}"
        else
            log "  Image pull secret: use existing ${IMAGE_PULL_SECRET_NAME} in ${NAMESPACE}"
        fi
    else
        log "  Image pull secret: not configured"
    fi
}

helm_value_overridden() {
    local key="$1"
    local arg

    if ((${#SET_ARGS[@]})); then
        for arg in "${SET_ARGS[@]}"; do
            if [[ "$arg" == "${key}="* || "$arg" == "${key}["* ]]; then
                return 0
            fi
        done
    fi

    if ((${#SET_STRING_ARGS[@]})); then
        for arg in "${SET_STRING_ARGS[@]}"; do
            if [[ "$arg" == "${key}="* || "$arg" == "${key}["* ]]; then
                return 0
            fi
        done
    fi

    return 1
}

quick_access_overridden() {
    local key

    if ((${#VALUES_ARGS[@]})); then
        return 0
    fi

    for key in \
        gateway.enabled \
        gateway.ingress.enabled \
        adminUi.service.type \
        adminUi.service.host \
        adminUi.service.url \
        adminUi.env.viteApiUrl \
        adminServer.service.type \
        adminServer.service.host \
        adminServer.service.url \
        adminServer.env.corsOrigin \
        adminServer.env.cookieSecure \
        adminServer.env.cookieSameSite; do
        if helm_value_overridden "$key"; then
            return 0
        fi
    done

    return 1
}

quick_access_release_pending_urls() {
    local admin_server_name
    local admin_ui_name
    local server_type
    local ui_type
    local vite_api_url

    admin_server_name="$(service_name_by_component admin-server)"
    admin_ui_name="$(service_name_by_component admin-ui)"
    [[ -n "$admin_server_name" && -n "$admin_ui_name" ]] || return 1

    server_type="$(kubectl_cmd get service "$admin_server_name" --namespace "$NAMESPACE" -o jsonpath='{.spec.type}' 2>/dev/null || true)"
    ui_type="$(kubectl_cmd get service "$admin_ui_name" --namespace "$NAMESPACE" -o jsonpath='{.spec.type}' 2>/dev/null || true)"
    [[ "$server_type" == "LoadBalancer" && "$ui_type" == "LoadBalancer" ]] || return 1

    vite_api_url="$(kubectl_cmd get deployment --namespace "$NAMESPACE" \
        -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=admin-ui" \
        -o jsonpath='{.items[0].spec.template.spec.containers[0].env[?(@.name=="VITE_API_URL")].value}' 2>/dev/null || true)"
    [[ -z "$vite_api_url" ]]
}

configure_quick_access_defaults() {
    if release_exists; then
        if quick_access_overridden; then
            return 0
        fi
        if quick_access_release_pending_urls; then
            log "Existing quick access install detected with unconfigured external URLs; they will be configured after this upgrade"
            QUICK_ACCESS_ENABLED="true"
            QUICK_ACCESS_MODE="loadbalancer"
        fi
        return 0
    fi

    if quick_access_overridden; then
        return 0
    fi

    QUICK_ACCESS_ENABLED="true"

    if [[ "$QUICK_ACCESS_INGRESS" == "true" ]]; then
        QUICK_ACCESS_MODE="ingress"
        if QUICK_ACCESS_INGRESS_CLASS="$(detect_default_ingress_class)"; then
            QUICK_ACCESS_INSTALL_INGRESS_NGINX="false"
        else
            QUICK_ACCESS_INGRESS_CLASS="nginx"
            QUICK_ACCESS_INSTALL_INGRESS_NGINX="true"
        fi
        QUICK_ACCESS_HELM_ARGS+=(--set gateway.ingress.enabled=true)
        QUICK_ACCESS_HELM_ARGS+=(--set-string "gateway.ingress.className=${QUICK_ACCESS_INGRESS_CLASS}")
        QUICK_ACCESS_HELM_ARGS+=(--set-string adminServer.env.cookieSecure=false)
        return 0
    fi

    QUICK_ACCESS_MODE="loadbalancer"
    QUICK_ACCESS_HELM_ARGS+=(--set adminUi.service.type=LoadBalancer)
    QUICK_ACCESS_HELM_ARGS+=(--set adminServer.service.type=LoadBalancer)
    QUICK_ACCESS_HELM_ARGS+=(--set-string adminServer.env.cookieSecure=false)
    QUICK_ACCESS_HELM_ARGS+=(--set-string adminServer.env.cookieSameSite=none)
}

detect_default_ingress_class() {
    local class
    local classes

    class="$(kubectl_cmd get ingressclass \
        -o go-template='{{range .items}}{{$name := .metadata.name}}{{with .metadata.annotations}}{{with index . "ingressclass.kubernetes.io/is-default-class"}}{{if eq . "true"}}{{$name}}{{"\n"}}{{end}}{{end}}{{end}}{{end}}' 2>/dev/null | head -n 1)"
    if [[ -n "$class" ]]; then
        printf '%s' "$class"
        return 0
    fi

    if kubectl_cmd get ingressclass nginx >/dev/null 2>&1; then
        printf 'nginx'
        return 0
    fi

    # No default class and no nginx class. If the cluster already has exactly
    # one ingress controller, reuse it. Installing a second controller here
    # would leave it permanently Pending on any cluster whose load balancer
    # can only bind :80/:443 once (k3s/k3d with the bundled traefik, MetalLB
    # with a single-address pool, and similar).
    classes="$(kubectl_cmd get ingressclass -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)"
    if [[ "$(printf '%s' "$classes" | grep -c .)" == "1" ]]; then
        printf '%s' "$(printf '%s' "$classes" | grep . | head -n 1)"
        return 0
    fi

    return 1
}

install_ingress_nginx() {
    local helm_args=(
        upgrade --install "$INGRESS_NGINX_RELEASE" "$INGRESS_NGINX_CHART"
        --namespace "$INGRESS_NGINX_NAMESPACE"
        --create-namespace
        --timeout "$TIMEOUT"
        --set controller.ingressClassResource.default=true
    )

    if [[ -n "$KUBE_CONTEXT" ]]; then
        helm_args+=(--kube-context "$KUBE_CONTEXT")
    fi

    if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
        helm_args+=("${HELM_SERVER_SIDE_ARGS[@]}")
    fi

    helm_args+=(--wait)

    if [[ "$DEBUG" == "true" ]]; then
        helm_args+=(--debug)
    fi

    log "Installing/updating ingress-nginx in ${INGRESS_NGINX_NAMESPACE}"
    helm repo add "$INGRESS_NGINX_REPO_NAME" "$INGRESS_NGINX_REPO_URL" >/dev/null 2>&1 || true
    helm repo update "$INGRESS_NGINX_REPO_NAME"
    helm "${helm_args[@]}"
}

service_external_host() {
    local service_name="$1"

    kubectl_cmd get service "$service_name" --namespace "$NAMESPACE" \
        -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true
}

service_name_by_component() {
    local component="$1"

    kubectl_cmd get service --namespace "$NAMESPACE" \
        -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=${component}" \
        -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
}

service_external_url() {
    local service_name="$1"
    local protocol="$2"
    local port="$3"
    local host

    host="$(service_external_host "$service_name")"
    [[ -n "$host" ]] || return 1

    if [[ "$protocol" == "http" && "$port" == "80" ]] || [[ "$protocol" == "https" && "$port" == "443" ]]; then
        printf '%s://%s' "$protocol" "$host"
    else
        printf '%s://%s:%s' "$protocol" "$host" "$port"
    fi
}

wait_for_service_external_host() {
    local service_name="$1"
    local deadline
    local host

    deadline=$((SECONDS + 600))
    while ((SECONDS < deadline)); do
        host="$(service_external_host "$service_name")"
        if [[ -n "$host" ]]; then
            printf '%s' "$host"
            return 0
        fi
        sleep 5
    done

    return 1
}

ingress_name_by_component() {
    local component="$1"

    kubectl_cmd get ingress --namespace "$NAMESPACE" \
        -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=${component}" \
        -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
}

ingress_external_host() {
    local ingress_name="$1"

    kubectl_cmd get ingress "$ingress_name" --namespace "$NAMESPACE" \
        -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true
}

wait_for_ingress_external_host() {
    local ingress_name="$1"
    local deadline
    local host

    deadline=$((SECONDS + 600))
    while ((SECONDS < deadline)); do
        host="$(ingress_external_host "$ingress_name")"
        if [[ -n "$host" ]]; then
            printf '%s' "$host"
            return 0
        fi
        sleep 5
    done

    return 1
}

configure_quick_access_urls() {
    [[ "$QUICK_ACCESS_ENABLED" == "true" ]] || return 0
    [[ "$DRY_RUN" != "true" ]] || return 0

    if [[ "$QUICK_ACCESS_MODE" == "ingress" ]]; then
        configure_quick_access_ingress_url
    else
        configure_quick_access_loadbalancer_urls
    fi
}

configure_quick_access_ingress_url() {
    local ingress_name
    local host
    local url
    local helm_args

    ingress_name="$(ingress_name_by_component gateway)"
    if [[ -z "$ingress_name" ]]; then
        log "Could not discover the gateway Ingress; skipping automatic quick access URL configuration"
        return 0
    fi

    log "Waiting for gateway Ingress external address"
    if ! host="$(wait_for_ingress_external_host "$ingress_name")"; then
        log "Gateway Ingress address is still pending; run kubectl get ingress -n ${NAMESPACE} ${ingress_name} and set adminUi.service.url once it is assigned"
        return 0
    fi

    url="http://${host}"

    helm_args=(
        upgrade "$RELEASE" "$CHART"
        --namespace "$NAMESPACE"
        --timeout "$TIMEOUT"
        --reuse-values
        --set-string "adminUi.service.url=$url"
    )

    if ((${#HELM_CONTEXT_ARGS[@]})); then
        helm_args+=("${HELM_CONTEXT_ARGS[@]}")
    fi
    if ((${#HELM_CHART_VERSION_ARGS[@]})); then
        helm_args+=("${HELM_CHART_VERSION_ARGS[@]}")
    fi
    if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
        helm_args+=("${HELM_SERVER_SIDE_ARGS[@]}")
    fi
    if [[ "$WAIT" == "true" ]]; then
        helm_args+=(--wait)
    fi
    if [[ "$DEBUG" == "true" ]]; then
        helm_args+=(--debug)
    fi

    log "Configuring public URL ${url}"
    helm "${helm_args[@]}"

    log "Admin UI: ${url}"
    log "Admin Server API: ${url}/api"
}

configure_quick_access_loadbalancer_urls() {
    local admin_server_name
    local admin_ui_name
    local admin_server_url
    local admin_ui_url
    local helm_args

    admin_server_name="$(service_name_by_component admin-server)"
    admin_ui_name="$(service_name_by_component admin-ui)"
    if [[ -z "$admin_server_name" || -z "$admin_ui_name" ]]; then
        log "Could not discover Admin UI/Admin Server services; skipping automatic quick access URL configuration"
        return 0
    fi

    log "Waiting for Admin Server external address"
    if ! wait_for_service_external_host "$admin_server_name" >/dev/null; then
        log "Admin Server LoadBalancer address is still pending; run kubectl get svc -n ${NAMESPACE} ${admin_server_name} and set adminUi.env.viteApiUrl once it is assigned"
        return 0
    fi

    log "Waiting for Admin UI external address"
    if ! wait_for_service_external_host "$admin_ui_name" >/dev/null; then
        log "Admin UI LoadBalancer address is still pending; run kubectl get svc -n ${NAMESPACE} ${admin_ui_name} once it is assigned"
        return 0
    fi

    admin_server_url="$(service_external_url "$admin_server_name" http 3000)"
    admin_ui_url="$(service_external_url "$admin_ui_name" http 80)"

    helm_args=(
        upgrade "$RELEASE" "$CHART"
        --namespace "$NAMESPACE"
        --timeout "$TIMEOUT"
        --reuse-values
        --set-string "adminServer.service.url=$admin_server_url"
        --set-string "adminUi.service.url=$admin_ui_url"
        --set-string "adminUi.env.viteApiUrl=$admin_server_url"
        --set-string "adminServer.env.corsOrigin=$admin_ui_url"
        --set-string adminServer.env.cookieSecure=false
        --set-string adminServer.env.cookieSameSite=none
    )

    if ((${#HELM_CONTEXT_ARGS[@]})); then
        helm_args+=("${HELM_CONTEXT_ARGS[@]}")
    fi
    if ((${#HELM_CHART_VERSION_ARGS[@]})); then
        helm_args+=("${HELM_CHART_VERSION_ARGS[@]}")
    fi
    if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
        helm_args+=("${HELM_SERVER_SIDE_ARGS[@]}")
    fi
    if [[ "$WAIT" == "true" ]]; then
        helm_args+=(--wait)
    fi
    if [[ "$DEBUG" == "true" ]]; then
        helm_args+=(--debug)
    fi

    log "Configuring Admin UI to use ${admin_server_url}"
    helm "${helm_args[@]}"

    log "Admin UI: ${admin_ui_url}"
    log "Admin Server: ${admin_server_url}"
}

kubectl_cmd() {
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" "$@"
    else
        kubectl "$@"
    fi
}

dump_kubeblocks_diagnostics() {
    log "KubeBlocks resources in ${KUBEBLOCKS_NAMESPACE}:"
    kubectl_cmd get deploy,pod,secret --namespace "$KUBEBLOCKS_NAMESPACE" -o wide || true
    log "Recent events in ${KUBEBLOCKS_NAMESPACE}:"
    kubectl_cmd get events --namespace "$KUBEBLOCKS_NAMESPACE" --sort-by=.lastTimestamp | tail -40 || true
}

dump_enterprise_diagnostics() {
    log "FalkorDB Enterprise resources in ${NAMESPACE}:"
    kubectl_cmd get deploy,statefulset,job,pod,pvc,secret,serviceaccount,role,rolebinding --namespace "$NAMESPACE" -o wide || true
    log "FalkorDB Enterprise cluster-scoped resources:"
    kubectl_cmd get clusterrole,clusterrolebinding,volumesnapshotclass -l "app.kubernetes.io/instance=${RELEASE}" -o wide || true
    log "Recent events in ${NAMESPACE}:"
    kubectl_cmd get events --namespace "$NAMESPACE" --sort-by=.lastTimestamp | tail -60 || true
}

run_enterprise_helm() {
    if [[ "$WAIT" != "true" || "$ENTERPRISE_WAIT" != "true" || "$DRY_RUN" == "true" || "$HELM_PROGRESS_INTERVAL" == "0" ]]; then
        helm "$@"
        return
    fi

    helm "$@" &
    local helm_pid=$!
    local exit_code=0
    local monitor_pid

    (
        while kill -0 "$helm_pid" 2>/dev/null; do
            sleep "$HELM_PROGRESS_INTERVAL" || true
            kill -0 "$helm_pid" 2>/dev/null || break
            log "Still waiting for FalkorDB Enterprise Helm release ${RELEASE}"
            dump_enterprise_diagnostics
        done
    ) &
    monitor_pid=$!

    wait "$helm_pid" || exit_code=$?
    kill "$monitor_pid" 2>/dev/null || true
    wait "$monitor_pid" 2>/dev/null || true

    if ((exit_code != 0)); then
        dump_enterprise_diagnostics
        return "$exit_code"
    fi
}

wait_for_kubeblocks() {
    [[ "$WAIT" == "true" ]] || return 0

    log "Waiting for KubeBlocks deployments to become available"
    if ! kubectl_cmd wait \
        --for=condition=Available \
        deployment \
        --namespace "$KUBEBLOCKS_NAMESPACE" \
        --selector "app.kubernetes.io/instance=${KUBEBLOCKS_RELEASE}" \
        --timeout "$TIMEOUT"; then
        dump_kubeblocks_diagnostics
        fail "KubeBlocks deployments did not become available within ${TIMEOUT}"
    fi
}

normalize_version() {
    printf '%s' "$1" | sed 's/^v//'
}

is_local_chart_dir() {
    [[ -d "$CHART" && -f "$CHART/Chart.yaml" ]]
}

# Version recorded in a chart directory's Chart.yaml. Used to report which
# version an unpinned pull actually resolved to, since "latest" is decided by
# the registry rather than by anything in this script.
chart_dir_version() {
    local chart_dir="$1"

    [[ -f "$chart_dir/Chart.yaml" ]] || return 0
    # Anchored to column 1 so the indented version: keys under dependencies:
    # cannot win.
    awk '/^version:[[:space:]]/ { gsub(/["'\'']/, "", $2); print $2; exit }' "$chart_dir/Chart.yaml"
}

# Chart version of the release currently deployed, if any. helm get metadata
# needs Helm 3.13+, and there is no release at all on a first install, so this
# must not fail the script: the version line is only informational.
deployed_chart_version() {
    local helm_args=(get metadata "$RELEASE" --namespace "$NAMESPACE")

    if [[ -n "$KUBE_CONTEXT" ]]; then
        helm_args=(--kube-context "$KUBE_CONTEXT" "${helm_args[@]}")
    fi

    helm "${helm_args[@]}" 2>/dev/null | awk '/^VERSION:[[:space:]]/ { print $2; exit }' || true
}

release_exists() {
    if [[ -n "$KUBE_CONTEXT" ]]; then
        helm --kube-context "$KUBE_CONTEXT" status "$RELEASE" --namespace "$NAMESPACE" >/dev/null 2>&1
    else
        helm status "$RELEASE" --namespace "$NAMESPACE" >/dev/null 2>&1
    fi
}

configure_helm_reuse_values() {
    [[ "$DRY_RUN" != "true" ]] || return 0

    if release_exists; then
        if helm_extra_args_include_values_mode; then
            log "Existing Enterprise release detected; custom Helm values mode detected"
        elif helm_supports_reset_then_reuse_values_flag; then
            log "Existing Enterprise release detected; resetting chart defaults before reusing stored values for this upgrade"
            REUSE_VALUES_ARGS+=(--reset-then-reuse-values)
        else
            log "Existing Enterprise release detected; reusing stored Helm values for this upgrade"
            REUSE_VALUES_ARGS+=(--reuse-values)
        fi
    fi

    return 0
}

extract_dependency_version() {
    local chart_yaml="$1"
    local dependency_name="$2"

    awk '
        $1 == "dependencies:" { in_dependencies = 1; next }
        in_dependencies && $1 == "-" {
            if (name == dep && version != "") { print version; found = 1; exit }
            name = ""
            version = ""
            if ($2 == "name:") { name = $3 }
            if ($2 == "version:") { version = $3 }
            next
        }
        in_dependencies && $1 == "name:" { name = $2; next }
        in_dependencies && $1 == "version:" {
            version = $2
            if (name == dep) { print version; found = 1; exit }
        }
        END {
            if (!found && name == dep && version != "") { print version }
        }
    ' dep="$dependency_name" "$chart_yaml"
}

resolve_kubeblocks_version() {
    if [[ -n "$KUBEBLOCKS_VERSION" ]]; then
        normalize_version "$KUBEBLOCKS_VERSION"
        return
    fi

    if is_local_chart_dir; then
        extract_dependency_version "$CHART/Chart.yaml" "kubeblocks"
        return
    fi

    if [[ -n "$CHART_VERSION" ]]; then
        helm show chart "$CHART" --version "$CHART_VERSION" | extract_dependency_version /dev/stdin "kubeblocks"
    else
        helm show chart "$CHART" "${HELM_DEVEL_ARGS[@]+"${HELM_DEVEL_ARGS[@]}"}" | extract_dependency_version /dev/stdin "kubeblocks"
    fi
}

helm_extra_args_include_server_side() {
    local arg
    if ((${#HELM_EXTRA_ARGS[@]} == 0)); then
        return 1
    fi

    for arg in "${HELM_EXTRA_ARGS[@]}"; do
        if [[ "$arg" == "--server-side" || "$arg" == --server-side=* ]]; then
            return 0
        fi
    done
    return 1
}

helm_supports_server_side_flag() {
    helm upgrade --help 2>/dev/null | grep -q -- '--server-side'
}

helm_supports_reset_then_reuse_values_flag() {
    helm upgrade --help 2>/dev/null | grep -q -- '--reset-then-reuse-values'
}

helm_extra_args_include_values_mode() {
    local arg
    if ((${#HELM_EXTRA_ARGS[@]} == 0)); then
        return 1
    fi

    for arg in "${HELM_EXTRA_ARGS[@]}"; do
        case "$arg" in
            --reuse-values|--reset-values|--reset-then-reuse-values)
                return 0
                ;;
        esac
    done
    return 1
}

configure_helm_server_side_mode() {
    if ! helm_supports_server_side_flag; then
        return 0
    fi

    if helm_extra_args_include_server_side; then
        log "Custom Helm --server-side setting detected; leaving it unchanged"
        return 0
    fi

    HELM_SERVER_SIDE_ARGS+=(--server-side=false)
    log "Disabling Helm server-side apply for KubeBlocks CRD compatibility"
}

generate_secret() {
    if command -v openssl >/dev/null 2>&1; then
        openssl rand -hex 32
    else
        LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 64
        printf '\n'
    fi
}

decode_base64() {
    if base64 --decode >/dev/null 2>&1 <<<""; then
        base64 --decode
    elif base64 -d >/dev/null 2>&1 <<<""; then
        base64 -d
    else
        base64 -D
    fi
}

existing_jwt_secret() {
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" get secret \
            -n "$NAMESPACE" \
            -l "app.kubernetes.io/instance=$RELEASE,app.kubernetes.io/component=admin-server" \
            -o jsonpath='{.items[0].data.jwt-secret}' 2>/dev/null | decode_base64 2>/dev/null || true
    else
        kubectl get secret \
            -n "$NAMESPACE" \
            -l "app.kubernetes.io/instance=$RELEASE,app.kubernetes.io/component=admin-server" \
            -o jsonpath='{.items[0].data.jwt-secret}' 2>/dev/null | decode_base64 2>/dev/null || true
    fi
}

ensure_namespace() {
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl --context "$KUBE_CONTEXT" apply -f -
    else
        kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
    fi
}

create_image_pull_secret() {
    [[ -n "$IMAGE_PULL_SECRET_NAME" ]] || return 0

    if [[ -z "$IMAGE_PULL_SECRET_USERNAME" && -z "$IMAGE_PULL_SECRET_PASSWORD" ]]; then
        log "Using existing image pull Secret ${IMAGE_PULL_SECRET_NAME} in ${NAMESPACE}"
        return
    fi

    [[ -n "$IMAGE_PULL_SECRET_USERNAME" ]] || fail "Image pull secret username is required when a registry password/token is provided"
    [[ -n "$IMAGE_PULL_SECRET_PASSWORD" ]] || fail "Image pull secret password/token is required when a registry username is provided"

    log "Creating/updating image pull Secret ${IMAGE_PULL_SECRET_NAME} in ${NAMESPACE}"
    ensure_namespace

    # The Secret is created imperatively rather than by the chart so that an
    # existing one is never orphaned. Stamping Helm's ownership metadata onto it
    # anyway keeps the door open: a user who later sets
    # `global.imageCredentials.create=true` gets the Secret adopted instead of
    # the "invalid ownership metadata" refusal Helm raises for foreign objects.
    kubectl_cmd create secret docker-registry "$IMAGE_PULL_SECRET_NAME" \
        --namespace "$NAMESPACE" \
        --docker-server="$IMAGE_PULL_SECRET_SERVER" \
        --docker-username="$IMAGE_PULL_SECRET_USERNAME" \
        --docker-password="$IMAGE_PULL_SECRET_PASSWORD" \
        --dry-run=client -o yaml |
        kubectl_cmd apply -f -

    kubectl_cmd label secret "$IMAGE_PULL_SECRET_NAME" \
        --namespace "$NAMESPACE" --overwrite \
        app.kubernetes.io/managed-by=Helm >/dev/null
    kubectl_cmd annotate secret "$IMAGE_PULL_SECRET_NAME" \
        --namespace "$NAMESPACE" --overwrite \
        "meta.helm.sh/release-name=${RELEASE}" \
        "meta.helm.sh/release-namespace=${NAMESPACE}" >/dev/null
}

AUTH_FAILURES=()

can_i() {
    local verb="$1"
    local resource="$2"
    local namespace="${3:-}"

    if [[ -n "$namespace" ]]; then
        kubectl_cmd --request-timeout="$AUTH_CHECK_REQUEST_TIMEOUT" auth can-i "$verb" "$resource" --namespace "$namespace" >/dev/null 2>&1
    else
        kubectl_cmd --request-timeout="$AUTH_CHECK_REQUEST_TIMEOUT" auth can-i "$verb" "$resource" >/dev/null 2>&1
    fi
}

can_i_all_namespaces() {
    local verb="$1"
    local resource="$2"

    kubectl_cmd --request-timeout="$AUTH_CHECK_REQUEST_TIMEOUT" auth can-i "$verb" "$resource" --all-namespaces >/dev/null 2>&1
}

has_broad_authorization() {
    can_i "*" "*" && can_i_all_namespaces "*" "*"
}

require_auth() {
    local verb="$1"
    local resource="$2"
    local namespace="${3:-}"
    local target="cluster"

    if [[ -n "$namespace" ]]; then
        target="namespace ${namespace}"
    fi

    if ! can_i "$verb" "$resource" "$namespace"; then
        AUTH_FAILURES+=("${verb} ${resource} (${target})")
    fi
}

require_auth_verbs() {
    local resource="$1"
    local namespace="$2"
    shift 2
    local verb

    for verb in "$@"; do
        require_auth "$verb" "$resource" "$namespace"
    done
}

crd_exists() {
    local crd="$1"

    kubectl_cmd get crd "$crd" >/dev/null 2>&1
}

sanitize_snapshot_class_name() {
    printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//'
}

default_storage_class_line() {
    local line

    line="$(kubectl_cmd get storageclass -o go-template='{{range .items}}{{$sc := .}}{{with .metadata.annotations}}{{if eq (index . "storageclass.kubernetes.io/is-default-class") "true"}}{{$sc.metadata.name}}{{"\t"}}{{$sc.provisioner}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | head -n 1 || true)"
    if [[ -z "$line" ]]; then
        line="$(kubectl_cmd get storageclass -o go-template='{{range .items}}{{$sc := .}}{{with .metadata.annotations}}{{if eq (index . "storageclass.beta.kubernetes.io/is-default-class") "true"}}{{$sc.metadata.name}}{{"\t"}}{{$sc.provisioner}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | head -n 1 || true)"
    fi

    printf '%s' "$line"
}

snapshot_capable_storage_class_line() {
    local line
    local selected_line=""
    local selected_count=0

    while IFS= read -r line; do
        [[ -n "$line" ]] || continue
        if is_snapshot_capable_provisioner "${line#*$'\t'}"; then
            selected_line="$line"
            selected_count=$((selected_count + 1))
        fi
    done < <(kubectl_cmd get storageclass -o go-template='{{range .items}}{{.metadata.name}}{{"\t"}}{{.provisioner}}{{"\n"}}{{end}}' 2>/dev/null || true)

    if [[ "$selected_count" -eq 1 ]]; then
        printf '%s' "$selected_line"
    fi
}

local_non_csi_storage_class_line() {
    local line
    local selected_line=""
    local selected_count=0

    while IFS= read -r line; do
        [[ -n "$line" ]] || continue
        if is_local_non_csi_provisioner "${line#*$'\t'}"; then
            selected_line="$line"
            selected_count=$((selected_count + 1))
        fi
    done < <(kubectl_cmd get storageclass -o go-template='{{range .items}}{{.metadata.name}}{{"\t"}}{{.provisioner}}{{"\n"}}{{end}}' 2>/dev/null || true)

    if [[ "$selected_count" -eq 1 ]]; then
        printf '%s' "$selected_line"
    fi
}

default_volume_snapshot_class_for_driver() {
    local driver="$1"

    if ! crd_exists volumesnapshotclasses.snapshot.storage.k8s.io; then
        return 0
    fi

    kubectl_cmd get volumesnapshotclass -o go-template="{{range .items}}{{\$vsc := .}}{{with .metadata.annotations}}{{if and (eq \$vsc.driver \"${driver}\") (eq (index . \"snapshot.storage.kubernetes.io/is-default-class\") \"true\")}}{{\$vsc.metadata.name}}{{\"\\n\"}}{{end}}{{end}}{{end}}" 2>/dev/null | head -n 1 || true
}

snapshot_class_owner() {
    local snapshot_class="$1"

    kubectl_cmd get volumesnapshotclass "$snapshot_class" -o go-template='{{with .metadata.annotations}}{{index . "meta.helm.sh/release-name"}}{{"\t"}}{{index . "meta.helm.sh/release-namespace"}}{{end}}' 2>/dev/null || true
}

is_snapshot_capable_provisioner() {
    local provisioner="$1"

    case "$provisioner" in
        ebs.csi.aws.com|efs.csi.aws.com|pd.csi.storage.gke.io|disk.csi.azure.com|file.csi.azure.com|hostpath.csi.k8s.io|driver.longhorn.io|rook-ceph.rbd.csi.ceph.com|rook-ceph.cephfs.csi.ceph.com|openshift-storage.rbd.csi.ceph.com|openshift-storage.cephfs.csi.ceph.com)
            return 0
            ;;
    esac

    [[ "$provisioner" == *".csi."* || "$provisioner" == csi.* || "$provisioner" == *".csi.k8s.io" ]]
}

is_local_non_csi_provisioner() {
    local provisioner="$1"

    case "$provisioner" in
        rancher.io/local-path|k8s.io/minikube-hostpath|kubernetes.io/host-path|microk8s.io/hostpath)
            return 0
            ;;
    esac

    return 1
}

demote_default_storage_class() {
    local storage_class="$1"

    [[ -n "$storage_class" ]] || return 0
    [[ "$DRY_RUN" != "true" ]] || return 0

    log "Marking StorageClass ${storage_class} as non-default so csi-hostpath-sc can become the default for new PVCs"
    kubectl_cmd patch storageclass "$storage_class" --type=merge -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false","storageclass.beta.kubernetes.io/is-default-class":"false"}}}' >/dev/null
}

configure_volume_snapshot_class() {
    local default_line
    local storage_class
    local provisioner
    local snapshot_class
    local snapshot_name
    local storage_class_source="default"

    default_line="$(default_storage_class_line)"
    if [[ -z "$default_line" ]]; then
        default_line="$(snapshot_capable_storage_class_line)"
        storage_class_source="CSI"
        if [[ -z "$default_line" ]]; then
            default_line="$(local_non_csi_storage_class_line)"
            storage_class_source="local non-CSI"
            if [[ -z "$default_line" ]]; then
                log "No default or uniquely detectable CSI StorageClass found; disabling chart-managed VolumeSnapshotClass. Pass --set volumeSnapshotClass.driver=<csi-driver> to enable volume-snapshot backups."
                SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=false)
                return
            fi
        fi
    fi

    storage_class="${default_line%%$'\t'*}"
    provisioner="${default_line#*$'\t'}"
    log "Detected ${storage_class_source} StorageClass ${storage_class} with provisioner ${provisioner}"

    if is_snapshot_capable_provisioner "$provisioner"; then
        if [[ "$storage_class_source" != "default" ]]; then
            log "Configuring admin-server persistence to use StorageClass ${storage_class}"
            ADMIN_SERVER_PERSISTENCE_HELM_ARGS+=(--set-string "adminServer.persistence.storageClassName=${storage_class}")
        fi

        snapshot_class="$(default_volume_snapshot_class_for_driver "$provisioner")"
        if [[ -n "$snapshot_class" ]]; then
            local owner
            local owner_release
            local owner_namespace

            owner="$(snapshot_class_owner "$snapshot_class")"
            owner_release="${owner%%$'\t'*}"
            owner_namespace="${owner#*$'\t'}"
            if [[ "$owner_release" == "$RELEASE" && "$owner_namespace" == "$NAMESPACE" ]]; then
                log "Detected VolumeSnapshotClass ${snapshot_class} for driver ${provisioner} owned by this release; keeping it chart-managed"
                SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=true)
                SNAPSHOT_CLASS_HELM_ARGS+=(--set-string "volumeSnapshotClass.name=${snapshot_class}")
                SNAPSHOT_CLASS_HELM_ARGS+=(--set-string "volumeSnapshotClass.driver=${provisioner}")
                SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.isDefault=true)
                SNAPSHOT_CLASS_HELM_ARGS+=(--set csi-hostpath-driver.enabled=false)
                return
            fi

            log "Detected default VolumeSnapshotClass ${snapshot_class} for driver ${provisioner}; using the existing class"
            SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=false)
            return
        fi

        snapshot_name="falkordb-enterprise-$(sanitize_snapshot_class_name "$provisioner")"
        log "Configuring chart-managed VolumeSnapshotClass ${snapshot_name} for driver ${provisioner}"
        SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=true)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set-string "volumeSnapshotClass.name=${snapshot_name}")
        SNAPSHOT_CLASS_HELM_ARGS+=(--set-string "volumeSnapshotClass.driver=${provisioner}")
        SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.isDefault=true)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set csi-hostpath-driver.enabled=false)
        return
    fi

    if is_local_non_csi_provisioner "$provisioner"; then
        log "Detected local non-CSI storage (${provisioner}); enabling CSI hostpath storage for new FalkorDB workload PVCs and volume-snapshot backups"
        demote_default_storage_class "$storage_class"
        SNAPSHOT_CLASS_HELM_ARGS+=(--set csi-hostpath-driver.enabled=true)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set csi-hostpath-driver.storageClass.default=true)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set-string "adminServer.persistence.storageClassName=${storage_class}")
        SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=true)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set-string volumeSnapshotClass.name=falkordb-enterprise-csi-hostpath)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set-string volumeSnapshotClass.driver=hostpath.csi.k8s.io)
        SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.isDefault=true)
        return
    fi

    log "Default StorageClass provisioner ${provisioner} is not known to be CSI snapshot-capable; disabling chart-managed VolumeSnapshotClass. Pass --set volumeSnapshotClass.driver=<csi-driver> if this storage supports snapshots."
    SNAPSHOT_CLASS_HELM_ARGS+=(--set volumeSnapshotClass.enabled=false)
}

existing_admin_server_pvc_storage_class() {
    local storage_class

    storage_class="$(kubectl_cmd get pvc \
        --namespace "$NAMESPACE" \
        -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=admin-server" \
        -o jsonpath='{.items[0].spec.storageClassName}' 2>/dev/null || true)"

    if [[ -z "$storage_class" ]]; then
        storage_class="$(kubectl_cmd get pvc \
            --namespace "$NAMESPACE" \
            "${RELEASE}-admin-server" \
            -o jsonpath='{.spec.storageClassName}' 2>/dev/null || true)"
    fi

    printf '%s' "$storage_class"
}

configure_admin_server_persistence_storage_class() {
    local storage_class

    storage_class="$(existing_admin_server_pvc_storage_class)"
    if [[ -z "$storage_class" ]]; then
        return
    fi

    log "Preserving existing admin-server PVC StorageClass ${storage_class} for this upgrade"
    ADMIN_SERVER_PERSISTENCE_HELM_ARGS+=(--set-string "adminServer.persistence.storageClassName=${storage_class}")
}

cleanup_unbound_admin_server_pvc() {
    local pvc_name="${RELEASE}-admin-server"
    local phase
    local volume_name

    phase="$(kubectl_cmd get pvc --namespace "$NAMESPACE" "$pvc_name" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
    [[ -n "$phase" && "$phase" != "Bound" ]] || return 0

    volume_name="$(kubectl_cmd get pvc --namespace "$NAMESPACE" "$pvc_name" -o jsonpath='{.spec.volumeName}' 2>/dev/null || true)"
    if [[ -n "$volume_name" ]]; then
        log "Found unbound admin-server PVC ${pvc_name} with volume ${volume_name}; leaving it in place"
        return
    fi

    log "Deleting unbound admin-server PVC ${pvc_name} before retrying install"
    kubectl_cmd delete pvc --namespace "$NAMESPACE" "$pvc_name" --ignore-not-found >/dev/null
}

verify_namespace_authorization() {
    local namespace="$1"
    local resource
    local resources=(
        configmaps
        secrets
        serviceaccounts
        services
        persistentvolumeclaims
        deployments.apps
        jobs.batch
        roles.rbac.authorization.k8s.io
        rolebindings.rbac.authorization.k8s.io
    )

    for resource in "${resources[@]}"; do
        require_auth_verbs "$resource" "$namespace" get list watch create update patch delete
    done

    require_auth_verbs pods "$namespace" get list watch
}

verify_existing_kubeblocks_resource_authorization() {
    local crd="$1"
    local resource="$2"
    shift 2

    local verbs=("$@")
    if ((${#verbs[@]} == 0)); then
        verbs=(get list watch create update patch delete)
    fi

    if crd_exists "$crd"; then
        require_auth_verbs "$resource" "" "${verbs[@]}"
    fi
}

verify_authorization() {
    if [[ "$DRY_RUN" == "true" || "$SKIP_AUTH_CHECK" == "true" ]]; then
        return
    fi

    AUTH_FAILURES=()

    log "Verifying Kubernetes authorization for installer actions"

    if has_broad_authorization; then
        log "Kubernetes authorization check passed (broad cluster permissions detected)"
        return
    fi

    log "Broad cluster permissions not detected; checking installer permissions individually"

    require_auth_verbs namespaces "" get list create
    require_auth_verbs customresourcedefinitions.apiextensions.k8s.io "" get
    require_auth_verbs clusterroles.rbac.authorization.k8s.io "" get list watch create update patch delete
    require_auth_verbs clusterrolebindings.rbac.authorization.k8s.io "" get list watch create update patch delete
    require_auth_verbs storageclasses.storage.k8s.io "" get list

    if snapshot_crds_exist; then
        require_auth_verbs volumesnapshotclasses.snapshot.storage.k8s.io "" get list
    fi

    if [[ "$INSTALL_CRDS" == "true" || "$INSTALL_SNAPSHOT_CRDS" == "true" ]]; then
        require_auth_verbs customresourcedefinitions.apiextensions.k8s.io "" create update patch
    elif snapshot_crds_exist; then
        require_auth_verbs customresourcedefinitions.apiextensions.k8s.io "" patch
    fi

    if [[ "$INSTALL_KUBEBLOCKS" == "true" ]]; then
        require_auth_verbs validatingwebhookconfigurations.admissionregistration.k8s.io "" get list watch create update patch delete
        require_auth_verbs mutatingwebhookconfigurations.admissionregistration.k8s.io "" get list watch create update patch delete
        require_auth_verbs storageclasses.storage.k8s.io "" get list watch create update patch delete
    fi

    verify_namespace_authorization "$NAMESPACE"

    if [[ "$INSTALL_KUBEBLOCKS" == "true" || "$INSTALL_SNAPSHOT_CONTROLLER" == "true" ]]; then
        verify_namespace_authorization "$KUBEBLOCKS_NAMESPACE"
    fi

    verify_existing_kubeblocks_resource_authorization actionsets.dataprotection.kubeblocks.io actionsets.dataprotection.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization backuppolicytemplates.dataprotection.kubeblocks.io backuppolicytemplates.dataprotection.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization clusterdefinitions.apps.kubeblocks.io clusterdefinitions.apps.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization componentdefinitions.apps.kubeblocks.io componentdefinitions.apps.kubeblocks.io get list watch create update patch
    verify_existing_kubeblocks_resource_authorization componentversions.apps.kubeblocks.io componentversions.apps.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization opsdefinitions.operations.kubeblocks.io opsdefinitions.operations.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization paramconfigrenderers.parameters.kubeblocks.io paramconfigrenderers.parameters.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization parametersdefinitions.parameters.kubeblocks.io parametersdefinitions.parameters.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization shardingdefinitions.apps.kubeblocks.io shardingdefinitions.apps.kubeblocks.io
    verify_existing_kubeblocks_resource_authorization storageproviders.dataprotection.kubeblocks.io storageproviders.dataprotection.kubeblocks.io

    if ((${#AUTH_FAILURES[@]})); then
        printf '[falkordb-enterprise] ERROR: Kubernetes authorization check failed. Missing permissions:\n' >&2
        printf '  - %s\n' "${AUTH_FAILURES[@]}" >&2
        printf '[falkordb-enterprise] Re-run with a context that has these permissions, or pass --skip-auth-check if permissions are delegated another way.\n' >&2
        exit 1
    fi

    log "Kubernetes authorization check passed"
}

apply_snapshot_crds() {
    log "Applying VolumeSnapshot CRDs (${SNAPSHOTTER_VERSION})"
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml"
        kubectl --context "$KUBE_CONTEXT" apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml"
        kubectl --context "$KUBE_CONTEXT" apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml"
    else
        kubectl apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml"
        kubectl apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml"
        kubectl apply --server-side -f "https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml"
    fi
}

snapshot_crds_exist() {
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" get crd volumesnapshots.snapshot.storage.k8s.io >/dev/null 2>&1
    else
        kubectl get crd volumesnapshots.snapshot.storage.k8s.io >/dev/null 2>&1
    fi
}

protect_existing_snapshot_crds() {
    local crds=(
        volumesnapshotclasses.snapshot.storage.k8s.io
        volumesnapshotcontents.snapshot.storage.k8s.io
        volumesnapshots.snapshot.storage.k8s.io
        volumegroupsnapshotclasses.groupsnapshot.storage.k8s.io
        volumegroupsnapshotcontents.groupsnapshot.storage.k8s.io
        volumegroupsnapshots.groupsnapshot.storage.k8s.io
    )

    for crd in "${crds[@]}"; do
        if kubectl_cmd get crd "$crd" >/dev/null 2>&1; then
            kubectl_cmd annotate crd "$crd" helm.sh/resource-policy=keep --overwrite >/dev/null
        fi
    done
}

repair_snapshot_crd_webhooks() {
    local crds=(
        volumesnapshotclasses.snapshot.storage.k8s.io
        volumesnapshotcontents.snapshot.storage.k8s.io
        volumesnapshots.snapshot.storage.k8s.io
        volumegroupsnapshotclasses.groupsnapshot.storage.k8s.io
        volumegroupsnapshotcontents.groupsnapshot.storage.k8s.io
        volumegroupsnapshots.groupsnapshot.storage.k8s.io
    )
    local webhook_secret="${SNAPSHOT_CONTROLLER_RELEASE}-conversion-webhook"
    local webhook_service="${SNAPSHOT_CONTROLLER_RELEASE}-conversion-webhook"
    local ca_bundle

    ca_bundle="$(kubectl_cmd get secret "$webhook_secret" \
        --namespace "$KUBEBLOCKS_NAMESPACE" \
        -o jsonpath='{.data.ca\.crt}' 2>/dev/null || true)"

    if [[ -z "$ca_bundle" ]]; then
        return
    fi

    for crd in "${crds[@]}"; do
        if [[ "$(kubectl_cmd get crd "$crd" -o jsonpath='{.spec.conversion.strategy}' 2>/dev/null || true)" == "Webhook" ]]; then
            kubectl_cmd patch crd "$crd" --type=merge \
                -p "{\"spec\":{\"conversion\":{\"webhook\":{\"clientConfig\":{\"caBundle\":\"${ca_bundle}\",\"service\":{\"name\":\"${webhook_service}\",\"namespace\":\"${KUBEBLOCKS_NAMESPACE}\"}}}}}}" >/dev/null
        fi
    done
}

apply_kubeblocks_crds() {
    local version="$1"

    [[ -n "$version" ]] || fail "Unable to determine KubeBlocks version. Pass --kubeblocks-version."

    log "Applying KubeBlocks CRDs (${version})"
    if [[ -n "$KUBE_CONTEXT" ]]; then
        kubectl --context "$KUBE_CONTEXT" apply --server-side -f "https://github.com/apecloud/kubeblocks/releases/download/v${version}/kubeblocks_crds.yaml"
        kubectl --context "$KUBE_CONTEXT" apply --server-side -f "https://raw.githubusercontent.com/apecloud/kubeblocks/${KUBEBLOCKS_CRD_REF}/deploy/helm/crds/apps.kubeblocks.io_componentdefinitions.yaml"
    else
        kubectl apply --server-side -f "https://github.com/apecloud/kubeblocks/releases/download/v${version}/kubeblocks_crds.yaml"
        kubectl apply --server-side -f "https://raw.githubusercontent.com/apecloud/kubeblocks/${KUBEBLOCKS_CRD_REF}/deploy/helm/crds/apps.kubeblocks.io_componentdefinitions.yaml"
    fi

    wait_for_kubeblocks_crds
}

# Helm reads every CRD-backed resource referenced by the chart during its
# pre-install check. If it does that before the API server has finished
# building handlers for the CRDs applied above, the read times out and the
# install aborts with e.g. 'could not get information about the resource
# StorageProvider "azureblob" ... the server was unable to return a response
# in the time allotted'.
wait_for_kubeblocks_crds() {
    local crds=()
    local name

    while IFS= read -r name; do
        [[ -n "$name" ]] && crds+=("$name")
    done < <(kubectl_cmd get crd -o name 2>/dev/null | grep '\.kubeblocks\.io$' || true)

    ((${#crds[@]})) || return 0

    log "Waiting for ${#crds[@]} KubeBlocks CRDs to be established"
    if ! kubectl_cmd wait --for=condition=Established --timeout=180s "${crds[@]}" >/dev/null; then
        fail "KubeBlocks CRDs did not become established within 180s. Re-run the installer or check API server health."
    fi
}

# Emit the KUBERNETES_SERVICE_* override as YAML list entries, indented by the
# optional prefix so the same body serves both a top-level and a nested key.
kubeblocks_apiserver_env_entries() {
    local indent="${1:-}"
    if [[ -n "$KUBERNETES_SERVICE_HOST_OVERRIDE" ]]; then
        printf '%s  - name: KUBERNETES_SERVICE_HOST\n%s    value: "%s"\n' "$indent" "$indent" "$KUBERNETES_SERVICE_HOST_OVERRIDE"
    fi
    if [[ -n "$KUBERNETES_SERVICE_PORT_OVERRIDE" ]]; then
        printf '%s  - name: KUBERNETES_SERVICE_PORT\n%s    value: "%s"\n' "$indent" "$indent" "$KUBERNETES_SERVICE_PORT_OVERRIDE"
        printf '%s  - name: KUBERNETES_SERVICE_PORT_HTTPS\n%s    value: "%s"\n' "$indent" "$indent" "$KUBERNETES_SERVICE_PORT_OVERRIDE"
    fi
}

install_kubeblocks() {
    local version="$1"
    local kubeblocks_values
    kubeblocks_values="$(generated_values_file)"
    cat >"$kubeblocks_values" <<'EOF'
# KubeBlocks bundles a set of addons it installs on its own; FalkorDB Enterprise
# manages the only addon it needs, so leave the list empty.
autoInstalledAddons: []
rbac:
  # The FalkorDB addon's OpsDefinitions manage ClusterRoles on behalf of each
  # database, which the stock KubeBlocks manager role does not cover.
  additionalPolicyRules:
    - apiGroups:
        - rbac.authorization.k8s.io
      resources:
        - clusterroles
      verbs:
        - create
        - delete
        - get
        - list
        - patch
        - update
        - watch
EOF

    # The chart-owned workloads take this through global.kubernetesServiceHost,
    # but KubeBlocks is a separate release here and does not read .Values.global.
    # Its own knobs are extraEnvs (manager) and dataProtection.extraEnvs. Written
    # into the values file rather than passed as --set extraEnvs[0].name=...,
    # which replaces the whole list element instead of merging into it.
    if [[ -n "$KUBERNETES_SERVICE_HOST_OVERRIDE" || -n "$KUBERNETES_SERVICE_PORT_OVERRIDE" ]]; then
        {
            printf 'extraEnvs:\n'
            kubeblocks_apiserver_env_entries
            printf 'dataProtection:\n  extraEnvs:\n'
            kubeblocks_apiserver_env_entries '  '
        } >>"$kubeblocks_values"
    fi

    local helm_args=(
        upgrade --install "$KUBEBLOCKS_RELEASE" "$KUBEBLOCKS_CHART"
        --namespace "$KUBEBLOCKS_NAMESPACE"
        --create-namespace
        --timeout "$TIMEOUT"
        -f "$kubeblocks_values"
    )

    [[ -n "$version" ]] || fail "Unable to determine KubeBlocks version. Pass --kubeblocks-version."

    if [[ -n "$KUBE_CONTEXT" ]]; then
        helm_args+=(--kube-context "$KUBE_CONTEXT")
    fi

    if [[ -n "$version" ]]; then
        helm_args+=(--version "$version")
    fi

    if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
        helm_args+=("${HELM_SERVER_SIDE_ARGS[@]}")
    fi

    if [[ "$DEBUG" == "true" ]]; then
        helm_args+=(--debug)
    fi

    log "Installing/updating KubeBlocks in ${KUBEBLOCKS_NAMESPACE}"
    helm repo add "$KUBEBLOCKS_REPO_NAME" "$KUBEBLOCKS_REPO_URL" >/dev/null 2>&1 || true
    helm repo update "$KUBEBLOCKS_REPO_NAME"
    helm "${helm_args[@]}"
    wait_for_kubeblocks
}

install_snapshot_controller() {
    local helm_args=(
        upgrade --install "$SNAPSHOT_CONTROLLER_RELEASE" "$SNAPSHOT_CONTROLLER_CHART"
        --namespace "$KUBEBLOCKS_NAMESPACE"
        --create-namespace
        --timeout "$TIMEOUT"
        --version "$SNAPSHOT_CONTROLLER_VERSION"
    )

    if [[ -n "$KUBE_CONTEXT" ]]; then
        helm_args+=(--kube-context "$KUBE_CONTEXT")
    fi

    if [[ "$INSTALL_SNAPSHOT_CRDS" == "true" ]]; then
        helm_args+=(--set installCRDs=false)
    elif snapshot_crds_exist; then
        helm_args+=(--set installCRDs=false)
    fi

    if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
        helm_args+=("${HELM_SERVER_SIDE_ARGS[@]}")
    fi

    if [[ "$WAIT" == "true" ]]; then
        helm_args+=(--wait)
    fi

    if [[ "$DEBUG" == "true" ]]; then
        helm_args+=(--debug)
    fi

    log "Installing/updating Snapshot Controller in ${KUBEBLOCKS_NAMESPACE}"
    helm repo add "$SNAPSHOT_CONTROLLER_REPO_NAME" "$SNAPSHOT_CONTROLLER_REPO_URL" >/dev/null 2>&1 || true
    helm repo update "$SNAPSHOT_CONTROLLER_REPO_NAME"
    helm "${helm_args[@]}"
}

parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --release)
                RELEASE="$2"
                shift 2
                ;;
            --namespace|-n)
                NAMESPACE="$2"
                shift 2
                ;;
            --kubeblocks-namespace)
                KUBEBLOCKS_NAMESPACE="$2"
                shift 2
                ;;
            --kubeblocks-release)
                KUBEBLOCKS_RELEASE="$2"
                shift 2
                ;;
            --kubeblocks-chart)
                KUBEBLOCKS_CHART="$2"
                shift 2
                ;;
            --kube-context)
                KUBE_CONTEXT="$2"
                shift 2
                ;;
            --chart)
                CHART="$2"
                CHART_FROM_LOCAL_CHECKOUT="false"
                shift 2
                ;;
            --version|--chart-version)
                CHART_VERSION="$2"
                shift 2
                ;;
            --channel)
                CHART_CHANNEL="$2"
                shift 2
                ;;
            --kubeblocks-version)
                KUBEBLOCKS_VERSION="$2"
                shift 2
                ;;
            --snapshotter-version)
                SNAPSHOTTER_VERSION="$2"
                shift 2
                ;;
            --values|-f)
                VALUES_ARGS+=("--values" "$2")
                shift 2
                ;;
            --set)
                SET_ARGS+=("--set" "$2")
                shift 2
                ;;
            --set-string)
                SET_STRING_ARGS+=("--set-string" "$2")
                shift 2
                ;;
            --jwt-secret)
                JWT_SECRET="$2"
                shift 2
                ;;
            --image-pull-secret)
                IMAGE_PULL_SECRET_NAME="$2"
                shift 2
                ;;
            --github-username|--registry-username)
                IMAGE_PULL_SECRET_USERNAME="$2"
                shift 2
                ;;
            --github-token|--registry-password)
                IMAGE_PULL_SECRET_PASSWORD="$2"
                shift 2
                ;;
            --registry-server)
                IMAGE_PULL_SECRET_SERVER="$2"
                shift 2
                ;;
            --image-registry)
                IMAGE_REGISTRY="$2"
                shift 2
                ;;
            --kubernetes-service-host)
                KUBERNETES_SERVICE_HOST_OVERRIDE="$2"
                shift 2
                ;;
            --kubernetes-service-port)
                KUBERNETES_SERVICE_PORT_OVERRIDE="$2"
                shift 2
                ;;
            --timeout)
                TIMEOUT="$2"
                shift 2
                ;;
            --skip-crds)
                INSTALL_CRDS="false"
                shift
                ;;
            --skip-snapshot-crds)
                INSTALL_SNAPSHOT_CRDS="false"
                shift
                ;;
            --install-snapshot-crds)
                INSTALL_SNAPSHOT_CRDS="true"
                shift
                ;;
            --skip-kubeblocks)
                INSTALL_KUBEBLOCKS="false"
                shift
                ;;
            --skip-snapshot-controller)
                INSTALL_SNAPSHOT_CONTROLLER="false"
                shift
                ;;
            --no-ingress)
                QUICK_ACCESS_INGRESS="false"
                shift
                ;;
            --skip-auth-check)
                SKIP_AUTH_CHECK="true"
                shift
                ;;
            --skip-dependency-update)
                UPDATE_DEPS="false"
                shift
                ;;
            --no-wait)
                WAIT="false"
                shift
                ;;
            --no-enterprise-wait)
                ENTERPRISE_WAIT="false"
                shift
                ;;
            --dry-run)
                DRY_RUN="true"
                shift
                ;;
            --yes|-y)
                YES="true"
                shift
                ;;
            --debug)
                DEBUG="true"
                shift
                ;;
            --helm-arg)
                HELM_EXTRA_ARGS+=("$2")
                shift 2
                ;;
            --help|-h)
                usage
                exit 0
                ;;
            *)
                fail "Unknown argument: $1"
                ;;
        esac
    done
}

parse_args "$@"

if [[ -z "$IMAGE_PULL_SECRET_NAME" && ( -n "$IMAGE_PULL_SECRET_USERNAME" || -n "$IMAGE_PULL_SECRET_PASSWORD" ) ]]; then
    IMAGE_PULL_SECRET_NAME="$DEFAULT_IMAGE_PULL_SECRET_NAME"
fi

if [[ "$DEBUG" == "true" ]]; then
    set -x
fi

require_command kubectl
require_command helm
require_command base64

trap cleanup_prepared_chart_dir EXIT

# Chart versions are published as bare semver OCI tags, so a v-prefixed
# argument would resolve to a tag that does not exist.
CHART_VERSION="$(normalize_version "$CHART_VERSION")"

case "$CHART_CHANNEL" in
    stable) ;;
    beta)
        # Beta releases are prerelease semver. Helm skips those unless asked.
        HELM_DEVEL_ARGS=(--devel)
        ;;
    *)
        fail "Unknown --channel '${CHART_CHANNEL}'. Use stable or beta."
        ;;
esac

# A checkout of this repository makes the working-tree chart the default, which
# would silently ignore a requested version. Asking for one means asking for a
# published release.
if [[ -n "$CHART_VERSION" && "$CHART_FROM_LOCAL_CHECKOUT" == "true" ]]; then
    log "Chart version ${CHART_VERSION} requested; installing from ${REMOTE_CHART} instead of the local checkout"
    CHART="$REMOTE_CHART"
    CHART_FROM_LOCAL_CHECKOUT="false"
fi

log "Target release: ${RELEASE}"
log "Target namespace: ${NAMESPACE}"
log "KubeBlocks release: ${KUBEBLOCKS_RELEASE}"
log "KubeBlocks namespace: ${KUBEBLOCKS_NAMESPACE}"
if [[ -n "$KUBE_CONTEXT" ]]; then
    log "Target kube context: ${KUBE_CONTEXT}"
fi
log "Chart: ${CHART}${CHART_VERSION:+@$CHART_VERSION}"

prepare_chart_for_install

HELM_CONTEXT_ARGS=()
HELM_CHART_VERSION_ARGS=()
if [[ -n "$KUBE_CONTEXT" ]]; then
    HELM_CONTEXT_ARGS+=("--kube-context" "$KUBE_CONTEXT")
fi
if [[ -n "$CHART_VERSION" ]]; then
    HELM_CHART_VERSION_ARGS+=("--version" "$CHART_VERSION")
fi

KUBEBLOCKS_VERSION_RESOLVED="$(resolve_kubeblocks_version)"
KUBEBLOCKS_VERSION_RESOLVED="$(normalize_version "$KUBEBLOCKS_VERSION_RESOLVED")"
configure_helm_server_side_mode
configure_quick_access_defaults

print_plan
confirm_or_exit "Proceed with installing/updating FalkorDB Enterprise?"
verify_authorization

if [[ "$DRY_RUN" != "true" ]]; then
    if [[ "$INSTALL_SNAPSHOT_CRDS" == "true" ]]; then
        apply_snapshot_crds
    fi

    protect_existing_snapshot_crds

    if [[ "$INSTALL_CRDS" == "true" ]]; then
        apply_kubeblocks_crds "$KUBEBLOCKS_VERSION_RESOLVED"
    fi

    if [[ "$INSTALL_KUBEBLOCKS" == "true" ]]; then
        install_kubeblocks "$KUBEBLOCKS_VERSION_RESOLVED"
    fi

    if [[ "$INSTALL_SNAPSHOT_CONTROLLER" == "true" ]]; then
        install_snapshot_controller
        repair_snapshot_crd_webhooks
    fi

    if [[ "$QUICK_ACCESS_INSTALL_INGRESS_NGINX" == "true" ]]; then
        install_ingress_nginx
    fi
else
    log "Dry run enabled; skipping CRD apply, KubeBlocks install, and Snapshot Controller install"
fi

if [[ "$DRY_RUN" != "true" ]]; then
    create_image_pull_secret
elif [[ -n "$IMAGE_PULL_SECRET_NAME" ]]; then
    log "Dry run enabled; skipping image pull Secret creation"
fi

if [[ -z "$JWT_SECRET" && "$DRY_RUN" != "true" ]]; then
    JWT_SECRET="$(existing_jwt_secret)"
fi

if [[ -z "$JWT_SECRET" ]]; then
    JWT_SECRET="$(generate_secret)"
    log "Generated adminServer.secret.jwtSecret for this install"
else
    log "Using provided or existing adminServer.secret.jwtSecret"
fi

if [[ "$DRY_RUN" != "true" ]]; then
    cleanup_unbound_admin_server_pvc
fi

configure_volume_snapshot_class
configure_admin_server_persistence_storage_class
configure_helm_reuse_values

if [[ "$DRY_RUN" == "true" ]]; then
    HELM_ARGS=(template "$RELEASE" "$CHART" --namespace "$NAMESPACE")
elif release_exists; then
    HELM_ARGS=(
        upgrade "$RELEASE" "$CHART"
        --namespace "$NAMESPACE"
        --create-namespace
        --timeout "$TIMEOUT"
    )
else
    HELM_ARGS=(
        install "$RELEASE" "$CHART"
        --namespace "$NAMESPACE"
        --create-namespace
        --timeout "$TIMEOUT"
    )
fi

if ((${#HELM_CONTEXT_ARGS[@]})); then
    HELM_ARGS+=("${HELM_CONTEXT_ARGS[@]}")
fi
if ((${#HELM_CHART_VERSION_ARGS[@]})); then
    HELM_ARGS+=("${HELM_CHART_VERSION_ARGS[@]}")
fi
if ((${#VALUES_ARGS[@]})); then
    HELM_ARGS+=("${VALUES_ARGS[@]}")
fi
if ((${#REUSE_VALUES_ARGS[@]})); then
    HELM_ARGS+=("${REUSE_VALUES_ARGS[@]}")
fi
if ((${#HELM_SERVER_SIDE_ARGS[@]})); then
    HELM_ARGS+=("${HELM_SERVER_SIDE_ARGS[@]}")
fi

HELM_ARGS+=(--set kubeblocks.enabled=false)
HELM_ARGS+=(--set snapshot-controller.enabled=false)
if [[ "$INSTALL_SNAPSHOT_CRDS" == "true" ]]; then
    HELM_ARGS+=(--set snapshot-controller.installCRDs=false)
fi
if ((${#SNAPSHOT_CLASS_HELM_ARGS[@]})); then
    HELM_ARGS+=("${SNAPSHOT_CLASS_HELM_ARGS[@]}")
fi
if ((${#ADMIN_SERVER_PERSISTENCE_HELM_ARGS[@]})); then
    HELM_ARGS+=("${ADMIN_SERVER_PERSISTENCE_HELM_ARGS[@]}")
fi
if ((${#QUICK_ACCESS_HELM_ARGS[@]})); then
    HELM_ARGS+=("${QUICK_ACCESS_HELM_ARGS[@]}")
fi
if [[ -n "$KUBERNETES_SERVICE_HOST_OVERRIDE" ]]; then
    APISERVER_HOST_HELM_ARGS+=(--set-string "global.kubernetesServiceHost=${KUBERNETES_SERVICE_HOST_OVERRIDE}")
fi
if [[ -n "$KUBERNETES_SERVICE_PORT_OVERRIDE" ]]; then
    APISERVER_HOST_HELM_ARGS+=(--set-string "global.kubernetesServicePort=${KUBERNETES_SERVICE_PORT_OVERRIDE}")
fi
if ((${#APISERVER_HOST_HELM_ARGS[@]})); then
    HELM_ARGS+=("${APISERVER_HOST_HELM_ARGS[@]}")
fi

if [[ -n "$IMAGE_PULL_SECRET_NAME" ]]; then
    # Written as a values file rather than --set: these are lists, and --set on
    # a list index replaces the whole element instead of merging into it.
    #
    # global.imageCredentials.name is what the admin server reads to decide
    # which Secret to replicate into each database namespace, so the FalkorDB
    # pods KubeBlocks creates can resolve falkordb-addon.imagePullSecrets there
    # without anyone seeding those namespaces by hand.
    PULL_SECRET_VALUES_FILE="$(generated_values_file)"
    cat >"$PULL_SECRET_VALUES_FILE" <<EOF
global:
  imageCredentials:
    name: "${IMAGE_PULL_SECRET_NAME}"
adminServer:
  imagePullSecrets:
    - name: "${IMAGE_PULL_SECRET_NAME}"
adminUi:
  imagePullSecrets:
    - name: "${IMAGE_PULL_SECRET_NAME}"
# FalkorDB database pods pull the private enterprise image too.
falkordb-addon:
  imagePullSecrets:
    - name: "${IMAGE_PULL_SECRET_NAME}"
EOF
    HELM_ARGS+=(-f "$PULL_SECRET_VALUES_FILE")
fi

if [[ -n "$IMAGE_REGISTRY" ]]; then
    HELM_ARGS+=(--set-string "global.imageRegistry=$IMAGE_REGISTRY")
fi

if ((${#SET_ARGS[@]})); then
    HELM_ARGS+=("${SET_ARGS[@]}")
fi
if ((${#SET_STRING_ARGS[@]})); then
    HELM_ARGS+=("${SET_STRING_ARGS[@]}")
fi

HELM_ARGS+=(--set-string "adminServer.secret.jwtSecret=$JWT_SECRET")

if ((${#HELM_EXTRA_ARGS[@]})); then
    HELM_ARGS+=("${HELM_EXTRA_ARGS[@]}")
fi

if [[ "$WAIT" == "true" && "$ENTERPRISE_WAIT" == "true" && "$DRY_RUN" != "true" ]]; then
    HELM_ARGS+=(--wait)
fi

if [[ "$DEBUG" == "true" ]]; then
    HELM_ARGS+=(--debug)
fi

if [[ "$DRY_RUN" == "true" ]]; then
    log "Rendering FalkorDB Enterprise chart"
else
    log "Installing/updating FalkorDB Enterprise"
fi
run_enterprise_helm "${HELM_ARGS[@]}"

configure_quick_access_urls

log "Done"