#!/bin/sh
# ramo.sh - Ramo trial helper. Subcommands: signup put get ls rm usage
# Requires: curl, openssl, awk. Uses aws-cli when available; falls back to
# openssl SigV4 for every operation, including ls (a single-page
# ListObjectsV2 call parsed with sed/awk). https://docs.ramo.io/trial
# `curl -fsSL <url> | sh -s -- signup` self-installs to ./ramo.sh first;
# see self_install() below.
set -eu
# Fixed locale: awk's decimal separator and byte-vs-char handling in the
# percent-encoder below both depend on LC_ALL, and this script's output
# format (decimal points, one byte per char) is only correct under C.
export LC_ALL=C

RAMO_API="${RAMO_API:-https://api.ramo.io}"
CRED_FILE="${RAMO_CRED_FILE:-$HOME/.config/ramo/credentials}"
# Script-managed aws-cli config, written alongside the credentials file so
# aws_s3 calls are path-style and region-scoped regardless of whatever the
# user's own ~/.aws/config says (or doesn't say).
AWS_CFG_FILE="${RAMO_AWS_CFG_FILE:-$(dirname "$CRED_FILE")/aws-config}"
# Canonical source this script downloads itself from during a piped
# signup (see self_install below). Override for staging or a remapped
# host, same pattern as RAMO_API/RAMO_CRED_FILE/RAMO_AWS_CFG_FILE.
SCRIPT_URL="${RAMO_SCRIPT_URL:-https://ramo.io/ramo.sh}"

die() { printf 'ramo.sh: %s\n' "$1" >&2; exit 1; }

load_creds() {
  [ -f "$CRED_FILE" ] || die "no credentials; run: sh ramo.sh signup"
  # shellcheck disable=SC1090
  . "$CRED_FILE"
}

# Minimal JSON field extractor (flat object, string or bare-token values).
# Matches the last "field": occurrence, then strips to a bare value: a
# quoted string is cut at the closing quote; a bare token (number, null)
# is cut at the next comma or closing brace.
json_field() {
  val=$(printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*//p")
  case "$val" in
    \"*)
      val=${val#\"}
      val=${val%%\"*}
      ;;
    *)
      val=${val%%,*}
      val=${val%%\}*}
      val=$(printf '%s' "$val" | sed -e 's/[[:space:]]*$//')
      ;;
  esac
  printf '%s' "$val"
}

# Escape a value for embedding inside a JSON string literal: backslashes
# first (so later substitutions' backslashes aren't re-escaped), then
# double quotes, then the control characters a trial name can plausibly
# carry (literal newline, tab, carriage return). RS is set to a NUL byte
# so awk reads the whole value as a single record even if it contains
# embedded newlines, instead of splitting on them and losing everything
# past the first one. Minimal on purpose - not a full JSON encoder.
# Every other control character is rejected by signup's own guard before
# a name ever reaches here, so the two stay coherent.
json_escape() {
  printf '%s' "$1" | awk 'BEGIN { RS = "\0" }
    {
      gsub(/\\/, "\\\\")
      gsub(/"/, "\\\"")
      gsub(/\r/, "\\r")
      gsub(/\t/, "\\t")
      gsub(/\n/, "\\n")
      printf "%s", $0
    }'
}

# Bytes -> whole or one-decimal GB string ("1" or "1.5"), no unit suffix.
gb_str() {
  awk -v b="$1" 'BEGIN {
    g = b / 1073741824
    if (g == int(g)) { printf "%d", g } else { printf "%.1f", g }
  }'
}

