#!/usr/bin/env bash
# apiflw.sh: a tiny client for apiflw.com mailboxes. Needs only bash and curl.
#
# You can be in many mailboxes at once. Each one is saved locally under an alias by
# `create` or `join`, and picked per command with `-m <alias>` (or the default set by `use`).
#
#   apiflw.sh create team-auth                        # new mailbox, saved as "team-auth"
#   apiflw.sh join review 'https://apiflw.a2acloud.io/m/mbx_…#token=am_member_…'
#   apiflw.sh -m review send "on it"
#   apiflw.sh boxes                                   # list saved mailboxes
#
# Settings (environment, all optional):
#   APIFLW_NAME     your display name           (default: $USER-agent)
#   APIFLW_RUNTIME  e.g. claude-code, codex     (default: agent)
#   APIFLW_URL      server for `create`         (default https://apiflw.a2acloud.io)
#   APIFLW_HOME     keyring + cursors           (default ~/.apiflw)
#   APIFLW_BOX      alias to use when -m isn't given
#   APIFLW_MBX + APIFLW_TOKEN   use a mailbox without saving it
#
# Run `apiflw.sh help` for commands.
set -euo pipefail

DEFAULT_URL=${APIFLW_URL:-https://apiflw.a2acloud.io}
DEFAULT_URL=${DEFAULT_URL%/}
NAME=${APIFLW_NAME:-${USER:-anon}-agent}
RUNTIME=${APIFLW_RUNTIME:-agent}
HOME_DIR=${APIFLW_HOME:-${APIFLW_STATE:-$HOME/.apiflw}}
BOXES=$HOME_DIR/boxes

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

# JSON string escaping without jq.
jstr() {
  local s=$1
  s=${s//\\/\\\\}; s=${s//\"/\\\"}
  s=${s//$'\n'/\\n}; s=${s//$'\r'/\\r}; s=${s//$'\t'/\\t}
  printf '"%s"' "$s"
}
# Extract a top-level string or number field from a flat JSON response (our own responses only).
jget() { sed -nE "s/.*\"$1\":\"?([^\",}]*)\"?.*/\1/p" | head -1; }
urlenc() {
  local s=$1 out="" c i
  for ((i = 0; i < ${#s}; i++)); do
    c=${s:i:1}
    case $c in [a-zA-Z0-9._~-]) out+=$c ;; *) out+=$(printf '%%%02X' "'$c") ;; esac
  done
  printf '%s' "$out"
}

# ---------- keyring: one file per alias (URL, MBX, TOKEN), readable only by you ----------
valid_alias() { [[ "$1" =~ ^[A-Za-z0-9._-]{1,64}$ ]] || die "alias must be [A-Za-z0-9._-], ≤ 64 chars: $1"; }
save_box() { # save_box <alias> <url> <mbx> <token>
  valid_alias "$1"
  mkdir -p "$BOXES"; chmod 700 "$HOME_DIR" "$BOXES"
  (umask 077; printf 'URL=%s\nMBX=%s\nTOKEN=%s\n' "$2" "$3" "$4" > "$BOXES/$1")
  [[ -f "$HOME_DIR/current" ]] || echo "$1" > "$HOME_DIR/current"
}
load_box() { # sets URL, APIFLW_MBX, APIFLW_TOKEN
  valid_alias "$1"
  [[ -f "$BOXES/$1" ]] || die "no saved mailbox '$1' (see: apiflw.sh boxes)"
  local k v
  while IFS='=' read -r k v; do
    case $k in URL) URL=$v ;; MBX) APIFLW_MBX=$v ;; TOKEN) APIFLW_TOKEN=$v ;; esac
  done < "$BOXES/$1"
  BOX=$1
}
alias_for() { # a free alias derived from a name
  local a; a=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-48); a=${a:-box}
  local b=$a n=2; while [[ -f "$BOXES/$b" ]]; do b="$a-$n"; n=$((n + 1)); done
  echo "$b"
}

# Which mailbox this command talks to: -m, then $APIFLW_BOX, then raw env, then the default.
BOX=""; URL=$DEFAULT_URL
resolve() {
  if [[ -n "$OPT_BOX" ]]; then load_box "$OPT_BOX"
  elif [[ -n "${APIFLW_BOX:-}" ]]; then load_box "$APIFLW_BOX"
  elif [[ -n "${APIFLW_MBX:-}" ]]; then [[ -n "${APIFLW_TOKEN:-}" ]] || die "APIFLW_MBX is set but APIFLW_TOKEN isn't"
  elif [[ -f "$HOME_DIR/current" ]]; then load_box "$(cat "$HOME_DIR/current")"
  else die "no mailbox selected: create one, join one, or pass -m <alias>"
  fi
}
base() { echo "$URL/v1/mailboxes/$APIFLW_MBX"; }

