#!/bin/bash
# Keycloak update script for the Carta identity servers.
#
# Usage:
#   co_update_keycloak.sh <production|development> <version>
#
# Both arguments are mandatory. There are no defaults: an unpinned version or an
# implied mode is how you end up with dev and prod on different builds.
#
# Required environment variables (no defaults, fail fast if missing):
#   production  -> KC_PROD_DB_PASSWORD
#   development -> KC_DEV_DB_PASSWORD
# Set them in the environment or a root-only sourced secrets file, not here.
#
# -e: exit on any failing command. -u: unset variable is an error.
# -o pipefail: a pipeline fails if any stage fails.
set -euo pipefail

# ---------------------------------------------------------------------------
# Constants (single source of truth)
# ---------------------------------------------------------------------------
REMOTE_DB_HOST="h4.lead.nl"
SQLCMD="/opt/mssql-tools/bin/sqlcmd"
RELEASE_API="https://api.github.com/repos/keycloak/keycloak/releases/tags"

# Custom themes to carry over from the previous installation, by directory name.
# Empty means: use the themes shipped with the distribution and nothing else.
# Every entry is copied verbatim, so an entry that was built against an older
# Keycloak will break that older Keycloak's templates on the new server.
CUSTOM_THEMES=()

# Custom provider JARs to carry over, by file name. Empty means: none.
# Anything found in the old providers/ that is not listed here aborts the run,
# so a forgotten artefact is a stop, not a silent carry-over.
CUSTOM_PROVIDERS=()

# Theme directory names that would shadow a theme shipped inside the
# distribution. Copying one of these over is what breaks the admin console.
# Maintain this list when Keycloak adds a built-in theme.
RESERVED_THEME_NAMES=(base keycloak keycloak.v2 welcome)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
echo_error_and_exit() {
    echo "Error: $1" >&2
    exit 1
}

get_user_consent() {
    local message="$1"
    read -r -p "$message [y/n]: " consent
    if [[ "$consent" != "y" ]]; then
        echo "User aborted operation."
        exit 1
    fi
}

require_command() {
    command -v "$1" >/dev/null 2>&1 || echo_error_and_exit "Required command not found: $1"
}

# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
if [[ $# -ne 2 ]]; then
    echo_error_and_exit "Usage: $(basename "$0") <production|development> <version>   (example: $(basename "$0") development 26.7.2)"
fi

mode="$1"
target_version="$2"

case "$mode" in
    production)
        BASE_NAME="keycloak"
        REMOTE_DB_USER="KeyCloakAdmin"
        REMOTE_DB_PASSWORD="${KC_PROD_DB_PASSWORD:?Set KC_PROD_DB_PASSWORD in the environment before running (production DB backup password)}"
        ;;
    development)
        BASE_NAME="keycloakdev"
        REMOTE_DB_USER="KeyCloakDevAdmin"
        REMOTE_DB_PASSWORD="${KC_DEV_DB_PASSWORD:?Set KC_DEV_DB_PASSWORD in the environment before running (development DB backup password)}"
        ;;
    *)
        echo_error_and_exit "Mode must be exactly 'production' or 'development', got '$mode'."
        ;;
esac

