#!/usr/bin/env bash
# bts — a small BTS API client for the bts-setup skill.
#
# Needs bash and curl, nothing else. It prints raw JSON from the API so
# Claude can read it, EXCEPT where a response would carry a secret value
# (env vars, the approved API key); those paths never print values.
#
#   bts login                 start device-code login (prints URL + code)
#   bts login --wait          wait for the approval, then save the key
#   bts whoami | logout
#   bts get|delete <path>
#   bts post|put|patch <path> [<json> | @<file>]
#   bts app                   GitHub App status + install URL (no auth)
#   bts catalog               addon types, versions, plan gates (no auth)
#   bts lookup <owner/repo>   BTS projects already linked to a repo
#   bts app-status <projectId> [--wait [timeoutSecs]]
#                             per repo, asked of GitHub live: is the App installed AND
#                             delivering to this project (ready)? If not, why, and the
#                             install link. --wait polls until every repo is ready.
#   bts verify-access <projectId>  prove the project can read its repos (lets BTS use the App)
#   bts import-yaml <nexos.yaml> [<teamId>]   create a project from YAML
#   bts apply-yaml <projectId> <nexos.yaml>   re-apply YAML to a project
#   bts validate-yaml <projectId> <nexos.yaml>
#   bts env-list <projectId>  keys + flags only, values are never printed
#   bts env-push <projectId> <dotenv-file> [--keys K1,K2] [--secret K1,K2 | --secret-all]
#                             [--exclude-from-workflows] [--allow K1,K2 | --allow-injected]
#                             (values go file → API, never printed)
#   bts svc-env-push <serviceId> <dotenv-file> [same flags]   one container only
#   bts seed-sql <projectId> <file.sql>       default Postgres seed (first deploy of each env)
#   bts seed <projectId> <file> [addon] [instance]
#                                             text seed for any addon that takes one
#                                             (postgres SQL, meilisearch JSON), ≤10 MB;
#                                             [instance] = a named instance (e.g. ANALYTICS)
#   bts seed-backup <projectId> <file.backup> [instance]
#                                             same, from a pg_dump -Fc archive (≤100 MB)
#   bts deploy <projectId> <branch>           manual deploy of a branch
#   bts wait <deploymentId> [timeoutSecs]     poll until done / failed
#   bts logs <deploymentId> [lines]           tail of the build log
#
# Credentials: $BTS_API_KEY, else ~/.bts/credentials.json (0600).
# API: $BTS_API_URL, else the one saved at login, else https://api.test.buildingswell.com

set -euo pipefail

DEFAULT_API_URL="https://api.test.buildingswell.com"
CONFIG_DIR="${BTS_CONFIG_DIR:-$HOME/.bts}"
CREDS="$CONFIG_DIR/credentials.json"
PENDING="$CONFIG_DIR/pending-login.json"

