#!/usr/bin/env bash
# sitepublish — publish a built static export as an immutable Release on the
# Hanzo Sites plane. One implementation, every `site:` in the fleet.
#
#   sitepublish <slug> <dir>      # e.g. sitepublish hanzo-console out
#
# WHY THIS EXISTS: the reusable's `site:` lane used to stage the export with
# `mc mirror` against hanzoai/s3, which needed S3_ADMIN_ACCESS_KEY and
# S3_ADMIN_SECRET_KEY. Those names are not in KMS for any org, and the lane
# fails closed without them — so `site:` refused for every caller that ever
# declared it, and hanzoai/console had to grow its own build→zip→publish
# workflow to ship at all. Per-repo build logic is the one thing this repo
# exists to prevent. The bytes now travel the SAME route console proved, and
# the credential is the one CI already holds.
#
# THE TWO STEPS:
#
#   POST /v1/projects/<slug>/deploy   Content-Type: application/zip, zip as body
#   POST /v1/sites/<slug>/publish     {"source":"<slug>"}
#
# The first lands the export at the org's site prefix; the second promotes that
# prefix into an immutable Release and flips it live. `source` is ORG-RELATIVE
# and the org segment is prepended server-side from the validated principal
# (cloud apps/projects/blob.go:60, `sitePrefix(org, slug) = org + "/" + slug`),
# which is why "<slug>" is the whole of it and why a caller cannot address
# another tenant's bytes by writing a longer path.
#
# ONE CREDENTIAL, ALREADY MINTED. HANZO_API_TOKEN is the IAM JWT the workflow's
# KMS step already holds: cloud's /v1/kms/auth/login is a broker that performs
# the IAM client_credentials exchange and returns IAM's own JWT verbatim, so the
# token that reads this org's secrets is the token that publishes this org's
# site. api.hanzo.ai validates it and mints X-Org-Id from the `owner` claim —
# identity is never a header this script sends, so there is nothing here to
# spoof. Do NOT reintroduce S3_ADMIN_*; a standing bucket key was what this
# replaced.
#
# THE SIZE BOUNDARY IS THE SERVER'S, NOT A PREFERENCE. cloud's public edge sets
# BodyLimit from GATEWAY_BODY_LIMIT, default 16 MiB (cloud internal/edge/edge.go
# :38, bound to the door's fasthttp socket at cmd/cloud/main.go:124), and
# fasthttp refuses an oversized POST BEFORE any handler runs — it surfaces as an
# opaque 400 "Error when parsing request" that reads like a malformed payload
# rather than a size cap (cloud apps/projects/grant.go:5 tells that story about
# a ~170 MB export). So the zip is measured HERE and refused HERE, with the
# number and the alternative, instead of being sent to fail unreadably. Measured
# across the estate's 24 built exports, 22 fit; hanzo.ai (27.9 MiB zipped, 8536
# files) and trillerfest.com (76.7 MiB) do not. Those go to bin/sitedeploy,
# which streams per-file against a presigned grant and posts no whole body — only
# its completion manifest crosses the edge, and that is measured there for the
# same reason. hanzo.ai is over the server's own 5000-entry cap anyway, so no
# transport makes it a Release.
#
# ENV: HANZO_API_TOKEN  (required) the IAM bearer; the workflow's KMS step mints it
#      HANZO_API        (https://api.hanzo.ai)
#      GATEWAY_BODY_LIMIT   (16777216) the edge's own name for its own limit
#      SITEPUBLISH_PLAN=1   print what it would send and exit; no network. Test seam.
set -euo pipefail

slug="${1:?usage: sitepublish <slug> <dir>}"
dir="${2:?usage: sitepublish <slug> <dir>}"
api="${HANZO_API:-https://api.hanzo.ai}"