# Persists this script to ./ramo.sh for a piped invocation
# (`curl ... | sh -s -- signup`): there, $0 is the shell's own name, not
# a path ending in ramo.sh, so nothing is left on disk for the
# subsequent put/get/ls/rm/usage calls the docs and skill.md describe.
# Downloads to a temp name in cwd and mv's into place, the same idiom
# get() uses below: curl -o opens (and so follows) the destination, so a
# ./ramo.sh that is a symlink would have curl write through it into
# whatever it points at; mv replaces the symlink itself instead. -d
# follows symlinks too, so a ./ramo.sh that is a directory (plain or
# via a symlink) is refused rather than silently moving the downloaded
# copy inside it. That check runs before the download, though, so a
# directory swapped into ./ramo.sh during the network round trip - a
# racing local process - would slip past it: mv onto an existing
# directory moves the source inside it rather than replacing it, and
# reports success either way. The post-mv check below closes that gap by
# confirming what's left at $target is a real, non-symlink file, not the
# still-untouched directory a raced mv would leave behind. -- before the
# URL keeps a $SCRIPT_URL value starting with "-" from being read as a
# curl option instead of the URL argument it is.
self_install() {
  target="./ramo.sh"
  [ -d "$target" ] && die "$target is a directory; remove it or run signup from elsewhere"
  tmp=$(mktemp ./.ramo-sh-install.XXXXXX) || die "self-install failed: could not create a temp file"
  if ! curl -fsSL -o "$tmp" -- "$SCRIPT_URL"; then
    rm -f "$tmp"
    die "self-install failed: could not download $SCRIPT_URL"
  fi
  # A successful download is not a usable download: an HTML error page,
  # an empty body, or a response reshaped into something else entirely
  # would all satisfy curl -f and still leave a broken helper on disk.
  # The first line must be exactly the shebang this file starts with.
  if [ "$(head -1 -- "$tmp")" != "#!/bin/sh" ]; then
    rm -f "$tmp"
    die "self-install failed: downloaded file from $SCRIPT_URL is not ramo.sh (unexpected content)"
  fi
  # And it must parse: a shebang-only truncation (cut mid-function, say)
  # would pass the check above but still leave every later subcommand
  # broken. sh -n only parses, never executes, so this costs nothing.
  if ! sh -n "$tmp" 2>/dev/null; then
    rm -f "$tmp"
    die "self-install failed: downloaded file from $SCRIPT_URL did not parse (truncated download?)"
  fi
  # sh -n only proves the bytes present are well-formed, not that all of
  # them arrived: a truncation landing exactly at a completed construct
  # (the end of a function, or the final dispatch line) parses cleanly
  # too, while silently dropping everything after the cut - every
  # subcommand defined later, or the dispatch itself. Catch that by
  # requiring the downloaded copy's last line to match the sentinel this
  # file ends with. Built from two concatenated parts rather than typed
  # out whole, so this check line itself can never double as a false
  # sentinel: were a download to truncate exactly here, the resulting
  # last line would be this assignment, not the bare comment it builds,
  # so tail -1 could not mistake it for a genuine end-of-file marker.
  sentinel_prefix="# ramo.sh"
  sentinel="${sentinel_prefix} end"
  if [ "$(tail -n 1 -- "$tmp")" != "$sentinel" ]; then
    rm -f "$tmp"
    die "self-install failed: downloaded file from $SCRIPT_URL is truncated (missing end marker)"
  fi
  mv "$tmp" "$target"
  [ -f "$target" ] && [ ! -L "$target" ] ||
    die "self-install failed: $target is not a regular file after install"
}