die() { printf 'bts: %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "needs '$1' on PATH"; }
need curl

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
chmod 700 "$TMP_DIR"

# First string value of a top-level-ish field in flat JSON on stdin.
json_field() {
  sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}

# Boolean value (true|false) of a field in flat JSON on stdin; empty if absent.
json_bool() {
  sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*(true|false).*/\1/p' | head -n 1
}

# JSON-encode stdin as one string literal (quotes included). sed does the
# escaping because awk's backslash handling in gsub replacements differs
# between implementations; awk only joins the lines with a literal \n.
TAB="$(printf '\t')"
CR="$(printf '\r')"
json_string() {
  sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e "s/$TAB/\\\\t/g" -e "s/$CR//g" \
    | awk 'BEGIN { printf "\"" } NR > 1 { printf "%s", "\\n" } { printf "%s", $0 } END { printf "\"" }'
}

# Decode the string value of field $1 from a JSON document on stdin
# (\n \t \" \\ \/ handled; \uXXXX left as-is). Linear: gsub, not a
# per-character loop, since a build log can be megabytes.
json_string_value() {
  awk -v field="$1" '
    BEGIN { RS = "\001"; key = "\"" field "\"" }
    {
      i = index($0, key); if (!i) exit 1
      s = substr($0, i + length(key))
      sub(/^[[:space:]]*:[[:space:]]*"/, "", s)
      gsub(/\\\\/, "\003", s)
      gsub(/\\"/, "\004", s)
      e = index(s, "\""); if (e) s = substr(s, 1, e - 1)
      gsub(/\\n/, "\n", s); gsub(/\\t/, "\t", s); gsub(/\\r/, "", s); gsub(/\\\//, "/", s)
      gsub(/\004/, "\"", s)
      # Restore escaped backslashes without split() (BSD awk also splits
      # on newlines) or a "\\" gsub replacement (differs between awks).
      out = ""
      while ((k = index(s, "\003")) > 0) { out = out substr(s, 1, k - 1) "\\"; s = substr(s, k + 1) }
      printf "%s", out s
    }'
}

api_url() {
  if [ -n "${BTS_API_URL:-}" ]; then printf '%s' "${BTS_API_URL%/}"; return; fi
  if [ -f "$CREDS" ]; then
    local saved; saved="$(json_field apiUrl < "$CREDS")"
    if [ -n "$saved" ]; then printf '%s' "${saved%/}"; return; fi
  fi
  printf '%s' "$DEFAULT_API_URL"
}

api_key() {
  if [ -n "${BTS_API_KEY:-}" ]; then printf '%s' "$BTS_API_KEY"; return 0; fi
  [ -f "$CREDS" ] || return 1
  local k; k="$(json_field apiKey < "$CREDS")"
  [ -n "$k" ] || return 1
  printf '%s' "$k"
}

# request METHOD PATH [BODY|@FILE] — body to stdout, non-zero on HTTP >= 400.
# The key goes through a 0600 header file so it never shows up in `ps`.
request() {
  local method="$1" path="$2" body="${3-}" auth="${4-auth}"
  local out="$TMP_DIR/out" hdr="$TMP_DIR/hdr"
  local args=(-sS -o "$out" -w '%{http_code}' -X "$method" -H "Accept: application/json")
  if [ "$auth" = "auth" ]; then
    local key; key="$(api_key)" || die "not logged in — run: bts login"
    ( umask 077; printf 'Authorization: Bearer %s\n' "$key" > "$hdr" )
    args+=(-H "@$hdr")
  fi
  # --data-binary reads a file for "@path" and sends anything else verbatim.
  if [ -n "$body" ]; then args+=(-H "Content-Type: application/json" --data-binary "$body"); fi
  local code
  code="$(curl "${args[@]}" "$(api_url)$path")" || { rm -f "$hdr"; die "network error: $method $path"; }
  rm -f "$hdr"
  cat "$out"
  [ -s "$out" ] && echo
  if [ "$code" -ge 400 ] 2>/dev/null; then
    printf 'bts: HTTP %s (%s %s)\n' "$code" "$method" "$path" >&2
    return 1
  fi
}

yaml_body() { # file [extra json members]
  [ -f "$1" ] || die "no such file: $1"
  printf '{"content":%s%s}' "$(json_string < "$1")" "${2-}"
}

cmd_login() {
  mkdir -p "$CONFIG_DIR"; chmod 700 "$CONFIG_DIR"
  if [ "${1-}" = "--wait" ]; then
    [ -f "$PENDING" ] || die "no login in progress — run: bts login"
    local device; device="$(json_field deviceCode < "$PENDING")"
    local deadline=$(( $(date +%s) + 600 ))
    while [ "$(date +%s)" -lt "$deadline" ]; do
      local resp status
      resp="$(request POST /auth/cli/device-code/token "{\"deviceCode\":\"$device\"}" noauth 2>/dev/null || true)"
      status="$(printf '%s' "$resp" | json_field status)"
      case "$status" in
        approved)
          local key; key="$(printf '%s' "$resp" | json_field apiKey)"
          [ -n "$key" ] || die "approved, but no key came back — run: bts login"
          ( umask 077; printf '{\n  "apiUrl": "%s",\n  "apiKey": "%s"\n}\n' "$(api_url)" "$key" > "$CREDS" )
          chmod 600 "$CREDS"; rm -f "$PENDING"
          echo "Logged in. Key saved to $CREDS"
          cmd_whoami_brief
          return 0 ;;
        denied)  rm -f "$PENDING"; die "the login was denied in the browser" ;;
        expired|not_found) rm -f "$PENDING"; die "the code expired — run: bts login" ;;
      esac
      sleep 3
    done
    die "timed out waiting for approval — run: bts login --wait again, or start over with: bts login"
  fi
  local host; host="$(hostname 2>/dev/null || echo machine)"
  local resp
  resp="$(request POST /auth/cli/device-code "{\"clientName\":\"BTS setup skill ($host)\"}" noauth)" \
    || die "could not start login"
  ( umask 077; printf '%s\n' "$resp" > "$PENDING" )
  echo "Open this URL, sign in to BTS, and click Approve:"
  echo "  $(printf '%s' "$resp" | json_field verificationUrlComplete)"
  echo "Code: $(printf '%s' "$resp" | json_field userCode)  (valid 10 minutes)"
  echo "Then run: bts login --wait"
}

cmd_whoami_brief() {
  local me; me="$(request GET /auth/me)" || return 1
  local who; who="$(printf '%s' "$me" | json_field githubUsername)"
  [ -n "$who" ] || who="$(printf '%s' "$me" | json_field username)"
  printf 'Signed in as %s\n' "$who"
}

cmd_logout() {
  if [ -f "$CREDS" ]; then
    # Revoke the key server-side too: find it by its 8-char prefix among
    # the caller's keys, and only if it is one this script minted.
    local key prefix id
    key="$(api_key || true)"; prefix="${key:0:8}"
    if [ -n "$prefix" ]; then
      id="$(request GET /auth/api-keys 2>/dev/null | tr '{' '\n' \
        | grep "\"keyPrefix\"[[:space:]]*:[[:space:]]*\"$prefix\"" | grep '"BTS setup skill' | json_field id || true)"
      if [ -n "$id" ] && request DELETE "/auth/api-keys/$id" >/dev/null 2>&1; then echo "Revoked the key on BTS."; fi
    fi
    rm -f "$CREDS"
  fi
  echo "Logged out."
}

