#!/usr/bin/env bash
# Deploy one exact git commit on the Docker Compose host (production or staging).
# Intended to run ON the server:
#   make prod-deploy SHA=...       # DEPLOY_TARGET=production (default)
#   make staging-deploy SHA=...    # DEPLOY_TARGET=staging
#
# Application rollback restores the previously deployed Git SHA.
# Database migrations are NOT rolled back automatically.
set -euo pipefail

usage() {
  cat <<'EOF'
Usage: scripts/deploy-production.sh [--dry-run] <40-char-commit-sha>

Env:
  DEPLOY_TARGET=production|staging   (default: production)

Deploys that SHA with: DB backup → git checkout -B <deploy-branch> SHA → image rebuild
→ composer install → migrate --force → cache/Horizon reload → health checks.
On failure after checkout: restore previous Git SHA and restart (no migrate rollback).

  --dry-run   Validate arguments and print the plan. Makes no changes.
EOF
}

DRY_RUN=0
if [[ "${1:-}" == "--dry-run" ]]; then
  DRY_RUN=1
  shift
fi

if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
  usage
  exit 0
fi

if [[ $# -lt 1 ]]; then
  usage
  exit 1
fi

TARGET_SHA="${1}"
TARGET_SHA="$(printf '%s' "$TARGET_SHA" | tr 'A-F' 'a-f')"
if [[ ! "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then
  echo "error: target must be a 40-character git SHA, got: ${TARGET_SHA}" >&2
  exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$REPO_ROOT"

# ── Target: production (default) or staging (same VPS, sibling folder/ports) ──
DEPLOY_TARGET="${DEPLOY_TARGET:-production}"
case "$DEPLOY_TARGET" in
  production)
    DEPLOY_BRANCH="${DEPLOY_BRANCH:-production}"
    COMPOSE_FILE="docker-compose.prod.yml"
    EXPECTED_APP_ENV="production"
    EDGE_HEALTH_URL="${EDGE_HEALTH_URL:-http://127.0.0.1:8088/up}"
    BACKUP_DIR="${BACKUP_DIR:-/var/backups/erpflow}"
    BACKEND_PORT_DEFAULT=8010
    FRONTEND_PORT_DEFAULT=3010
    CONTAINER_MYSQL=erpflow-mysql
    CONTAINER_REDIS=erpflow-redis
    CONTAINER_BACKEND=erpflow-backend
    CONTAINER_BACKEND_NGINX=erpflow-backend-nginx
    CONTAINER_BACKEND_EDGE=erpflow-backend-edge
    CONTAINER_FRONTEND=erpflow-frontend
    CONTAINER_QUEUE=erpflow-queue
    CONTAINER_SCHEDULER=erpflow-scheduler
    DEPLOY_PATH_ENV_NAME=PRODUCTION_DEPLOY_PATH
    DEPLOY_PATH_VALUE="${PRODUCTION_DEPLOY_PATH:-}"
    VITE_FORBIDDEN_PORT_RE=':8010'
    ;;
  staging)
    # Isolated ports: frontend 3012, edge 8089, loopback nginx 8014.
    # APP_ENV=staging (Horizon has a matching environments.staging block).
    DEPLOY_BRANCH="${DEPLOY_BRANCH:-staging}"
    COMPOSE_FILE="docker-compose.staging.yml"
    EXPECTED_APP_ENV="staging"
    EDGE_HEALTH_URL="${EDGE_HEALTH_URL:-http://127.0.0.1:8089/up}"
    BACKUP_DIR="${BACKUP_DIR:-/var/backups/erpflow-staging}"
    BACKEND_PORT_DEFAULT=8014
    FRONTEND_PORT_DEFAULT=3012
    CONTAINER_MYSQL=erpflow-staging-mysql
    CONTAINER_REDIS=erpflow-staging-redis
    CONTAINER_BACKEND=erpflow-staging-backend
    CONTAINER_BACKEND_NGINX=erpflow-staging-backend-nginx
    CONTAINER_BACKEND_EDGE=erpflow-staging-backend-edge
    CONTAINER_FRONTEND=erpflow-staging-frontend
    CONTAINER_QUEUE=erpflow-staging-queue
    CONTAINER_SCHEDULER=erpflow-staging-scheduler
    DEPLOY_PATH_ENV_NAME=STAGING_DEPLOY_PATH
    DEPLOY_PATH_VALUE="${STAGING_DEPLOY_PATH:-}"
    VITE_FORBIDDEN_PORT_RE=':8014'
    ;;
  *)
    echo "error: DEPLOY_TARGET must be production or staging (got '${DEPLOY_TARGET}')" >&2
    exit 1
    ;;
esac

COMPOSE=(docker compose -f "$COMPOSE_FILE" --env-file backend/.env)
PREV_SHA=""
SWITCHED_REVISION=0
IN_ROLLBACK=0

log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "error: required command '$1' not found" >&2
    exit 1
  }
}

