#!/usr/bin/env bash
#
# Drives remote deploys for ruforum-iilmp from the developer's local machine.
# Reads SSH + GitLab credentials from `.env.deploy` (gitignored).
#
# Usage:
#   scripts/deploy.sh init       # one-time: clone repo on server, seed .env
#   scripts/deploy.sh redeploy   # pull current branch, rebuild prod stack
#   scripts/deploy.sh logs       # tail prod logs over SSH
#   scripts/deploy.sh status     # docker compose ps on the server
#   scripts/deploy.sh shell      # interactive bash inside the web container
#
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.deploy"

if [ ! -f "$ENV_FILE" ]; then
  echo "error: $ENV_FILE not found. Copy .env.deploy.example to .env.deploy and fill it in." >&2
  exit 1
fi

# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a

: "${SSH_HOST:?SSH_HOST not set in .env.deploy}"
: "${SSH_USER:?SSH_USER not set in .env.deploy}"
: "${DEPLOY_PATH:?DEPLOY_PATH not set in .env.deploy}"
: "${GITLAB_USERNAME:?GITLAB_USERNAME not set in .env.deploy}"
: "${GITLAB_TOKEN:?GITLAB_TOKEN not set in .env.deploy}"
: "${GITLAB_REPO:?GITLAB_REPO not set in .env.deploy}"
: "${LOCAL_ENV_FILE:?LOCAL_ENV_FILE not set in .env.deploy}"

SSH_PORT="${SSH_PORT:-22}"
SSH_TARGET="${SSH_USER}@${SSH_HOST}"

SSH_OPTS=(-p "$SSH_PORT" -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30)
SCP_OPTS=(-P "$SSH_PORT" -o StrictHostKeyChecking=accept-new)
if [ -n "${SSH_KEY:-}" ]; then
  SSH_OPTS+=(-i "$SSH_KEY")
  SCP_OPTS+=(-i "$SSH_KEY")
fi

REMOTE_URL="https://${GITLAB_USERNAME}:${GITLAB_TOKEN}@${GITLAB_REPO}"

remote_exec() {
  ssh "${SSH_OPTS[@]}" "$SSH_TARGET" "$@"
}

remote_bash() {
  # Pipe a heredoc into a remote bash -s shell. Any extra args are forwarded
  # as positional parameters ($1, $2, ...) inside the heredoc — use this to
  # smuggle local values across SSH (env vars are NOT forwarded by default).
  ssh "${SSH_OPTS[@]}" "$SSH_TARGET" bash -s -- "$@"
}

# Read a KEY=value from a .env-style file (last occurrence wins; quotes stripped).
env_value() {
  local file="$1" key="$2"
  [ -f "$file" ] || return 0
  grep -E "^${key}=" "$file" | tail -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '\r'
}

# Truthy test for env flags: true / 1 / yes / on (case-insensitive).
_is_true() {
  case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
    true|1|yes|on) return 0 ;;
    *) return 1 ;;
  esac
}

# Upload the legacy SQL dump to ${DEPLOY_PATH}/<name> on the server, skipping the
# transfer when an identically-sized copy is already there (the dump is ~1.5 GB).
_upload_dump() {
  local src="$1" name="$2" lsize rsize
  if [ ! -f "$src" ]; then
    echo "error: REPOSITORY_DUMP_FILE not found: $src" >&2
    exit 1
  fi
  lsize=$(wc -c < "$src" | tr -d '[:space:]')
  rsize=$(remote_exec "stat -c%s '${DEPLOY_PATH}/${name}' 2>/dev/null || echo 0" | tr -d '[:space:]')
  if [ -n "$lsize" ] && [ "$lsize" = "$rsize" ]; then
    echo ">> Dump already on server (${name}, ${lsize} bytes) — skipping upload."
    return
  fi
  echo ">> Uploading repository dump ${name} (${lsize} bytes) — large file, may take a while..."
  scp "${SCP_OPTS[@]}" "$src" "${SSH_TARGET}:${DEPLOY_PATH}/${name}"
}

# Generic large-artifact upload to ${DEPLOY_PATH}/<name>, skipping the transfer
# when an identically-sized copy is already there. Same size-skip optimisation as
# _upload_dump but with a neutral message — used by the Moodle / SME-Hub migrations.
_upload_artifact() {
  local src="$1" name="$2" lsize rsize
  if [ ! -f "$src" ]; then
    echo "error: source artifact not found: $src" >&2
    exit 1
  fi
  lsize=$(wc -c < "$src" | tr -d '[:space:]')
  rsize=$(remote_exec "stat -c%s '${DEPLOY_PATH}/${name}' 2>/dev/null || echo 0" | tr -d '[:space:]')
  if [ -n "$lsize" ] && [ "$lsize" = "$rsize" ]; then
    echo ">> ${name} already on server (${lsize} bytes) — skipping upload."
    return
  fi
  echo ">> Uploading ${name} (${lsize} bytes) — may take a while..."
  scp "${SCP_OPTS[@]}" "$src" "${SSH_TARGET}:${DEPLOY_PATH}/${name}"
}

sync_env() {
  local local_path="${ROOT_DIR}/${LOCAL_ENV_FILE}"
  if [ ! -f "$local_path" ]; then
    echo "error: $local_path not found. Create it from .env.example with production secrets." >&2
    exit 1
  fi
  echo ">> Syncing ${LOCAL_ENV_FILE} -> ${SSH_TARGET}:${DEPLOY_PATH}/.env"
  scp "${SCP_OPTS[@]}" "$local_path" "${SSH_TARGET}:${DEPLOY_PATH}/.env"
}

cmd_init() {
  echo ">> Bootstrapping ${SSH_TARGET}:${DEPLOY_PATH}"
  remote_bash "$DEPLOY_PATH" "$REMOTE_URL" <<'EOF'
set -euo pipefail

DEPLOY_PATH="$1"
REMOTE_URL="$2"

if [ "$(id -u)" -eq 0 ]; then
  SUDO=""
else
  SUDO="sudo"
fi

export DEBIAN_FRONTEND=noninteractive

OS_ID=""
if [ -r /etc/os-release ]; then
  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-}"
fi

# --- git -----------------------------------------------------------------
if ! command -v git >/dev/null 2>&1; then
  echo ">> Installing git..."
  case "$OS_ID" in
    ubuntu|debian)
      $SUDO apt-get update -y
      $SUDO apt-get install -y git
      ;;
    centos|rhel|rocky|almalinux|fedora)
      $SUDO yum install -y git || $SUDO dnf install -y git
      ;;
    *)
      echo "error: unsupported distro '$OS_ID' for automatic git install." >&2
      echo "Install git manually then re-run 'make deploy-init'." >&2
      exit 1
      ;;
  esac
