#!/usr/bin/env bash
# Playground launcher: put this machine on the newest build, then start it.
#
#   playground                  newest build
#   playground -host            ... and host a session
#   playground -join <addr>     ... and join one
#   playground --version 0.1.0  a specific build, pinned
#   playground --list           what is published
#
# Set up a machine once, then it keeps itself current:
#
#   curl -fsSL https://git.kennethblack.me/api/packages/kblack0610/generic/unity-core-playground/latest/play.sh \
#     -o ~/.local/bin/playground && chmod +x ~/.local/bin/playground && playground
set -euo pipefail

# The game this launcher installs: scripts/build/stamp-launchers.sh writes a game build's own here.
GAME_PACKAGE=barrage
GAME_DIR=barrage
# The binary inside Playground.app: the product name Unity gives it (UpdateInstaller agrees).
GAME_BINARY="Shape City"

OWNER="${PLAYGROUND_OWNER:-kblack0610}"
PACKAGE="${PLAYGROUND_PACKAGE:-$GAME_PACKAGE}"
# The same registry by two routes, LAN first. At home git.kblab.me is about ten
# times faster; git.kennethblack.me is the one that works from anywhere else.
read -r -a HOSTS <<< "${PLAYGROUND_HOSTS:-https://git.kblab.me https://git.kennethblack.me}"
ROOT="${PLAYGROUND_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/$GAME_DIR}"
KEEP_VERSIONS="${PLAYGROUND_KEEP:-3}"

ORIGINAL_ARGS=("$@")

die() { echo "error: $*" >&2; exit 1; }

case "$(uname -s)" in
    Linux)  PLATFORM=linux; EXE=playground ;;
    # The binary inside the app bundle; UpdateInstaller starts the same one.
    Darwin) PLATFORM=mac; EXE="Playground.app/Contents/MacOS/$GAME_BINARY" ;;
    *)      die "unsupported system '$(uname -s)'" ;;
esac

PIN_VERSION=""
DO_UPDATE=1
DO_LIST=0
GAME_ARGS=()

while [ $# -gt 0 ]; do
    case "$1" in
        --version)   [ $# -ge 2 ] || die "--version needs a version"; PIN_VERSION="$2"; shift 2 ;;
        --version=*) PIN_VERSION="${1#*=}"; shift ;;
        --list)      DO_LIST=1; shift ;;
        --no-update) DO_UPDATE=0; shift ;;
        -h|--help)   sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        # Anything else belongs to the game: -host, -join <addr>, and whatever
        # the build grows later. The launcher stays out of its argument space.
        *)           GAME_ARGS+=("$1"); shift ;;
    esac
done

for tool in curl unzip; do
    command -v "$tool" >/dev/null 2>&1 || die "$tool is not installed"
done

command -v jq >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1 \
    || die "need jq or python3 to read the registry"

api=""      # the host that answered

# Chosen once, in this shell. Picking it inside api_get would be tidier to read
# but the calls happen in $(...) subshells, so the choice would be thrown away
# and every later call would build a URL with no host in it.
pick_host() {
    local h
    for h in "${HOSTS[@]}"; do
        if curl -fsS --connect-timeout 3 --max-time 15 -o /dev/null \
            "$h/api/v1/packages/$OWNER?type=generic&q=$PACKAGE&limit=1" 2>/dev/null; then
            api="$h"
            return 0
        fi
    done
    return 1
}

api_get() {
    [ -n "$api" ] || return 1
    curl -fsS --connect-timeout 3 --max-time 60 "$api$1" 2>/dev/null
}

# A published version is either a real build (digits and dots) or something with
# a suffix, like 0.2.0-grapple, which is a branch build. Only a plain version is
# picked up automatically; a branch build is opt-in with --version, so pushing
# one never drags the other machines onto it.
newest_version() {
    local listing
    listing="$(api_get "/api/v1/packages/$OWNER?type=generic&q=$PACKAGE&limit=100")" || return 1
    printf '%s' "$listing" | if command -v jq >/dev/null 2>&1; then
        jq -r --arg p "$PACKAGE" '
            [ .[] | select(.name == $p) | select(.version | test("^[0-9]+(\\.[0-9]+)*$")) ]
            | sort_by(.created_at) | reverse | .[0].version // empty'
    else
        python3 -c 'import json,re,sys
rows = [r for r in json.load(sys.stdin)
        if r.get("name") == sys.argv[1] and re.fullmatch(r"[0-9]+(\.[0-9]+)*", r["version"])]
if rows:
    print(sorted(rows, key=lambda r: r["created_at"])[-1]["version"])' "$PACKAGE"
    fi
}