cmd_env_list() {
  local resp; resp="$(request GET "/projects/$1/env-vars")" || exit 1
  # Split objects, keep key + flags, drop values.
  printf '%s' "$resp" | tr '{' '\n' | grep '"key"' | while IFS= read -r obj; do
    local k s w
    k="$(printf '%s' "$obj" | json_field key)"
    s="$(printf '%s' "$obj" | sed -nE 's/.*"isSecret"[[:space:]]*:[[:space:]]*(true|false).*/\1/p')"
    w="$(printf '%s' "$obj" | sed -nE 's/.*"excludeFromWorkflows"[[:space:]]*:[[:space:]]*(true|false).*/\1/p')"
    printf '%s\tsecret=%s\texcludeFromWorkflows=%s\n' "$k" "${s:-?}" "${w:-?}"
  done
}

# Parse one dotenv file into "KEY<TAB>VALUE" lines (export prefix, quotes
# and comments handled; multi-line values are not supported).
dotenv_pairs() {
  awk '
    /^[[:space:]]*(#|$)/ { next }
    {
      line = $0; sub(/^[[:space:]]*export[[:space:]]+/, "", line)
      eq = index(line, "="); if (!eq) next
      k = substr(line, 1, eq - 1); v = substr(line, eq + 1)
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", k)
      if (v ~ /^[[:space:]]*"/) { sub(/^[[:space:]]*"/, "", v); sub(/"[[:space:]]*$/, "", v) }
      else if (v ~ /^[[:space:]]*\047/) { sub(/^[[:space:]]*\047/, "", v); sub(/\047[[:space:]]*$/, "", v) }
      else { sub(/[[:space:]]+#.*$/, "", v); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) }
      printf "%s\t%s\n", k, v
    }' "$1"
}

in_list() { case ",$2," in *",$1,"*) return 0 ;; esac; return 1; }

# Keys BTS injects itself (addons, PORT, preview URLs) — the exact names
# from the addon catalog, not globs, so an app's own DB_ENCRYPTION_KEY or
# PGP_PRIVATE_KEY is never caught. A user value silently WINS over the
# injected one, so a local `.env`'s DATABASE_URL=postgres://localhost…
# would point every preview at a database that doesn't exist. env-push
# refuses these unless named in --allow (or --allow-injected for all).
INJECTED_KEYS=" PORT SERVICE_NAME PREVIEW_URL PREVIEW_HOST \
 DATABASE_URL DB_URL DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD \
 POSTGRES_HOST POSTGRES_PORT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD \
 PGHOST PGPORT PGDATABASE PGUSER PGPASSWORD \
 REDIS_URL REDIS_HOST REDIS_PORT REDIS_DB \
 MYSQL_URL MYSQL_HOST MYSQL_PORT MYSQL_DATABASE MYSQL_USER MYSQL_PASSWORD \
 MONGO_URL MONGODB_URI MONGO_HOST MONGO_PORT MONGO_DB MONGO_USER MONGO_PASSWORD MONGO_AUTH_SOURCE \
 S3_ENDPOINT S3_PUBLIC_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET S3_REGION S3_FORCE_PATH_STYLE \
 AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_REGION AWS_S3_ENDPOINT AWS_S3_PUBLIC_ENDPOINT AWS_S3_BUCKET \
 MINIO_ENDPOINT MINIO_PUBLIC_ENDPOINT MINIO_ACCESS_KEY MINIO_SECRET_KEY MINIO_BUCKET \
 SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM \
 MAIL_HOST MAIL_PORT MAIL_MAILER MAIL_ENCRYPTION MAIL_FROM MAIL_FROM_ADDRESS \
 ELASTICSEARCH_URL ELASTICSEARCH_HOST ELASTICSEARCH_PORT ELASTICSEARCH_INDEX_PREFIX \
 MEILISEARCH_URL MEILISEARCH_HOST MEILISEARCH_API_KEY MEILISEARCH_INDEX_PREFIX MEILI_URL MEILI_MASTER_KEY \
 QDRANT_URL QDRANT_HOST QDRANT_PORT QDRANT_GRPC_PORT QDRANT_API_KEY QDRANT_COLLECTION_PREFIX \
 CLICKHOUSE_URL CLICKHOUSE_HOST CLICKHOUSE_PORT CLICKHOUSE_NATIVE_PORT CLICKHOUSE_DB CLICKHOUSE_DATABASE CLICKHOUSE_USER CLICKHOUSE_PASSWORD \
 AMQP_URL RABBITMQ_URL CLOUDAMQP_URL RABBITMQ_HOST RABBITMQ_PORT RABBITMQ_VHOST RABBITMQ_USER RABBITMQ_USERNAME RABBITMQ_PASSWORD \
 KAFKA_BROKERS KAFKA_BROKER KAFKA_URL REDPANDA_BROKERS KAFKA_TOPIC_PREFIX KAFKA_USERNAME KAFKA_PASSWORD \
 KAFKA_SASL_USERNAME KAFKA_SASL_PASSWORD KAFKA_SASL_MECHANISM KAFKA_SECURITY_PROTOCOL \
 KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_ISSUER OIDC_ISSUER_URL OIDC_CLIENT_ID OIDC_CLIENT_SECRET \
 KEYCLOAK_CLIENT_ID KEYCLOAK_CLIENT_SECRET KEYCLOAK_ADMIN_USER KEYCLOAK_ADMIN_PASSWORD "
# Named addon instances (e.g. a second Postgres called ANALYTICS) inject
# the same names with their name as a prefix — ANALYTICS_DATABASE_URL —
# so those are BTS's too. Filled in by cmd_env_push, for project and
# service pushes alike. (The list above is checked against the addon
# catalog by the API's test:addon-instances.)
INSTANCE_PREFIXES=""
injected_key() {
  case "$1" in *_INTERNAL_URL) return 0 ;; esac
  case "$INJECTED_KEYS" in *" $1 "*) return 0 ;; esac
  local p
  for p in $INSTANCE_PREFIXES; do
    case "$1" in
      "${p}"_*) case "$INJECTED_KEYS" in *" ${1#"${p}"_} "*) return 0 ;; esac ;;
    esac
  done
  return 1
}

