#!/usr/bin/env bash
# bmcloud - command-line client for BareMetal Cloud.
#
# Requires: bash 4+, curl, jq.
#
# Authenticate with an API key created in the portal (API keys page):
#   bmcloud login                 # prompts for the key and saves it
#   export BMC_API_KEY=bmc_...    # or provide it per shell/session
#
# Usage: bmcloud [--json] [--url URL] <command> [args...]
#
# Account
#   login                          Save an API key for future commands
#   logout                         Remove the saved API key
#   whoami                         Account, plan, balance and limits
#
# VMs (<vm> is a VM id or a unique name)
#   vm list
#   vm show <vm>
#   vm create <name> --kernel <image> [--ram MiB] [--hotplug MiB]
#             [--disk ext2|raw|none|image] [--disk-size MiB] [--disk-image <image>]
#             [--args "app args"] [--no-start] [--no-https]
#   vm start|stop|suspend|resume <vm>
#   vm delete <vm> [--yes]
#   vm args <vm> "<app args>"      Change app arguments (VM must be stopped)
#   vm memory <vm> <ram-mib> [hotplug-mib]  Change memory (VM must be stopped)
#   vm console <vm> [--bytes N] [--follow]
#   vm wait <vm> <status> [--timeout SECONDS]
#   vm usage <vm>
#   vm forwards <vm>
#   vm forward-add <vm> <http|tcp|udp> <guest-port> [public-port]
#   vm forward-rm <vm> <forward-id>
#   vm domains <vm>                Custom domains and the DNS records they need
#   vm domain-add <vm> <hostname>
#   vm domain-check <vm> <domain-id>  Retry DNS check and certificate now
#   vm domain-rm <vm> <domain-id>
#
# Images (<image> is an image id or a unique name)
#   image list
#   image upload <file> [--name NAME] [--kind auto|kernel|disk]
#   image delete <image> [--yes]
#
# Billing
#   billing                        Balance, spend and recent transactions
#   billing usage                  Hourly usage records
#   billing topup <amount>         Fund the account (opens Stripe Checkout)
#
# Options
#   --json       Print raw JSON API responses (for scripting)
#   --url URL    Portal URL (default: $BMC_URL, the saved URL, or the
#                portal this script was downloaded from)
#
# Environment: BMC_API_KEY, BMC_URL, BMC_CONFIG (credentials file path).
set -euo pipefail
shopt -s inherit_errexit # fail inside $(...) too

VERSION=1
DEFAULT_URL="https://baremetal.returninfinity.com"
CONFIG="${BMC_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/bmcloud/credentials}"
JSON=0
URL_FLAG=""