# Ask the registry what the file is actually called rather than rebuilding the
# build script's naming convention here, where it would quietly rot.
zip_name() {
    local files
    files="$(api_get "/api/v1/packages/$OWNER/generic/$PACKAGE/$1/files")" || return 1
    printf '%s' "$files" | if command -v jq >/dev/null 2>&1; then
        jq -r --arg s "-$PLATFORM.zip" '.[] | select(.name | endswith($s)) | .name' | head -n1
    else
        python3 -c 'import json,sys
for r in json.load(sys.stdin):
    if r["name"].endswith(sys.argv[1]):
        print(r["name"]); break' "-$PLATFORM.zip"
    fi
}

version_rows() {
    if command -v jq >/dev/null 2>&1; then
        jq -r --arg p "$PACKAGE" '.[] | select(.name == $p) | "\(.version)\t\(.created_at)"'
    else
        python3 -c 'import json,sys
for r in json.load(sys.stdin):
    if r.get("name") == sys.argv[1]:
        print("%s\t%s" % (r["version"], r["created_at"]))' "$PACKAGE"
    fi
}

list_versions() {
    local listing rows numbered newest v d mark
    listing="$(api_get "/api/v1/packages/$OWNER?type=generic&q=$PACKAGE&limit=100")" \
        || die "could not reach the registry"
    rows="$(printf '%s' "$listing" | version_rows)"

    numbered="$(printf '%s\n' "$rows" | awk -F'\t' '$1 ~ /^[0-9]/' | sort -Vr)"
    newest="$(printf '%s\n' "$numbered" | head -n1 | cut -f1)"

    while IFS=$'\t' read -r v d; do
        [ -n "$v" ] || continue
        mark=""
        if [ "$v" = "$newest" ]; then mark="  <- newest, what you get by default"; fi
        if [ -x "$ROOT/versions/$v/$EXE" ]; then mark="$mark  [installed here]"; fi
        printf '  %-18s %s%s\n' "$v" "$d" "$mark"
    done <<< "$numbered"

    # 'latest' is a real version to the registry, so it lands in this list next
    # to the numbered ones and looks like a build of its own. It is not: its
    # files are overwritten on every publish. Saying so here stops that costing
    # someone a puzzled minute every time they run --list.
    if printf '%s\n' "$rows" | grep -q '^latest'; then
        echo
        echo "  latest             a bookmarkable alias, not a build of its own. Its files are"
        echo "                     replaced on every publish, so right now it holds $newest."
        echo "                     The date the registry shows for it is when the alias was"
        echo "                     first created, never when it was last updated."
    fi
}

# The launcher is published beside the builds, so a fix to it reaches every
# machine the same way a build does. The env guard makes a re-exec loop
# impossible even if the published copy somehow never matches.
self_update() {
    [ -z "${PLAYGROUND_SELF_UPDATED:-}" ] || return 0

    local self tmp
    self="${BASH_SOURCE[0]}"
    [ -f "$self" ] || return 0
    self="$(cd "$(dirname "$self")" && pwd)/$(basename "$self")"
    [ -w "$self" ] || return 0

    tmp="$(mktemp)"
    if ! curl -fsSL --connect-timeout 3 --max-time 60 -o "$tmp" \
        "$api/api/packages/$OWNER/generic/$PACKAGE/latest/play.sh" 2>/dev/null; then
        rm -f "$tmp"
        return 0
    fi
    # A truncated or error-page download must never overwrite a working launcher.
    if ! head -n1 "$tmp" | grep -q '^#!/usr/bin/env bash'; then
        rm -f "$tmp"
        return 0
    fi
    if cmp -s "$tmp" "$self"; then
        rm -f "$tmp"
        return 0
    fi

    echo "==> updating the launcher"
    chmod +x "$tmp"
    mv "$tmp" "$self"
    PLAYGROUND_SELF_UPDATED=1 exec "$self" ${ORIGINAL_ARGS+"${ORIGINAL_ARGS[@]}"}
}

