#!/usr/bin/env bash
# Build landings into fresh release directories and swap them in.
# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user.
#
#   sudo -u exo exo-deploy medcenterphysio                 # from git, origin/main
#   sudo -u exo exo-deploy medcenterphysio my-branch
#   sudo -u exo exo-deploy --zip /tmp/exo.zip medcenterphysio
#   sudo -u exo exo-deploy --zip /tmp/exo.zip all          # all four, in order
#
# The zip must contain the landing source directories (fitnes/, hotel/, ...),
# not a prebuilt dist/: the build always runs here, so VITE_SITE_URL comes from
# /etc/exo/<app>.build.env and never from whoever packed the archive.
set -Eeuo pipefail

APPS_FALLBACK=(fitnes hotel medcenterphysio medcenterstart)
EXO_ROOT="${EXO_ROOT:-/srv/exo}"
EXO_ETC="${EXO_ETC:-/etc/exo}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KEEP=3

usage() {
  cat <<'USAGE'
usage:
  exo-deploy <app|all> [git-ref]              deploy from the bare repo (default ref: main)
  exo-deploy --zip <archive.zip> <app|all>    deploy from a zip of the landing sources

apps: fitnes | hotel | medcenterphysio | medcenterstart | all
USAGE
}

fatal() { echo "FATAL: $*" >&2; exit 1; }

sha256() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
  else shasum -a 256 "$1" | cut -d' ' -f1; fi
}

# apps.conf is the source of truth when the script is run from the repo; the
# installed copy in /usr/local/bin has no repo next to it, hence the fallback.
known_apps() {
  if [[ -r $HERE/../apps.conf ]]; then
    awk -F'|' '/^[a-z]/ { print $1 }' "$HERE/../apps.conf"
  else
    printf '%s\n' "${APPS_FALLBACK[@]}"
  fi
}

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

ZIP=""
POS=()
while (($#)); do
  case $1 in
    --zip)   ZIP="${2:-}"; [[ -n $ZIP ]] || fatal "--zip needs a path"; shift 2 ;;
    --zip=*) ZIP="${1#--zip=}"; shift ;;
    -h|--help) usage; exit 0 ;;
    --) shift; while (($#)); do POS+=("$1"); shift; done ;;
    -*) usage >&2; fatal "unknown option $1" ;;
    *)  POS+=("$1"); shift ;;
  esac
done

APP="${POS[0]:-}"
REF="${POS[1]:-}"
[[ -n $APP ]] || { usage >&2; exit 1; }
[[ -z $ZIP || -z $REF ]] || fatal "--zip takes no git ref (got '$REF')"
[[ -n $ZIP ]] || REF="${REF:-main}"