# Names of the project's named addon instances, space-separated (empty
# when it has none). A key that may not list addons (it needs data:manage)
# gets a warning on stderr: without the list, named instances' variables
# (ANALYTICS_DATABASE_URL, …) aren't recognised as BTS's own.
instance_prefixes() {
  local out
  if ! out="$(request GET "/projects/$1/addons" 2>/dev/null)"; then
    echo "bts: warning: couldn't list this project's addon instances (the key needs data:manage), so a named instance's variables (e.g. ANALYTICS_DATABASE_URL) won't be skipped — use --keys to push only what you mean to" >&2
    return 0
  fi
  printf '%s' "$out" | tr '{' '\n' \
    | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([A-Z][A-Z0-9_]*\)".*/\1/p' | tr '\n' ' '
}

# cmd_env_push <list/create path> <patch path prefix> <dotenv-file> [flags]
cmd_env_push() {
  local base="$1" patch_prefix="$2" file="$3"; shift 3
  [ -f "$file" ] || die "no such file: $file"
  local only="" secrets="" all_secret=0 allow_injected=0 allow="" no_wf=false
  while [ $# -gt 0 ]; do
    case "$1" in
      --keys) only="$2"; shift 2 ;;
      --secret) secrets="$2"; shift 2 ;;
      --secret-all) all_secret=1; shift ;;
      --allow-injected) allow_injected=1; shift ;;
      --allow) allow="$2"; shift 2 ;;
      --exclude-from-workflows) no_wf=true; shift ;;
      *) die "unknown flag: $1" ;;
    esac
  done
  local existing; existing="$(request GET "$base")" || exit 1
  # Every container gets every instance's variables, so a service push
  # needs the prefixes as much as a project push: the service listing says
  # which project it is in.
  local pid=""
  case "$base" in
    /projects/*/env-vars) pid="${base#/projects/}"; pid="${pid%/env-vars}" ;;
    /services/*/env-vars) pid="$(printf '%s' "$existing" | json_field projectId || true)" ;;
  esac
  if [ -n "$pid" ]; then INSTANCE_PREFIXES="$(instance_prefixes "$pid")"; fi
  local pushed=0 skipped=0
  while IFS="$(printf '\t')" read -r key value; do
    [ -n "$key" ] || continue
    if [ -n "$only" ] && ! in_list "$key" "$only"; then continue; fi
    if [ -z "$value" ]; then echo "  skip  $key (empty in $file)"; skipped=$((skipped+1)); continue; fi
    if [ "$allow_injected" = 0 ] && injected_key "$key" && ! in_list "$key" "$allow"; then
      echo "  skip  $key (BTS injects this itself; a value here would override it — pass --allow $key only if that is intended)"
      skipped=$((skipped+1)); continue
    fi
    local secret=false
    if [ "$all_secret" = 1 ] || in_list "$key" "$secrets"; then secret=true; fi
    local body; body="{\"key\":\"$key\",\"value\":$(printf '%s' "$value" | json_string),\"isSecret\":$secret,\"excludeFromWorkflows\":$no_wf}"
    local id
    id="$(printf '%s' "$existing" | tr '{' '\n' | grep "\"key\"[[:space:]]*:[[:space:]]*\"$key\"" | json_field id || true)"
    if [ -n "$id" ]; then
      request PATCH "$patch_prefix/$id" "$body" >/dev/null || die "failed to update $key"
      echo "  update $key (secret=$secret)"
    else
      request POST "$base" "$body" >/dev/null || die "failed to create $key"
      echo "  create $key (secret=$secret)"
    fi
    pushed=$((pushed+1))
  done <<EOF