# Refuse temp-file / wrong-tree execution (GHA must run clone/scripts/deploy-production.sh).
assert_repo_root() {
  if [[ "$SCRIPT_DIR" != "$REPO_ROOT/scripts" ]]; then
    echo "error: deploy script must live at \$REPO_ROOT/scripts (SCRIPT_DIR=${SCRIPT_DIR})" >&2
    exit 1
  fi
  case "$REPO_ROOT" in
    /tmp|/*/tmp/*|/var/tmp/*|/private/tmp/*|/var/folders/*|/private/var/folders/*)
      echo "error: refusing temporary REPO_ROOT=${REPO_ROOT}" >&2
      echo "error: run ./scripts/deploy-production.sh from ${DEPLOY_PATH_ENV_NAME}" >&2
      exit 1
      ;;
  esac
  if [[ ! -f "$REPO_ROOT/$COMPOSE_FILE" || ! -f "$REPO_ROOT/scripts/deploy-production.sh" ]]; then
    echo "error: REPO_ROOT=${REPO_ROOT} does not look like the ERPFlow clone (missing ${COMPOSE_FILE})" >&2
    exit 1
  fi
  if [[ -n "$DEPLOY_PATH_VALUE" ]]; then
    local expected actual
    expected="$(cd "$DEPLOY_PATH_VALUE" && pwd)"
    actual="$(cd "$REPO_ROOT" && pwd)"
    if [[ "$expected" != "$actual" ]]; then
      echo "error: REPO_ROOT (${actual}) != ${DEPLOY_PATH_ENV_NAME} (${expected})" >&2
      exit 1
    fi
    log "Repo matches ${DEPLOY_PATH_ENV_NAME}=${expected}"
  fi
}

read_dotenv() {
  local key="$1"
  local file="${2:-backend/.env}"
  grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '\r'
}

assert_deploy_env() {
  if [[ ! -f backend/.env ]]; then
    echo "error: backend/.env is missing (secrets live on the server, not in git)" >&2
    exit 1
  fi
  local app_env
  app_env="$(read_dotenv APP_ENV || true)"
  if [[ "$app_env" != "$EXPECTED_APP_ENV" ]]; then
    echo "error: backend/.env APP_ENV must be ${EXPECTED_APP_ENV} for DEPLOY_TARGET=${DEPLOY_TARGET} (got '${app_env:-empty}')" >&2
    exit 1
  fi
}

assert_vite_api_url() {
  local url
  url="$(read_dotenv VITE_API_URL || true)"
  if [[ -z "$url" ]]; then
    echo "error: VITE_API_URL must be set in backend/.env (baked into the frontend image)" >&2
    exit 1
  fi
  if echo "$url" | grep -Eqi "localhost|127\\.0\\.0\\.1|${VITE_FORBIDDEN_PORT_RE}"; then
    echo "error: VITE_API_URL must be the public API URL on the edge port, not localhost or loopback backend port" >&2
    exit 1
  fi
  log "Frontend image will bake VITE_API_URL from backend/.env"
}

# Browser Origin for the SPA: scheme://<VITE_API_URL host>:<FRONTEND_PROD_PORT>
frontend_browser_origin() {
  local url host scheme port
  url="$(read_dotenv VITE_API_URL || true)"
  port="$(read_dotenv FRONTEND_PROD_PORT || true)"
  port="${port:-$FRONTEND_PORT_DEFAULT}"
  scheme="$(printf '%s' "$url" | sed -E 's#^([a-zA-Z][a-zA-Z0-9+.-]*)://.*#\1#')"
  host="$(printf '%s' "$url" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*#\1#')"
  if [[ -z "$scheme" || -z "$host" || "$scheme" == "$url" || "$host" == "$url" ]]; then
    echo "error: cannot derive frontend Origin from VITE_API_URL='${url}'" >&2
    return 1
  fi
  printf '%s://%s:%s' "$scheme" "$host" "$port"
}

cors_preflight_ok() {
  local origin="$1"
  local headers
  headers="$(curl -sS --max-time 15 -D - -o /dev/null -X OPTIONS \
    "${EDGE_HEALTH_URL%/up}/api/v1/auth/login" \
    -H "Origin: ${origin}" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: content-type,authorization" || true)"
  echo "$headers" | grep -i '^Access-Control-Allow-Origin:' | grep -Fq "$origin"
}

wait_for() {
  local description="$1"
  shift
  local attempt
  for attempt in $(seq 1 45); do
    if "$@" >/dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  echo "error: timed out waiting for ${description}" >&2
  return 1
}

mysql_ping() {
  "${COMPOSE[@]}" exec -T mysql sh -c 'mysqladmin ping -h localhost -uroot -p"$MYSQL_ROOT_PASSWORD" --silent'
}

redis_ping() {
  "${COMPOSE[@]}" exec -T redis redis-cli ping | grep -q PONG
}

curl_ok() {
  curl -fsS --max-time 15 -o /dev/null "$1"
}

health_check() {
  log "Health: required containers running (${DEPLOY_TARGET})"
  local name
  for name in \
    "$CONTAINER_MYSQL" \
    "$CONTAINER_REDIS" \
    "$CONTAINER_BACKEND" \
    "$CONTAINER_BACKEND_NGINX" \
    "$CONTAINER_BACKEND_EDGE" \
    "$CONTAINER_FRONTEND" \
    "$CONTAINER_QUEUE" \
    "$CONTAINER_SCHEDULER"
  do
    local status
    status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || true)"
    if [[ "$status" != "running" ]]; then
      echo "error: container ${name} is not running (status=${status:-missing})" >&2
      return 1
    fi
  done

  log "Health: MySQL"
  wait_for "MySQL ping" mysql_ping

  log "Health: Redis"
  wait_for "Redis ping" redis_ping

  local backend_port frontend_port
  backend_port="$(read_dotenv BACKEND_PORT || true)"
  backend_port="${backend_port:-$BACKEND_PORT_DEFAULT}"
  frontend_port="$(read_dotenv FRONTEND_PROD_PORT || true)"
  frontend_port="${frontend_port:-$FRONTEND_PORT_DEFAULT}"

  log "Health: Laravel /up on loopback :${backend_port}"
  wait_for_laravel_up_loopback "http://127.0.0.1:${backend_port}/up"

  log "Health: Laravel /up on edge ${EDGE_HEALTH_URL}"
  wait_for "${EDGE_HEALTH_URL}" curl_ok "${EDGE_HEALTH_URL}"

  log "Health: frontend HTTP on :${frontend_port}"
  wait_for "http://127.0.0.1:${frontend_port}/" curl_ok "http://127.0.0.1:${frontend_port}/"

  log "Health: Horizon"
  wait_for "Horizon status" "${COMPOSE[@]}" exec -T queue php artisan horizon:status

  local fe_origin
  fe_origin="$(frontend_browser_origin)"
  log "Health: CORS preflight for Origin ${fe_origin}"
  wait_for "CORS preflight ${fe_origin}" cors_preflight_ok "$fe_origin"
}

# php-fpm runs as www-data against the bind-mounted tree. git checkout
# inherits this process umask — if it is 077, new files land as 0600 and
# new dirs as 0700. Laravel never boots, and the browser reports CORS.
fix_bindmount_permissions() {
  log "Making bind-mounted PHP source readable by php-fpm (www-data)"
  find "$REPO_ROOT/backend" \
    \( -path '*/vendor/*' -o -path '*/storage/*' -o -path '*/node_modules/*' \) -prune -o \
    -type f -perm 600 ! -name '.env' ! -name '.env.*' \
    -exec chmod a+r {} + 2>/dev/null || true
  find "$REPO_ROOT/backend" \
    \( -path '*/vendor/*' -o -path '*/storage/*' -o -path '*/node_modules/*' \) -prune -o \
    -type d \( -perm 700 -o -perm 710 -o -perm 711 \) \
    -exec chmod a+rx {} + 2>/dev/null || true
}

backup_database() {
  mkdir -p "$BACKUP_DIR"
  chmod 700 "$BACKUP_DIR"
  local stamp backup_file old_umask
  stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
  backup_file="${BACKUP_DIR}/erpflow-${DEPLOY_TARGET}-${TARGET_SHA}-${stamp}.sql"
  log "Backing up database to ${backup_file}"
  old_umask="$(umask)"
  umask 077
  "${COMPOSE[@]}" exec -T mysql \
    sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers "$MYSQL_DATABASE"' \
    > "$backup_file"
  umask "$old_umask"
  if [[ ! -s "$backup_file" ]]; then
    echo "error: database backup is empty; aborting" >&2
    rm -f "$backup_file"
    exit 1
  fi
  if ! grep -q 'Dump completed' "$backup_file"; then
    echo "error: database backup does not look like a mysqldump; aborting" >&2
    exit 1
  fi
  log "Backup OK ($(wc -c < "$backup_file" | tr -d ' ') bytes)"
}

checkout_sha() {
  local sha="$1"
  log "Fetching origin and checking out ${sha}"
  git fetch --prune origin
  if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
    git fetch --prune origin "$sha"
  fi
  if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
    echo "error: commit ${sha} is not in this repository after git fetch" >&2
    exit 1
  fi
  # Stay on DEPLOY_BRANCH (never detached) so operators and follow-up deploys
  # do not land on a detached HEAD.
  git checkout --force -B "$DEPLOY_BRANCH" "$sha"
  local actual branch
  actual="$(git rev-parse HEAD)"
  branch="$(git branch --show-current)"
  if [[ "$actual" != "$sha" ]]; then
    echo "error: HEAD is ${actual}, expected ${sha}" >&2
    exit 1
  fi
  if [[ "$branch" != "$DEPLOY_BRANCH" ]]; then
    echo "error: expected branch ${DEPLOY_BRANCH} after checkout, got '${branch:-detached}'" >&2
    exit 1
  fi
  SWITCHED_REVISION=1
  fix_bindmount_permissions
}

rebuild_and_up() {
  log "Rebuilding images and starting stack"
  BACKEND_BIND="${BACKEND_BIND:-127.0.0.1}" "${COMPOSE[@]}" build
  BACKEND_BIND="${BACKEND_BIND:-127.0.0.1}" "${COMPOSE[@]}" up -d --build --remove-orphans
  wait_for "MySQL after rebuild" mysql_ping
  wait_for "backend artisan" "${COMPOSE[@]}" exec -T backend php artisan --version
  log "Installing PHP dependencies from composer.lock"
  # Source-installed vendor packages (with .git) can gain dirty trees on bind mounts;
  # without discard-changes, --no-dev removals abort the deploy.
  "${COMPOSE[@]}" exec -T -e COMPOSER_DISCARD_CHANGES=1 backend \
    composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
  # install is a no-op when the lockfile is unchanged, so merge-plugin would
  # skip a newly added Modules/*/composer.json and Laravel would 500 before
  # CORS headers (browser reports CORS). Always rebuild the classmap.
  log "Dumping Composer autoload (module PSR-4 merge)"
  "${COMPOSE[@]}" exec -T -e COMPOSER_DISCARD_CHANGES=1 backend \
    composer dump-autoload --no-interaction --optimize
  verify_module_autoload
}

