#!/bin/bash

################################################
#### GitLab Upgrade Automation              ####
#### Multi-stop upgrade with air-gapped     ####
#### environment support                    ####
####                                        ####
#### Author: Phil Connor                    ####
#### Contact: contact@mylinux.work          ####
#### Version: 1.01-051326                   ####
################################################

set -o pipefail

SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_NAME

# Required version stops (as of May 2026)
# Source: https://docs.gitlab.com/update/upgrade_paths/
readonly VERSION_STOPS=(
    "14.0.12"
    "14.3.6"
    "14.9.5"
    "14.10.5"
    "15.0.5"
    "15.4.6"
    "15.11.13"
    "16.0.10"
    "16.3.9"
    "16.7.10"
    "16.11.10"
    "17.3.7"
    "17.5.5"
    "17.8.7"
    "17.11.7"
    "18.0.2"
    "18.2.6"
    "18.5.2"
    "18.8.7"
    "18.11.0"
)

# Default configuration
readonly DEFAULT_PACKAGE_DIR="/var/opt/gitlab/upgrade-packages"
readonly DEFAULT_BACKUP_DIR="/var/opt/gitlab/backups"
readonly DEFAULT_MIN_DISK_GB=5
readonly DEFAULT_MIGRATION_TIMEOUT=3600
readonly DEFAULT_MIGRATION_POLL_INTERVAL=30

# Configuration variables (can be overridden by environment)
PACKAGE_DIR=${PACKAGE_DIR:-$DEFAULT_PACKAGE_DIR}
BACKUP_DIR=${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}
MIN_DISK_GB=${MIN_DISK_GB:-$DEFAULT_MIN_DISK_GB}
MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-$DEFAULT_MIGRATION_TIMEOUT}
MIGRATION_POLL_INTERVAL=${MIGRATION_POLL_INTERVAL:-$DEFAULT_MIGRATION_POLL_INTERVAL}
DEBUG=${DEBUG:-}

# Runtime flags
EDITION=""
MODE="upgrade"
AUTO_YES=false
SKIP_BACKUP=false
TARGET_VERSION=""
PKG_MANAGER=""
OS_FAMILY=""
CURRENT_VERSION=""

handle_error() {
    local exit_code=$1
    local line_number=$2
    echo "Error: $SCRIPT_NAME failed at line $line_number with exit code $exit_code" >&2
    exit "$exit_code"
}

trap 'handle_error $? $LINENO' ERR

debug_echo() {
    if [[ -n "$DEBUG" ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

log_info() {
    echo "[INFO] $*"
}

log_warn() {
    echo "[WARN] $*" >&2
}

log_error() {
    echo "[ERROR] $*" >&2
}

show_help() {
    cat << EOF
Usage: $SCRIPT_NAME [OPTIONS]

Automate multi-stop GitLab upgrades with air-gapped environment support.

Handles required version stops from 14.x through 18.x, background migration
checks, backups, health verification, and supports both online and offline
(air-gapped) installations.

MODES:
    (default)           Perform the upgrade (online or offline)
    --download-only     Download all packages for the upgrade path to PACKAGE_DIR
    --list-stops        Show the upgrade path for your current version and exit

OPTIONS:
    --target VERSION    Target GitLab version (default: latest in stop list)
    --edition ce|ee     GitLab edition (default: auto-detect from installed package)
    --offline           Install from local packages in PACKAGE_DIR instead of repos
    --yes               Skip confirmation prompts between stops
    --skip-backup       Skip the pre-upgrade backup (not recommended)
    --help, -h          Show this help message

ENVIRONMENT VARIABLES:
    PACKAGE_DIR               Directory for downloaded/offline packages (default: $DEFAULT_PACKAGE_DIR)
    BACKUP_DIR                Backup directory (default: $DEFAULT_BACKUP_DIR)
    MIN_DISK_GB               Minimum free disk space in GB (default: $DEFAULT_MIN_DISK_GB)
    MIGRATION_TIMEOUT         Max seconds to wait for background migrations (default: $DEFAULT_MIGRATION_TIMEOUT)
    MIGRATION_POLL_INTERVAL   Seconds between migration status checks (default: $DEFAULT_MIGRATION_POLL_INTERVAL)
    DEBUG                     Enable debug output

EXAMPLES:
    # Show what stops are needed
    $SCRIPT_NAME --list-stops

    # Upgrade to latest (online)
    sudo $SCRIPT_NAME --yes

    # Upgrade to a specific version
    sudo $SCRIPT_NAME --target 17.5.5

    # Download packages for air-gapped transfer
    sudo $SCRIPT_NAME --download-only --target 17.5.5

    # Install from downloaded packages (air-gapped)
    sudo $SCRIPT_NAME --offline --yes

    # Force EE edition
    sudo $SCRIPT_NAME --edition ee --yes
EOF
}

detect_os() {
    if [[ -f /etc/os-release ]]; then
        # shellcheck disable=SC1091
        source /etc/os-release
        case "$ID" in
            ubuntu|debian)
                OS_FAMILY="debian"
                PKG_MANAGER="apt"
                ;;
            rhel|centos|rocky|almalinux|ol|fedora)
                OS_FAMILY="rhel"
                if command -v dnf >/dev/null 2>&1; then
                    PKG_MANAGER="dnf"
                else
                    PKG_MANAGER="yum"
                fi
                ;;
            *)
                log_error "Unsupported OS: $ID"
                exit 1
                ;;
        esac
    else
        log_error "Cannot detect OS — /etc/os-release not found"
        exit 1
    fi
    debug_echo "Detected OS: $OS_FAMILY ($PKG_MANAGER)"
}

