#!/usr/bin/env bash
# Deploy one exact git commit on the production Docker Compose host.
# Intended to run ON the server (Makefile: make prod-deploy SHA=...).
#
# 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>

Deploys that SHA with: DB backup → git checkout -B development 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"

# Host-network edge proxy listens on 8088 (see docker/nginx/host-edge.conf).
EDGE_HEALTH_URL="${EDGE_HEALTH_URL:-http://127.0.0.1:8088/up}"

COMPOSE=(docker compose -f docker-compose.prod.yml --env-file backend/.env)
BACKUP_DIR="${BACKUP_DIR:-/var/backups/erpflow}"
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 PRODUCTION_DEPLOY_PATH" >&2
      exit 1
      ;;
  esac
  if [[ ! -f "$REPO_ROOT/docker-compose.prod.yml" || ! -f "$REPO_ROOT/scripts/deploy-production.sh" ]]; then
    echo "error: REPO_ROOT=${REPO_ROOT} does not look like the ERPFlow clone" >&2
    exit 1
  fi
  if [[ -n "${PRODUCTION_DEPLOY_PATH:-}" ]]; then
    local expected actual
    expected="$(cd "$PRODUCTION_DEPLOY_PATH" && pwd)"
    actual="$(cd "$REPO_ROOT" && pwd)"
    if [[ "$expected" != "$actual" ]]; then
      echo "error: REPO_ROOT (${actual}) != PRODUCTION_DEPLOY_PATH (${expected})" >&2
      exit 1
    fi
    log "Repo matches PRODUCTION_DEPLOY_PATH=${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_production_env() {
  if [[ ! -f backend/.env ]]; then
    echo "error: backend/.env is missing (production 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" != "production" ]]; then
    echo "error: backend/.env APP_ENV must be production (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|:8010'; then
    echo "error: VITE_API_URL must be the public API URL on edge :8088, not localhost or :8010" >&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:-3010}"
  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"
  local name
  for name in \
    erpflow-mysql \
    erpflow-redis \
    erpflow-backend \
    erpflow-backend-nginx \
    erpflow-backend-edge \
    erpflow-frontend \
    erpflow-queue \
    erpflow-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:-8010}"
  frontend_port="$(read_dotenv FRONTEND_PROD_PORT || true)"
  frontend_port="${frontend_port:-3010}"

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

  log "Health: Laravel /up on edge :8088"
  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-${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 branch development (never detached). Deploy clone must track
  # development at the deployed SHA so operators and follow-up deploys do not
  # land on a detached HEAD.
  git checkout --force -B development "$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" != "development" ]]; then
    echo "error: expected branch development 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
}

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'
  "${COMPOSE[@]}" exec -T backend php artisan config:cache
  "${COMPOSE[@]}" exec -T backend php artisan view:cache
  "${COMPOSE[@]}" exec -T queue php artisan horizon:terminate
  "${COMPOSE[@]}" restart backend queue scheduler 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
  local backend_port
  backend_port="$(read_dotenv BACKEND_PORT || true)"
  backend_port="${backend_port:-8010}"
  wait_for "Laravel /up after restart" curl_ok "http://127.0.0.1:${backend_port}/up"
}

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 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}"
  log "Repo: ${REPO_ROOT}"
  test -f docker-compose.prod.yml
  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" == "production" ]]; then
      assert_vite_api_url
    else
      log "DRY-RUN: skipping VITE_API_URL production checks (not a production .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}"
  cat <<EOF
DRY-RUN plan:
  1. mysqldump → ${BACKUP_DIR}/erpflow-${TARGET_SHA}-<utc>.sql (umask 077 only for dump)
  2. git fetch origin && git checkout --force -B development ${TARGET_SHA}
  3. fix_bindmount_permissions (www-data can read PHP source; avoids fake CORS)
  4. docker compose -f docker-compose.prod.yml --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_production_env
assert_vite_api_url

PREV_SHA="$(git rev-parse HEAD)"
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} succeeded"
exit 0