install_version() {
    local version="$1" file dest tmp
    dest="$ROOT/versions/$version"

    if [ -x "$dest/$EXE" ]; then
        echo "==> $version is current"
        return 0
    fi

    file="$(zip_name "$version")" || die "could not list the files for $version"
    [ -n "$file" ] || die "$version has no $PLATFORM build"

    mkdir -p "$ROOT/cache"
    tmp="$ROOT/cache/$file"

    # A version directory is immutable, so resuming a partial download is safe
    # and a publish happening mid-transfer cannot swap the bytes underneath us.
    echo "==> downloading $version ($PLATFORM)"
    curl -fL -C - --progress-bar --max-time 900 \
        -o "$tmp" "$api/api/packages/$OWNER/generic/$PACKAGE/$version/$file" \
        || die "download failed"

    rm -rf "$dest.part" "$dest"
    mkdir -p "$dest.part"
    # unzip verifies each entry's CRC, so a corrupt download fails here rather
    # than becoming a half-installed build. Unpack aside, then move into place.
    unzip -q -o "$tmp" -d "$dest.part" || { rm -rf "$dest.part"; die "could not unpack $file"; }
    [ -f "$dest.part/$EXE" ] || { rm -rf "$dest.part"; die "$file has no $EXE in it"; }
    # The zip carries modes, but not every extractor restores them.
    chmod +x "$dest.part/$EXE"
    mv "$dest.part" "$dest"
    rm -f "$tmp"
    echo "    installed to $dest"
}

# Newest by version, not by file time: the most recently unpacked build is
# whatever was pinned last, which is not the one to fall back to.
newest_installed() {
    local d
    for d in "$ROOT"/versions/*/; do
        [ -x "$d$EXE" ] || continue
        basename "${d%/}"
    done | sort -V | tail -n1
}

# Pruning does go by file time, deliberately: what to throw away is a question
# about what is still being used, not about which number is largest.
prune_versions() {
    local dirs=() d
    while IFS= read -r d; do dirs+=("$d"); done < <(ls -1dt "$ROOT"/versions/*/ 2>/dev/null)
    [ ${#dirs[@]} -gt "$KEEP_VERSIONS" ] || return 0
    for d in "${dirs[@]:$KEEP_VERSIONS}"; do
        rm -rf "$d"
        echo "    removed old build $(basename "${d%/}")"
    done
}

if [ "$DO_LIST" = 1 ]; then
    pick_host || die "could not reach the registry"
    list_versions
    exit 0
fi

VERSION=""
if [ "$DO_UPDATE" = 1 ]; then
    if pick_host; then
        VERSION="${PIN_VERSION:-$(newest_version || true)}"
        if [ -n "$VERSION" ]; then
            self_update
            install_version "$VERSION"
            prune_versions
        else
            echo "==> the registry has no published build yet" >&2
        fi
    else
        echo "==> could not reach the registry; using what is already installed" >&2
    fi
fi

if [ -z "$VERSION" ]; then
    VERSION="${PIN_VERSION:-$(newest_installed || true)}"
    [ -n "$VERSION" ] || die "nothing installed yet and the registry is unreachable"
fi

DEST="$ROOT/versions/$VERSION"
[ -x "$DEST/$EXE" ] || die "$VERSION is not installed (--list shows what exists)"

# Hosting means the others need this machine's address, and hunting for it is the
# slow part of getting four people into one session.
for arg in ${GAME_ARGS+"${GAME_ARGS[@]}"}; do
    if [ "$arg" = "-host" ] && command -v tailscale >/dev/null 2>&1; then
        addr="$(tailscale ip -4 2>/dev/null | head -n1 || true)"
        [ -z "$addr" ] || echo "==> others join with: playground -join $addr"
        break
    fi
done

echo "==> playground $VERSION"
exec "$DEST/$EXE" ${GAME_ARGS+"${GAME_ARGS[@]}"}