detect_edition() {
    if [[ -n "$EDITION" ]]; then
        debug_echo "Edition set by flag: $EDITION"
        return
    fi

    if command -v dpkg >/dev/null 2>&1; then
        if dpkg -l gitlab-ee 2>/dev/null | grep -q "^ii"; then
            EDITION="ee"
        elif dpkg -l gitlab-ce 2>/dev/null | grep -q "^ii"; then
            EDITION="ce"
        fi
    elif command -v rpm >/dev/null 2>&1; then
        if rpm -q gitlab-ee >/dev/null 2>&1; then
            EDITION="ee"
        elif rpm -q gitlab-ce >/dev/null 2>&1; then
            EDITION="ce"
        fi
    fi

    if [[ -z "$EDITION" ]]; then
        log_error "Cannot detect GitLab edition. Use --edition ce|ee"
        exit 1
    fi
    debug_echo "Detected edition: $EDITION"
}

get_current_version() {
    local version_file="/opt/gitlab/embedded/service/gitlab-rails/VERSION"
    if [[ -f "$version_file" ]]; then
        CURRENT_VERSION=$(cat "$version_file")
    else
        log_error "GitLab does not appear to be installed ($version_file not found)"
        exit 1
    fi
    debug_echo "Current version: $CURRENT_VERSION"
}