fi

# --- curl (needed for docker installer) ----------------------------------
if ! command -v curl >/dev/null 2>&1; then
  echo ">> Installing curl..."
  case "$OS_ID" in
    ubuntu|debian) $SUDO apt-get install -y curl ;;
    centos|rhel|rocky|almalinux|fedora) $SUDO yum install -y curl || $SUDO dnf install -y curl ;;
  esac
fi

# --- docker engine + compose plugin --------------------------------------
if ! command -v docker >/dev/null 2>&1; then
  echo ">> Installing Docker Engine (via get.docker.com)..."
  curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
  $SUDO sh /tmp/get-docker.sh
  rm -f /tmp/get-docker.sh
  $SUDO systemctl enable --now docker || true
fi

if ! docker compose version >/dev/null 2>&1; then
  echo ">> Installing docker compose plugin..."
  case "$OS_ID" in
    ubuntu|debian) $SUDO apt-get install -y docker-compose-plugin ;;
    centos|rhel|rocky|almalinux|fedora) $SUDO yum install -y docker-compose-plugin || $SUDO dnf install -y docker-compose-plugin ;;
    *)
      echo "error: could not auto-install docker compose plugin on '$OS_ID'." >&2
      exit 1
      ;;
  esac
fi

# Non-root: add user to docker group so subsequent runs don't need sudo.
if [ "$(id -u)" -ne 0 ] && ! id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then
  echo ">> Adding $USER to docker group (log out / log in for it to take effect)"
  $SUDO usermod -aG docker "$USER" || true
fi

echo ">> docker:         $(docker --version)"
echo ">> docker compose: $(docker compose version)"

# --- deploy path ---------------------------------------------------------
parent="$(dirname "$DEPLOY_PATH")"
if [ ! -d "$parent" ]; then
  echo ">> Creating $parent"
  $SUDO mkdir -p "$parent"
  if [ "$(id -u)" -ne 0 ]; then
    $SUDO chown "$USER":"$USER" "$parent"
  fi
fi

# --- repo ----------------------------------------------------------------
if [ -d "$DEPLOY_PATH/.git" ]; then
  echo ">> Repo already present at $DEPLOY_PATH"
else
  echo ">> Cloning into $DEPLOY_PATH"
  git clone "$REMOTE_URL" "$DEPLOY_PATH"
fi

cd "$DEPLOY_PATH"
git remote set-url origin "$REMOTE_URL"
echo ">> Server ready at $DEPLOY_PATH on branch $(git rev-parse --abbrev-ref HEAD)"
EOF

  sync_env

  echo
  echo ">> deploy-init complete."
  echo ">> Next: run 'make redeploy' from your current branch."
}

cmd_redeploy() {
  local branch
  branch="$(git rev-parse --abbrev-ref HEAD)"

  if [ "$branch" = "HEAD" ]; then
    echo "error: detached HEAD locally — check out a branch before deploying." >&2
    exit 1
  fi

  echo ">> Local branch: $branch"
  if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
    echo "error: branch '$branch' is not on origin. Run 'git push -u origin $branch' first." >&2
    exit 1
  fi

  if [ "$branch" != "main" ] && [ -z "${CI:-}" ]; then
    printf "Deploy non-main branch '%s' to %s? [y/N] " "$branch" "$SSH_HOST"
    read -r ans
    case "$ans" in
      y|Y|yes|YES) ;;
      *) echo "Aborted." ; exit 1 ;;
    esac
  fi

  sync_env

  # Optional one-time legacy-repository migration, gated by MIGRATE_REPOSITORY in
  # the runtime env (the same file synced to the server, so it's the single source
  # of truth). When on, upload the dump now so the remote import step can mount it.
  local migrate_repo="false" dump_name=""
  if _is_true "$(env_value "${ROOT_DIR}/${LOCAL_ENV_FILE}" MIGRATE_REPOSITORY)"; then
    : "${REPOSITORY_DUMP_FILE:?MIGRATE_REPOSITORY=TRUE but REPOSITORY_DUMP_FILE is not set in .env.deploy}"
    migrate_repo="true"
    dump_name="$(basename "$REPOSITORY_DUMP_FILE")"
    echo ">> MIGRATE_REPOSITORY=TRUE — this redeploy will WIPE + re-import the Repository."
    _upload_dump "$REPOSITORY_DUMP_FILE" "$dump_name"
  fi

  echo ">> Deploying $branch to ${SSH_TARGET}:${DEPLOY_PATH}"
  remote_bash "$DEPLOY_PATH" "$REMOTE_URL" "$branch" "$migrate_repo" "$dump_name" <<'EOF'
set -euo pipefail

DEPLOY_PATH="$1"
REMOTE_URL="$2"
BRANCH="$3"
MIGRATE_REPO="${4:-false}"
DUMP_NAME="${5:-}"

cd "$DEPLOY_PATH"
git remote set-url origin "$REMOTE_URL"

echo ">> Fetching origin..."
git fetch origin --prune

echo ">> Checking out $BRANCH..."
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
  git checkout "$BRANCH"
else
  git checkout -b "$BRANCH" "origin/$BRANCH"
fi

echo ">> Resetting to origin/$BRANCH..."
git reset --hard "origin/$BRANCH"

# Rebuild Tailwind CSS so any utility class added to a template since the
# last commit of static/css/output.css (Tailwind JIT only ships classes it
# saw at build time) is in the bundle. Runs inside a one-off node:20-alpine
# container with a cached node_modules volume to keep subsequent runs fast.
echo ">> Building Tailwind CSS..."
docker run --rm \
  -v "$DEPLOY_PATH:/app" \
  -v iilmp_node_modules:/app/node_modules \
  -w /app \
  node:20-alpine \
  sh -c "npm ci --no-audit --no-fund --prefer-offline && npm run build:css"

COMPOSE="docker compose -f docker-compose.prod.yml"

echo ">> Building images + starting infrastructure (postgres + redis only)..."
# Bring up ONLY the datastores and build the app images. The app services
# (web / celery_worker / celery_beat) are deliberately NOT started yet:
# migrations take AccessExclusiveLock on the tables they ALTER, and a live app
# holding AccessShareLock on those same tables deadlocks the migration
# (psycopg.errors.DeadlockDetected). Migrate against a quiet DB, then start the
# app.
$COMPOSE up -d --build postgres redis
$COMPOSE build web celery_worker celery_beat