signup() {
  # Flags and the optional trial name can come in either order, so scan
  # every argument rather than assuming position 1 is the name.
  force=0
  name=""
  for arg in "$@"; do
    case "$arg" in
      --force) force=1 ;;
      *) name="$arg" ;;
    esac
  done
  [ -n "$name" ] || name="agent-demo"

  # Self-install before anything else touches the network. $0 ends in
  # ramo.sh whenever this file is already on disk and invoked as such
  # (`sh ramo.sh ...` or `sh /any/path/ramo.sh ...`); it does not when
  # piped, where $0 is the shell's own name. Fail loud on any
  # self-install failure, before the signup POST below: a user must
  # never end up holding trial credentials with no persisted helper to
  # use them with (the alternative - soldiering on with a subcommand
  # they then can't repeat - is worse than refusing outright).
  case "$0" in
    */ramo.sh|ramo.sh) : ;;
    *) self_install ;;
  esac

  # Reject any control character (U+0001-U+001F, including \r \t \n -
  # never intentional in what becomes a bucket label) before the JSON
  # body is built. json_escape() below only escapes what can legitimately
  # appear; this guard is what keeps everything else out.
  case "$name" in
    *[[:cntrl:]]*)
      die "trial name contains control characters; use letters, digits, dots and dashes"
      ;;
  esac

  esc_name=$(json_escape "$name")

  # Refuse to clobber an existing trial's credentials before touching the
  # network: a re-run (accidental or scripted) must not silently orphan
  # the previous bucket's keys. --force or RAMO_FORCE=1 opts in.
  if [ -f "$CRED_FILE" ] && [ "$force" != 1 ] && [ "${RAMO_FORCE:-0}" != 1 ]; then
    die "credentials already exist at $CRED_FILE; re-run with --force (or RAMO_FORCE=1) to overwrite"
  fi

  # Capture body and status separately (no -f) so a non-201 response can
  # be diagnosed instead of just failing; body_file is removed as soon as
  # its contents are read, on every path below.
  body_file=$(mktemp) || die "signup failed: could not create a temp file"
  status=$(curl -sS -o "$body_file" -w '%{http_code}' -X POST "$RAMO_API/api/portal/trials" \
    -H 'content-type: application/json' \
    -d "{\"name\": \"$esc_name\"}") || {
    rm -f "$body_file"
    die "signup request failed (network error?)"
  }
  resp=$(cat "$body_file")
  rm -f "$body_file"

  if [ "$status" != "201" ]; then
    detail=$(json_field "$resp" detail)
    if [ -n "$detail" ]; then
      die "signup failed (HTTP $status): $detail"
    else
      die "signup failed (HTTP $status): $(printf '%s' "$resp" | cut -c1-200)"
    fi
  fi

  endpoint=$(json_field "$resp" endpoint)
  bucket=$(json_field "$resp" bucket)
  access_key=$(json_field "$resp" access_key)
  secret_key=$(json_field "$resp" secret_key)
  portal_token=$(json_field "$resp" portal_token)
  quota_bytes=$(json_field "$resp" quota_bytes)
  expires_at=$(json_field "$resp" expires_at)

  # Fail loud rather than write a partial or empty credentials file: an
  # unparseable or reshaped response should stop here, not silently hand
  # back credentials that look complete but fail later in ls or usage.
  # All seven persisted fields are required, not just the four S3 keys.
  if [ -z "$endpoint" ] || [ -z "$bucket" ] || [ -z "$access_key" ] || [ -z "$secret_key" ] ||
    [ -z "$portal_token" ] || [ -z "$quota_bytes" ] || [ -z "$expires_at" ]; then
    die "signup response missing one of endpoint/bucket/access_key/secret_key/portal_token/quota_bytes/expires_at; no credentials written"
  fi

  # umask before mkdir so the credentials directory itself is 700, not
  # just the file inside it.
  umask 077
  mkdir -p "$(dirname "$CRED_FILE")"
  {
    printf 'RAMO_ENDPOINT=%s\n' "$endpoint"
    printf 'RAMO_BUCKET=%s\n' "$bucket"
    printf 'RAMO_ACCESS_KEY=%s\n' "$access_key"
    printf 'RAMO_SECRET_KEY=%s\n' "$secret_key"
    printf 'RAMO_PORTAL_TOKEN=%s\n' "$portal_token"
    printf 'RAMO_QUOTA_BYTES=%s\n' "$quota_bytes"
    printf 'RAMO_EXPIRES_AT=%s\n' "$expires_at"
  } > "$CRED_FILE"
  ensure_aws_cfg

  printf 'Trial ready: bucket %s at %s (%s GB, expires %s)\n' \
    "$bucket" "$endpoint" "$(gb_str "$quota_bytes")" "$expires_at"
  printf 'Credentials saved to %s\n' "$CRED_FILE"
}

have_aws() { command -v aws >/dev/null 2>&1; }