verify_module_autoload() {
  log "Verifying module autoload (merge-plugin merged Modules/*/composer.json)"
  # nwidart modules resolve providers via module.json, but the classes only
  # load when composer-merge-plugin merged each Modules/*/composer.json into
  # vendor/composer/autoload_psr4.php. A stale vendor dir (or a code update
  # without composer install) produces:
  #   Class "Modules\...\Providers\...ServiceProvider" not found
  # at ProviderRepository::createProvider() on the next artisan call.
  # Fail fast here, before migrate/config:cache.
  "${COMPOSE[@]}" exec -T backend php -r '
    require "vendor/autoload.php";
    $map = require "vendor/composer/autoload_psr4.php";
    $missing = [];
    foreach (glob("Modules/*/module.json") ?: [] as $file) {
      $json = json_decode(@file_get_contents($file), true);
      if (!is_array($json) || empty($json["providers"])) {
        continue;
      }
      foreach ((array) $json["providers"] as $provider) {
        // Derive the expected PSR-4 prefix: Modules\<Name>\ from the FQCN.
        if (!preg_match("/^(Modules\\\\[^\\\\]+\\\\)/", $provider, $m)) {
          continue;
        }
        $prefix = $m[1];
        if (empty($map[$prefix])) {
          $missing[] = $provider . " (missing autoload_psr4 prefix " . $prefix . ")";
          continue;
        }
        if (!class_exists($provider)) {
          $missing[] = $provider . " (prefix mapped but class not loadable)";
        }
      }
    }
    if ($missing) {
      fwrite(STDERR, "error: module autoload incomplete; run composer install/dump-autoload:\n");
      foreach ($missing as $m) {
        fwrite(STDERR, "  - " . $m . "\n");
      }
      exit(1);
    }
    echo "Module autoload OK\n";
  '
}

