#!/usr/bin/env bash
# [impl->REQ-DEP-04]
#
# scripts/client-release.sh — runs ON THE FLY MACHINE, invoked via:
#   flyctl ssh console -a rebno-staging -C "bash /tmp/client-release-<sha>.sh <sha>"
#
# Purpose: Atomically deploy a client-only Vite bundle that was already
# uploaded as /tmp/client-assets-<sha>.tgz via `flyctl ssh sftp put`.
# Implements RESEARCH Pattern 1 (atomic `mv -T` swap) + Pattern 2
# (stage-check-swap ordering) + Pitfall 5 (readlink-protected GC).
#
# Argument: one positional <sha> — must match ^[a-f0-9]{40}$ (a lowercase git
# SHA). Path-traversal mitigation per RESEARCH § Security Domain V5.
#
# Exit codes:
#   0  — success: extracted, sanity-checked, swapped, GC'd, tarball removed
#   1  — anything `set -e` catches (unexpected failure of a step)
#   2  — invalid SHA argument (regex mismatch OR missing usage)
#   3  — uploaded tarball missing at /tmp/client-assets-<sha>.tgz
#   4  — extracted tarball is missing index.html or .vite/manifest.json
#         (corrupt-release detection — swap is NOT performed in this case)
#  127 — bats not on PATH at test time (test-runner only; informational)
#
# Tunables (overridable for bats tests; default to the Fly machine paths):
#   ROOT      — base volume root (default: /data/client-assets)
#   TGZ_DIR   — tarball staging directory (default: /tmp)
#   KEEP_LAST — GC retention count (default: 5)
#
# This script is invoked once per deploy by the CI fast-path workflow
# (Plan 04) AND by operators running rollback drills (see docs/deploy/ROLLBACK.md).

set -euo pipefail

SHA="${1:?usage: $0 <40-hex-git-sha>}"

ROOT="${ROOT:-/data/client-assets}"
TGZ_DIR="${TGZ_DIR:-/tmp}"
KEEP_LAST="${KEEP_LAST:-5}"

TGZ="${TGZ_DIR}/client-assets-${SHA}.tgz"
REL="${ROOT}/releases/${SHA}"
CUR="${ROOT}/current"

# 1. SHA validation — only lowercase 40-hex (git SHA form). Rejects
#    uppercase, short SHAs, and any path-traversal payload like
#    "../etc/passwd". RESEARCH § Security Domain V5; threat T-06.5-06.
if [[ ! "${SHA}" =~ ^[a-f0-9]{40}$ ]]; then
  echo "invalid SHA: ${SHA}" >&2
  echo "  expected: 40 lowercase hex chars (^[a-f0-9]{40}$)" >&2
  exit 2
fi

# 2. Tarball presence check — CI's flyctl ssh sftp put step is the upstream
#    contract that places it; failing fast here gives a clear error.
if [[ ! -f "${TGZ}" ]]; then
  echo "tarball missing: ${TGZ}" >&2
  exit 3
fi

# 3. Create the content-addressed release dir. `mkdir -p` is idempotent —
#    re-running the script on the same SHA is safe (last-write-wins on
#    the tarball contents; the SHA itself guarantees the contents match).
mkdir -p "${REL}"

# 4. Extract with the canonical relative-path form (RESEARCH § Pitfall 8).
#    Upstream contract from Plan 04 is `tar -czf <tgz> -C apps/server/public .`
#    so archive entries are `./index.html`, `./.vite/manifest.json`, etc.
tar -xzf "${TGZ}" -C "${REL}"

# 5. Sanity-check the extract BEFORE swapping (RESEARCH § Pattern 2
#    stage-check-swap). A corrupt tarball never becomes `current`.
#    Threat T-06.5-07 (corrupt release becomes current) — mitigated here.
if [[ ! -f "${REL}/index.html" ]]; then
  echo "missing index.html in extracted tarball at ${REL}/index.html" >&2
  exit 4
fi
if [[ ! -f "${REL}/.vite/manifest.json" ]]; then
  echo "missing vite manifest at ${REL}/.vite/manifest.json" >&2
  exit 4
fi

# 6. Ensure ROOT exists (defensive — `mkdir -p` is no-op if present).
mkdir -p "${ROOT}"

# 7. Atomic symlink swap (RESEARCH § Pattern 1). `mv -T` invokes
#    rename(2) which is atomic on ext4: there is no instant at which
#    `${CUR}` is missing for express.static to 404 on. The non-atomic
#    alternative (a `ln -s` with `-fn` force-no-deref flag pair) is
#    BANNED — it is a two-syscall sequence (unlink + symlink)
#    with a real zero-existence window. Threat T-06.5-09.
ln -s "${REL}" "${CUR}.new"
mv -T "${CUR}.new" "${CUR}"
echo "swapped ${CUR} -> ${REL}"

# 8. Garbage-collect old releases — keep the last ${KEEP_LAST} by mtime.
#    `ls -1dt .../*/` sorts directories by mtime, newest first; the
#    trailing `/` on the glob keeps non-dir entries out (defensive
#    even though releases/ only contains dirs).
#    Skip the dir that `current` currently resolves to — even if it
#    would otherwise fall outside the retention window. Threat T-06.5-08
#    (GC deletes current target); RESEARCH § Pitfall 5.
CURTARGET="$(readlink -f "${CUR}")"
for dir in $(ls -1dt "${ROOT}/releases/"*/ 2>/dev/null | tail -n +"$((KEEP_LAST + 1))"); do
  # Strip the trailing slash so readlink -f canonicalizes the same way
  # as $CURTARGET (which has no trailing slash).
  dir_no_slash="${dir%/}"
  if [[ "$(readlink -f "${dir_no_slash}")" == "${CURTARGET}" ]]; then
    echo "GC: skipping current target ${dir_no_slash}"
    continue
  fi
  rm -rf "${dir_no_slash}"
  echo "GC: removed ${dir_no_slash}"
done

# 9. Remove the uploaded tarball — final step, only reached if every
#    prior step succeeded (set -e short-circuits on any failure).
rm -f "${TGZ}"
echo "removed tarball ${TGZ}"

echo "client-release ${SHA} complete"
