#!/bin/sh
# Publish exactly one cart, and prove it.
#
#   ./publish-cart arcade                  package arcade for linux-amd64
#   ./publish-cart arcade --platforms=html5,linux-amd64
#   ./publish-cart arcade --dry-run        stage + verify, do not package
#   ./publish-cart --list                  list carts and their publish titles
#
# dragonruby-publish packages a whole game directory, and this one holds every
# cart under carts/. Publishing it as-is would ship the selftest, the widgets
# gallery and every other cart inside the arcade build. So this script never
# points dragonruby-publish at ./ -- it stages a throwaway directory that
# contains the console library, exactly one cart directory (code and its own
# sprites/sounds/maps/data), and a main.rb that pins that cart, then packages
# the staging directory instead.
#
# Staging the cart wholesale is what makes the published build self-contained:
# a cart that referenced art from the console root would find it missing here,
# so verify_assets below refuses to package that rather than ship a game with
# invisible sprites.
#
# The single-cart guarantee is enforced by verify_staging below, which reads
# the staged directory back off disk rather than trusting the copy loop. It
# fails the run rather than shipping a build with extra carts in it.
#
# Anything not understood here is passed straight through to
# dragonruby-publish, so --platforms, --package and friends work as usual.
set -eu

cd "$(dirname "$0")/.."
DIR=$(pwd)
ROOT=$(dirname "$DIR")
DRAGONRUBY_PUBLISH="$ROOT/dragonruby-publish"
CARTS_DIR="carts"
STAGE_ROOT="$ROOT/builds/cart-staging"

die() {
  echo "publish-cart: $*" >&2
  exit 1
}

# Cart names in the default gallery, sorted. Derived from the filesystem every
# run so a newly added cart is publishable without touching this script. A
# directory without an entry file is not a cart.
carts() {
  ls -d "$CARTS_DIR"/*/ 2>/dev/null | sed 's|/$||; s|.*/||' | sort | while read -r c; do
    [ -n "$(cart_entry "$CARTS_DIR/$c")" ] && echo "$c"
  done
}

# A cart's entry file, app/main.rb preferred. Same order the loader uses.
cart_entry() {
  if [ -f "$1/app/main.rb" ]; then
    echo "$1/app/main.rb"
  elif [ -f "$1/app/$(basename "$1").rb" ]; then
    echo "$1/app/$(basename "$1").rb"
  fi
}