$(dotenv_pairs "$file")
EOF
  echo "$pushed pushed, $skipped skipped. Values were sent straight from $file and not printed."
}

cmd_wait() {
  local id="$1" timeout="${2:-1500}" last="" deadline
  deadline=$(( $(date +%s) + timeout ))
  while [ "$(date +%s)" -lt "$deadline" ]; do
    local resp status
    resp="$(request GET "/deployments/$id")" || exit 1
    status="$(printf '%s' "$resp" | json_field status)"
    if [ "$status" != "$last" ]; then echo "$(date +%H:%M:%S) $status"; last="$status"; fi
    case "$status" in
      done) return 0 ;;
      failed|cancelled)
        local msg; msg="$(printf '%s' "$resp" | json_field errorMessage)"
        [ -n "$msg" ] && echo "error: $msg"
        return 1 ;;
    esac
    sleep 5
  done
  echo "still '$last' after ${timeout}s"; return 2
}

# The project's linked repos as "id<TAB>fullName<TAB>primary|-" lines.
app_status_repos() {
  local resp; resp="$(request GET "/projects/$1/repos")" || return 1
  printf '%s' "$resp" | tr '{' '\n' | { grep '"githubRepoFullName"' || true; } | while IFS= read -r obj; do
    local id name primary
    id="$(printf '%s' "$obj" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
    name="$(printf '%s' "$obj" | json_field githubRepoFullName)"
    primary="-"; [ "$(printf '%s' "$obj" | json_bool isPrimary)" = "true" ] && primary="primary"
    if [ -n "$id" ]; then printf '%s\t%s\t%s\n' "$id" "$name" "$primary"; fi
  done
}