# Write the script-managed aws-cli config once, if it isn't there yet:
# path-style addressing plus the region, nested under [default] the way
# aws-cli's own config format requires. Idempotent, so aws_s3 can call it
# on every invocation without rewriting the file each time.
ensure_aws_cfg() {
  [ -f "$AWS_CFG_FILE" ] && return 0
  umask 077
  mkdir -p "$(dirname "$AWS_CFG_FILE")"
  printf '[default]\nregion = us-east-1\ns3 =\n    addressing_style = path\n' > "$AWS_CFG_FILE"
}

aws_s3() {
  ensure_aws_cfg
  AWS_ACCESS_KEY_ID="$RAMO_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$RAMO_SECRET_KEY" \
    AWS_CONFIG_FILE="$AWS_CFG_FILE" AWS_DEFAULT_REGION=us-east-1 \
    aws s3 "$@" --endpoint-url "$RAMO_ENDPOINT"
}

ls_cmd() {
  json=0
  for arg in "$@"; do
    case "$arg" in
      --json) json=1 ;;
      *) die "usage: ramo.sh ls [--json]" ;;
    esac
  done

  load_creds
  [ -n "${RAMO_QUOTA_BYTES:-}" ] || die "credentials file missing quota; re-run signup"
  quota_gb=$(gb_str "$RAMO_QUOTA_BYTES")

  # Capture the listing before printing anything: a failed call must not
  # render an "empty bucket" table, it must die with the real error.
  # aws-cli paginates internally so its path is never truncated; the
  # SigV4 fallback below fetches a single ListObjectsV2 page (first 1000
  # keys) and surfaces IsTruncated as a trailing note instead.
  truncated=0
  if have_aws; then
    if ! listing=$(aws_s3 ls "s3://$RAMO_BUCKET/" --recursive 2>&1); then
      die "listing failed: $listing"
    fi
  else
    body_file=$(mktemp) || die "listing failed: could not create a temp file"
    if ! sigv4_list "$body_file"; then
      rm -f "$body_file"
      die "listing failed (network or auth error)"
    fi
    xml=$(cat "$body_file")
    rm -f "$body_file"
    listing=$(parse_list_xml "$xml")
    case "$xml" in
      *'<IsTruncated>true</IsTruncated>'*) truncated=1 ;;
    esac
  fi

  mode="table"
  [ "$json" = 1 ] && mode="json"
  printf '%s' "$listing" | format_listing "$mode" "$quota_gb" "$truncated"
}

# Renders a captured listing (both the aws-cli path and the SigV4
# fallback normalize to the same "date time size key..." lines on
# stdin, so this is the one place either path's output is built) as
# either the canonical markdown table or a JSON array. human() is
# declared before the main pattern block: some awks refuse a forward
# reference. Modified time is truncated to HH:MM to match the
# canonical format exactly. The key is recovered by stripping the
# date+time+size prefix off the raw record rather than rejoining
# fields split on whitespace: awk's default field splitter collapses
# runs of spaces, so a key with repeated or leading whitespace would
# come back wrong (a regression this file's tests pin: a key with two
# consecutive spaces round-trips exactly). size/n are seeded in
# BEGIN so an empty bucket renders "0 objects · 0 B", not " B" from an
# uninitialized string.
format_listing() {
  mode="$1"; quota="$2"; truncated="$3"
  awk -v mode="$mode" -v quota="$quota" -v truncated="$truncated" -v bucket="$RAMO_BUCKET" '
    BEGIN { size = 0; n = 0; json_rows = ""; first = 1 }
    function human(b,    s) {
      if (b >= 1073741824) { s = sprintf("%.1f GB", b / 1073741824) }
      else if (b >= 1048576) { s = sprintf("%.1f MB", b / 1048576) }
      else if (b >= 1024) { s = sprintf("%.1f KB", b / 1024) }
      else { s = sprintf("%d", b) " B" }
      return s
    }
    # Markdown table cells use "|" as a column separator, so a key
    # containing one must be escaped or it silently adds a column.
    function md_escape(s) {
      gsub(/\|/, "\\|", s)
      return s
    }
    function json_escape_str(s) {
      gsub(/\\/, "\\\\", s)
      gsub(/"/, "\\\"", s)
      return s
    }
    NF > 0 {
      # Both listing sources (aws-cli fixed-width `s3 ls` output and
      # the printf in parse_list_xml above) emit exactly one literal
      # space between the size digits and the key, however much
      # padding separates the earlier fields. Matching up through that
      # one space and taking the rest of the record verbatim recovers
      # the key exactly, including any whitespace it contains.
      match($0, /^[^ ]+ +[^ ]+ +[0-9]+ /)
      key = substr($0, RSTART + RLENGTH)
      size += $3; n += 1
      modified = $1 " " substr($2, 1, 5) " UTC"
      if (mode == "json") {
        row = sprintf("{\"key\":\"%s\",\"size\":%d,\"modified\":\"%s\"}", json_escape_str(key), $3, modified)
        json_rows = json_rows (first ? "" : ",") row
        first = 0
      } else {
        rows = rows sprintf("| %s | %s | %s |\n", md_escape(key), human($3), modified)
      }
    }
    END {
      is_truncated = (truncated == "1") ? "true" : "false"
      if (mode == "json") {
        printf "{\"objects\":[%s],\"truncated\":%s}\n", json_rows, is_truncated
      } else {
        noun = (n == 1) ? "object" : "objects"
        printf "%s · %d %s · %s / %s GB trial quota\n\n", bucket, n, noun, human(size), quota
        printf "| Object | Size | Modified |\n|--------|------|----------|\n%s", rows
        if (truncated == "1") { printf "note: showing first 1000 objects\n" }
      }
    }'
}