# Server-side caps, mirrored so a breach is named in CI instead of arriving as a
# 413 (or, for the body limit, as an unreadable 400). cloud apps/projects/blob.go
# :28-32. bin/sitepublish_test.sh pins these numbers; if cloud moves them the
# test is what catches the drift.
#
# The body limit carries the SERVER'S OWN NAME — one value, one name, wherever it
# travels. bin/sitedeploy reads the same variable for the same edge, so a runner
# told a different limit is told it once and both lanes obey.
#
# READ THE WAY THE SERVER READS IT. edge.envInt (edge.go:40) is TrimSpace, then
# strconv.Atoi, then `err != nil || n <= 0` falls back to the default — so the
# server SHRUGS at a malformed value and enforces 16 MiB. Bash does not shrug:
# `[ "$zbytes" -le "$max_body" ]` with a non-numeric operand is an error, the
# `||` branch fires, and the publish refuses against a limit that was never in
# force, printing a number that does not exist. One typo in one variable would
# stop every publisher, fail-CLOSED. Mirrored so the two cannot disagree about
# the value of the same variable.
max_body="${GATEWAY_BODY_LIMIT-}"             # 16 << 20, cloud internal/edge/edge.go:38
max_body="${max_body#"${max_body%%[![:space:]]*}"}"    # TrimSpace
max_body="${max_body%"${max_body##*[![:space:]]}"}"
max_body="${max_body#+}"                               # Atoi accepts a leading sign
case "$max_body" in ''|*[!0-9]*) max_body=16777216 ;; esac
# 18 digits: past that bash's own `-gt` errors, exactly where Atoi returns an
# error and Go keeps its default. The two agree everywhere below 10^18 bytes.
{ [ "${#max_body}" -le 18 ] && [ "$max_body" -gt 0 ]; } || max_body=16777216
max_files=5000                                # maxFiles
max_file_bytes=$((64 << 20))                  # maxFileBytes, 64 MiB
max_total_bytes=$((512 << 20))                # maxTotalBytes uncompressed, 512 MiB