# One repo's live App status as "state<TAB>line": the state is "ready", the
# blocker (`reason`), or "error"; the line is what to tell the user.
app_status_line() { # projectId repoId fullName primary|-
  local label="$3"; [ "$4" = "primary" ] && label="$3 (primary)"
  local resp
  if ! resp="$(request GET "/projects/$1/repos/$2/github-app")"; then
    local err; err="$(printf '%s' "$resp" | json_field error)"
    printf 'error\t%s: could not check: %s\n' "$label" "${err:-see the error above}"
    return 0
  fi
  local ready reason url kind live configured cached=""
  ready="$(printf '%s' "$resp" | json_bool ready)"
  reason="$(printf '%s' "$resp" | json_field reason)"
  url="$(printf '%s' "$resp" | json_field installUrl)"
  kind="$(printf '%s' "$resp" | json_field installKind)"
  live="$(printf '%s' "$resp" | json_bool live)"
  configured="$(printf '%s' "$resp" | json_bool configured)"
  # Not live on a platform WITH an App: GitHub couldn't be asked. (Without
  # an App nothing is ever asked, and `live` is false for that reason.)
  if [ "$live" = "false" ] && [ "$configured" = "true" ]; then
    cached="GitHub unreachable just now, so this is BTS's cached answer"
  fi
  if [ "$ready" = "true" ]; then
    printf 'ready\t%s: ready%s\n' "$label" "${cached:+ ($cached)}"
    return 0
  fi
  local next
  case "$reason" in
    not_installed)
      if [ -z "$url" ]; then next="install the BTS GitHub App on it (bts app has the install page)"
      elif [ "$kind" = "configure" ]; then next="add it to the App's existing installation: $url"
      else next="install: $url"; fi ;;
    access_unverified)
      next="the App is installed, but this project hasn't proven it can read the repo. A workspace member who can see it connects GitHub (or set a repo PAT), then: bts verify-access $1" ;;
    app_webhook_unconfigured)
      next="this BTS's App can't deliver events, so the repo needs a webhook instead (github-and-prs.md § Legacy fallback)" ;;
    not_configured)
      next="this BTS has no GitHub App, so the repo needs a webhook (github-and-prs.md § Legacy fallback)" ;;
    *) reason="${reason:-unknown}"; next="unexpected answer: $(printf '%s' "$resp" | tr -d '\n' | cut -c1-200)" ;;
  esac
  printf '%s\t%s: not ready (%s%s) — %s\n' "$reason" "$label" "$reason" "${cached:+; $cached}" "$next"
}