# `</dev/null` is REQUIRED: this heredoc is piped to `ssh bash -s`, so the
# script body is on the shell's stdin. `docker compose run` attaches stdin by
# default and DRAINS the rest of the heredoc (collectstatic, service start,
# smoke checks) as the container's input — bash then runs out of script and
# exits 0 after migrate, leaving web/celery/nginx never started. Redirect stdin
# so each `compose run` reads nothing and the following commands survive. (Same
# class of bug as the documented `exec -T over SSH needs </dev/null` gotcha.)
echo ">> Running migrations (no app traffic competing for table locks)..."
$COMPOSE run --rm web python manage.py migrate --noinput </dev/null

# Idempotent: (re)creates one Group per UserRole and attaches each role's
# default RimsCapability/RepCapability permissions. Safe to run on every
# redeploy — it only adds Groups/permissions, never removes them, so it picks
# up any role added to apps/core/permissions/roles.py without a manual step.
echo ">> Seeding role groups + default permissions..."
$COMPOSE run --rm web python manage.py seed_roles </dev/null

# Fails the deploy (raises SystemExit(1)) if any UserRole has neither a seeded
# Group permission nor a role-gate reference — see apps/core/management/commands/audit_roles.py.
echo ">> Verifying every role has seeded permissions or a role-gate..."
$COMPOSE run --rm web python manage.py audit_roles </dev/null

echo ">> Collecting static files..."
$COMPOSE run --rm web python manage.py collectstatic --noinput </dev/null

echo ">> Starting web + celery + nginx + Moodle services..."
$COMPOSE up -d web celery_worker celery_beat nginx moodle moodle-mariadb

# Restart web so gunicorn workers reload the WhiteNoise manifest from disk.
# Workers cache the staticfiles manifest in memory at first access; any
# request that hit the worker before `collectstatic` finished would
# poison-cache an incomplete manifest and 500 on {% static '...' %} lookups
# for newly-added assets (most recent: img/brand/login.jpg).
echo ">> Restarting web so workers reload the staticfiles manifest..."
$COMPOSE restart web

# Force nginx to re-resolve the web upstream after a recreate. The resolver
# directive in nginx.conf covers this within ~10s, but restarting gives an
# instant clean cutover instead of a brief 502 window.
echo ">> Restarting nginx to refresh web upstream..."
$COMPOSE restart nginx

# Post-deploy smoke check. Verifies the new CSS bundle is actually being
# served by nginx end-to-end. Failure mode this guards against: collectstatic
# silently leaves the static_data volume's staticfiles.json stale, so the
# running web container keeps mapping `css/output.css` to a previous deploy's
# hashed filename. Site looks deployed (Python code is new) but CSS is stale.
echo ">> Waiting for web container to be ready..."
for i in $(seq 1 30); do
  if $COMPOSE exec -T web true 2>/dev/null; then break; fi
  sleep 1
done

echo ">> Smoke-checking served CSS bundle..."
EXPECTED_URL=$($COMPOSE exec -T web python -c \
  'from django.contrib.staticfiles.storage import staticfiles_storage as s; print(s.url("css/output.css"))')
EXPECTED_URL="${EXPECTED_URL%$'\r'}"   # strip stray CR from exec -T
SOURCE_HASH=$($COMPOSE exec -T web sh -c \
  'md5sum /app/static/css/output.css | cut -c1-12')
SOURCE_HASH="${SOURCE_HASH%$'\r'}"

if ! echo "$EXPECTED_URL" | grep -q "$SOURCE_HASH"; then
  echo "!! Manifest is stale: web is serving $EXPECTED_URL but source hash is $SOURCE_HASH" >&2
  echo "!! collectstatic likely failed mid-run, or web wasn't restarted afterward." >&2
  echo "!! Investigate with: $COMPOSE run --rm web python manage.py collectstatic --noinput" >&2
  exit 1
fi

# Resolve through nginx (port 80) and assert 200 + new bundle is reachable.
HTTP_CODE=$(curl -fsS -o /dev/null -w '%{http_code}' "http://localhost${EXPECTED_URL}" || true)
if [ "$HTTP_CODE" != "200" ]; then
  echo "!! nginx returned HTTP $HTTP_CODE for $EXPECTED_URL" >&2
  echo "!! Static file pipeline is broken end-to-end. Check nginx logs:" >&2
  echo "!!   $COMPOSE logs --tail=50 nginx" >&2
  exit 1
fi

echo ">> Smoke check OK: nginx serving $EXPECTED_URL (HTTP 200)"

# Critical JS assets. The CSS-only smoke check above let a real regression ship
# on 2026-05-24: collectstatic did not publish js/htmx_progress.js, so nginx
# 404'd it and every client-side loader silently died while the deploy went
# green. These bundles are loaded via {% versioned_static %} (original name +
# ?v=mtime), so assert the ORIGINAL file is both collected and served 200.
echo ">> Smoke-checking critical JS assets..."
for asset in js/htmx_progress.js js/htmx_errors.js; do
  if ! $COMPOSE exec -T web test -f "/app/staticfiles/$asset" </dev/null; then
    echo "!! $asset is missing from STATIC_ROOT — collectstatic did not publish it." >&2
    echo "!! Re-run: $COMPOSE run --rm web python manage.py collectstatic --noinput" >&2
    exit 1
  fi
  JS_CODE=$(curl -fsS -o /dev/null -w '%{http_code}' "http://localhost/static/$asset" || true)
  if [ "$JS_CODE" != "200" ]; then
    echo "!! nginx returned HTTP $JS_CODE for /static/$asset (expected 200)" >&2
    exit 1
  fi
  echo ">>   /static/$asset (HTTP 200)"
done
echo ">> JS smoke check OK"
echo
$COMPOSE ps
echo
echo ">> ✓ DEPLOY OK"