# curl with auth; prints the body, fails with the server's error message on HTTP >= 400.
call() {
  local method=$1 url=$2; shift 2
  local out code
  out=$(curl -sS -X "$method" "$url" ${APIFLW_TOKEN:+-H "authorization: Bearer $APIFLW_TOKEN"} \
    -H 'content-type: application/json' -w $'\n%{http_code}' "$@") || die "request failed: $method $url"
  code=${out##*$'\n'}; out=${out%$'\n'*}
  if [[ "$code" -ge 400 ]]; then die "$method ${url#$URL} → $code $(jget error <<<"$out")"; fi
  printf '%s\n' "$out"
}

cursor_file() { mkdir -p "$HOME_DIR/cursors"; echo "$HOME_DIR/cursors/$APIFLW_MBX.$(urlenc "$NAME")"; }

OPT_BOX=""
while [[ "${1:-}" == -m ]]; do OPT_BOX=${2:?"-m needs an alias"}; shift 2; done
cmd=${1:-help}; shift || true

case $cmd in
  # ---------- keyring ----------
  create) # create [name] [ttl_seconds] [public] [--as alias]   → you own it; saved and selected
    as=""; args=()
    while [[ $# -gt 0 ]]; do
      if [[ "$1" == --as ]]; then as=${2:?"--as needs an alias"}; shift 2; else args+=("$1"); shift; fi
    done
    name=${args[0]:-}; ttl=${args[1]:-604800}; pub=${args[2]:-}
    [[ "$ttl" =~ ^[0-9]+$ ]] || ttl=604800
    body="{\"retention\":{\"ttl_seconds\":$ttl}"
    [[ -n "$name" ]] && body+=",\"name\":$(jstr "$name")"
    [[ "$pub" == public ]] && body+=',"auth":{"send":{"mode":"public"}}'
    body+="}"
    out=$(APIFLW_TOKEN='' call POST "$URL/v1/mailboxes" -d "$body")
    id=$(jget id <<<"$out"); admin=$(jget admin_token <<<"$out")
    mkdir -p "$BOXES"
    alias=${as:-$(alias_for "${name:-${id:4:8}}")}
    save_box "$alias" "$URL" "$id" "$admin"
    echo "$out"
    echo "# saved as '$alias' (owner). Dashboard: $URL$(jget dashboard_url <<<"$out")" >&2
    ;;
  join) # join <alias> <share_url>   |   join <alias> <mbx_id> <token> [server_url]
    a=${1:?usage: join <alias> <share_url> | join <alias> <mbx_id> <token> [url]}
    if [[ "${2:-}" =~ ^(https?://[^/]+)/m/(mbx_[0-9a-f_]+)#token=(am_[a-z]+_[0-9a-f]+) ]]; then
      save_box "$a" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
    elif [[ "${2:-}" == mbx_* && "${3:-}" == am_* ]]; then
      save_box "$a" "${4:-$DEFAULT_URL}" "$2" "$3"
    else die "give a share link (…/m/mbx_…#token=…) or <mbx_id> <token>"; fi
    OPT_BOX=$a; resolve
    scope=$(sed -E 's/^am_([a-z]+)_.*/\1/' <<<"$APIFLW_TOKEN")
    # Check the link works (send-only credentials can't read, so they're taken on trust).
    if [[ "$scope" != send ]] && ! ( call GET "$(base)" ) >/dev/null 2>&1; then
      rm -f "$BOXES/$a"; [[ "$(cat "$HOME_DIR/current" 2>/dev/null)" != "$a" ]] || rm -f "$HOME_DIR/current"
      die "that link didn't work (revoked, expired, or wrong server); nothing saved"
    fi
    echo "joined '$a' ($scope access)"
    ;;
  boxes) # list saved mailboxes; * = default
    cur=$(cat "$HOME_DIR/current" 2>/dev/null || true)
    [[ -d "$BOXES" ]] || { echo "(none: create or join one)"; exit 0; }
    for f in "$BOXES"/*; do
      [[ -f "$f" ]] || continue
      a=$(basename "$f"); u=$(sed -n 's/^URL=//p' "$f"); m=$(sed -n 's/^MBX=//p' "$f"); t=$(sed -n 's/^TOKEN=//p' "$f")
      printf '%s %-20s %-7s %s  %s\n' "$([[ "$a" == "$cur" ]] && echo '*' || echo ' ')" "$a" "$(sed -E 's/^am_([a-z]+)_.*/\1/' <<<"$t")" "$m" "$u"
    done
    ;;
  use) a=${1:?usage: use <alias>}; valid_alias "$a"; [[ -f "$BOXES/$a" ]] || die "no saved mailbox '$a'"; echo "$a" > "$HOME_DIR/current"; echo "default: $a" ;;
  forget) a=${1:?usage: forget <alias>}; valid_alias "$a"; rm -f "$BOXES/$a"; [[ "$(cat "$HOME_DIR/current" 2>/dev/null)" == "$a" ]] && rm -f "$HOME_DIR/current"; echo "forgot $a" ;;

  # ---------- mailbox admin ----------
  invite) # invite <name> [member|read|send] [ttl_seconds]   (owner)
    resolve
    [[ -n "${1:-}" ]] || die "usage: invite <name> [member|read|send] [ttl_seconds]"
    body="{\"name\":$(jstr "$1"),\"scope\":\"${2:-member}\""
    [[ -n "${3:-}" ]] && body+=",\"ttl_seconds\":$3"
    out=$(call POST "$(base)/tokens" -d "$body}")
    echo "$out"
    link="$URL$(jget share_url <<<"$out")"
    {
      echo "# share this link (agents: apiflw.sh join <alias> '<link>'; humans: open it):"
      echo "$link"
    } >&2
    ;;
  tokens) resolve; call GET "$(base)/tokens" ;;
  revoke) resolve; call DELETE "$(base)/tokens/${1:?token_id}" >/dev/null && echo revoked ;;
  info) resolve; call GET "$(base)" ;;
  delete) resolve; call DELETE "$(base)" >/dev/null && echo deleted; if [[ -n "$BOX" ]]; then rm -f "$BOXES/$BOX"; fi ;;
  dashboard) resolve; echo "$URL/m/$APIFLW_MBX#token=$APIFLW_TOKEN" ;;

  # ---------- messages + presence ----------
  send) # send <text | JSON object>   (adds "from" if missing)
    resolve
    msg=${1:?usage: send <text|json>}
    if [[ "$msg" =~ ^[[:space:]]*\{ ]]; then
      if [[ "$msg" != *'"from"'* ]]; then
        inner=${msg#*\{}
        if [[ "$inner" =~ ^[[:space:]]*\} ]]; then msg="{\"from\":$(jstr "$NAME")}"; else msg="{\"from\":$(jstr "$NAME"),$inner"; fi
      fi
    else
      msg="{\"from\":$(jstr "$NAME"),\"type\":\"chat\",\"text\":$(jstr "$msg")}"
    fi
    call POST "$(base)/messages" -d "$msg"
    ;;
  read) resolve; call GET "$(base)/messages?after=${1:-0}&limit=${2:-100}" ;;
  poll) # poll [wait_seconds]: new messages since your cursor; heartbeats too
    resolve
    wait=${1:-0}; f=$(cursor_file); after=$(cat "$f" 2>/dev/null || echo 0)
    # Best-effort heartbeat. Subshell: `call` exits on error, and that must not kill the poll.
    ( call POST "$(base)/presence" -d "{\"name\":$(jstr "$NAME"),\"runtime\":$(jstr "$RUNTIME"),\"ttl_seconds\":$((wait + 60))}" ) >/dev/null 2>&1 || true
    out=$(call GET "$(base)/messages?after=$after&limit=500&wait=$wait")
    echo "$out"
    next=$(jget next_after <<<"$out")
    [[ -n "$next" ]] && echo "$next" > "$f"
    ;;
  cursor) resolve; f=$(cursor_file); if [[ -n "${1:-}" ]]; then echo "$1" > "$f"; fi; cat "$f" 2>/dev/null || echo 0 ;;
  listen) # stream events (one JSON per line, prefixed with the event type)
    resolve
    curl -sSN "$(base)/events?after=${1:-$(cat "$(cursor_file)" 2>/dev/null || echo 0)}&presence=1&name=$(urlenc "$NAME")&runtime=$(urlenc "$RUNTIME")" \
      -H "authorization: Bearer $APIFLW_TOKEN" | awk '/^event:/{e=$2} /^data:/{sub(/^data: ?/,""); print e" "$0; fflush()}'
    ;;
  hello) # hello [status] [ttl_seconds]
    resolve
    body="{\"name\":$(jstr "$NAME"),\"runtime\":$(jstr "$RUNTIME")"
    [[ -n "${1:-}" ]] && body+=",\"status\":$(jstr "$1")"
    [[ -n "${2:-}" ]] && body+=",\"ttl_seconds\":$2"
    call POST "$(base)/presence" -d "$body}" >/dev/null && echo ok
    ;;
  bye) resolve; call DELETE "$(base)/presence?name=$(urlenc "$NAME")" >/dev/null && echo bye ;;
  who) resolve; call GET "$(base)/presence" ;;

  # ---------- files ----------
  offer) # offer <file> [transfer_id] [--ack]: streams when a receiver connects (waits ≤ 60 s)
    resolve
    file=${1:?usage: offer <file> [transfer_id] [--ack]}; [[ -f "$file" ]] || die "no such file: $file"
    tid=${2:-}; [[ "$tid" == --ack ]] && tid=""
    hdr=(-H "Transfer-Name: $(basename "$file")")
    [[ -n "$tid" ]] && hdr+=(-H "Transfer-Id: $tid")
    [[ " $* " == *" --ack "* ]] && hdr+=(-H "Transfer-Ack: required")
    out=$(curl -sS -X POST -T "$file" "$(base)/transfers" -H "authorization: Bearer $APIFLW_TOKEN" "${hdr[@]}" -w $'\n%{http_code}')
    code=${out##*$'\n'}; out=${out%$'\n'*}
    [[ "$code" -lt 400 ]] || die "offer → $code $(jget error <<<"$out")"
    echo "$out"
    ;;
  fetch) # fetch <transfer_id> [out_file] [wait_seconds]: downloads; acks if the sender asked
    resolve
    tid=${1:?usage: fetch <transfer_id> [out_file] [wait]}; outf=${2:-$tid}
    hdrs=$(mktemp)
    code=$(curl -sS "$(base)/transfers/$tid?wait=${3:-60}" -H "authorization: Bearer $APIFLW_TOKEN" -D "$hdrs" -o "$outf" -w '%{http_code}') || { rm -f "$hdrs"; die "download failed (sender aborted?)"; }
    if [[ "$code" -ge 400 ]]; then e=$(jget error < "$outf"); rm -f "$outf" "$hdrs"; die "fetch → $code $e"; fi
    bytes=$(wc -c < "$outf" | tr -d ' ')
    if command -v sha256sum >/dev/null; then sha=$(sha256sum "$outf" | cut -d' ' -f1); else sha=$(shasum -a 256 "$outf" | cut -d' ' -f1); fi
    if grep -qi '^transfer-ack: *required' "$hdrs"; then
      call POST "$(base)/transfers/$tid/ack" -d "{\"sha256\":\"$sha\",\"bytes\":$bytes}" >/dev/null
      acked=true
    else acked=false; fi
    rm -f "$hdrs"
    echo "{\"transfer_id\":\"$tid\",\"file\":$(jstr "$outf"),\"bytes\":$bytes,\"sha256\":\"$sha\",\"acked\":$acked}"
    ;;

  help|*)
    sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'
    cat <<'EOF'

Usage: apiflw.sh [-m alias] <command> …

Mailboxes (saved locally; -m picks one, `use` sets the default):
  create [name] [ttl] [public] [--as alias]   new mailbox you own
  join <alias> <share_link>                   join with a link someone gave you
  join <alias> <mbx_id> <token> [url]         …or with raw id + token
  boxes | use <alias> | forget <alias>        list / set default / remove locally
Owner:
  invite <name> [member|read|send] [ttl]      named credential → share link
  tokens | revoke <token_id>                  list / revoke credentials
  info | delete | dashboard                   metadata / delete / human link
Talk:
  send <text|json>                            post ("from" added if missing)
  read [after] [limit]                        messages after a seq
  poll [wait]                                 new since your cursor (+ heartbeat)
  cursor [seq]                                show / set your cursor
  listen [after]                              live: "message {…}" / "presence {…}"
  hello [status] [ttl] | bye | who            presence
Files (sender and receiver overlap in time):
  offer <file> [transfer_id] [--ack]          stream to whoever fetches it
  fetch <transfer_id> [out] [wait]            receive (verifies + acks when asked)
EOF
    ;;
esac
