#!/usr/bin/env bash
#
# Production deploy for Parkspace Galaxy (MCS).
#
# Run from the application root on the production host:
#
#     ./deploy.sh              # prompts before touching anything
#     ./deploy.sh --yes        # non-interactive (for cron/CI)
#     ./deploy.sh --dry-run    # print the plan, change nothing
#
# To keep a record (recommended — Composer output can be long):
#     ./deploy.sh --yes 2>&1 | tee ~/deploy-$(date +%Y%m%d-%H%M).log
#
# WHY THE EXPLICIT PHP BINARY
# The host serves this app on PHP 8.5 (php8.5-fpm, supervisor workers, scheduler) but the
# system-wide `update-alternatives` default for `php` is 8.3, because another application on
# the same box (/var/www/html/CSA/csa_web) needs 8.3. We therefore pin 8.5 per invocation
# rather than changing the global alternative, which would silently move that other app.
#
# composer.json requires php ^8.5, so vendor/composer/platform_check.php aborts on anything
# below 8.5. That includes Composer's own script steps: `composer install` runs
# post-autoload-dump -> `@php artisan package:discover`, and `@php` is whichever PHP is
# running Composer. An 8.3 Composer therefore fails mid-install. Hence $COMPOSER is invoked
# as "$PHP <composer.phar>" rather than bare `composer`.
#
# See .kiro/steering/php-runtime.md for the full runtime topology.

set -Eeuo pipefail

# --- configuration ----------------------------------------------------------------------

# Override with: PHP_BIN=/usr/bin/php8.6 ./deploy.sh
PHP="${PHP_BIN:-/usr/bin/php8.5}"

# Minimum PHP the vendor tree will tolerate. Keep in sync with "require.php" in composer.json;
# the preflight below fails loudly if they drift apart.
MIN_PHP_ID=80500
MIN_PHP_LABEL="8.5.0"

# Supervisor group for this app's queue workers. Used only by --restart-workers.
WORKER_GROUP="parkspace-worker:*"

ASSUME_YES=0
DRY_RUN=0
RESTART_WORKERS=0

# --- plumbing ---------------------------------------------------------------------------

BOLD=$(tput bold 2>/dev/null || true)
RED=$(tput setaf 1 2>/dev/null || true)
GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)

MAINTENANCE_ENGAGED=0
STEP=""

log()  { printf '%s[deploy]%s %s\n' "$BOLD" "$RESET" "$1"; }
warn() { printf '%s[deploy] WARNING:%s %s\n' "$YELLOW" "$RESET" "$1" >&2; }
die()  { printf '%s[deploy] ERROR:%s %s\n' "$RED" "$RESET" "$1" >&2; exit 1; }

run() {
    if [ "$DRY_RUN" -eq 1 ]; then
        # %q so multi-word arguments (e.g. the error_reporting expression) are shown as the
        # single argument they actually are, rather than looking like shell operators.
        printf '  would run:'
        printf ' %q' "$@"
        printf '\n'
        return 0
    fi
    "$@"
}

# On any failure we deliberately leave the application in maintenance mode: a half-installed
# vendor tree or a failed migration should not be serving traffic. Make that state, and the
# recovery command, impossible to miss.
on_error() {
    local code=$?
    printf '\n%s%s DEPLOY FAILED %s during step: %s (exit %d)\n' "$BOLD" "$RED" "$RESET" "${STEP:-unknown}" "$code" >&2
    if [ "$MAINTENANCE_ENGAGED" -eq 1 ]; then
        printf '%sThe application is STILL IN MAINTENANCE MODE.%s\n' "$YELLOW" "$RESET" >&2
        printf 'Investigate, then bring it back up with:\n\n    %s artisan up\n\n' "$PHP" >&2
    else
        printf 'The application was never taken down; no traffic was affected.\n\n' >&2
    fi
    exit "$code"
}
trap on_error ERR

step() { STEP="$1"; log "$1"; }

# --- arguments --------------------------------------------------------------------------

while [ $# -gt 0 ]; do
    case "$1" in
        -y|--yes)             ASSUME_YES=1 ;;
        -n|--dry-run)         DRY_RUN=1 ;;
        --restart-workers)    RESTART_WORKERS=1 ;;
        -h|--help)
            sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//'
            exit 0 ;;
        *) die "Unknown option: $1 (try --help)" ;;
    esac
    shift
done

# --- preflight --------------------------------------------------------------------------
# Everything here runs BEFORE `artisan down`, so a misconfigured deploy fails without ever
# taking the site offline.

step 'Preflight checks'

cd "$(dirname "$(readlink -f "$0")")"

[ -f composer.json ] || die "composer.json not found in $(pwd) — run this from the app root."
[ -f artisan ]       || die "artisan not found in $(pwd) — run this from the app root."
[ -f .env ]          || die ".env not found in $(pwd); refusing to deploy without it."

[ -x "$PHP" ] || die "PHP binary not executable: $PHP (override with PHP_BIN=...)"

PHP_ID=$("$PHP" -r 'echo PHP_VERSION_ID;')
PHP_VER=$("$PHP" -r 'echo PHP_VERSION;')

if [ "$PHP_ID" -lt "$MIN_PHP_ID" ]; then
    die "$PHP is PHP $PHP_VER, but this codebase requires >= $MIN_PHP_LABEL.
       vendor/composer/platform_check.php will abort every artisan and composer script step.
       Point PHP_BIN at a 8.5+ binary (the system default 'php' is 8.3 on this host)."