# --- Optional legacy-repository migration (gated by MIGRATE_REPOSITORY) -------
# Runs LAST, after the site is confirmed healthy: it WIPES the Repository tables
# and re-imports ~2,900 documents + ~2.5 GB of PDFs (60–100 min). The dump was
# uploaded by deploy.sh to $DEPLOY_PATH/$DUMP_NAME; mount it read-only into the
# one-off importer container (prod has no code bind-mount). Downloaded PDFs land
# in the persistent uploads_data volume that 'web' already mounts.
if [ "$MIGRATE_REPO" = "true" ] && [ -n "$DUMP_NAME" ]; then
  echo
  echo ">> ============================================================"
  echo ">> MIGRATE_REPOSITORY=TRUE — wiping Repository tables and importing"
  echo ">> $DUMP_NAME (downloads ~2.5 GB of PDFs; 60–100 min; the repository"
  echo ">> is empty until it repopulates). Rest of the platform is unaffected."
  echo ">> ============================================================"
  chmod a+r "$DEPLOY_PATH/$DUMP_NAME" || true
  $COMPOSE run --rm \
    -v "$DEPLOY_PATH/$DUMP_NAME:/tmp/dump.sql:ro" \
    web python manage.py import_drupal_repository --dump /tmp/dump.sql --flush --download
  echo
  echo ">> ✓ Repository migration complete."
  echo ">> ⚠ REMINDER: set MIGRATE_REPOSITORY=FALSE in .env.production so the next"
  echo ">>            'make redeploy' does NOT wipe + re-migrate the repository."
fi
EOF

  echo
  echo ">> redeploy complete: $branch is live on $SSH_HOST."

  # Optional one-time legacy migrations, gated by flags in the runtime env (the
  # same file synced to the server). MIGRATE_MOODLE now runs the NATIVE restore
  # (cmd_migrate_moodle) into the prod Moodle service — it DROPS and recreates the
  # Moodle DB, so treat it as a one-time opt-in: set MIGRATE_MOODLE=TRUE once, run
  # a redeploy, then set it back to FALSE. (SME-Hub is still the ephemeral-MariaDB
  # importer.)
  if _is_true "$(env_value "${ROOT_DIR}/${LOCAL_ENV_FILE}" MIGRATE_MOODLE)"; then
    echo ">> MIGRATE_MOODLE=TRUE — running the native Moodle restore."
    YES=1 cmd_migrate_moodle
  fi
  if _is_true "$(env_value "${ROOT_DIR}/${LOCAL_ENV_FILE}" MIGRATE_SMEHUB)"; then
    echo ">> MIGRATE_SMEHUB=TRUE — running the SME-Hub migration."
    YES=1 cmd_migrate_smehub
  fi
}

cmd_logs() {
  remote_exec "cd '${DEPLOY_PATH}' && docker compose -f docker-compose.prod.yml logs -f --tail=200"
}

cmd_status() {
  remote_exec "cd '${DEPLOY_PATH}' && docker compose -f docker-compose.prod.yml ps"
}

cmd_shell() {
  ssh -t "${SSH_OPTS[@]}" "$SSH_TARGET" "cd '${DEPLOY_PATH}' && docker compose -f docker-compose.prod.yml exec web bash"
}

cmd_css_build() {
  # Standalone Tailwind rebuild — useful when a template change shipped but
  # nothing else needs to redeploy (no Python / migration changes). Pulls the
  # current state of static/css/output.css forward without rebuilding the
  # web image; the next 'collectstatic' (or a redeploy) is what actually
  # publishes it. After this, run 'make redeploy' OR just restart web so
  # collectstatic + manifest hashing pick up the new bundle.
  echo ">> Rebuilding Tailwind CSS on ${SSH_TARGET}..."
  remote_bash "$DEPLOY_PATH" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"
cd "$DEPLOY_PATH"
docker run --rm \
  -v "$DEPLOY_PATH:/app" \
  -v iilmp_node_modules:/app/node_modules \
  -w /app \
  node:20-alpine \
  sh -c "npm ci --no-audit --no-fund --prefer-offline && npm run build:css"
docker compose -f docker-compose.prod.yml run --rm web python manage.py collectstatic --noinput
docker compose -f docker-compose.prod.yml restart web nginx
EOF
}

cmd_seed() {
  # Run apps/core's seed_all orchestrator inside a one-off web container.
  # First positional arg is the volume (demo|heavy); subsequent args are
  # forwarded to seed_all (e.g. --only repository, --skip rep_live).
  local volume="${1:-demo}"
  shift || true

  case "$volume" in
    demo|heavy) ;;
    *)
      echo "error: volume must be 'demo' or 'heavy' (got '$volume')." >&2
      exit 2
      ;;
  esac

  if [ -z "${CI:-}" ] && [ -z "${YES:-}" ]; then
    printf "Run seed_all --volume %s on %s? This writes demo data to the prod DB. [y/N] " "$volume" "$SSH_HOST"
    read -r ans
    case "$ans" in
      y|Y|yes|YES) ;;
      *) echo "Aborted." ; exit 1 ;;
    esac
  fi

  echo ">> Running seed_all --volume $volume $* on ${SSH_TARGET}"
  remote_bash "$DEPLOY_PATH" "$volume" "$@" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"
VOLUME="$2"
shift 2
cd "$DEPLOY_PATH"
docker compose -f docker-compose.prod.yml run --rm web \
  python manage.py seed_all --volume "$VOLUME" "$@" </dev/null
echo ">> Verifying every role has seeded permissions or a role-gate..."
docker compose -f docker-compose.prod.yml run --rm web python manage.py audit_roles </dev/null
EOF
}

cmd_seed_rims() {
  # Run seed_rims directly (bypassing seed_all's continue_on_error swallowing)
  # so any crash surfaces in the foreground instead of being silently logged.
  # --reset wipes RIMS tables first, so half-seeded rows from a prior crash
  # don't block re-runs via unique constraints.
  if [ -z "${CI:-}" ] && [ -z "${YES:-}" ]; then
    printf "Run seed_rims --reset --comprehensive on %s? Wipes + recreates all RIMS tables. [y/N] " "$SSH_HOST"
    read -r ans
    case "$ans" in
      y|Y|yes|YES) ;;
      *) echo "Aborted." ; exit 1 ;;
    esac
  fi

  echo ">> Running seed_rims --reset --comprehensive --volume heavy on ${SSH_TARGET}"
  remote_bash "$DEPLOY_PATH" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"
cd "$DEPLOY_PATH"
docker compose -f docker-compose.prod.yml run --rm web \
  python manage.py seed_rims \
    --reset \
    --comprehensive \
    --password password123 \
    --volume heavy
EOF
}

cmd_reindex_repository_search() {
  # Rebuild the Postgres full-text search_vector over the Repository corpus
  # (FRREP-SR001/004). The legacy Drupal import populated documents without
  # building their vectors, so author / facet search misses those rows until
  # this runs. Safe + idempotent; pass extra args through (e.g. --missing-only,
  # --collection <slug>, --since <iso-date>).
  echo ">> Running reindex_repository_search ${*:-} on ${SSH_TARGET}"
  remote_bash "$DEPLOY_PATH" "$@" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"; shift
cd "$DEPLOY_PATH"
docker compose -f docker-compose.prod.yml exec -T web \
  python manage.py reindex_repository_search "$@"
EOF
}