# A name or a path, resolved the way the loader resolves it: a bare name is the
# gallery shorthand for carts/<name>.
cart_dir() {
  case "$1" in
    */*) printf '%s' "$1" ;;
    *) printf '%s/%s' "$CARTS_DIR" "$1" ;;
  esac
}

cart_exists() {
  [ -n "$(cart_entry "$(cart_dir "$1")")" ]
}

# A cart's TITLE constant, read without requiring it. Same trick --list uses,
# so the published title and the listed title can never disagree.
cart_title() {
  entry=$(cart_entry "$(cart_dir "$1")")
  [ -n "$entry" ] || return 0
  sed -n "s/^TITLE[[:space:]]*=[[:space:]]*['\"]\(.*\)['\"][[:space:]]*$/\1/p" \
    "$entry" | head -1
}

do_list() {
  echo "Publishable carts:"
  for c in $(carts); do
    t=$(cart_title "$c")
    [ -n "$t" ] && t="  -- $t"
    echo "  $c$t"
  done
  echo ""
  echo "Publish one with:  ./publish-cart <cart>"
}

# --- usage ----------------------------------------------------------------

[ $# -gt 0 ] || {
  echo "usage: ./publish-cart <cart> [--platforms=...] [--dry-run]" >&2
  echo "       ./publish-cart --list" >&2
  exit 2
}

if [ "$1" = "--list" ]; then
  do_list
  exit 0
fi

# Accepts a name or a path, like ./bin/run does. Whatever the cart is called here,
# it is staged (and pinned) as carts/<its directory name>, so a cart outside the
# gallery publishes under the same layout a gallery cart would.
CART_ARG="$1"
shift

CART_SRC=$(cart_dir "$CART_ARG")
CART=$(basename "$CART_SRC")

cart_exists "$CART_SRC" || {
  echo "publish-cart: no cart at '$CART_SRC'" >&2
  echo "publish-cart: available: $(carts | tr '\n' ' ')" >&2
  exit 1
}

DRY_RUN=0
PLATFORM_ARGS=""
for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=1 ;;
    --platforms=*) PLATFORM_ARGS="$arg" ;;
    --package|--package-with-remote-hotload|--only-package|"") ;;
    *) PLATFORM_ARGS="$PLATFORM_ARGS $arg" ;;
  esac
done

# Default to the host's desktop build; --platforms= overrides it.
[ -n "$PLATFORM_ARGS" ] || PLATFORM_ARGS="--platforms=linux-amd64"

# --- stage ----------------------------------------------------------------

STAGE="$STAGE_ROOT/$CART"

# Rebuild from scratch every time. A stale staging directory is exactly how a
# deleted cart ends up back inside a build, so it is removed rather than
# merged into.
rm -rf "$STAGE"
mkdir -p "$STAGE/app" "$STAGE/metadata"

# 1. The console library. The cart is meaningless without it.
cp -R app/console "$STAGE/app/console"

# 2. Exactly one cart, whole. Copying the directory rather than picking files
#    out of it is deliberate: the cart owns its sprites, sounds, maps and data,
#    so those have to travel with it. Named explicitly, so this loop cannot
#    drift into "copy whatever is in carts/".
mkdir -p "$STAGE/carts"
cp -R "$CART_SRC" "$STAGE/carts/$CART"

# 3. Fonts. DragonRuby reads these from the game root, so they cannot live
#    inside a cart; the console ships its own copy for a standalone build.
[ -f font.ttf ] && cp font.ttf "$STAGE/font.ttf"
[ -f tiny.ttf ] && cp tiny.ttf "$STAGE/tiny.ttf"

# 4. Metadata.
#
# DragonRuby reads metadata/game_metadata.txt from the GAME ROOT -- the path is
# hardcoded in the engine, so a cart can never have the engine read its own copy
# -- which is why this file is generated per cart instead of shipped as-is.
#
# The part that matters: DragonRuby loads the whole file into
# Cvars["game_metadata.*"]. The first six lines are read positionally (devid,
# devtitle, gameid, gametitle, version, icon), but every key after them is read
# by name and decides real behaviour -- hd, highdpi, orientation, aspect_mode,
# sprites_directory. Writing a fresh six-line file here would silently drop all
# of them, and the published build would stop matching the checkout you tested
# (a cart with highdpi=true in the console metadata builds at lowdpi, and
# nothing tells you).
#
# So: start from the console's file, or from the cart's own when it ships one,
# and rewrite only the cart-specific fields. Every other key is inherited.

meta_get() { sed -n "s/^$2=//p" "$1" | head -1; }
meta_set() { sed -i "s|^$2=.*|$2=$3|" "$1"; }

stage_metadata() {
  cart_meta="$CART_SRC/metadata/game_metadata.txt"
  base="metadata/game_metadata.txt"
  icon_root="."

  if [ -f "$cart_meta" ]; then
    # The cart states its own identity and its own engine settings.
    base="$cart_meta"
    icon_root="$CART_SRC"
    echo "publish-cart: using the metadata '$CART' ships"
  fi

  [ -f "$base" ] || die "no metadata to publish (looked for $base)"
  mkdir -p "$STAGE/metadata"
  cp "$base" "$STAGE/metadata/game_metadata.txt"
  target="$STAGE/metadata/game_metadata.txt"

  # The first six lines are positional, so a file missing one of them would
  # shift every value across. Refuse rather than publish a scrambled identity.
  missing=""
  for key in devid devtitle gameid gametitle version icon; do
    grep -q "^$key=" "$target" || missing="$missing $key"
  done
  [ -z "$missing" ] || die "metadata is missing required keys:$missing"

  if [ -f "$cart_meta" ]; then
    # Only fill in identity the cart left blank; do not overrule it.
    [ -n "$(meta_get "$target" gameid)" ] || meta_set "$target" gameid "$CART"
    [ -n "$(meta_get "$target" gametitle)" ] ||
      meta_set "$target" gametitle "$(cart_title "$CART_SRC")"
  else
    # Derived from the cart, so the published title and --list can never
    # disagree.
    meta_set "$target" gameid "${CART_GAMEID:-$CART}"
    meta_set "$target" gametitle "${CART_GAMETITLE:-$(cart_title "$CART")}"
  fi

  [ -z "${CART_GAMETITLE:-}" ] || meta_set "$target" gametitle "$CART_GAMETITLE"
  [ -z "${CART_VERSION:-}" ] || meta_set "$target" version "$CART_VERSION"
  [ -z "${CART_DEVID:-}" ] || meta_set "$target" devid "$CART_DEVID"
  [ -z "${CART_DEVTITLE:-}" ] || meta_set "$target" devtitle "$CART_DEVTITLE"

  # An icon with the cart wins; otherwise the one this metadata names. Copied
  # rather than referenced, because the staged build has no cart tree at the
  # path the source used.
  cart_icon="$CART_SRC/metadata/icon.png"
  if [ -f "$cart_icon" ]; then
    meta_set "$target" icon "metadata/icon.png"
    cp "$cart_icon" "$STAGE/metadata/icon.png"
  else
    icon_path=$(meta_get "$target" icon)
    if [ -n "$icon_path" ] && [ -f "$icon_root/$icon_path" ]; then
      mkdir -p "$STAGE/$(dirname "$icon_path")"
      cp "$icon_root/$icon_path" "$STAGE/$icon_path"
    fi
  fi
}

stage_metadata

# 6. An entry point that pins the cart. This is what makes "only this cart" a
#    property of the build rather than a promise in a README: the pin outranks
#    --cart and $CART, and a pinned cart that is missing aborts the boot
#    instead of quietly falling back to the default.
{
  echo "# GENERATED BY ./publish-cart -- DO NOT EDIT."
  echo "#"
  echo "# Single-cart build of '$CART'. The console library is unmodified; only this"
  echo "# entry point differs from a normal checkout."
  # The require list is READ FROM THE REAL app/main.rb rather than duplicated
  # here. A second copy of that list in this script is exactly how a published
  # build ends up missing a library file the development checkout has.
  grep "^require 'app/console/" app/main.rb
  cat <<'RUBY'

module Main
  def boot
    Console::CartLoader.new($args).pin('carts/__CART__').run
  end

  def tick
    Console.tick args
  end

  def shutdown
    Console.debug 'shutdown'
  end
end
RUBY
} | sed "s/__CART__/$CART/" > "$STAGE/app/main.rb"

# --- verify ---------------------------------------------------------------

# Read the staged directory back and confirm it holds one cart and one cart
# only. Deliberately independent of the copy above: if cp ever copied a
# directory, or a stale file survived, this is what catches it.
verify_staging() {
  staged=$(ls -d "$STAGE/carts"/*/ 2>/dev/null | sed 's|/$||; s|.*/||' | sort)
  count=$(printf '%s\n' "$staged" | grep -c . || true)
  expected="$CART"

  if [ "$count" -ne 1 ]; then
    echo "publish-cart: ABORT staging '$STAGE' has $count carts, expected 1:" >&2
    printf '%s\n' "$staged" | sed 's/^/    /' >&2
    return 1
  fi

  if [ "$staged" != "$expected" ]; then
    echo "publish-cart: ABORT staged cart is '$staged', expected '$expected'" >&2
    return 1
  fi

  if [ -z "$(cart_entry "$STAGE/carts/$CART")" ]; then
    echo "publish-cart: ABORT staged cart has no entry file" >&2
    return 1
  fi

  # The pin has to be in the entry point, or the build is merely a console
  # with one cart present rather than one that can only run that cart.
  if ! grep -q "pin('carts/$CART')" "$STAGE/app/main.rb"; then
    echo "publish-cart: ABORT staged main.rb does not pin '$CART'" >&2
    return 1
  fi

  # No other cart may be requireable from anywhere in the staged tree.
  #
  # The directory check above already proves no other cart's code is *present*;
  # this catches a cart reaching out and pulling one in. It matches require
  # calls rather than the bare string, because the console library documents
  # paths like 'carts/<name>/' in its own comments and those are not references.
  for other in $(carts); do
    [ "$other" = "$CART" ] && continue
    if grep -rlE "require[[:space:]]+['\"]carts/$other/" "$STAGE" >/dev/null 2>&1; then
      echo "publish-cart: ABORT staged build requires cart '$other'" >&2
      return 1
    fi
  done

  echo "publish-cart: verified 1 cart ('$CART') in $(basename "$STAGE")"
  return 0
}