die() { echo "::error::$*"; exit 1; }
# LC_ALL=C because under a comma-decimal locale awk renders "16,0 MiB". The
# number in a refusal is read by people and matched by tests, and neither wants
# it to depend on the runner's locale.
human() { LC_ALL=C awk -v b="$1" 'BEGIN{ split("B KiB MiB GiB", u, " "); i = 1
  while (b >= 1024 && i < 4) { b /= 1024; i++ }
  printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'; }

# The slug is a URL path segment AND an S3 key segment, so it is held to the
# grammar cloud validates project slugs with — checked before anything is built
# or sent, because a bad slug is a typo to fix and not a 404 to interpret.
echo "$slug" | grep -qE '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$' \
  || die "slug '$slug' is not a project slug (^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?\$)"
[ -d "$dir" ] || die "'$dir' is not a directory — the build produced no export"

# A site is a thing with an index. cloud enforces it however the bytes arrive;
# checking here turns a failed build into a failed publish with a readable cause
# rather than a promoted release that 404s at its own root.
[ -f "$dir/index.html" ] || die "'$dir' has no index.html at its root — /v1/sites refuses a source without one"

# CNAME does not travel: a GitHub Pages artifact that means nothing to S3 and
# would ship a stale hostname claim into the bucket. Same exclusion bin/sitedeploy
# makes, for the same reason.
# `tr -d` because BSD wc pads its count to a fixed width and GNU wc does not, so
# this is "2" on a runner and "       2" on a dev machine — and the number goes
# into a message and into the plan line, where the padding is the difference
# between a readable count and a broken-looking one.
files=$(cd "$dir" && find . -type f ! -name CNAME | wc -l | tr -d ' ')
[ "$files" -gt 0 ] || die "'$dir' contains no files — refusing to publish an empty release"
[ "$files" -le "$max_files" ] \
  || die "'$dir' holds $files files; cloud caps one artifact at $max_files (apps/projects/blob.go maxFiles). Use bin/sitedeploy — but note the Releases plane has the same cap, so this export cannot become a Release."

bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
[ "$bytes" -le "$max_total_bytes" ] \
  || die "'$dir' is $bytes bytes uncompressed; cloud caps an artifact at $max_total_bytes (maxTotalBytes)"

# Per-file cap, checked with find rather than a loop so a 9000-file export costs
# one traversal. -size uses 512-byte blocks with `c` for bytes; +N c is "strictly
# greater than N bytes", which is the cap's own boundary.
#
# No `-printf`: it is a GNU extension, and on BSD find it does not merely skip
# the check — find exits 1, `set -o pipefail` propagates it, and `set -e` ends the
# script here, before it has done anything. One path per line is the whole report
# this needs. `head` runs on the RESULT rather than in the pipeline, so a fourth
# oversized file cannot SIGPIPE the find that found it.
big=$(cd "$dir" && find . -type f ! -name CNAME -size +${max_file_bytes}c)
[ -z "$big" ] || die "these files exceed cloud's $(human "$max_file_bytes") per-file cap (maxFileBytes):
$(printf '%s\n' "$big" | head -3)"

if [ -n "${SITEPUBLISH_PLAN:-}" ]; then
  printf 'slug=%s dir=%s files=%s bytes=%s api=%s source=%s\n' \
    "$slug" "$dir" "$files" "$bytes" "$api" "$slug"
  exit 0
fi

# No apostrophe in this message: inside "${VAR:?word}" a single quote opens a
# quoted section for the PARSER, and the script dies at EOF with "unexpected EOF
# while looking for matching quote" — a syntax error reported at the last line,
# nowhere near the one that caused it.
: "${HANZO_API_TOKEN:?HANZO_API_TOKEN is unset. It is the IAM bearer minted by the KMS step of the reusable workflow, from KMS_CLIENT_ID/KMS_CLIENT_SECRET; there is no second credential to seal.}"

tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
zipf="$tmp/site.zip"

# -X drops extra file attributes (uid/gid/timestamps beyond the DOS fields) so
# the same tree zips to the same bytes on any runner. Entries are stored
# RELATIVE to the export root — cloud strips a leading "./" and refuses absolute
# or ".."-escaping names (blob.go:362-370), and strips a common top-level prefix
# if one exists, so a flat archive is what it expects and what this sends.
( cd "$dir" && zip -qXr "$zipf" . -x './CNAME' ) || die "zip of '$dir' failed"
zbytes=$(wc -c < "$zipf" | tr -d ' ')   # BSD wc pads its count, GNU does not

# The refusal the whole size story is about. Above this the POST dies at the
# edge with a 400 that names nothing.
# Exact bytes beside the human number, because the band where an export FIRST
# crosses is the ~100 KiB just above the cap, and there both sides round to
# "16.0 MiB" — a refusal that reads as 16.0 over 16.0 explains nothing.
[ "$zbytes" -le "$max_body" ] || die "publish aborted: the zipped release is $(human "$zbytes") ($zbytes bytes) for $files files, over the $(human "$max_body") ($max_body bytes) edge limit (GATEWAY_BODY_LIMIT, cloud internal/edge/edge.go:38). fasthttp refuses the POST before any handler runs and answers only 'Error when parsing request', which names neither size nor cause — so it is refused here, where the number is. Publish this export with bin/sitedeploy, which streams per-file against a presigned grant and posts no whole body."

# One line, every publish, whether or not it is anywhere near the limit. Same
# shape bin/sitedeploy prints, because it is the same fact about the same edge.
echo "release: $(human "$bytes") / $files files; zip $(human "$zbytes") of the $(human "$max_body") edge limit"

# ---- 1. the bytes -----------------------------------------------------------
# Every response body is KEPT and printed on failure. cloud answers these routes
# with a specific status per cause — 402 hosting gate, 403 wrong org, 404 no such
# site for this tenant, 409 the source moved mid-publish, 413 past the caps, 503
# storage unconfigured — and each is a different fix, so discarding the body
# discards the answer.
code=$(curl -sS -o "$tmp/deploy.json" -w '%{http_code}' \
  -X POST "$api/v1/projects/$slug/deploy" \
  -H "Authorization: Bearer $HANZO_API_TOKEN" \
  -H 'Content-Type: application/zip' \
  --data-binary "@$zipf") \
  || die "POST $api/v1/projects/$slug/deploy did not complete"
case "$code" in
  2??) ;;
  *) echo "::error::upload $slug → HTTP $code"; head -c 800 "$tmp/deploy.json"; echo; exit 1 ;;
esac

