#!/usr/bin/env bash
#
# cin90 installer — served at https://coderin90.com/cli/install.sh
#
# Usage:   curl -fsSL https://coderin90.com/cli/install.sh | bash
#
# Fetches the server-verified release manifest, picks the artifact matching
# the running platform, downloads it, verifies its sha256 against the manifest,
# and installs to ~/.local/bin/cin90 (no sudo). The manifest URL is a
# server-controlled constant baked in below — it points at the server-side gate
# (/cli/release/latest.json), NOT at raw S3, so the server's Ed25519 verify is
# the trust gate. A compromised S3 object that has not passed the gate cannot
# reach this installer.
set -euo pipefail

# Server-controlled. The manifest the gate serves is the api.Release shape
# (version, released_at, notes_url, artifacts[]), the same document cin90
# self-update fetches; install.sh additionally verifies the sha256 of the
# downloaded artifact against the manifest (transport integrity).
MANIFEST_URL="https://coderin90.com/cli/release/latest.json"

INSTALL_DIR="${HOME}/.local/bin"
BIN_NAME="cin90"

err() { echo "install: $*" >&2; exit 1; }

need() { command -v "$1" >/dev/null 2>&1 || err "required command not found: $1"; }
need uname
need curl
need cut
# awk reads the manifest (see below). It is POSIX and present on every system
# this script supports, which python3 is not.
need awk

# The sha256 tool differs by platform and neither name is universal: `shasum`
# is a perl script, absent from minimal/server/cloud/container images of
# Debian, Ubuntu and the RHEL family, while `sha256sum` is coreutils and absent
# from a stock macOS. So pick whichever exists instead of requiring one.
#
# This was `need shasum || need sha256sum`, which never fell through — `need`
# calls `err`, and `err` exits — so any Linux without perl died here, in a
# preflight for a command the download check below never runs on Linux (#289).
if command -v sha256sum >/dev/null 2>&1; then
    sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
    sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
    err "required command not found: sha256sum (or shasum)"
fi

# OS/arch → the manifest's artifact os/arch vocabulary. The manifest carries
# one row per (os, arch); an unsupported platform has no row and the installer
# exits non-zero rather than downloading a binary that will not run.
case "$(uname -s)" in
    Darwin*) os="darwin" ;;
    Linux*)  os="linux" ;;
    *)       err "unsupported OS: $(uname -s). cin90 ships for macOS and Linux." ;;
esac
case "$(uname -m)" in
    x86_64|amd64) arch="amd64" ;;
    arm64|aarch64) arch="arm64" ;;
    *)             err "unsupported arch: $(uname -m)." ;;
esac

echo "Installing cin90 for ${os}/${arch}..."

# Fetch the verified manifest. -fsS fails the pipe on a non-2xx response or a
# transfer error; the gate answers 503 when no verified release is available,
# which surfaces here as a clear failure rather than an empty file.
#
# No --compressed here, unlike the artifact download below, and the asymmetry
# is deliberate: this URL is our own origin, which compresses only what a
# client asks for, while the artifact comes from a bucket that serves whatever
# it was uploaded with. Asking would also put the zlib-less-curl failure (see
# below) in front of the manifest, where it would report as an unreachable
# server.
manifest="$(curl -fsSL "$MANIFEST_URL")" || err "could not fetch the release manifest"

# Pick the matching artifact. Three strings are read — version, url, sha256 —
# from the one artifact row whose os/arch match, so this needs a field
# extractor, not a JSON parser, and awk is a far safer dependency than python3:
# /usr/bin/python3 on a Mac without the Xcode Command Line Tools is a stub that
# prompts to install them rather than running Python, and the RHEL-family
# minimal images ship no python3 at all (#289).
#
# The document is split on braces, which is sound for this manifest because no
# string value contains one. A part is an artifact row when it carries both an
# "os" and an "arch"; everything is read by key, so neither row order nor the
# unread fields (signature, signature_key_id) can shift the result. The
# manifest is generated in another repo and reaches this script verbatim, so
# neither its key order nor its key set is something the parse gets to assume.
# Signature verification already happened server-side; the sha256 below is
# checked against the row this picks.
#
# "version" is read from the top-level object with every nested object first
# collapsed away, so a nested "version" — a "minimum" block, say — cannot
# shadow the real one. It is deliberately NOT allowed to fail the install: it
# is used once, in the closing message, and taking a whole cohort offline over
# a cosmetic field would be a worse bug than the one this fixes. url and
# sha256 are load-bearing and do fail.
#
# The three exits are three different messages, because they are three
# different problems for the student. 1 is "this manifest has builds, none for
# you". 2 is "this is not a manifest" — a gateway error page, an empty
# artifacts list, a matching row with no url — which hits every student at once
# and must never be reported as an unsupported platform. Anything else is awk
# itself dying, which is neither, and says so rather than guessing.
status=0
row="$(printf '%s' "$manifest" | awk -v want_os="$os" -v want_arch="$arch" '
    function field(s, key,   rest, q) {
        if (!match(s, "\"" key "\"[ \t]*:[ \t]*\"")) return ""
        rest = substr(s, RSTART + RLENGTH)
        q = index(rest, "\"")
        if (q == 0) return ""
        return substr(rest, 1, q - 1)
    }
    { doc = doc $0 " " }
    END {
        top = doc
        sub(/^[^{]*\{/, "", top)
        sub(/\}[^}]*$/, "", top)
        collapsed = 1
        while (collapsed > 0) collapsed = gsub(/\{[^{}]*\}/, "", top)
        version = field(top, "version")
        if (version == "") version = "unknown"

        rows = 0
        n = split(doc, part, /[{}]/)
        for (i = 1; i <= n; i++) {
            row_os = field(part[i], "os")
            row_arch = field(part[i], "arch")
            if (row_os == "" || row_arch == "") continue
            rows = rows + 1
            if (row_os != want_os || row_arch != want_arch) continue
            url = field(part[i], "url")
            sha = field(part[i], "sha256")
            # Both load-bearing fields must be exactly one well-formed
            # token, so that field ORDER below is not load-bearing. `read`
            # gives only its last variable the remainder of the line, so a
            # value carrying whitespace either vanishes or truncates
            # depending on where it sits — a manifest defect that used to
            # fail closed by accident of ordering. Checked here instead, so
            # no reordering can reintroduce it.
            #
            # `length()` and a negated class rather than /^[0-9a-f]{64}$/:
            # mawk needs -W re-interval before it honours {n}, and silently
            # treats the braces as literals without it.
            if (url == "" || url ~ /[ \t]/) exit 2
            if (length(sha) != 64 || sha ~ /[^0-9a-fA-F]/) exit 2
            # version LAST: `read` gives its final variable the whole
            # remainder of the line, so a version containing a space stays
            # in `version` instead of shifting url and sha along by a
            # field. url and sha cannot contain spaces; a version can, and
            # nothing upstream forbids it.
            print url, sha, version
            exit 0
        }
        if (rows == 0) exit 2
        exit 1
    }