# SigV4 fallback (used for every operation without aws-cli): sign with
# openssl. Path-style requests against $RAMO_ENDPOINT; payload hash is
# the SHA-256 of the body; string-to-sign and signing-key chain per AWS
# SigV4.
#
# Exposure note: openssl's -macopt passes the HMAC key as a command-line
# argument, so during fallback signing the trial secret key, and each
# key in the derived signing chain below, is briefly visible in this
# process's argv - readable by other local users via `ps` or
# /proc/<pid>/cmdline on a shared machine. aws-cli passes credentials
# through the environment instead and has no such exposure, so it is
# preferred whenever installed (see have_aws above). On a shared
# machine, install aws-cli (pip install awscli) rather than relying on
# this fallback. None of k_date/k_region/k_service/k_signing/signature
# below is ever printed or logged; keep it that way in any future change
# to this function or to sigv4_authorization.
hmac_hex() { printf '%s' "$2" | openssl dgst -sha256 -mac HMAC -macopt "$1" | sed 's/^.* //'; }
sha256_file() { openssl dgst -sha256 "$1" | sed 's/^.* //'; }

# Derives the Authorization header value for a canonical request: hashes
# it, builds the string-to-sign, walks the date/region/service/request
# signing-key chain, and signs. Shared by sigv4_request (put/get/rm) and
# sigv4_list (ls fallback) so that chain - the security-sensitive part -
# lives in exactly one place.
sigv4_authorization() {
  canonical="$1"; amz_date="$2"; date_stamp="$3"; signed_headers="$4"
  creq_hash=$(printf '%s' "$canonical" | openssl dgst -sha256 | sed 's/^.* //')
  sts="AWS4-HMAC-SHA256
$amz_date
$date_stamp/us-east-1/s3/aws4_request
$creq_hash"
  k_date=$(hmac_hex "key:AWS4$RAMO_SECRET_KEY" "$date_stamp")
  k_region=$(hmac_hex "hexkey:$k_date" "us-east-1")
  k_service=$(hmac_hex "hexkey:$k_region" "s3")
  k_signing=$(hmac_hex "hexkey:$k_service" "aws4_request")
  signature=$(hmac_hex "hexkey:$k_signing" "$sts")
  printf 'AWS4-HMAC-SHA256 Credential=%s/%s/us-east-1/s3/aws4_request, SignedHeaders=%s, Signature=%s' \
    "$RAMO_ACCESS_KEY" "$date_stamp" "$signed_headers" "$signature"
}