die() { echo "bmcloud: $*" >&2; exit 1; }
usage() { sed -n '2,/^set -euo pipefail/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }

command -v curl >/dev/null 2>&1 || die "curl is required"
command -v jq >/dev/null 2>&1 || die "jq is required"

config_get() { # config_get <key>
	[ -f "$CONFIG" ] || return 0
	sed -n "s/^$1=//p" "$CONFIG" | head -1
}

base_url() {
	local u=${URL_FLAG:-${BMC_URL:-$(config_get url)}}
	u=${u:-$DEFAULT_URL}
	[[ $u == http*://* ]] || die "no portal URL configured (use --url or BMC_URL)"
	echo "${u%/}"
}

api_key() {
	local k=${BMC_API_KEY:-$(config_get api_key)}
	[ -n "$k" ] || die "not logged in: run 'bmcloud login' or set BMC_API_KEY"
	echo "$k"
}

# auth_config - curl config (read with --config -) carrying the API key, so
# the key is passed on stdin and never appears in the process list.
auth_config() {
	local key
	key=$(api_key) # separate assignment so a missing key aborts
	printf 'header = "Authorization: Bearer %s"\n' "$key"
}

# api METHOD PATH [JSON] - prints the response body; exits with the API's
# error message on failure.
api() {
	local method=$1 path=$2 body=${3-} out status conf
	conf=$(auth_config)
	out=$(mktemp)
	local args=(-sS --config - -o "$out" -w '%{http_code}' -X "$method" -H "User-Agent: bmcloud-cli/$VERSION")
	if [ -n "$body" ]; then
		args+=(-H 'Content-Type: application/json' --data-binary "$body")
	fi
	if ! status=$(curl "${args[@]}" "$(base_url)$path" <<< "$conf"); then
		rm -f "$out"
		die "cannot reach $(base_url)"
	fi
	if [[ $status != 2* ]]; then
		local msg
		msg=$(jq -r '.error // empty' < "$out" 2>/dev/null || true)
		rm -f "$out"
		die "${msg:-HTTP $status}"
	fi
	cat "$out"
	rm -f "$out"
}

# table: format tab-separated stdin into aligned columns.
table() {
	if command -v column >/dev/null 2>&1; then column -t -s $'\t'; else cat; fi
}

# money NANOS [DECIMALS] - format an amount in nanounits of the currency.
money() { awk -v n="$1" -v d="${2:-2}" 'BEGIN { printf "%.*f", d, n / 1e9 }'; }

confirm() { # confirm "question" - skipped with --yes
	[ "${YES:-0}" = 1 ] && return 0
	[ -t 0 ] || die "refusing to $1 without --yes (not a terminal)"
	read -r -p "$1? [y/N] " a
	[[ $a =~ ^[Yy] ]] || die "cancelled"
}

json_or() { # json_or FILTER - print raw JSON with --json, else apply jq FILTER
	if [ "$JSON" = 1 ]; then jq .; else jq -r "$1"; fi
}

# resolve_vm <id-or-name> - prints the VM id.
resolve_vm() {
	local ref=$1
	if [[ $ref =~ ^vm[a-z0-9]{10}$ ]]; then echo "$ref"; return; fi
	local ids
	ids=$(api GET /api/vms | jq -r --arg n "$ref" '.vms[] | select(.name == $n) | .id')
	case $(grep -c . <<< "$ids") in
		0) die "no VM named '$ref'" ;;
		1) echo "$ids" ;;
		*) die "several VMs are named '$ref'; use the VM id" ;;
	esac
}

resolve_image() {
	local ref=$1
	if [[ $ref =~ ^img[a-z0-9]{10}$ ]]; then echo "$ref"; return; fi
	local ids
	ids=$(api GET /api/images | jq -r --arg n "$ref" '.images[] | select(.name == $n) | .id')
	case $(grep -c . <<< "$ids") in
		0) die "no image named '$ref'" ;;
		1) echo "$ids" ;;
		*) die "several images are named '$ref'; use the image id" ;;
	esac
}

need() { [ "$1" -ge "$2" ] || die "missing arguments (see 'bmcloud help')"; }

# --- account ----------------------------------------------------------------

cmd_login() {
	local key me
	if [ -t 0 ]; then
		read -r -s -p "API key (from the portal's API keys page): " key
		echo
	else
		read -r key
	fi
	[[ $key =~ ^bmc_[A-Za-z0-9_-]{43}$ ]] || die "that does not look like an API key (bmc_...)"
	me=$(BMC_API_KEY=$key api GET /api/me) || exit 1
	mkdir -p "$(dirname "$CONFIG")"
	(umask 077; printf 'url=%s\napi_key=%s\n' "$(base_url)" "$key" > "$CONFIG")
	echo "Logged in to $(base_url) as $(jq -r .user.email <<< "$me"). Key saved to $CONFIG"
}

cmd_logout() {
	if [ -f "$CONFIG" ]; then rm -f "$CONFIG"; echo "Removed $CONFIG"; else echo "Not logged in"; fi
	echo "The key itself stays valid until you revoke it in the portal."
}