cmd_app_status() {
  local pid="${1-}"; [ -n "$pid" ] || die "usage: bts app-status <projectId> [--wait [timeoutSecs]]"
  local wait=0 timeout=900
  if [ "${2-}" = "--wait" ]; then
    wait=1
    if [ -n "${3-}" ]; then
      case "$3" in *[!0-9]*) die "timeoutSecs must be a number of seconds" ;; esac
      timeout="$3"
    fi
  elif [ -n "${2-}" ]; then
    die "unknown flag: $2"
  fi

  local repos; repos="$(app_status_repos "$pid")" || exit 1
  [ -n "$repos" ] || die "no linked repos found"

  if [ "$wait" = 0 ]; then
    local total=0 ready=0 id name primary state line
    while IFS="$TAB" read -r id name primary; do
      IFS="$TAB" read -r state line <<EOF
$(app_status_line "$pid" "$id" "$name" "$primary")
EOF
      # A network error kills the check's subshell before it prints.
      [ -n "$state" ] || { state="error"; line="$name: could not check (see the error above)"; }
      echo "$line"
      total=$((total + 1))
      [ "$state" = "ready" ] && ready=$((ready + 1))
    done <<EOF
$repos
EOF
    echo "ready: $ready/$total"
    return 0
  fi

  # --wait: only repos that aren't ready yet are asked again (each check
  # is a live GitHub call), and a line is printed only when a repo's state
  # changes. Installs rarely come undone within minutes, so a repo that
  # reached `ready` is not re-checked.
  local states="$TMP_DIR/app-status.states" deadline last_verdict=""
  : > "$states"
  deadline=$(( $(date +%s) + timeout ))
  while :; do
    local now_repos
    now_repos="$(app_status_repos "$pid" 2>/dev/null)" || now_repos="$repos"
    [ -n "$now_repos" ] && repos="$now_repos"
    local total=0 ready=0 stuck=0 id name primary prev state line
    while IFS="$TAB" read -r id name primary; do
      total=$((total + 1))
      prev="$(awk -F "$TAB" -v id="$id" '$1 == id { print $2 }' "$states")"
      if [ "$prev" = "ready" ]; then ready=$((ready + 1)); continue; fi
      IFS="$TAB" read -r state line <<EOF
$(app_status_line "$pid" "$id" "$name" "$primary")
EOF
      # A network error kills the check's subshell before it prints.
      [ -n "$state" ] || { state="error"; line="$name: could not check (see the error above)"; }
      if [ "$state" != "$prev" ]; then
        echo "$(date +%H:%M:%S) $line"
        awk -F "$TAB" -v id="$id" '$1 != id' "$states" > "$states.new"
        printf '%s\t%s\n' "$id" "$state" >> "$states.new"
        mv "$states.new" "$states"
      fi
      case "$state" in
        ready) ready=$((ready + 1)) ;;
        # The platform's own App setup: nothing the user does in GitHub
        # changes it while we wait.
        not_configured|app_webhook_unconfigured) stuck=$((stuck + 1)) ;;
      esac
    done <<EOF