# Resolve the archive against the caller's directory, then leave it: the usual
# invocation is `sudo -u exo` from root's shell, and `exo` cannot read /root —
# GNU find complains it cannot restore that cwd. Everything below is absolute.
if [[ -n $ZIP && $ZIP != /* ]]; then ZIP="$PWD/$ZIP"; fi
cd /

ALL_APPS=()
while IFS= read -r a; do
  if [[ -n $a ]]; then ALL_APPS+=("$a"); fi
done < <(known_apps)
[[ ${#ALL_APPS[@]} -gt 0 ]] || fatal "no apps found — check apps.conf"

TARGETS=()
if [[ $APP == all ]]; then
  TARGETS=("${ALL_APPS[@]}")
else
  for a in "${ALL_APPS[@]}"; do
    if [[ $a == "$APP" ]]; then TARGETS=("$APP"); fi
  done
  [[ ${#TARGETS[@]} -gt 0 ]] || fatal "unknown app '$APP' — known: ${ALL_APPS[*]} | all"
fi

# ------------------------------------------------------- archive, if any

STAGE=""
cleanup() { if [[ -n $STAGE ]]; then rm -rf "$STAGE"; fi; }
trap cleanup EXIT

# Shallowest <app>/ directory in the archive that holds a package.json, so both
# `fitnes/...` and `bundle/fitnes/...` (what Finder produces) work.
app_src() {
  local app="$1" d
  while IFS= read -r d; do
    if [[ -f $d/package.json ]]; then
      printf '%s\n' "$d"
      return 0
    fi
  done < <(find "$STAGE" -maxdepth 4 -type d -name "$app" \
             | awk '{ print gsub(/\//,"/"), $0 }' | sort -n | cut -d' ' -f2-)
  return 1
}

if [[ -n $ZIP ]]; then
  [[ -r $ZIP ]] || fatal "cannot read $ZIP"
  command -v unzip >/dev/null 2>&1 || fatal "unzip is not installed (apt install -y unzip)"

  STAGE="$(mktemp -d)"
  echo "==> unpacking $ZIP"
  # node_modules/dist are rebuilt here; __MACOSX/.DS_Store are Finder litter.
  # unzip exits 1 and chatters on stderr for every exclude pattern the archive
  # happens not to contain, which is normal here — filter it, keep the rest.
  UNZIP_ERR="$(unzip -q -o "$ZIP" -d "$STAGE" \
    -x 'node_modules/*' '*/node_modules/*' 'dist/*' '*/dist/*' \
       '__MACOSX/*' '*/__MACOSX/*' '.DS_Store' '*/.DS_Store' 2>&1)" || {
    rc=$?
    (( rc <= 1 )) || { echo "$UNZIP_ERR" >&2; fatal "unzip failed (exit $rc)"; }
  }
  echo "$UNZIP_ERR" | grep -v 'excluded filename not matched' | grep . >&2 || true

  # Validate every target up front, and report all of them at once: better to
  # hear about a missing lock file now than after two landings are already live.
  PROBLEMS=()
  for app in "${TARGETS[@]}"; do
    if ! src="$(app_src "$app")"; then
      PROBLEMS+=("no $app/ directory with a package.json inside")
      continue
    fi
    if [[ ! -f $src/package-lock.json ]]; then
      PROBLEMS+=("$app/package-lock.json is missing — npm ci needs it")
      continue
    fi
    echo "    found $app -> ${src#$STAGE/}"
  done
  if [[ ${#PROBLEMS[@]} -gt 0 ]]; then
    for p in "${PROBLEMS[@]}"; do echo "FATAL: $(basename "$ZIP"): $p" >&2; done
    exit 1
  fi

  ZIP_SHA="$(sha256 "$ZIP")"
  SOURCE_DESC="zip $(basename "$ZIP") sha256=$ZIP_SHA"
else
  SOURCE_DESC=""   # filled per app, it carries the resolved commit
fi

# ------------------------------------------------------------- deploy one

deploy_app() {
  local APP="$1"
  local BASE="$EXO_ROOT/$APP"
  local ENVFILE="$EXO_ETC/$APP.env"
  local BUILDENV="$EXO_ETC/$APP.build.env"
  local REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)"
  local PORT PREV HEALTH CURRENT src desc

  for f in "$ENVFILE" "$BUILDENV"; do
    [[ -r $f ]] || fatal "cannot read $f"
  done

  PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")"
  [[ -n $PORT ]] || fatal "PORT not set in $ENVFILE"

  mkdir -p "$REL"

  if [[ -n $ZIP ]]; then
    src="$(app_src "$APP")" || fatal "$ZIP: no $APP/ directory with a package.json inside"
    echo "==> copying $APP/ out of $(basename "$ZIP") into $REL"
    cp -a "$src/." "$REL/"
    desc="$SOURCE_DESC"
    # Whatever local config the packer had in there must not survive: the build
    # env below is the only .env a release is allowed to carry.
    rm -f "$REL/.env" "$REL/.env.local"
  else
    [[ -d $BASE/repo ]] || fatal "no bare repo at $BASE/repo"
    echo "==> fetching $REF"
    git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*'
    git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null

    echo "==> extracting $APP/ into $REL"
    git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1
    [[ -f $REL/package.json ]] || { rm -rf "$REL"; fatal "$APP/ not found at $REF"; }
    desc="git $REF $(git -C "$BASE/repo" rev-parse --short "$REF")"
  fi

  # 2.6-3.9 MB of base64-embedded reference HTML that is never served.
  rm -rf "$REL/legacy"

  # So a live release can say where it came from long after the deploy scrolled
  # off the screen.
  printf '%s\n%s\n' "$desc" "deployed $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$REL/.deploy-source"

  # Public build-time vars only. The amoCRM token stays in $ENVFILE, which
  # systemd injects at runtime; dotenv does not override real env vars, so the
  # two never collide.
  cp "$BUILDENV" "$REL/.env"

  cd "$REL"

  # devDependencies are REQUIRED here: vite, typescript and tailwindcss all live
  # there and `npm run build` needs them. --omit=dev breaks the build.
  echo "==> npm ci"
  npm ci --no-audit --no-fund

  echo "==> npm run build"
  npm run build

  # Fail loudly rather than shipping broken SEO tags or an empty bundle.
  [[ -s dist/client/index.html ]] || fatal "dist/client/index.html missing or empty"
  if grep -q '%VITE_SITE_URL%' dist/client/index.html; then
    fatal "VITE_SITE_URL was not substituted — check $BUILDENV"
  fi

  # Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release.
  echo "==> pruning devDependencies"
  npm prune --omit=dev

  # Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose.
  find dist/client -type f \
    \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \
    -exec gzip -9 -k -f {} +

  echo "==> swapping $BASE/current -> $REL"
  PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)"
  ln -sfn "$REL" "$BASE/current.tmp"
  mv -Tf "$BASE/current.tmp" "$BASE/current"   # single rename(2): atomic

  sudo systemctl restart "exo@$APP"

  echo "==> waiting for health on 127.0.0.1:$PORT"
  for _ in $(seq 1 20); do
    if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi
    sleep 1
  done
  HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || {
    echo "FATAL: health check failed. Rolling back." >&2
    if [[ -n $PREV ]]; then
      ln -sfn "$PREV" "$BASE/current.tmp"
      mv -Tf "$BASE/current.tmp" "$BASE/current"
      sudo systemctl restart "exo@$APP"
    fi
    exit 1
  }
  echo "    $HEALTH"
  grep -q '"ok":true' <<<"$HEALTH" || fatal "health not ok"
  grep -q '"amo":true' <<<"$HEALTH" || echo "    WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE."

  # Prune old releases, never the live one.
  CURRENT="$(readlink -f "$BASE/current")"
  ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do
    [[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue
    rm -rf "$old"
  done

  echo "==> deployed $APP [$desc] -> $REL"
}

# ------------------------------------------------------------------ run

n=0
for app in "${TARGETS[@]}"; do
  n=$((n + 1))
  if [[ ${#TARGETS[@]} -gt 1 ]]; then echo "### [$n/${#TARGETS[@]}] $app"; fi
  deploy_app "$app"
done