version_compare() {
    # Returns 0 if $1 < $2, 1 if equal, 2 if $1 > $2
    if [[ "$1" == "$2" ]]; then
        echo 1
        return
    fi

    local IFS=.
    local i
    read -ra ver1 <<< "$1"
    read -ra ver2 <<< "$2"

    for ((i = 0; i < ${#ver1[@]}; i++)); do
        local v1=${ver1[i]:-0}
        local v2=${ver2[i]:-0}
        if ((v1 < v2)); then
            echo 0
            return
        elif ((v1 > v2)); then
            echo 2
            return
        fi
    done
    echo 1
}

get_upgrade_path() {
    local current="$1"
    local target="$2"
    local path=()

    for stop in "${VERSION_STOPS[@]}"; do
        local cmp_current
        cmp_current=$(version_compare "$current" "$stop")
        if [[ "$cmp_current" == "0" ]]; then
            # Current < stop, so this stop is needed
            if [[ -n "$target" ]]; then
                local cmp_target
                cmp_target=$(version_compare "$stop" "$target")
                if [[ "$cmp_target" == "0" || "$cmp_target" == "1" ]]; then
                    path+=("$stop")
                fi
            else
                path+=("$stop")
            fi
        fi
    done

    # If target is specified and not already in the path, add it
    if [[ -n "$target" ]]; then
        local last="${path[*]: -1}"
        if [[ "$last" != "$target" ]]; then
            local in_stops=false
            for stop in "${VERSION_STOPS[@]}"; do
                if [[ "$stop" == "$target" ]]; then
                    in_stops=true
                    break
                fi
            done
            if [[ "$in_stops" == false ]]; then
                path+=("$target")
            fi
        fi
    fi

    echo "${path[@]}"
}

format_package_name() {
    local version="$1"
    if [[ "$OS_FAMILY" == "debian" ]]; then
        echo "gitlab-${EDITION}=${version}-${EDITION}.0"
    else
        echo "gitlab-${EDITION}-${version}-${EDITION}.0"
    fi
}

format_package_filename() {
    local version="$1"
    if [[ "$OS_FAMILY" == "debian" ]]; then
        echo "gitlab-${EDITION}_${version}-${EDITION}.0_amd64.deb"
    else
        echo "gitlab-${EDITION}-${version}-${EDITION}.0.el*.x86_64.rpm"
    fi
}

check_disk_space() {
    local check_path="$1"
    local required_gb="$2"

    local available_kb
    available_kb=$(df --output=avail "$check_path" 2>/dev/null | tail -1 | tr -d ' ')
    local available_gb=$((available_kb / 1024 / 1024))

    if ((available_gb < required_gb)); then
        log_error "Insufficient disk space on $check_path: ${available_gb}GB available, ${required_gb}GB required"
        return 1
    fi
    debug_echo "Disk space on $check_path: ${available_gb}GB available (${required_gb}GB required)"
    return 0
}

check_background_migrations() {
    log_info "Checking background migrations..."

    local pending
    pending=$(sudo gitlab-rails runner -e production \
        'puts Gitlab::Database::BackgroundMigration::BatchedMigration.queued.count' 2>/dev/null) || {
        log_warn "Could not check background migrations via Rails runner"
        return 0
    }

    if [[ "$pending" != "0" ]]; then
        log_warn "$pending background migration(s) still pending"
        log_info "Attempting to finalize background migrations..."
        sudo gitlab-rake db:background_migrations:finalize

        local elapsed=0
        while ((elapsed < MIGRATION_TIMEOUT)); do
            pending=$(sudo gitlab-rails runner -e production \
                'puts Gitlab::Database::BackgroundMigration::BatchedMigration.queued.count' 2>/dev/null) || break
            if [[ "$pending" == "0" ]]; then
                log_info "All background migrations complete"
                return 0
            fi
            log_info "Waiting for $pending migration(s)... (${elapsed}s / ${MIGRATION_TIMEOUT}s)"
            sleep "$MIGRATION_POLL_INTERVAL"
            elapsed=$((elapsed + MIGRATION_POLL_INTERVAL))
        done

        if [[ "$pending" != "0" ]]; then
            log_error "Background migrations did not complete within ${MIGRATION_TIMEOUT}s"
            log_error "Run: sudo gitlab-rake db:background_migrations:status"
            return 1
        fi
    else
        log_info "No pending background migrations"
    fi
    return 0
}

create_backup() {
    if [[ "$SKIP_BACKUP" == true ]]; then
        log_warn "Skipping backup (--skip-backup specified)"
        return 0
    fi

    log_info "Creating GitLab backup..."
    local backup_name
    backup_name="pre-upgrade-$(date +%Y%m%d-%H%M%S)"

    check_disk_space "$BACKUP_DIR" "$MIN_DISK_GB" || return 1

    sudo gitlab-backup create BACKUP="$backup_name" || {
        log_error "Backup failed"
        return 1
    }

    # Back up config files
    log_info "Backing up configuration files..."
    sudo cp /etc/gitlab/gitlab.rb "${BACKUP_DIR}/gitlab.rb.pre-upgrade.bak"
    sudo cp /etc/gitlab/gitlab-secrets.json "${BACKUP_DIR}/gitlab-secrets.json.pre-upgrade.bak"

    log_info "Backup complete: $backup_name"
    return 0
}

verify_health() {
    local version="$1"
    log_info "Verifying GitLab health after upgrade to $version..."

    # Check service status
    if ! sudo gitlab-ctl status >/dev/null 2>&1; then
        log_error "gitlab-ctl status reports unhealthy services"
        sudo gitlab-ctl status
        return 1
    fi
    log_info "All services running"

    # Verify installed version
    local installed
    installed=$(cat /opt/gitlab/embedded/service/gitlab-rails/VERSION 2>/dev/null)
    if [[ "$installed" != "$version" ]]; then
        log_warn "Expected version $version but found $installed"
    else
        log_info "Version confirmed: $installed"
    fi

    # Run health check
    log_info "Running gitlab:check..."
    if ! sudo gitlab-rake gitlab:check SANITIZE=true >/dev/null 2>&1; then
        log_warn "gitlab:check reported issues — review manually"
    else
        log_info "Health check passed"
    fi

    return 0
}

install_package_online() {
    local version="$1"
    local pkg_name
    pkg_name=$(format_package_name "$version")

    log_info "Installing $pkg_name (online)..."

    case "$PKG_MANAGER" in
        apt)
            sudo apt-get update -qq || true
            sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg_name" || return 1
            ;;
        dnf)
            sudo dnf install -y "$pkg_name" || return 1
            ;;
        yum)
            sudo yum install -y "$pkg_name" || return 1
            ;;
    esac
    return 0
}