cmd_whoami() {
	local me
	me=$(api GET /api/me)
	if [ "$JSON" = 1 ]; then jq . <<< "$me"; return; fi
	local cur
	cur=$(jq -r '.currency | ascii_upcase' <<< "$me")
	jq -r '"Email:    \(.user.email)\nPlan:     \(.user.plan)\(if .user.role == "admin" then " (admin)" else "" end)"' <<< "$me"
	echo "Balance:  $(money "$(jq -r .user.balance_nanos <<< "$me")" 4) $cur"
	jq -r '.limits | "Limits:   \(.max_vms) VMs, \(.max_ram_mib) MiB RAM, \(.max_disk_mib) MiB disk, \(.max_hotplug_mib) MiB hot-plug, \(.max_forwards) forwards/VM, \(.max_domains) domains/VM, \(.max_images) images"' <<< "$me"
	echo "Portal:   $(base_url)"
}

# --- VMs ----------------------------------------------------------------------

cmd_vm() {
	local sub=${1:-list}
	[ $# -gt 0 ] && shift
	case $sub in
		list|ls)
			api GET /api/vms | json_or '(["ID", "NAME", "STATUS", "RAM", "DISK", "CPU", "HOSTNAME"] | @tsv),
				(.vms[] | [.id, .name, .status + (if .status_reason != "" then " (" + .status_reason + ")" else "" end),
				 "\(.ram_mib)M" + (if .hotplug_mib > 0 then "+\(.hotplug_mib)M" else "" end),
				 (if .disk_mib > 0 then "\(.disk_mib)M" else "-" end),
				 (if .status == "running" then "\(.cpu_pct * 10 | round / 10)%" else "-" end),
				 (if .hostname != "" then .hostname else "-" end)] | @tsv)' | if [ "$JSON" = 1 ]; then cat; else table; fi
			;;
		show)
			need $# 1
			local id d
			id=$(resolve_vm "$1")
			d=$(api GET "/api/vms/$id")
			if [ "$JSON" = 1 ]; then jq . <<< "$d"; return; fi
			jq -r '.vm as $v |
				"ID:        \($v.id)\nName:      \($v.name)\nStatus:    \($v.status) (desired: \($v.desired))" +
				(if $v.status_reason != "" then "\nReason:    \($v.status_reason)" else "" end) +
				(if $v.last_error != "" then "\nError:     \($v.last_error)" else "" end) +
				"\nHostname:  \(if $v.hostname != "" then "https://" + $v.hostname else "-" end)" +
				"\nNode IP:   \(.node_ip // "-")" +
				"\nRAM:       \($v.ram_mib) MiB boot" + (if $v.hotplug_mib > 0 then " + \($v.mem_plugged_mib) of \($v.hotplug_mib) MiB hot-plug" else "" end) +
				"\nDisk:      " + (if $v.disk_mib > 0 then "\($v.disk_used_mib) MiB used of \($v.disk_mib) MiB (\($v.disk_source))" else "none" end) +
				"\nCPU:       " + (if $v.status == "running" then "\($v.cpu_pct * 10 | round / 10)%" else "-" end) +
				"\nApp args:  \(if $v.app_args != "" then $v.app_args else "-" end)" +
				"\nForwards:  " + ([.forwards[] | if .proto == "http" then "https -> \(.guest_port)" else "\(.proto) \(.public_port) -> \(.guest_port)" end] | if length == 0 then "-" else join(", ") end) +
				"\nDomains:   " + ([.domains[] | "\(.hostname) (\(.status))"] | if length == 0 then "-" else join(", ") end)' <<< "$d"
			;;
		create)
			need $# 1
			local name=$1 kernel="" ram=16 hotplug=0 disk=ext2 dsize=64 dimage="" args="" start=true https=true
			shift
			while [ $# -gt 0 ]; do
				case $1 in
					--kernel) kernel=${2:?--kernel needs an image}; shift 2 ;;
					--ram) ram=${2:?}; shift 2 ;;
					--hotplug) hotplug=${2:?}; shift 2 ;;
					--disk) disk=${2:?}; shift 2 ;;
					--disk-size) dsize=${2:?}; shift 2 ;;
					--disk-image) dimage=${2:?}; disk=image; shift 2 ;;
					--args) args=${2-}; shift 2 ;;
					--no-start) start=false; shift ;;
					--no-https) https=false; shift ;;
					*) die "unknown option $1" ;;
				esac
			done
			[ -n "$kernel" ] || die "--kernel <image> is required"
			local kid did="" body
			kid=$(resolve_image "$kernel")
			[ -z "$dimage" ] || did=$(resolve_image "$dimage")
			[ "$disk" != none ] || dsize=0
			[ "$disk" != image ] || [ -n "$did" ] || die "--disk image needs --disk-image <image>"
			[ "$disk" != image ] || [ "$dsize" != 64 ] || dsize=0 # default: the image's own size
			body=$(jq -n --arg name "$name" --arg k "$kid" --argjson ram "$ram" --argjson hp "$hotplug" --arg ds "$disk" \
				--argjson dm "$dsize" --arg di "$did" --arg args "$args" --argjson start "$start" --argjson https "$https" \
				'{name: $name, kernel_image_id: $k, ram_mib: $ram, hotplug_mib: $hp, disk_source: $ds, disk_mib: $dm,
				  disk_image_id: $di, app_args: $args, start: $start, https_forward: $https}')
			api POST /api/vms "$body" | json_or '"Created \(.vm.name) (\(.vm.id)); status \(.vm.status). Track it with: bmcloud vm show \(.vm.id)"'
			;;
		start|stop|suspend|resume)
			need $# 1
			local id
			id=$(resolve_vm "$1")
			api POST "/api/vms/$id/$sub" > /dev/null
			echo "$sub requested for $id (applied asynchronously; see 'bmcloud vm wait')"
			;;
		delete|rm)
			need $# 1
			local id ref=$1
			[ "${2:-}" = --yes ] && YES=1
			id=$(resolve_vm "$ref")
			confirm "delete VM $ref ($id) and its disk"
			api DELETE "/api/vms/$id" > /dev/null
			echo "Deletion of $id requested"
			;;
		args)
			need $# 2
			local id body
			id=$(resolve_vm "$1")
			body=$(jq -n --arg a "$2" '{app_args: $a}')
			api PATCH "/api/vms/$id" "$body" | json_or '"App args of \(.vm.id) set to \"\(.vm.app_args)\"; they apply at the next start"'
			;;
		memory|mem)
			need $# 2
			[[ $2 =~ ^[0-9]+$ ]] && [[ ${3-0} =~ ^[0-9]+$ ]] || die "memory sizes are whole numbers of MiB"
			local id body
			id=$(resolve_vm "$1")
			body=$(jq -n --argjson r "$2" '{ram_mib: $r}')
			[ -z "${3-}" ] || body=$(jq --argjson h "$3" '. + {hotplug_mib: $h}' <<< "$body")
			api PATCH "/api/vms/$id" "$body" |
				json_or '"Memory of \(.vm.id) set to \(.vm.ram_mib) MiB\(if .vm.hotplug_mib > 0 then " + \(.vm.hotplug_mib) MiB hot-plug" else "" end); it applies at the next start"'
			;;
		console|logs)
			need $# 1
			local id bytes=65536 follow=0
			id=$(resolve_vm "$1")
			shift
			while [ $# -gt 0 ]; do
				case $1 in
					--bytes) bytes=${2:?}; shift 2 ;;
					--follow|-f) follow=1; shift ;;
					*) die "unknown option $1" ;;
				esac
			done
			local prev="" cur
			while :; do
				cur=$(api GET "/api/vms/$id/console?bytes=$bytes" | jq -r '.log')
				if [ -z "$prev" ]; then
					printf '%s\n' "$cur"
				elif [ "$cur" != "$prev" ]; then
					# Print only what is new since the last fetch.
					local anchor=${prev: -200} rest
					if [[ $cur == "$prev"* ]]; then rest=${cur#"$prev"}
					else rest=${cur#*"$anchor"}; [ "$rest" != "$cur" ] || rest=$cur
					fi
					printf '%s\n' "${rest#$'\n'}"
				fi
				[ "$follow" = 1 ] || break
				prev=$cur
				sleep 2
			done
			;;
		wait)
			need $# 2
			local id want=$2 timeout=120 st
			id=$(resolve_vm "$1")
			[ "${3:-}" != --timeout ] || timeout=${4:?}
			local deadline=$((SECONDS + timeout))
			while :; do
				st=$(api GET "/api/vms/$id" | jq -r '.vm.status')
				[ "$st" = "$want" ] && { echo "$id is $st"; return; }
				[ $SECONDS -lt $deadline ] || die "timed out: $id is $st, not $want"
				sleep 2
			done
			;;
		usage)
			need $# 1
			local id d cur
			id=$(resolve_vm "$1")
			d=$(api GET "/api/vms/$id")
			if [ "$JSON" = 1 ]; then jq '.usage' <<< "$d"; return; fi
			cur=$(api GET /api/me | jq -r '.currency | ascii_upcase')
			{
				printf 'PERIOD END\tCPU s\tRAM MiB-h\tDISK MiB-h\tRUN h\tCOST %s\n' "$cur"
				jq -r '.usage[] | [(.period_end | todate), (.cpu_usec / 1e6 | tostring), (.ram_mib_sec / 3600 | tostring),
					(.disk_mib_sec / 3600 | tostring), ((.run_sec // 0) / 3600 | tostring),
					(if .charged then (.cost_nanos / 1e9 | tostring) else "free" end)] | @tsv' <<< "$d" |
					awk -F'\t' -v OFS='\t' '{ for (i = 2; i <= 6; i++) if ($i != "free") $i = sprintf(i == 6 ? "%.6f" : i == 5 ? "%.2f" : "%.1f", $i); print }'
			} | table
			echo "Unbilled this hour: $(money "$(jq -r .pending_cost_nanos <<< "$d")" 6) $cur"
			;;
		forwards)
			need $# 1
			local id
			id=$(resolve_vm "$1")
			api GET "/api/vms/$id" | json_or '(["ID", "PROTO", "PUBLIC", "GUEST PORT"] | @tsv),
				(.vm.hostname as $h | .forwards[] | [.id, .proto,
				 (if .proto == "http" then "https://" + $h else "\($h):\(.public_port)" end), .guest_port] | @tsv)' |
				if [ "$JSON" = 1 ]; then jq '.forwards'; else table; fi
			;;
		forward-add)
			need $# 3
			local id body
			id=$(resolve_vm "$1")
			body=$(jq -n --arg p "$2" --argjson g "$3" --argjson pub "${4:-0}" '{proto: $p, guest_port: $g, public_port: $pub}')
			api POST "/api/vms/$id/forwards" "$body" | json_or 'if .forward.proto == "http" then "HTTPS hostname now routes to guest port \(.forward.guest_port)"
				else "\(.forward.proto) public port \(.forward.public_port) -> guest port \(.forward.guest_port) (forward id \(.forward.id))" end'
			;;
		forward-rm)
			need $# 2
			local id
			id=$(resolve_vm "$1")
			api DELETE "/api/vms/$id/forwards/$2" > /dev/null
			echo "Removed forward $2"
			;;
		domains)
			need $# 1
			local id
			id=$(resolve_vm "$1")
			api GET "/api/vms/$id" | if [ "$JSON" = 1 ]; then jq '.domains'; else jq -r '.vm.hostname as $h | .domains[] |
				"\(.id)  \(.hostname)  \(if .status == "pending" then "pending" elif .cert_installed then "active" else "installing" end)" +
				(if .cert_expires_at > 0 then "  (certificate expires \(.cert_expires_at | todate))" else "" end) +
				(if (.last_error // "") != "" then "\n    \(.last_error)" else "" end) +
				"\n    CNAME  \(.hostname)  ->  \(if $h != "" then $h else "(assigned when the VM is placed)" end)" +
				"\n    CNAME  _acme-challenge.\(.hostname)  ->  \(.challenge_target)"'; fi
			;;
		domain-add)
			need $# 2
			local id
			id=$(resolve_vm "$1")
			api POST "/api/vms/$id/domains" "$(jq -n --arg h "$2" '{hostname: $h}')" | json_or '.domain |
				"Added \(.hostname) (domain id \(.id)). Create these DNS records:" +
				"\n  CNAME  _acme-challenge.\(.hostname)  ->  \(.challenge_target)" +
				"\n  and point \(.hostname) at the VM hostname (see bmcloud vm domains)."'
			;;
		domain-check)
			need $# 2
			local id
			id=$(resolve_vm "$1")
			api POST "/api/vms/$id/domains/$2/check" > /dev/null
			echo "Checking domain $2; see 'bmcloud vm domains $1' for the result"
			;;
		domain-rm)
			need $# 2
			local id
			id=$(resolve_vm "$1")
			api DELETE "/api/vms/$id/domains/$2" > /dev/null
			echo "Removed domain $2"
			;;
		*) die "unknown vm command '$sub' (see 'bmcloud help')" ;;
	esac
}