[[ "$target_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
    || echo_error_and_exit "Version must be a pinned release tag such as 26.7.2, got '$target_version'."

require_command curl
require_command jq
require_command unzip
require_command sha256sum
[[ -x "$SQLCMD" ]] || echo_error_and_exit "sqlcmd not found or not executable at $SQLCMD."

timestamp="$(date +'%Y%m%d_%H%M%S')"
destination_folder="/opt/$BASE_NAME"
previous_folder="/opt/$BASE_NAME.previous_$timestamp"
REMOTE_DB_NAME="$BASE_NAME"
REMOTE_BACKUP_PATH="/var/backups/$BASE_NAME/backup_$timestamp.bak"
SERVICE_NAME="$BASE_NAME"
work_folder="/var/tmp/$BASE_NAME/$timestamp"
zip_folder="$work_folder/zip"
installation_folder="$work_folder/install"
staging_folder="$installation_folder/keycloak-$target_version"

[[ -d "$destination_folder" ]] || echo_error_and_exit "Destination $destination_folder does not exist."

echo "Mode:              $mode"
echo "Target version:    $target_version"
echo "Destination:       $destination_folder"
echo "Installed version: $("$destination_folder/bin/kc.sh" --version 2>/dev/null | head -1 || echo 'unknown')"
echo

# ---------------------------------------------------------------------------
# Download and verify
# ---------------------------------------------------------------------------
get_user_consent "Do you want to download Keycloak $target_version?"

mkdir -p "$zip_folder" "$installation_folder"

release_json="$(curl -fsSL "$RELEASE_API/$target_version")" \
    || echo_error_and_exit "Release tag $target_version not found on GitHub."

asset_name="keycloak-$target_version.zip"
download_url="$(jq -r --arg n "$asset_name" '.assets[] | select(.name==$n) | .browser_download_url' <<<"$release_json")"
expected_digest="$(jq -r --arg n "$asset_name" '.assets[] | select(.name==$n) | .digest' <<<"$release_json")"

[[ -n "$download_url" && "$download_url" != "null" ]] \
    || echo_error_and_exit "Release $target_version has no asset named $asset_name."
[[ "$expected_digest" == sha256:* ]] \
    || echo_error_and_exit "Release $target_version publishes no sha256 digest for $asset_name; refusing to install unverified bytes."

zip_path="$zip_folder/$asset_name"
curl -fL "$download_url" -o "$zip_path" || echo_error_and_exit "Failed to download $download_url."

actual_sha="$(sha256sum "$zip_path" | cut -d' ' -f1)"
[[ "$actual_sha" == "${expected_digest#sha256:}" ]] \
    || echo_error_and_exit "Checksum mismatch for $asset_name. Expected ${expected_digest#sha256:}, got $actual_sha."
unzip -tq "$zip_path" || echo_error_and_exit "Downloaded archive is corrupt."
echo "Downloaded and verified $asset_name (sha256 $actual_sha)."

unzip -q "$zip_path" -d "$installation_folder" || echo_error_and_exit "Failed to extract $zip_path."
[[ -d "$staging_folder" ]] || echo_error_and_exit "Archive did not contain keycloak-$target_version."

# ---------------------------------------------------------------------------
# Stop the service, then back up. A copy taken while Keycloak is running is not
# consistent, and neither is a database backup that predates the last write.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to stop $SERVICE_NAME and take backups?"

sudo systemctl stop "$SERVICE_NAME" || echo_error_and_exit "Failed to stop $SERVICE_NAME."
echo "Service $SERVICE_NAME stopped."

BACKUP_COMMAND="BACKUP DATABASE [$REMOTE_DB_NAME] TO DISK = N'$REMOTE_BACKUP_PATH' WITH NOFORMAT, NOINIT, NAME = N'$REMOTE_DB_NAME-full', SKIP, NOREWIND, NOUNLOAD, STATS = 10"
"$SQLCMD" -S "$REMOTE_DB_HOST" -U "$REMOTE_DB_USER" -P "$REMOTE_DB_PASSWORD" -Q "$BACKUP_COMMAND" \
    || echo_error_and_exit "Database backup failed on $REMOTE_DB_HOST."
echo "Database backed up to $REMOTE_BACKUP_PATH on $REMOTE_DB_HOST."

# ---------------------------------------------------------------------------
# Stage the new installation: config, curated themes, curated providers, logs.
# ---------------------------------------------------------------------------
cp -a "$destination_folder/conf/." "$staging_folder/conf/" \
    || echo_error_and_exit "Failed to copy conf/ into the staging folder."
echo "Copied conf/."

for theme in "${CUSTOM_THEMES[@]}"; do
    source_theme="$destination_folder/themes/$theme"
    [[ -d "$source_theme" ]] || echo_error_and_exit "Custom theme '$theme' not found in $destination_folder/themes."
    for reserved in "${RESERVED_THEME_NAMES[@]}"; do
        if [[ "$theme" == "$reserved" ]]; then
            echo_error_and_exit "Custom theme '$theme' shadows a built-in theme. Rename it instead of overriding it."
        fi
    done
    if [[ -d "$source_theme/META-INF" || -d "$source_theme/theme" ]]; then
        echo_error_and_exit "Custom theme '$theme' looks like an unpacked JAR (contains META-INF/ or theme/). Deploy the JAR in providers/ or unpack it at the right level."
    fi
    cp -a "$source_theme" "$staging_folder/themes/" \
        || echo_error_and_exit "Failed to copy theme '$theme'."
    echo "Copied theme '$theme'."
done
if [[ ${#CUSTOM_THEMES[@]} -eq 0 ]]; then
    echo "No custom themes configured; using the distribution themes."
fi

# Anything present in the old providers/ but not on the allow list is a stop.
shopt -s nullglob
for existing in "$destination_folder/providers/"*; do
    name="$(basename "$existing")"
    known="no"
    for allowed in "${CUSTOM_PROVIDERS[@]}"; do
        if [[ "$name" == "$allowed" ]]; then
            known="yes"
        fi
    done
    if [[ "$known" != "yes" ]]; then
        echo_error_and_exit "Unknown artefact in providers/: '$name'. Add it to CUSTOM_PROVIDERS or remove it, then run again."
    fi
done
for provider in "${CUSTOM_PROVIDERS[@]}"; do
    source_provider="$destination_folder/providers/$provider"
    [[ -f "$source_provider" ]] || echo_error_and_exit "Custom provider '$provider' not found in $destination_folder/providers."
    cp -a "$source_provider" "$staging_folder/providers/" \
        || echo_error_and_exit "Failed to copy provider '$provider'."
    echo "Copied provider '$provider'."
done
shopt -u nullglob
if [[ ${#CUSTOM_PROVIDERS[@]} -eq 0 ]]; then
    echo "No custom providers configured."
fi

# Keep the logs. They are needed most on the day an upgrade goes wrong.
if [[ -d "$destination_folder/data/log" ]]; then
    mkdir -p "$staging_folder/data"
    cp -a "$destination_folder/data/log" "$staging_folder/data/" \
        || echo_error_and_exit "Failed to carry over data/log."
    echo "Carried over data/log."
fi

sudo chown -R "$BASE_NAME:$BASE_NAME" "$staging_folder" \
    || echo_error_and_exit "Failed to set ownership on the staging folder."

# ---------------------------------------------------------------------------
# Deploy. The old installation is moved aside, not deleted, so a rollback is a
# move back rather than a restore from backup.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to deploy $target_version to $destination_folder? (current install moves to $previous_folder)"

sudo mv "$destination_folder" "$previous_folder" || echo_error_and_exit "Failed to move the current installation aside."
sudo mv "$staging_folder" "$destination_folder" || echo_error_and_exit "Failed to move the new installation into place."
sudo chown -R "$BASE_NAME:$BASE_NAME" "$destination_folder" \
    || echo_error_and_exit "Failed to set ownership on $destination_folder."
echo "Deployed $target_version to $destination_folder."

# ---------------------------------------------------------------------------
# Build in both modes, so dev exercises the same code path as production.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to run 'kc.sh build'?"
sudo -u "$BASE_NAME" "$destination_folder/bin/kc.sh" build || echo_error_and_exit "Keycloak build failed."
echo "Build completed."

# ---------------------------------------------------------------------------
# Start and verify
# ---------------------------------------------------------------------------
get_user_consent "Do you want to start $SERVICE_NAME?"
sudo systemctl start "$SERVICE_NAME" || echo_error_and_exit "Failed to start $SERVICE_NAME."

running_version="$("$destination_folder/bin/kc.sh" --version | head -1)"
grep -q "$target_version" <<<"$running_version" \
    || echo_error_and_exit "Deployed version does not report $target_version: $running_version"
echo "Service started. $running_version"

# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
get_user_consent "Do you want to remove the temporary files in $work_folder?"
rm -rf "$work_folder"

echo
echo "Done. Verify the login page, the admin console and a test login before you"
echo "remove the previous installation:"
echo "  sudo rm -rf $previous_folder"
echo "Rollback, if needed:"
echo "  sudo systemctl stop $SERVICE_NAME"
echo "  sudo mv $destination_folder /opt/$BASE_NAME.failed_$timestamp"
echo "  sudo mv $previous_folder $destination_folder"
echo "  sudo systemctl start $SERVICE_NAME"