$repos
EOF
    local verdict="ready: $ready/$total"
    if [ "$verdict" != "$last_verdict" ]; then echo "$verdict"; last_verdict="$verdict"; fi
    [ "$total" -gt 0 ] && [ "$ready" = "$total" ] && return 0
    if [ "$stuck" -gt 0 ] && [ $((ready + stuck)) = "$total" ]; then
      echo "waiting won't change this: the remaining repo(s) need a repo webhook, not an App install ($verdict)"
      return 2
    fi
    [ "$(date +%s)" -lt "$deadline" ] || break
    sleep 5
  done
  echo "still not ready after ${timeout}s ($verdict)"
  return 2
}

cmd_logs() {
  local resp; resp="$(request GET "/deployments/$1/logs")" || exit 1
  # What the commit's nexos.yaml did (applied, pending until merge, or
  # rejected, with errors / warnings / changes). Null when the deploy
  # didn't read the file.
  if printf '%s' "$resp" | grep -q '"configSummary":"'; then
    printf '%s' "$resp" | json_string_value configSummary
    echo; echo
  fi
  printf '%s' "$resp" | json_string_value logs | tail -n "${2:-200}"
  echo
  echo "[status: $(printf '%s' "$resp" | json_field status)]"
}

sub="${1-help}"; shift || true
case "$sub" in
  login) cmd_login "${1-}" ;;
  logout) cmd_logout ;;
  whoami) request GET /auth/me ;;
  get) request GET "$1" ;;
  delete) request DELETE "$1" ;;
  post|put|patch)
    m="$(printf '%s' "$sub" | tr '[:lower:]' '[:upper:]')"
    request "$m" "$1" "${2-}" ;;
  app) request GET /auth/github/app "" noauth ;;
  catalog) request GET /addons/catalog "" noauth ;;
  lookup) request GET "/projects/lookup?fullName=$1" ;;
  app-status) cmd_app_status "$@" ;;
  verify-access)
    repos="$(request GET "/projects/$1/repos")" || exit 1
    ids="$(printf '%s' "$repos" | tr '{' '\n' | grep '"githubRepoFullName"' \
      | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
    [ -n "$ids" ] || die "no linked repos found"
    for rid in $ids; do request POST "/projects/$1/repos/$rid/verify-access" "{}"; done ;;
  import-yaml)
    extra=""; [ -n "${2-}" ] && extra=",\"teamId\":\"$2\""
    request POST /projects/import-yaml "$(yaml_body "$1" "$extra")" ;;
  apply-yaml) request PUT "/projects/$1/nexos-yaml" "$(yaml_body "$2")" ;;
  validate-yaml) request POST "/projects/$1/nexos-yaml/validate" "$(yaml_body "$2")" ;;
  env-list) cmd_env_list "$1" ;;
  env-push) p="$1"; shift; cmd_env_push "/projects/$p/env-vars" /env-vars "$@" ;;
  svc-env-push) sid="$1"; shift; cmd_env_push "/services/$sid/env-vars" /service-env-vars "$@" ;;
  seed-sql|seed)
    [ -f "$2" ] || die "no such file: $2"
    addon="${3:-postgres}"
    qs=""; [ -n "${4-}" ] && qs="?instance=$4"
    request PUT "/projects/$1/addons/$addon/seed$qs" "{\"sql\":$(json_string < "$2")}" ;;
  seed-backup)
    [ -f "$2" ] || die "no such file: $2"
    key="$(api_key)" || die "not logged in — run: bts login"
    ( umask 077; printf 'Authorization: Bearer %s\n' "$key" > "$TMP_DIR/hdr" )
    curl -sS -f -X PUT -H "@$TMP_DIR/hdr" -H "Content-Type: application/octet-stream" \
      --data-binary "@$2" "$(api_url)/projects/$1/addons/postgres/seed/backup${3:+?instance=$3}" || die "upload failed"
    echo ;;
  deploy) request POST "/projects/$1/deployments" "{\"branch\":\"$2\"}" ;;
  wait) cmd_wait "$@" ;;
  logs) cmd_logs "$@" ;;
  help|-h|--help) awk 'NR > 1 && !/^#/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$0" ;;
  *) die "unknown command: $sub (try: bts help)" ;;
esac