install_package_offline() {
    local version="$1"
    local pattern
    pattern=$(format_package_filename "$version")

    # Find the package file
    local pkg_file
    pkg_file=$(find "$PACKAGE_DIR" -name "$pattern" -type f 2>/dev/null | head -1)

    if [[ -z "$pkg_file" ]]; then
        log_error "Package not found in $PACKAGE_DIR matching: $pattern"
        log_error "Run --download-only first to fetch packages"
        return 1
    fi

    log_info "Installing from local package: $pkg_file"

    case "$OS_FAMILY" in
        debian)
            sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$pkg_file" || {
                log_info "Resolving dependencies..."
                sudo apt-get install -f -y || return 1
            }
            ;;
        rhel)
            if [[ "$PKG_MANAGER" == "dnf" ]]; then
                sudo dnf install -y "$pkg_file" || return 1
            else
                sudo yum localinstall -y "$pkg_file" || return 1
            fi
            ;;
    esac
    return 0
}

download_packages() {
    local -a path
    IFS=' ' read -ra path <<< "$(get_upgrade_path "$CURRENT_VERSION" "$TARGET_VERSION")"

    if [[ ${#path[@]} -eq 0 ]]; then
        log_info "No upgrades needed — already at or above target version"
        return 0
    fi

    mkdir -p "$PACKAGE_DIR"

    log_info "Downloading ${#path[@]} package(s) to $PACKAGE_DIR"
    log_info "Upgrade path: ${path[*]}"

    local failed=0
    for version in "${path[@]}"; do
        local pkg_name
        pkg_name=$(format_package_name "$version")
        log_info "Downloading: $pkg_name"

        case "$PKG_MANAGER" in
            apt)
                (cd "$PACKAGE_DIR" && sudo apt-get download "$pkg_name" 2>/dev/null) || {
                    # Try apt-get with download option
                    sudo apt-get install --download-only -y -o Dir::Cache::Archives="$PACKAGE_DIR" "$pkg_name" 2>/dev/null || {
                        log_error "Failed to download $pkg_name"
                        failed=$((failed + 1))
                    }
                }
                ;;
            dnf)
                sudo dnf download --destdir="$PACKAGE_DIR" "$pkg_name" || {
                    log_error "Failed to download $pkg_name"
                    failed=$((failed + 1))
                }
                ;;
            yum)
                sudo yumdownloader --destdir="$PACKAGE_DIR" "$pkg_name" || {
                    log_error "Failed to download $pkg_name"
                    failed=$((failed + 1))
                }
                ;;
        esac
    done

    if ((failed > 0)); then
        log_error "$failed package(s) failed to download"
        return 1
    fi

    log_info "All packages downloaded to $PACKAGE_DIR"
    log_info "Transfer this directory to your air-gapped system, then run:"
    log_info "  PACKAGE_DIR=$PACKAGE_DIR $SCRIPT_NAME --offline"
    return 0
}