backend_loopback_port() {
  local port
  port="$(read_dotenv BACKEND_PORT || true)"
  echo "${port:-$BACKEND_PORT_DEFAULT}"
}

# Imunify360/CSF often flush the Docker NAT chain. Published ports then
# look "up" (container running) but host curl to 127.0.0.1:8010 hangs —
# edge :8088 502s, and the browser reports CORS.
restore_docker_nat_chains() {
  if command -v sudo >/dev/null 2>&1; then
    sudo -n iptables -t nat -N DOCKER 2>/dev/null || true
    sudo -n iptables -t filter -N DOCKER 2>/dev/null || true
  fi
}

# Ensure backend-nginx is running. When force=1 (or first arg "force"), also
# recreate the publish binding after repairing Docker NAT — needed when the
# container is still "running" but loopback :BACKEND_PORT no longer accepts.
ensure_backend_nginx() {
  local force="${1:-}"
  local running=0
  if docker ps --filter name="$CONTAINER_BACKEND_NGINX" --filter status=running \
      --format '{{.Names}}' | grep -qx "$CONTAINER_BACKEND_NGINX"; then
    running=1
  fi

  if [[ "$running" -eq 1 && "$force" != "force" ]]; then
    return 0
  fi

  if [[ "$force" == "force" ]]; then
    log "Repairing backend-nginx publish (Docker NAT + recreate)"
  else
    log "backend-nginx is down; restoring Docker NAT chain and starting it"
  fi
  restore_docker_nat_chains
  if [[ "$force" == "force" ]]; then
    BACKEND_BIND="${BACKEND_BIND:-127.0.0.1}" \
      "${COMPOSE[@]}" up -d --force-recreate --no-deps backend-nginx
    return 0
  fi
  docker start "$CONTAINER_BACKEND_NGINX" >/dev/null 2>&1 || true
  if ! docker ps --filter name="$CONTAINER_BACKEND_NGINX" --filter status=running \
      --format '{{.Names}}' | grep -qx "$CONTAINER_BACKEND_NGINX"; then
    BACKEND_BIND="${BACKEND_BIND:-127.0.0.1}" "${COMPOSE[@]}" up -d --no-deps backend-nginx
  fi
}