# Every asset the cart names has to be inside the staged cart.
#
# The staged build has no console-root art to fall back on -- that is the whole
# point of staging -- so a path that does not resolve here is a file the cart
# borrows from the console, and it would ship as an invisible sprite. Failing
# here is the difference between "I forgot to move the art" and "the published
# game is missing a texture nobody noticed".
verify_assets() {
  cart_dir="$STAGE/carts/$CART"
  refs=$(grep -rhoE "['\"](sprites|sounds|maps|data)/[A-Za-z0-9_./-]+['\"]" \
    "$cart_dir" 2>/dev/null | tr -d "'\"" | sort -u)

  missing=""
  for a in $refs; do
    # Trailing slashes are directory probes, and a real directory satisfies them.
    [ -e "$cart_dir/$a" ] || missing="$missing $a"
  done

  if [ -n "$missing" ]; then
    echo "publish-cart: ABORT '$CART' uses assets it does not own:" >&2
    for m in $missing; do
      echo "    $m  (missing from $cart_dir)" >&2
    done
    echo "    move them into carts/$CART/, or the build will not have them" >&2
    return 1
  fi

  [ -n "$refs" ] && echo "publish-cart: verified $CART owns its referenced assets"
  return 0
}

verify_staging || die "refusing to package a build that is not single-cart"