# ---- 2. promote + activate --------------------------------------------------
# `source` is the org-relative prefix the upload just landed at. Content types
# and cache policy are not ours to set: copyRelease rewrites the metadata on
# every object it promotes, so a release serves identically however its bytes
# were staged.
code=$(curl -sS -o "$tmp/publish.json" -w '%{http_code}' \
  -X POST "$api/v1/sites/$slug/publish" \
  -H "Authorization: Bearer $HANZO_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$(jq -nc --arg s "$slug" '{source:$s}')") \
  || die "POST $api/v1/sites/$slug/publish did not complete"
case "$code" in
  2??) ;;
  *) echo "::error::publish $slug → HTTP $code"; head -c 800 "$tmp/publish.json"; echo; exit 1 ;;
esac

# Guard the SHAPE before reading a field out of it. `jq -r '.releaseId // empty'`
# on an error body, an array, or a null yields "" and every later test on it
# passes vacuously — which is the defect class this whole script is written
# against. Assert it is an object carrying the field, then read it.
jq -e 'type == "object" and (.releaseId | type) == "string" and (.releaseId | length) > 0' \
  "$tmp/publish.json" >/dev/null \
  || { echo "::error::publish answered $code but the body is not a release object with a releaseId:"; head -c 800 "$tmp/publish.json"; echo; exit 1; }
rid=$(jq -r '.releaseId' "$tmp/publish.json")
objs=$(jq -r '.objects // 0' "$tmp/publish.json")
url=$(jq -r '.url // ""' "$tmp/publish.json")

# ---- 3. verify the release we just made is the one that is live -------------
# A 200 from publish says the request was accepted, not that this release is
# serving. The list is the only thing that can say so.
#
# THE LIST IS A BARE JSON ARRAY — `type projectsReleases []projectsRelease`
# (cloud apps/projects/release.go:518). `.releases[]` against it resolves
# NOTHING, and an assertion that resolves nothing is an assertion that cannot
# fail for the reason it was written. Hence `.[]`, and hence the shape guard
# first: if cloud ever wraps this in an object, THAT is what goes red, loudly,
# instead of the check quietly matching zero releases forever.
code=$(curl -sS -o "$tmp/releases.json" -w '%{http_code}' \
  "$api/v1/sites/$slug/releases" \
  -H "Authorization: Bearer $HANZO_API_TOKEN") \
  || die "GET $api/v1/sites/$slug/releases did not complete"
case "$code" in
  2??) ;;
  *) echo "::error::list releases for $slug → HTTP $code"; head -c 800 "$tmp/releases.json"; echo; exit 1 ;;
esac
jq -e 'type == "array" and length > 0' "$tmp/releases.json" >/dev/null \
  || { echo "::error::GET /v1/sites/$slug/releases did not answer a non-empty JSON array (it is the bare-array shape from release.go:518). Body:"; head -c 800 "$tmp/releases.json"; echo; exit 1; }

# Exactly one release is active. Counting first is what makes "none active"
# distinguishable from "the wrong one is active" — a bare grep for '"active":true'
# matches ANY release in the list and would pass on both.
nactive=$(jq '[.[] | select(.active == true)] | length' "$tmp/releases.json")
[ "$nactive" = 1 ] \
  || { echo "::error::expected exactly 1 active release for $slug, found $nactive:"; jq -c '[.[]|{releaseId,active}]' "$tmp/releases.json"; exit 1; }

active=$(jq -r 'map(select(.active == true))[0].releaseId // ""' "$tmp/releases.json")
# Both sides proven non-empty BEFORE they are compared. `[ "$a" = "$b" ]` with
# two empty strings is TRUE, so an equality test on unguarded values reports
# success precisely when it learned nothing.
[ -n "$active" ] || { echo "::error::the active release for $slug carries no releaseId:"; jq -c '.' "$tmp/releases.json"; exit 1; }
[ -n "$rid" ] || die "publish returned no releaseId to verify against"
[ "$active" = "$rid" ] \
  || { echo "::error::published $rid but $active is live for $slug — the flip did not take"; exit 1; }

echo "live: $rid ($objs objects) → ${url:-no public host — that slug is claimed; rename the site or bind a domain}"

if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
  { echo "### Site — \`$slug\`"; echo
    echo "| release | objects | files | zipped | url |"
    echo "|---|---|---|---|---|"
    echo "| \`$rid\` | $objs | $files | $zbytes B | ${url:-—} |"
  } >> "$GITHUB_STEP_SUMMARY"
fi