cmd_migrate_repository() {
  # Run the legacy-repository migration WITHOUT a full redeploy: upload the dump
  # (skipped if already present) and run the importer (wipe + import + download).
  # Useful for re-running, or migrating after a deploy where the flag was off.
  : "${REPOSITORY_DUMP_FILE:?REPOSITORY_DUMP_FILE not set in .env.deploy}"
  if [ ! -f "$REPOSITORY_DUMP_FILE" ]; then
    echo "error: REPOSITORY_DUMP_FILE not found: $REPOSITORY_DUMP_FILE" >&2
    exit 1
  fi
  local dump_name
  dump_name="$(basename "$REPOSITORY_DUMP_FILE")"

  if [ -z "${CI:-}" ] && [ -z "${YES:-}" ]; then
    printf "WIPE + re-migrate the Repository on %s? Imports ~2,900 docs + ~2.5 GB of PDFs (60–100 min). [y/N] " "$SSH_HOST"
    read -r ans
    case "$ans" in
      y|Y|yes|YES) ;;
      *) echo "Aborted." ; exit 1 ;;
    esac
  fi

  _upload_dump "$REPOSITORY_DUMP_FILE" "$dump_name"

  echo ">> Migrating repository on ${SSH_TARGET} (wipe + import + download)..."
  remote_bash "$DEPLOY_PATH" "$dump_name" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"
DUMP_NAME="$2"
cd "$DEPLOY_PATH"
chmod a+r "$DEPLOY_PATH/$DUMP_NAME" || true
docker compose -f docker-compose.prod.yml run --rm \
  -v "$DEPLOY_PATH/$DUMP_NAME:/tmp/dump.sql:ro" \
  web python manage.py import_drupal_repository --dump /tmp/dump.sql --flush --download
echo ">> ✓ Repository migration complete."
EOF
}

cmd_migrate_moodle() {
  # NATIVE "move to a new server" restore of the legacy Moodle into the prod
  # `moodle` + `moodle-mariadb` services (real Moodle replaced the retired REP LMS,
  # so the old `manage.py import_moodle` path is gone). This mirrors the dev script
  # scripts/migrate_moodle_into_docker.sh step-for-step — KEEP THE TWO IN SYNC.
  #
  # Data is expected ON THE SERVER at $DEPLOY_PATH/moodle_src.sql (the DB dump) and
  # $DEPLOY_PATH/moodle_filedir.tar.gz (the moodledata blob store) — OR an already
  # extracted `.../filedir` directory under $DEPLOY_PATH. If you instead keep them
  # locally, set MOODLE_DUMP_FILE / MOODLE_FILEDIR_ARCHIVE in .env.deploy and they
  # are uploaded first (size-skip). ⚠ This DROPS and recreates the Moodle DB.
  local IILMP_BASE="${IILMP_BASE_URL:-https://iilmp.ruforum.org}"

  # Optional upload from the local machine (skipped when the vars are unset —
  # the user's case: the dump + filedir are already on the EC2 host).
  if [ -n "${MOODLE_DUMP_FILE:-}" ]; then _upload_artifact "$MOODLE_DUMP_FILE" moodle_src.sql; fi
  if [ -n "${MOODLE_FILEDIR_ARCHIVE:-}" ]; then _upload_artifact "$MOODLE_FILEDIR_ARCHIVE" moodle_filedir.tar.gz; fi

  if [ -z "${CI:-}" ] && [ -z "${YES:-}" ]; then
    printf "Native-restore legacy Moodle (DB + ~1 GB filedir) into the prod Moodle on %s? This DROPS the current Moodle DB. [y/N] " "$SSH_HOST"
    read -r ans
    case "$ans" in y|Y|yes|YES) ;; *) echo "Aborted." ; exit 1 ;; esac
  fi

  echo ">> Native Moodle restore on ${SSH_TARGET}..."
  remote_bash "$DEPLOY_PATH" "$IILMP_BASE" \
    "${MOODLE_DB_NAME:-bitnami_moodle}" "${MOODLE_DB_USER:-bn_moodle}" \
    "${MOODLE_SITE_SHORTNAME:-REP}" "${MOODLE_SITE_FULLNAME:-REP}" \
    "${MOODLE_FORCELOGIN:-1}" "${MOODLE_RESET_ADMIN_PASSWORD:-}" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"; IILMP_BASE="$2"; DB_NAME="$3"; DB_USER="$4"
SITE_SHORT="$5"; SITE_FULL="$6"; FORCELOGIN="$7"; RESET_PW="$8"
cd "$DEPLOY_PATH"
COMPOSE="docker compose -f docker-compose.prod.yml"
MCODE="/opt/bitnami/moodle"
MDATA="/bitnami/moodledata"

echo ">> Ensuring moodle + moodle-mariadb are up..."
$COMPOSE up -d moodle-mariadb moodle

# The MariaDB root password lives in the container env (from MOODLE_DB_ROOT_PASSWORD).
DB_ROOT_PW="$($COMPOSE exec -T moodle-mariadb printenv MARIADB_ROOT_PASSWORD 2>/dev/null | tr -d '\r')"
[ -n "$DB_ROOT_PW" ] || { echo "!! could not read MARIADB_ROOT_PASSWORD from moodle-mariadb." >&2; exit 1; }

echo ">> Waiting for moodle-mariadb..."
for i in $(seq 1 60); do
  if $COMPOSE exec -T -e PW="$DB_ROOT_PW" moodle-mariadb sh -c 'mariadb -uroot -p"$PW" -e "SELECT 1"' >/dev/null 2>&1; then break; fi
  sleep 2; [ "$i" = "60" ] && { echo "!! moodle-mariadb never became ready." >&2; exit 1; }
done

# --- 1. Load the SQL dump (drop/recreate, re-grant, load) ------------------
DUMP="$DEPLOY_PATH/moodle_src.sql"
[ -f "$DUMP" ] || { echo "!! dump not found: $DUMP (place it there or set MOODLE_DUMP_FILE)." >&2; exit 1; }
echo ">> Dropping & recreating \`$DB_NAME\` and loading the dump..."
printf 'DROP DATABASE IF EXISTS `%s`;\nCREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\nGRANT ALL PRIVILEGES ON `%s`.* TO `%s`@`%%`;\nFLUSH PRIVILEGES;\n' \
  "$DB_NAME" "$DB_NAME" "$DB_NAME" "$DB_USER" \
  | $COMPOSE exec -T -e PW="$DB_ROOT_PW" moodle-mariadb sh -c 'mariadb -uroot -p"$PW"'