# Percent-encode a key for use in an S3 path: RFC 3986 unreserved
# characters pass through, "/" is preserved as a path separator, and
# everything else (including spaces) is percent-encoded. Used for both
# the canonical URI and the request URL below, so the two always agree.
urlencode_path() {
  printf '%s' "$1" | awk '
    BEGIN {
      for (i = 0; i <= 255; i++) { ord[sprintf("%c", i)] = i }
      safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~/-"
    }
    {
      out = ""
      for (i = 1; i <= length($0); i++) {
        c = substr($0, i, 1)
        if (index(safe, c) > 0) { out = out c }
        else { out = out sprintf("%%%02X", ord[c]) }
      }
      printf "%s", out
    }'
}

sigv4_request() {
  # $1 method, $2 key, $3 body-file ("" for none), $4 output ("-" for stdout)
  method="$1"; key="$2"; body="$3"; out="$4"
  enc_key=$(urlencode_path "$key")
  host="${RAMO_ENDPOINT#https://}"; host="${host#http://}"
  # RAMO_SIGV4_DATE overrides the request timestamp when set; test-only
  # seam so signature tests can pin a known amz_date, never set this for
  # a real request.
  amz_date="${RAMO_SIGV4_DATE:-$(date -u +%Y%m%dT%H%M%SZ)}"
  date_stamp=$(printf '%s' "$amz_date" | cut -c1-8)
  payload_hash="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  [ -n "$body" ] && payload_hash=$(sha256_file "$body")
  canonical="$method
/$RAMO_BUCKET/$enc_key

host:$host
x-amz-content-sha256:$payload_hash
x-amz-date:$amz_date

host;x-amz-content-sha256;x-amz-date
$payload_hash"
  auth=$(sigv4_authorization "$canonical" "$amz_date" "$date_stamp" "host;x-amz-content-sha256;x-amz-date")
  # --path-as-is: the signature above covers the literal request path,
  # so curl must not collapse or normalize any dot segments in it before
  # sending.
  curl -fsS --path-as-is -X "$method" "$RAMO_ENDPOINT/$RAMO_BUCKET/$enc_key" \
    -H "Authorization: $auth" -H "x-amz-date: $amz_date" \
    -H "x-amz-content-sha256: $payload_hash" \
    ${body:+--data-binary "@$body"} -o "$out"
}

# SigV4 fallback for `ls`: a single ListObjectsV2 page (list-type=2,
# first 1000 keys) against the bucket root. Unlike sigv4_request above,
# the canonical request here carries a non-empty canonical query string
# instead of a key in the path.
sigv4_list() {
  out="$1"
  host="${RAMO_ENDPOINT#https://}"; host="${host#http://}"
  amz_date="${RAMO_SIGV4_DATE:-$(date -u +%Y%m%dT%H%M%SZ)}"
  date_stamp=$(printf '%s' "$amz_date" | cut -c1-8)
  payload_hash="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  canonical="GET
/$RAMO_BUCKET
list-type=2
host:$host
x-amz-content-sha256:$payload_hash
x-amz-date:$amz_date

host;x-amz-content-sha256;x-amz-date
$payload_hash"
  auth=$(sigv4_authorization "$canonical" "$amz_date" "$date_stamp" "host;x-amz-content-sha256;x-amz-date")
  curl -fsS --path-as-is -X GET "$RAMO_ENDPOINT/$RAMO_BUCKET?list-type=2" \
    -H "Authorization: $auth" -H "x-amz-date: $amz_date" \
    -H "x-amz-content-sha256: $payload_hash" \
    -o "$out"
}