dump_loopback_up_diagnostics() {
  local port="$1"
  local url="http://127.0.0.1:${port}/up"
  echo "---- diagnostics: ${url} ----" >&2
  curl -v --max-time 5 "$url" >&2 || true
  docker ps --filter name="$CONTAINER_BACKEND" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' >&2 || true
  "${COMPOSE[@]}" logs --tail=40 backend backend-nginx >&2 || true
  echo "---- end diagnostics ----" >&2
}

# Wait for loopback Laravel /up. If the port is dead while nginx still
# shows running (flushed Docker NAT), recreate backend-nginx once and retry.
wait_for_laravel_up_loopback() {
  local description="${1:-Laravel /up on loopback}"
  local port
  port="$(backend_loopback_port)"
  local url="http://127.0.0.1:${port}/up"
  local attempt

  for attempt in $(seq 1 45); do
    if curl_ok "$url"; then
      return 0
    fi
    sleep 2
  done

  log "Loopback ${url} still down; attempting NAT repair + backend-nginx recreate"
  ensure_backend_nginx force
  # php-fpm may still be warming after backend restart.
  wait_for "backend artisan before /up retry" "${COMPOSE[@]}" exec -T backend php artisan --version || true
  for attempt in $(seq 1 45); do
    if curl_ok "$url"; then
      return 0
    fi
    sleep 2
  done

  dump_loopback_up_diagnostics "$port"
  echo "error: timed out waiting for ${description}" >&2
  return 1
}