$COMPOSE exec -T -e PW="$DB_ROOT_PW" -e DB="$DB_NAME" moodle-mariadb \
  sh -c 'mariadb -uroot -p"$PW" "$DB"' < "$DUMP"

# --- 2. Restore the filedir blob store (+ secret key) into the volume ------
if [ "${SKIP_FILES:-0}" = "1" ]; then
  echo ">> SKIP_FILES=1 — leaving moodledata untouched."
else
  # Find the filedir: an already-extracted dir under $DEPLOY_PATH, else extract the tarball.
  SRC="$(find "$DEPLOY_PATH" -maxdepth 4 -type d -name filedir 2>/dev/null | head -n1)"
  if [ -z "$SRC" ] && [ -f "$DEPLOY_PATH/moodle_filedir.tar.gz" ]; then
    echo ">> Extracting moodle_filedir.tar.gz..."
    rm -rf "$DEPLOY_PATH/moodle_extract"; mkdir -p "$DEPLOY_PATH/moodle_extract"
    tar xzf "$DEPLOY_PATH/moodle_filedir.tar.gz" -C "$DEPLOY_PATH/moodle_extract"
    SRC="$(find "$DEPLOY_PATH/moodle_extract" -maxdepth 3 -type d -name filedir | head -n1)"
  fi
  [ -n "$SRC" ] || { echo "!! no 'filedir/' found on the host (tarball or extracted dir)." >&2; exit 1; }
  SRCPARENT="$(dirname "$SRC")"
  echo ">> Streaming filedir into the moodle_moodledata volume from: $SRC"
  $COMPOSE exec -T moodle sh -c "rm -rf $MDATA/filedir"
  tar -C "$SRCPARENT" -cf - filedir | $COMPOSE exec -T moodle sh -c "tar -xf - -C $MDATA"
  if [ -d "$SRCPARENT/secret" ]; then
    echo ">> Restoring moodledata/secret (encryption key)..."
    $COMPOSE exec -T moodle sh -c "rm -rf $MDATA/secret"
    tar -C "$SRCPARENT" -cf - secret | $COMPOSE exec -T moodle sh -c "tar -xf - -C $MDATA"
  fi
  $COMPOSE exec -T moodle chown -R daemon:daemon "$MDATA"
fi

# --- 3. Upgrade the DB to our Moodle version (as daemon, never root) --------
printf "UPDATE \`mdl_config\` SET value='0' WHERE name IN ('upgraderunning','maintenance_enabled');\n" \
  | $COMPOSE exec -T -e PW="$DB_ROOT_PW" -e DB="$DB_NAME" moodle-mariadb sh -c 'mariadb -uroot -p"$PW" "$DB"' 2>/dev/null || true
echo ">> Running Moodle upgrade..."
$COMPOSE exec -T -u daemon moodle php "$MCODE/admin/cli/upgrade.php" --non-interactive

# --- 4. Re-brand: theme, forcelogin, site name, nav, purge -----------------
$COMPOSE exec -T -u daemon moodle php "$MCODE/admin/cli/cfg.php" --name=theme --set=iilmp
$COMPOSE exec -T -u daemon moodle php "$MCODE/admin/cli/cfg.php" --name=forcelogin --set="$FORCELOGIN"
$COMPOSE exec -T -u daemon -e SN="$SITE_SHORT" -e FN="$SITE_FULL" moodle php -r '
define("CLI_SCRIPT",true); require("/opt/bitnami/moodle/config.php");
$DB->set_field("course","shortname",getenv("SN"),["id"=>1]);
$DB->set_field("course","fullname",getenv("FN"),["id"=>1]);'
MENU="$(printf '%s\n' \
  "Repository|$IILMP_BASE/repository/" \
  "Funding|$IILMP_BASE/rims/grants/calls/" \
  "Marketplace|$IILMP_BASE/smehub/marketplace/hub/" \
  "Alumni|$IILMP_BASE/alumni/" \
  "MEL|$IILMP_BASE/mel/events/dashboard/")"
$COMPOSE exec -T -u daemon moodle php "$MCODE/admin/cli/cfg.php" --name=custommenuitems --set="$MENU"
$COMPOSE exec -T -u daemon moodle php "$MCODE/admin/cli/purge_caches.php"

# --- 5. Reset the primary admin password (optional) ------------------------
if [ -n "$RESET_PW" ]; then
  $COMPOSE exec -T -u daemon -e NP="$RESET_PW" moodle php -r '
  define("CLI_SCRIPT",true); require("/opt/bitnami/moodle/config.php"); require_once($CFG->libdir."/moodlelib.php");
  $a=reset(get_admins()); if($a){$DB->set_field("user","auth","manual",["id"=>$a->id]); $a->auth="manual"; update_internal_user_password($a, getenv("NP"));}'
  echo ">> admin password reset."
fi

# --- 6. Verify -------------------------------------------------------------
WWW="$($COMPOSE exec -T -u daemon moodle php -r 'define("CLI_SCRIPT",true);require("/opt/bitnami/moodle/config.php");echo $CFG->wwwroot;' 2>/dev/null | tr -d '\r')"
USERS="$($COMPOSE exec -T -e PW="$DB_ROOT_PW" -e DB="$DB_NAME" moodle-mariadb sh -c 'mariadb -uroot -p"$PW" "$DB" -N -e "SELECT COUNT(*) FROM mdl_user WHERE deleted=0"' 2>/dev/null | tr -d '[:space:]')"
echo ">> ✓ Native Moodle restore complete. wwwroot=$WWW users=${USERS:-?}"
case "$WWW" in
  https://*) echo ">>   next: run 'make configure-moodle-sso' to wire the OAuth issuer + WS token." ;;
  *) echo ">> ⚠ wwwroot is NOT https ($WWW) — SSO needs https. Set MOODLE_HOST + MOODLE_REVERSEPROXY:yes, or fix config.php." ;;
esac
EOF
}