# --- images ---------------------------------------------------------------------

cmd_image() {
	local sub=${1:-list}
	[ $# -gt 0 ] && shift
	case $sub in
		list|ls)
			api GET /api/images | json_or '(["ID", "NAME", "TYPE", "SIZE", "IN USE", "UPLOADED"] | @tsv),
				(.images[] | [.id, .name, .kind, (if .size_bytes >= 1048576 then "\(.size_bytes / 1048576 * 10 | round / 10)M"
				 else "\(.size_bytes / 1024 | round)K" end), (if .in_use > 0 then "yes" else "no" end), (.created_at | todate)] | @tsv)' |
				if [ "$JSON" = 1 ]; then cat; else table; fi
			;;
		upload)
			need $# 1
			local file=$1 name="" kind=auto
			shift
			while [ $# -gt 0 ]; do
				case $1 in
					--name) name=${2:?}; shift 2 ;;
					--kind) kind=${2:?}; shift 2 ;;
					*) die "unknown option $1" ;;
				esac
			done
			[ -f "$file" ] || die "no such file: $file"
			if [ -z "$name" ]; then
				name=$(basename "$file" | tr -c 'A-Za-z0-9 _.\n-' '-' | sed 's/^[^A-Za-z0-9]*//' | cut -c1-40)
				name=${name:-image}
			fi
			local out status conf progress=(-sS)
			conf=$(auth_config)
			out=$(mktemp)
			[ -t 2 ] && progress=(--progress-bar)
			status=$(curl "${progress[@]}" --config - -o "$out" -w '%{http_code}' -X POST --upload-file "$file" \
				-H 'Content-Type: application/octet-stream' -H "User-Agent: bmcloud-cli/$VERSION" \
				"$(base_url)/api/images?name=$(jq -rn --arg n "$name" '$n | @uri')&kind=$kind" <<< "$conf") || { rm -f "$out"; die "upload failed"; }
			if [[ $status != 2* ]]; then
				local msg
				msg=$(jq -r '.error // empty' < "$out" 2>/dev/null || true)
				rm -f "$out"
				die "${msg:-HTTP $status}"
			fi
			json_or '"Uploaded \(.image.name) as a \(.image.kind) image (\(.image.id))"' < "$out"
			rm -f "$out"
			;;
		delete|rm)
			need $# 1
			local id ref=$1
			[ "${2:-}" = --yes ] && YES=1
			id=$(resolve_image "$ref")
			confirm "delete image $ref ($id)"
			api DELETE "/api/images/$id" > /dev/null
			echo "Deleted $id"
			;;
		*) die "unknown image command '$sub' (see 'bmcloud help')" ;;
	esac
}