list_stops() {
    local -a path
    IFS=' ' read -ra path <<< "$(get_upgrade_path "$CURRENT_VERSION" "$TARGET_VERSION")"

    echo "Current version: $CURRENT_VERSION"
    echo "Edition:         gitlab-$EDITION"
    echo "OS:              $OS_FAMILY ($PKG_MANAGER)"
    if [[ -n "$TARGET_VERSION" ]]; then
        echo "Target version:  $TARGET_VERSION"
    else
        echo "Target version:  latest (${VERSION_STOPS[-1]})"
    fi
    echo ""

    if [[ ${#path[@]} -eq 0 ]]; then
        echo "No upgrades needed — already at or above target version."
        return
    fi

    echo "Required upgrade path (${#path[@]} stops):"
    echo ""
    echo "  $CURRENT_VERSION (current)"
    for stop in "${path[@]}"; do
        echo "    → $stop"
    done
    echo ""
    echo "Estimated time: $((${#path[@]} * 15))-$((${#path[@]} * 30)) minutes (varies by database size)"
}

run_upgrade() {
    local -a path
    IFS=' ' read -ra path <<< "$(get_upgrade_path "$CURRENT_VERSION" "$TARGET_VERSION")"

    if [[ ${#path[@]} -eq 0 ]]; then
        log_info "No upgrades needed — already at or above target version"
        return 0
    fi

    local offline=false
    if [[ "$MODE" == "offline" ]]; then
        offline=true
    fi

    echo "============================================"
    echo "  GitLab Upgrade: $CURRENT_VERSION → ${path[-1]}"
    echo "  Edition: gitlab-$EDITION"
    echo "  Stops: ${#path[@]}"
    echo "  Mode: $(if $offline; then echo "offline"; else echo "online"; fi)"
    echo "============================================"
    echo ""

    if [[ "$AUTO_YES" != true ]]; then
        echo "Press Enter to start the upgrade, or Ctrl+C to abort..."
        read -r
    fi

    # Pre-upgrade checks
    check_disk_space "/opt/gitlab" "$MIN_DISK_GB" || exit 1
    check_disk_space "/var/opt/gitlab" "$MIN_DISK_GB" || exit 1
    check_disk_space "/tmp" 1 || exit 1

    # Create backup before starting
    create_backup || exit 1

    # Check initial background migrations
    check_background_migrations || exit 1

    local stop_num=0
    local total=${#path[@]}
    for version in "${path[@]}"; do
        stop_num=$((stop_num + 1))
        echo ""
        echo "============================================"
        echo "  Stop $stop_num/$total: Upgrading to $version"
        echo "============================================"

        # Check background migrations before this stop
        if ((stop_num > 1)); then
            check_background_migrations || {
                log_error "Cannot proceed — background migrations incomplete"
                exit 1
            }
        fi

        # Install the package
        if $offline; then
            install_package_offline "$version" || {
                log_error "Failed to install $version from local packages"
                exit 1
            }
        else
            install_package_online "$version" || {
                log_error "Failed to install $version"
                exit 1
            }
        fi

        # Verify health
        verify_health "$version" || {
            log_warn "Health verification had issues — review before continuing"
        }

        # Check for new background migrations
        log_info "Checking for new background migrations..."
        sudo gitlab-rake db:background_migrations:status 2>/dev/null || true

        if ((stop_num < total)); then
            if [[ "$AUTO_YES" != true ]]; then
                echo ""
                echo "Stop $stop_num/$total complete. Press Enter for next stop, or Ctrl+C to abort..."
                read -r
            else
                log_info "Stop $stop_num/$total complete — proceeding to next stop"
            fi
        fi
    done

    echo ""
    echo "============================================"
    echo "  Upgrade Complete"
    echo "============================================"
    local final_version
    final_version=$(cat /opt/gitlab/embedded/service/gitlab-rails/VERSION 2>/dev/null)
    echo "  Final version: $final_version"
    echo ""
    echo "Post-upgrade tasks:"
    echo "  1. sudo gitlab-rake gitlab:check SANITIZE=true"
    echo "  2. sudo gitlab-rake db:background_migrations:status"
    echo "  3. Verify CI/CD pipelines and runners"
    echo "  4. Check container registry functionality"
    echo "  5. Remove maintenance notification"
}

parse_arguments() {
    while [[ $# -gt 0 ]]; do
        case $1 in
            --download-only)
                MODE="download"
                shift
                ;;
            --offline)
                MODE="offline"
                shift
                ;;
            --list-stops)
                MODE="list"
                shift
                ;;
            --target)
                TARGET_VERSION="$2"
                shift 2
                ;;
            --edition)
                EDITION="$2"
                if [[ "$EDITION" != "ce" && "$EDITION" != "ee" ]]; then
                    log_error "Edition must be 'ce' or 'ee'"
                    exit 1
                fi
                shift 2
                ;;
            --yes)
                AUTO_YES=true
                shift
                ;;
            --skip-backup)
                SKIP_BACKUP=true
                shift
                ;;
            --help|-h)
                show_help
                exit 0
                ;;
            *)
                log_error "Unknown option: $1"
                show_help >&2
                exit 1
                ;;
        esac
    done
}

validate_requirements() {
    # Must run as root for upgrade/download operations
    if [[ "$MODE" != "list" && $EUID -ne 0 ]]; then
        log_error "This script must be run as root (use sudo)"
        exit 1
    fi

    detect_os
    detect_edition
    get_current_version

    # Validate target version format if specified
    if [[ -n "$TARGET_VERSION" ]]; then
        if ! [[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            log_error "Invalid target version format: $TARGET_VERSION (expected: X.Y.Z)"
            exit 1
        fi
    fi

    # For offline mode, verify package directory exists
    if [[ "$MODE" == "offline" ]]; then
        if [[ ! -d "$PACKAGE_DIR" ]]; then
            log_error "Package directory not found: $PACKAGE_DIR"
            log_error "Run --download-only first, then transfer packages to this directory"
            exit 1
        fi
    fi
}

main() {
    parse_arguments "$@"

    # Allow --list-stops and --help without root
    if [[ "$MODE" == "list" ]]; then
        detect_os
        detect_edition
        get_current_version
        list_stops
        exit 0
    fi

    validate_requirements

    case "$MODE" in
        download)
            download_packages
            ;;
        upgrade|offline)
            run_upgrade
            ;;
    esac

    debug_echo "Script completed successfully"
}

# Execute main function if script is run directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