cmd_configure_moodle_sso() {
  # Configure the Moodle-side integration, reproducibly:
  #   1. (SSO only) seed the Django OAuth2 Application (idempotent),
  #   2. configure Moodle: the MEL web service + token ALWAYS; the "Log in with
  #      IILMP" OAuth2 issuer + auth_oauth2 only when MOODLE_SSO_CLIENT_SECRET is
  #      set AND Moodle's wwwroot is https (Moodle rejects http:// issuers),
  #   3. persist MOODLE_WS_TOKEN into the LOCAL .env.production so it survives the
  #      next redeploy (which re-syncs .env), then restart web/celery to load it.
  # AWS-domain HTTP phase: leave MOODLE_SSO_CLIENT_SECRET unset → mints the MEL
  # token only. Full SSO: set it (and optionally MOODLE_SSO_CLIENT_ID,
  # IILMP_BASE_URL) in .env.deploy, with Moodle on a real https domain.
  local CLIENT_ID="${MOODLE_SSO_CLIENT_ID:-moodle-sso}"
  local CLIENT_SECRET="${MOODLE_SSO_CLIENT_SECRET:-}"
  local IILMP_BASE="${IILMP_BASE_URL:-https://iilmp.ruforum.org}"
  local envfile="${ROOT_DIR}/${LOCAL_ENV_FILE}"
  local script="${ROOT_DIR}/scripts/configure_moodle_prod.php"
  [ -f "$script" ] || { echo "error: $script not found" >&2; exit 1; }

  if [ -n "$CLIENT_SECRET" ]; then
    echo ">> [1/4] Seeding the Django OAuth Application on ${SSH_TARGET}..."
    remote_bash "$DEPLOY_PATH" "$CLIENT_ID" "$CLIENT_SECRET" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"; CID="$2"; CSEC="$3"; cd "$DEPLOY_PATH"
docker compose -f docker-compose.prod.yml run --rm \
  -e MOODLE_SSO_CLIENT_ID="$CID" -e MOODLE_SSO_CLIENT_SECRET="$CSEC" \
  web python manage.py seed_moodle_oauth_app
EOF
  else
    echo ">> [1/4] No MOODLE_SSO_CLIENT_SECRET — skipping the Django OAuth app (MEL token only; SSO deferred)."
  fi

  echo ">> [2/4] Configuring Moodle (MEL WS token${CLIENT_SECRET:+ + SSO issuer}) — feeding configure_moodle_prod.php..."
  # The PHP prints exactly "MOODLE_WS_TOKEN=<token>" to STDOUT; capture it (never echo).
  local token
  token="$(ssh "${SSH_OPTS[@]}" "$SSH_TARGET" \
    "cd '$DEPLOY_PATH' && docker compose -f docker-compose.prod.yml exec -T -u daemon \
       -e IILMP_BASE_URL='$IILMP_BASE' -e MOODLE_SSO_CLIENT_ID='$CLIENT_ID' \
       -e MOODLE_SSO_CLIENT_SECRET='$CLIENT_SECRET' \
       moodle php /dev/stdin" < "$script" \
    | grep '^MOODLE_WS_TOKEN=' | head -1 | cut -d= -f2- | tr -d '[:space:]')"
  if [ -z "$token" ]; then
    echo "!! Did not receive a WS token from Moodle — check the diagnostics above." >&2
    exit 1
  fi

  echo ">> [3/4] Persisting MOODLE_WS_TOKEN into ${LOCAL_ENV_FILE} (len ${#token})..."
  if [ -f "$envfile" ]; then
    grep -v '^MOODLE_WS_TOKEN=' "$envfile" > "${envfile}.tmp" && mv "${envfile}.tmp" "$envfile"
  fi
  printf 'MOODLE_WS_TOKEN=%s\n' "$token" >> "$envfile"

  echo ">> [4/4] Re-syncing env + restarting web/celery to load the token..."
  sync_env
  remote_exec "cd '$DEPLOY_PATH' && docker compose -f docker-compose.prod.yml up -d web celery_worker celery_beat"
  if [ -n "$CLIENT_SECRET" ]; then
    echo ">> ✓ SSO + MEL token configured. Sign into ${IILMP_BASE}, click 'Learning' → land in Moodle authenticated."
  else
    echo ">> ✓ MEL web-service token configured (SSO deferred). Landing catalogue + MEL pull can now reach Moodle."
  fi
}

cmd_migrate_smehub() {
  # Import the legacy SME-Hub (EHC) platform into the SME-Hub module. Same
  # ephemeral-MariaDB approach as cmd_migrate_moodle. EHC uploaded files (Laravel
  # storage/) are not in the dump and the server pull is blocked, so this is a
  # metadata-only EHC pass (the `files` stage self-skips with no --files-root),
  # PLUS a second ephemeral MariaDB for the UltimatePOS accounting dump so the
  # `pos` stage can import real sales + products.
  : "${EHC_DUMP_FILE:?EHC_DUMP_FILE not set in .env.deploy}"
  : "${POS_DUMP_FILE:?POS_DUMP_FILE not set in .env.deploy}"
  if [ ! -f "$EHC_DUMP_FILE" ]; then
    echo "error: EHC_DUMP_FILE not found: $EHC_DUMP_FILE" >&2
    exit 1
  fi
  if [ ! -f "$POS_DUMP_FILE" ]; then
    echo "error: POS_DUMP_FILE not found: $POS_DUMP_FILE" >&2
    exit 1
  fi

  if [ -z "${CI:-}" ] && [ -z "${YES:-}" ]; then
    printf "Import legacy SME-Hub EHC (metadata) + UltimatePOS sales into prod on %s? [y/N] " "$SSH_HOST"
    read -r ans
    case "$ans" in
      y|Y|yes|YES) ;;
      *) echo "Aborted." ; exit 1 ;;
    esac
  fi

  _upload_artifact "$EHC_DUMP_FILE" ehc_src.sql
  _upload_artifact "$POS_DUMP_FILE" pos_src.sql

  echo ">> Migrating SME-Hub on ${SSH_TARGET} (ephemeral MariaDB x2 + import)..."
  remote_bash "$DEPLOY_PATH" <<'EOF'
set -euo pipefail
DEPLOY_PATH="$1"
cd "$DEPLOY_PATH"

COMPOSE="docker compose -f docker-compose.prod.yml"

NETWORK="$($COMPOSE ps -q web | head -n1 | xargs -r docker inspect \
  -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{end}}')"
if [ -z "$NETWORK" ]; then
  echo "!! Could not resolve the prod compose network (is 'web' running? run 'make redeploy' first)." >&2
  exit 1
fi
echo ">> Using docker network: $NETWORK"

# --- ehc-src (EHC metadata) -------------------------------------------------
echo ">> Starting ephemeral ehc-src MariaDB..."
docker rm -f ehc-src >/dev/null 2>&1 || true
docker run -d --name ehc-src --network "$NETWORK" \
  -e MARIADB_ROOT_PASSWORD=ehc -e MARIADB_DATABASE=ehc mariadb:10.11 >/dev/null