refresh_runtime() {
  log "Running database migrations"
  "${COMPOSE[@]}" exec -T backend php artisan migrate --force
  log "Refreshing Laravel caches and Horizon"
  "${COMPOSE[@]}" exec -T backend sh -c 'for dir in Modules/*; do [ -d "$dir" ] || continue; mkdir -p "$dir/resources/views"; done'
  # Checkout under umask 077 can leave new module files 0600; re-apply before
  # php-fpm serves HTTP (CLI artisan can still work as root).
  fix_bindmount_permissions
  "${COMPOSE[@]}" exec -T backend php artisan config:cache
  "${COMPOSE[@]}" exec -T backend php artisan view:cache
  "${COMPOSE[@]}" exec -T queue php artisan horizon:terminate
  # Do not restart backend-nginx with the workers: republishing :8010 fails when
  # the Docker NAT chain is missing. Repair nginx only if loopback /up is dead.
  "${COMPOSE[@]}" restart backend queue scheduler
  ensure_backend_nginx
  # Restart can 502 until php-fpm is listening; browsers show that as CORS.
  wait_for "backend artisan after restart" "${COMPOSE[@]}" exec -T backend php artisan --version
  wait_for "backend healthy after restart" backend_container_healthy
  wait_for_laravel_up_loopback "Laravel /up after restart"
}