')" || status=$?
case "$status" in
    0) ;;
    1) err "no release artifact for ${os}/${arch}" ;;
    2) err "could not read the release manifest" ;;
    *) err "could not read the release manifest (awk exited ${status})" ;;
esac
read -r url sha256 version <<<"$row"
# Belt and braces. awk now refuses a blank url itself, so this should be
# unreachable — but it is the last thing standing between a manifest defect and
# a download of the empty string, which real curl rejects with exit 2, the one
# code mapped to "please update curl". Being wrong there sends every affected
# student off to upgrade a curl that is fine.
[ -n "$url" ] || err "could not read the release manifest"

# Download to a temp file and verify sha256 against the manifest. The signature
# has already been verified by the server before the manifest was served; this
# check guards the transport between server and student, which the signature
# does not cover (the student does not have the keyring pinned locally until
# after the first install).
#
# --compressed is load-bearing, not an optimisation (#291). Release artifacts
# may be stored gzip-compressed under their raw key names, tagged
# `Content-Encoding: gzip` — that is ~60% off every student's download. S3 does
# not content-negotiate: it returns the stored bytes with the stored header no
# matter what the request asked for, so the object decides, not this script.
# Plain `curl -fsSL` writes that gzip stream to disk verbatim, and the sha256
# below — which commits to the RAW binary — then mismatches. It fails closed,
# but it fails, and "sha256 mismatch" reads as a tampered release rather than a
# missing flag. With --compressed curl decodes the response, and an
# uncompressed object (every release published so far) arrives unchanged, so
# the one flag covers both shapes.
#
# The two exits are two different problems, as with the manifest parse above.
# curl exits 2 for option and init errors and essentially never for transport
# ones, so 2 here means this curl was built without the zlib it needs to honour
# --compressed: nothing was downloaded, nothing is wrong with the release, and
# reporting a download failure would send the student to check a network that
# is fine. Every other non-zero is a real transfer problem.
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
status=0
curl -fsSL --compressed "$url" -o "$tmp" || status=$?
case "$status" in
    0) ;;
    2) err "this curl cannot decode compressed downloads (--compressed); please update curl" ;;
    *) err "could not download the release artifact" ;;
esac
got="$(sha256_of "$tmp")"
[ "$got" = "$sha256" ] || err "sha256 mismatch: manifest $sha256, download $got"

mkdir -p "$INSTALL_DIR"
install -m 0755 "$tmp" "${INSTALL_DIR}/${BIN_NAME}"

echo
# "unknown" is what the parse above yields when the manifest names no version.
# The binary is installed and verified either way; say so plainly rather than
# printing "Installed cin90 unknown", which reads like a failure.
if [ "$version" = "unknown" ]; then
    echo "Installed cin90 to ${INSTALL_DIR}/${BIN_NAME} (the manifest did not name a version)."
else
    echo "Installed cin90 ${version} to ${INSTALL_DIR}/${BIN_NAME}."
fi
case ":${PATH}:" in
    *":${INSTALL_DIR}:"*) ;;
    *) cat <<HINT

Add ${INSTALL_DIR} to your PATH (restart your shell afterwards):
  echo 'export PATH="${INSTALL_DIR}:\$PATH"' >> ~/.zshrc   # macOS default
  echo 'export PATH="${INSTALL_DIR}:\$PATH"' >> ~/.bashrc  # Linux default
HINT
       ;;
esac
echo
echo "Next: run 'cin90 login' to connect your account."