echo ">> Waiting for ehc-src to accept connections..."
for i in $(seq 1 60); do
  if docker exec ehc-src mariadb -uroot -pehc -e 'SELECT 1' ehc >/dev/null 2>&1; then break; fi
  sleep 2
  if [ "$i" = "60" ]; then echo "!! ehc-src never became ready." >&2; docker rm -f ehc-src; exit 1; fi
done
echo ">> Loading EHC dump into ehc-src..."
docker exec -i ehc-src mariadb -uroot -pehc ehc < "$DEPLOY_PATH/ehc_src.sql"

# --- pos-src (UltimatePOS accounting) ---------------------------------------
echo ">> Starting ephemeral pos-src MariaDB..."
docker rm -f pos-src >/dev/null 2>&1 || true
docker run -d --name pos-src --network "$NETWORK" \
  -e MARIADB_ROOT_PASSWORD=pos -e MARIADB_DATABASE=pos mariadb:10.11 >/dev/null
echo ">> Waiting for pos-src to accept connections..."
for i in $(seq 1 60); do
  if docker exec pos-src mariadb -uroot -ppos -e 'SELECT 1' pos >/dev/null 2>&1; then break; fi
  sleep 2
  if [ "$i" = "60" ]; then echo "!! pos-src never became ready." >&2; docker rm -f ehc-src pos-src; exit 1; fi
done
echo ">> Loading POS dump into pos-src..."
docker exec -i pos-src mariadb -uroot -ppos pos < "$DEPLOY_PATH/pos_src.sql"

echo ">> Running import_smehub_ehc (--stage all, metadata)..."
$COMPOSE run --rm web python manage.py import_smehub_ehc \
  --source-host ehc-src --source-user root --source-password ehc --source-db ehc \
  --stage all --report /tmp/ehc_import.csv

echo ">> Running import_smehub_ehc (--stage pos)..."
$COMPOSE run --rm web python manage.py import_smehub_ehc \
  --source-host ehc-src --source-user root --source-password ehc --source-db ehc \
  --stage pos \
  --pos-source-host pos-src --pos-source-user root --pos-source-password pos --pos-source-db pos

echo ">> Tearing down ehc-src + pos-src..."
docker rm -f ehc-src pos-src >/dev/null

echo ">> ✓ SME-Hub migration complete."
echo ">> ⚠ REMINDER: set MIGRATE_SMEHUB=FALSE in .env.production so the next"
echo ">>            'make redeploy' does NOT re-run the SME-Hub import."
EOF
}

usage() {
  cat >&2 <<USAGE
Usage: $0 {init|redeploy|css-build|seed [demo|heavy] [extra args...]|seed-rims|migrate-repository|reindex-repository-search [extra args...]|migrate-moodle|migrate-smehub|logs|status|shell}

  init       Bootstrap the server: clone repo, sync runtime .env, verify Docker.
  redeploy   Pull current local branch on the server and rebuild the prod stack
             (includes a fresh Tailwind CSS build via node:20-alpine).
  css-build  Rebuild Tailwind CSS only and refresh collectstatic, without a
             full redeploy. Use after a template-only change that added new
             utility classes.
  seed       Run every seed_* command via apps/core's seed_all orchestrator.
             Volume defaults to 'demo'. Extra args after the volume are passed
             through to seed_all (e.g. --only repository, --skip rep_live).
             Skip the y/N prompt with: YES=1 $0 seed ...
  seed-rims  Re-seed the RIMS module directly (bypasses seed_all). Use when
             seed_all silently skipped seed_rims; tracebacks surface in the
             foreground. Wipes + recreates all RIMS tables.
  migrate-repository
             Wipe + re-import the legacy Repository (documents/authors/keywords/
             collections) from the Drupal dump and download ~2.5 GB of PDFs,
             WITHOUT a full redeploy. Requires REPOSITORY_DUMP_FILE in .env.deploy.
             Skip the y/N prompt with: YES=1 $0 migrate-repository
  reindex-repository-search
             Rebuild the Repository full-text search_vector over the corpus so
             author / facet search finds the imported backlog (the Drupal import
             leaves vectors empty). Idempotent. Extra args pass through, e.g.
             '$0 reindex-repository-search --missing-only'.
  migrate-moodle
             NATIVE restore of the legacy Moodle (DB dump + ~1 GB moodledata filedir)
             into the prod moodle/moodle-mariadb services — mirrors the dev
             migrate_moodle_into_docker.sh. ⚠ DROPS + recreates the Moodle DB. Expects
             moodle_src.sql + moodle_filedir.tar.gz already on the server (or set
             MOODLE_DUMP_FILE/MOODLE_FILEDIR_ARCHIVE in .env.deploy to upload). Run
             'make redeploy' first (starts Moodle). YES=1 skips prompt.
  configure-moodle-sso
             Wire 'Log in with IILMP' SSO + the MEL web-service token, reproducibly:
             seed the Django OAuth Application, configure the Moodle OAuth2 issuer +
             auth_oauth2 + WS service, and persist MOODLE_WS_TOKEN into .env.production.
             Requires MOODLE_SSO_CLIENT_SECRET (+ optional MOODLE_SSO_CLIENT_ID,
             IILMP_BASE_URL) in .env.deploy. Run after migrate-moodle, TLS live.
  migrate-smehub
             Import the legacy SME-Hub EHC platform (metadata) + UltimatePOS sales.
             Stands up two ephemeral MariaDBs (ehc-src, pos-src), runs
             import_smehub_ehc --stage all then --stage pos, tears them down.
             Requires EHC_DUMP_FILE + POS_DUMP_FILE in .env.deploy. EHC uploaded
             files stay unattached (server pull blocked). YES=1 skips prompt.
  logs       Tail prod docker compose logs.
  status     Show prod docker compose service status.
  shell      Open an interactive bash shell inside the web container.
USAGE
  exit 2
}

case "${1:-}" in
  init)      cmd_init ;;
  redeploy)  cmd_redeploy ;;
  css-build) cmd_css_build ;;
  seed)      shift; cmd_seed "$@" ;;
  seed-rims) cmd_seed_rims ;;
  migrate-repository) cmd_migrate_repository ;;
  reindex-repository-search) shift; cmd_reindex_repository_search "$@" ;;
  migrate-moodle) cmd_migrate_moodle ;;
  configure-moodle-sso) cmd_configure_moodle_sso ;;
  migrate-smehub) cmd_migrate_smehub ;;
  logs)      cmd_logs ;;
  status)    cmd_status ;;
  shell)     cmd_shell ;;
  *)         usage ;;
esac