# The console's own diagnostic cart is exempt from the asset check: it names
# paths deliberately, because it is the thing testing resolution -- files that
# do not exist, files that live at the console root, and files another cart
# owns. Every other cart is a game, and a game owns everything it references.
if [ "$CART" = "selftest" ]; then
  echo "publish-cart: '$CART' is the diagnostic cart; skipping the asset check"
else
  verify_assets || die "refusing to package a build with missing assets"
fi

if [ "$DRY_RUN" -eq 1 ]; then
  echo ""
  echo "staged tree:"
  find "$STAGE" -type f | sed "s|$STAGE/|  |" | sort
  echo ""
  echo "publish-cart: --dry-run, nothing packaged"
  exit 0
fi

# --- package --------------------------------------------------------------

[ -x "$DRAGONRUBY_PUBLISH" ] || die "cannot find $DRAGONRUBY_PUBLISH"

# dragonruby-publish resolves a relative game directory against the DragonRuby
# root it ships in, and silently fails to read metadata from an absolute one --
# so run it from the root and pass the staging dir relative to it.
REL_STAGE=$(printf '%s' "$STAGE" | sed "s|^$ROOT/||")

echo "publish-cart: packaging '$CART' -> $ROOT/builds"
cd "$ROOT"
# shellcheck disable=SC2086
"$DRAGONRUBY_PUBLISH" --only-package $PLATFORM_ARGS "$REL_STAGE"

echo ""
echo "publish-cart: done. artifacts are in $ROOT/builds"