# --- billing --------------------------------------------------------------------

cmd_billing() {
	local sub=${1:-show}
	[ $# -gt 0 ] && shift
	case $sub in
		show)
			local b cur
			b=$(api GET /api/billing)
			if [ "$JSON" = 1 ]; then jq . <<< "$b"; return; fi
			cur=$(api GET /api/me | jq -r '.currency | ascii_upcase')
			echo "Plan:            $(jq -r .plan <<< "$b")"
			echo "Balance:         $(money "$(jq -r .balance_nanos <<< "$b")" 4) $cur"
			echo "This month:      $(money "$(jq -r .month_nanos <<< "$b")" 4) $cur"
			echo "Unbilled (hour): $(money "$(jq -r .unbilled_nanos <<< "$b")" 6) $cur"
			echo "Max hourly burn: $(money "$(jq -r .max_hourly_nanos <<< "$b")" 4) $cur"
			echo
			{
				printf 'DATE\tDESCRIPTION\tAMOUNT\tBALANCE\n'
				jq -r '.transactions[:15][] | [(.created_at | todate), .description, (.amount_nanos | tostring), (.balance_after_nanos | tostring)] | @tsv' <<< "$b" |
					awk -F'\t' -v OFS='\t' '{ $3 = sprintf("%.4f", $3 / 1e9); $4 = sprintf("%.4f", $4 / 1e9); print }'
			} | table
			;;
		usage)
			local b
			b=$(api GET /api/billing)
			if [ "$JSON" = 1 ]; then jq '.usage' <<< "$b"; return; fi
			{
				printf 'PERIOD END\tVM\tCPU s\tRAM MiB-h\tDISK MiB-h\tRUN h\tCOST\n'
				jq -r '.usage[] | [(.period_end | todate), .vm_id, (.cpu_usec / 1e6 | tostring), (.ram_mib_sec / 3600 | tostring),
					(.disk_mib_sec / 3600 | tostring), ((.run_sec // 0) / 3600 | tostring),
					(if .charged then (.cost_nanos / 1e9 | tostring) else "free" end)] | @tsv' <<< "$b" |
					awk -F'\t' -v OFS='\t' '{ for (i = 3; i <= 7; i++) if ($i != "free") $i = sprintf(i == 7 ? "%.6f" : i == 6 ? "%.2f" : "%.1f", $i); print }'
			} | table
			;;
		topup)
			need $# 1
			[[ $1 =~ ^[0-9]+(\.[0-9]{1,2})?$ ]] || die "amount must be a number like 10 or 25.50"
			local cents url
			cents=$(awk -v a="$1" 'BEGIN { printf "%d", a * 100 + 0.5 }')
			url=$(api POST /api/billing/checkout "{\"amount_cents\": $cents}" | jq -r .url)
			echo "Complete the payment in your browser:"
			echo "  $url"
			if [ -t 1 ]; then
				if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 || true
				elif command -v open >/dev/null 2>&1; then open "$url" >/dev/null 2>&1 || true
				fi
			fi
			;;
		*) die "unknown billing command '$sub' (see 'bmcloud help')" ;;
	esac
}

# --- main -----------------------------------------------------------------------

while [ $# -gt 0 ]; do
	case $1 in
		--json) JSON=1; shift ;;
		--url) URL_FLAG=${2:?--url needs a value}; shift 2 ;;
		--yes|-y) YES=1; shift ;;
		-h|--help) usage; exit 0 ;;
		--version) echo "bmcloud $VERSION"; exit 0 ;;
		-*) die "unknown option $1" ;;
		*) break ;;
	esac
done

cmd=${1:-help}
[ $# -gt 0 ] && shift
case $cmd in
	login) cmd_login "$@" ;;
	logout) cmd_logout "$@" ;;
	whoami|me|account) cmd_whoami "$@" ;;
	vm|vms) cmd_vm "$@" ;;
	image|images) cmd_image "$@" ;;
	billing) cmd_billing "$@" ;;
	help) usage ;;
	version) echo "bmcloud $VERSION" ;;
	*) die "unknown command '$cmd' (see 'bmcloud help')" ;;
esac