fi

# Catch drift between this script's floor and composer.json's actual constraint.
# shellcheck disable=SC2016  # single quotes are deliberate: this is PHP, $c must not be expanded by the shell
REQUIRED_PHP=$("$PHP" -r '$c=json_decode(file_get_contents("composer.json"),true); echo $c["require"]["php"] ?? "";')
case "$REQUIRED_PHP" in
    *8.5*) ;;
    '')    warn "Could not read require.php from composer.json." ;;
    *)     warn "composer.json requires php '$REQUIRED_PHP' but this script enforces >= $MIN_PHP_LABEL. Update MIN_PHP_ID." ;;
esac

# Composer must be executed *by* $PHP, so we need the phar path, not the wrapper on PATH.
COMPOSER_BIN="${COMPOSER_BIN:-$(command -v composer || true)}"
[ -n "$COMPOSER_BIN" ] || die "composer not found on PATH (set COMPOSER_BIN=/path/to/composer.phar)"
if ! head -c 2 "$COMPOSER_BIN" | grep -q '#!'; then
    warn "$COMPOSER_BIN does not look like a script/phar; if the next step fails set COMPOSER_BIN explicitly."
fi

COMPOSER=("$PHP" "$COMPOSER_BIN")

# Composer's own vendored libraries (symfony/console, justinrainbow/json-schema,
# composer/pcre, composer/ca-bundle, ...) trip PHP 8.5 deprecations for implicit-nullable
# parameters and the removed E_STRICT constant. It prints one line per occurrence — hundreds
# per run — which buries the real output and overruns the terminal scrollback. None of it comes
# from application code.
#
# This CANNOT be suppressed with `php -d error_reporting=...`. Composer's ErrorHandler::handle()
# deliberately exempts deprecations from the mask:
#
#     $isDeprecationNotice = $level === E_DEPRECATED || $level === E_USER_DEPRECATED;
#     if (!$isDeprecationNotice && 0 === (error_reporting() & $level)) { return true; }
#
# so the notices are emitted regardless of error_reporting. Filtering the output is the only
# reliable option. sed is used rather than grep because grep exits 1 when nothing matches,
# which under `set -o pipefail` would turn a clean run into a spurious failure; sed always
# exits 0, so Composer's own exit status is what propagates.
composer_run() {
    if [ "$DRY_RUN" -eq 1 ]; then
        run "${COMPOSER[@]}" "$@"
        return 0
    fi
    "${COMPOSER[@]}" "$@" 2>&1 | sed -E '/^Deprecation Notice: /d'
}

BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ "$BRANCH" = "main" ] || warn "On branch '$BRANCH', not 'main'."

if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
    warn "Working tree has uncommitted changes to tracked files; 'git pull' may conflict:"
    git status --short --untracked-files=no | sed 's/^/    /' >&2
fi

log "  php        : $PHP ($PHP_VER)"
log "  composer   : $PHP $COMPOSER_BIN"
log "  branch     : $BRANCH at $(git rev-parse --short HEAD)"
log "  app root   : $(pwd)"

# --- confirmation -----------------------------------------------------------------------

if [ "$ASSUME_YES" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
    printf '\nThis will take the application DOWN, pull, install, migrate and bring it up.\n'
    printf 'Migrations run with --force (no per-migration prompt).\n'
    printf 'Continue? [y/N] '
    read -r reply
    case "$reply" in
        y|Y|yes|YES) ;;
        *) log 'Aborted; nothing was changed.'; exit 0 ;;
    esac
fi

# --- deploy -----------------------------------------------------------------------------

step 'Enabling maintenance mode'
run "$PHP" artisan down
[ "$DRY_RUN" -eq 1 ] || MAINTENANCE_ENGAGED=1

step 'Pulling latest code'
run git pull

step 'Installing dependencies (production)'
composer_run install --no-dev --optimize-autoloader --no-interaction

step 'Clearing caches'
composer_run clear-all --no-interaction

step 'Rebuilding caches'
# composer cache-all -> `@php artisan optimize` -> config:cache. This MUST run under the same
# PHP that serves requests: config:cache freezes resolved values as literals, and
# config/database.php picks a PDO constant whose numeric value differs between 8.3 and 8.5.
composer_run cache-all --no-interaction

step 'Running migrations'
run "$PHP" artisan migrate --force

step 'Signalling queue workers to restart'
run "$PHP" artisan queue:restart

if [ "$RESTART_WORKERS" -eq 1 ]; then
    step "Restarting supervisor workers ($WORKER_GROUP)"
    run sudo supervisorctl restart "$WORKER_GROUP"
fi

step 'Disabling maintenance mode'
run "$PHP" artisan up
[ "$DRY_RUN" -eq 1 ] || MAINTENANCE_ENGAGED=0

# --- done -------------------------------------------------------------------------------

trap - ERR

if [ "$DRY_RUN" -eq 1 ]; then
    printf '\n%s[deploy] Dry run complete; nothing was changed.%s\n' "$GREEN" "$RESET"
    exit 0
fi

printf '\n%s[deploy] Deploy complete.%s %s at %s\n' "$GREEN" "$RESET" "$BRANCH" "$(git rev-parse --short HEAD)"
log "Laravel: $("$PHP" artisan --version)"