# Parses a ListObjectsV2 response body into the same "date time size
# key" shape aws-cli's `s3 ls --recursive` emits, so format_listing
# renders both paths identically. Works on minified (no inter-tag
# whitespace) XML by first splitting adjacent tags onto their own line;
# only Key/Size/LastModified are read, everything else (ETag,
# StorageClass, top-level Name/Prefix/...) is ignored. Key is minimally
# entity-decoded; &amp; is decoded last so a literal "&lt;" in a key
# (encoded as &amp;lt;) round-trips instead of becoming "<".
parse_list_xml() {
  printf '%s' "$1" | sed 's/></>\n</g' | awk -v sq="'" '
    function decode_entities(s) {
      gsub(/&lt;/, "<", s)
      gsub(/&gt;/, ">", s)
      gsub(/&quot;/, "\"", s)
      gsub(/&#39;/, sq, s)
      gsub(/&amp;/, "\\&", s)
      return s
    }
    /^<Contents>/ { key = ""; size = ""; modified = ""; next }
    /^<Key>/ {
      key = $0
      sub(/^<Key>/, "", key)
      sub(/<\/Key>$/, "", key)
      key = decode_entities(key)
      next
    }
    /^<Size>/ {
      size = $0
      sub(/^<Size>/, "", size)
      sub(/<\/Size>$/, "", size)
      next
    }
    /^<LastModified>/ {
      lm = $0
      sub(/^<LastModified>/, "", lm)
      sub(/<\/LastModified>$/, "", lm)
      # ISO8601 "YYYY-MM-DDTHH:MM:SS.sssZ" -> "YYYY-MM-DD HH:MM:SS".
      modified = substr(lm, 1, 10) " " substr(lm, 12, 8)
      next
    }
    /^<\/Contents>/ {
      if (key != "" && size != "") { printf "%s %s %s\n", modified, size, key }
      next
    }
  '
}

put() {
  [ $# -ge 1 ] || die "usage: ramo.sh put <file> [key]"
  load_creds
  file="$1"; key="${2:-$(basename "$file")}"
  [ -f "$file" ] || die "no such file: $file"
  if have_aws; then
    aws_s3 cp "$file" "s3://$RAMO_BUCKET/$key"
  else
    sigv4_request PUT "$key" "$file" /dev/null
    printf 'uploaded %s to %s\n' "$file" "$key"
  fi
}

get() {
  [ $# -ge 1 ] || die "usage: ramo.sh get <key> [dest]"
  load_creds
  key="$1"; dest="${2:-$(basename "$key")}"
  if have_aws; then
    aws_s3 cp "s3://$RAMO_BUCKET/$key" "$dest"
  else
    # Download to a temp file beside dest and mv into place only on
    # success, so a failed request (its S3 XML error body) never lands
    # in dest; curl's -f suppresses most of this already, but temp+mv
    # doesn't depend on that flag's exact behavior across curl versions.
    # mktemp (not "$$") so the name isn't predictable by another local
    # user racing to plant a symlink at the same path.
    tmp="$(mktemp "$(dirname "$dest")/.ramo-get.XXXXXX")"
    if sigv4_request GET "$key" "" "$tmp"; then
      mv "$tmp" "$dest"
      printf 'downloaded %s to %s\n' "$key" "$dest"
    else
      rm -f "$tmp"
      die "download failed: $key"
    fi
  fi
}

rm_cmd() {
  [ $# -ge 1 ] || die "usage: ramo.sh rm <key>"
  load_creds
  key="$1"
  if have_aws; then
    aws_s3 rm "s3://$RAMO_BUCKET/$key"
  else
    sigv4_request DELETE "$key" "" /dev/null
    printf 'removed %s\n' "$key"
  fi
}

usage_cmd() {
  load_creds
  curl -fsS -H "Authorization: Bearer $RAMO_PORTAL_TOKEN" "$RAMO_API/api/portal/usage"
  printf '\n'
}

main() {
  if [ $# -eq 0 ]; then
    cmd=""
  else
    cmd="$1"
    shift
  fi
  case "$cmd" in
    signup) signup "$@" ;;
    put) put "$@" ;;
    get) get "$@" ;;
    ls) ls_cmd "$@" ;;
    rm) rm_cmd "$@" ;;
    usage) usage_cmd ;;
    *) die "usage: ramo.sh signup [name] [--force] | put <file> [key] | get <key> [dest] | ls [--json] | rm <key> | usage" ;;
  esac
}

# self_install() above compares a downloaded copy's last line against
# the sentinel below, so a truncation landing exactly on a completed
# construct (a finished function, or this dispatch line) still gets
# caught even though it would otherwise parse cleanly under sh -n.
# Keep the sentinel comment the literal last line of this file: nothing
# after it, no trailing blank line.
main "$@"

# ramo.sh end