backend_container_healthy() {
  [[ "$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER_BACKEND" 2>/dev/null || true)" == "healthy" ]]
}

rollback_application() {
  IN_ROLLBACK=1
  if [[ -z "$PREV_SHA" ]]; then
    echo "error: no previous SHA recorded; cannot roll back application" >&2
    return 1
  fi
  log "Rolling back application to ${PREV_SHA} (database is not rolled back)"
  checkout_sha "$PREV_SHA"
  rebuild_and_up
  "${COMPOSE[@]}" exec -T backend sh -c 'for dir in Modules/*; do [ -d "$dir" ] || continue; mkdir -p "$dir/resources/views"; done'
  "${COMPOSE[@]}" exec -T backend php artisan config:cache
  "${COMPOSE[@]}" exec -T backend php artisan view:cache
  "${COMPOSE[@]}" exec -T queue php artisan horizon:terminate || true
  "${COMPOSE[@]}" restart backend queue scheduler
  ensure_backend_nginx
  health_check
  log "Application rollback to ${PREV_SHA} succeeded"
}

on_error() {
  local code=$?
  trap - ERR
  if [[ $DRY_RUN -eq 1 || $IN_ROLLBACK -eq 1 ]]; then
    exit "$code"
  fi
  if [[ $SWITCHED_REVISION -eq 0 ]]; then
    echo "error: deploy failed before switching revision (exit ${code}); no application rollback" >&2
    exit "$code"
  fi
  echo "error: deploy failed (exit ${code}); attempting application rollback" >&2
  if ! rollback_application; then
    echo "error: application rollback failed" >&2
  fi
  exit "$code"
}

if [[ $DRY_RUN -eq 1 ]]; then
  require_cmd git
  assert_repo_root
  log "DRY-RUN: would deploy ${TARGET_SHA} (DEPLOY_TARGET=${DEPLOY_TARGET}, branch=${DEPLOY_BRANCH})"
  log "Repo: ${REPO_ROOT}"
  test -f "$COMPOSE_FILE"
  test -f Makefile
  test -f frontend/Dockerfile
  test -f backend/Dockerfile
  if git cat-file -e "${TARGET_SHA}^{commit}" 2>/dev/null; then
    log "DRY-RUN: SHA exists locally ($(git log -1 --oneline "$TARGET_SHA"))"
  else
    log "DRY-RUN: SHA is well-formed but not in this clone (fetch would be required on the server)"
  fi
  if [[ -f backend/.env ]]; then
    local_app_env="$(read_dotenv APP_ENV || true)"
    log "DRY-RUN: backend/.env APP_ENV=${local_app_env:-unset}"
    if [[ "$local_app_env" == "$EXPECTED_APP_ENV" ]]; then
      assert_vite_api_url
    else
      log "DRY-RUN: skipping VITE_API_URL checks (APP_ENV != ${EXPECTED_APP_ENV})"
    fi
  else
    log "DRY-RUN: backend/.env absent here (expected on a laptop; required on the server)"
  fi
  log "DRY-RUN: edge health probe ${EDGE_HEALTH_URL}"
  if [[ -n "$DEPLOY_PATH_VALUE" ]]; then
    log "DRY-RUN: ${DEPLOY_PATH_ENV_NAME}=${DEPLOY_PATH_VALUE}"
  fi
  cat <<EOF
DRY-RUN plan (${DEPLOY_TARGET}):
  1. mysqldump → ${BACKUP_DIR}/erpflow-${DEPLOY_TARGET}-${TARGET_SHA}-<utc>.sql (umask 077 only for dump)
  2. git fetch origin && git checkout --force -B ${DEPLOY_BRANCH} ${TARGET_SHA}
  3. fix_bindmount_permissions (www-data can read PHP source; avoids fake CORS)
  4. docker compose -f ${COMPOSE_FILE} --env-file backend/.env build && up -d
  5. composer install --no-dev (lockfile)
  6. php artisan migrate --force
  7. config/view cache, horizon:terminate, restart workers
  8. health: containers + MySQL + Redis + loopback /up + ${EDGE_HEALTH_URL} + frontend + Horizon
  9. CORS preflight: OPTIONS /api/v1/auth/login with Origin from VITE_API_URL host + FRONTEND_PROD_PORT
  On failure after checkout: restore previous git SHA and restart (no migrate rollback)
EOF
  exit 0
fi

trap on_error ERR

require_cmd git
require_cmd docker
require_cmd curl
require_cmd grep

assert_repo_root
assert_deploy_env
assert_vite_api_url

PREV_SHA="$(git rev-parse HEAD)"
log "DEPLOY_TARGET=${DEPLOY_TARGET} branch=${DEPLOY_BRANCH}"
log "Currently deployed SHA: ${PREV_SHA}"
log "Target SHA:             ${TARGET_SHA}"

backup_database
checkout_sha "$TARGET_SHA"
rebuild_and_up
refresh_runtime
health_check

log "Deploy of ${TARGET_SHA} (${DEPLOY_TARGET}) succeeded"
exit 0
