#!/usr/bin/env bash
# tailscale-api.sh — CLI for the ACG Tailscale tailnet via the Tailscale REST API v2
# (base: https://api.tailscale.com/api/v2). Read-only by default; every device
# delete/authorize and every auth-key create/delete is GATED behind --confirm.
#
# AUTH (resolved from the SOPS vault, OAuth preferred, token fallback):
#   vault path: tailscale/api-access.sops.yaml  (override: --vault <path> or env TAILSCALE_VAULT)
#     credentials.client_id + credentials.client_secret  -> OAuth client (preferred, non-expiring)
#                                                            POST /oauth/token -> short-lived bearer
#     credentials.api_key                                 -> raw API access token (tskey-api-...) fallback
#   tailnet (path segment): credentials.tailnet | top-level plaintext `tailnet:` | env
#     TAILSCALE_TAILNET | default `-` (the authenticated principal's default tailnet).
#
# Secrets never touch argv/stdout: the bearer is written to a 0600 curl config file
# (-K) and the OAuth secret is fed to curl over stdin (--data @-).
#
# Subcommands:
#   status                                       auth check + tailnet + device count
#   devices [--json]                             list devices (name/id/addr/os/user/lastSeen/tags/update/online)
#   device  <name|id|100.x> [--json]             device detail
#   delete-device <name|id|100.x> [--confirm]    DELETE a device (GATED)
#   authorize <name|id|100.x> [--confirm]        POST authorized=true (GATED)
#   keys [--json]                                list auth keys
#   create-key [--tag tag:x]... [--reusable] [--preauth] [--ephemeral]
#              [--expiry-days N] [--desc "..."] [--confirm]     create an auth key (GATED)
#   delete-key <keyId> [--confirm]               DELETE/revoke an auth key (GATED)
#
# Device args accept a NAME, the stable numeric/node id, or a 100.x address; the name is
# resolved to an id against the live device list. On an AMBIGUOUS match (>1 device) the
# candidates are printed and the op STOPS — it never acts on a guess.
#
# Usage: bash .claude/skills/tailscale/scripts/tailscale-api.sh <subcommand> [args] [--confirm]
# Runs `--help` with no credentials.
set -uo pipefail

API="https://api.tailscale.com/api/v2"
OAUTH_URL="https://api.tailscale.com/api/v2/oauth/token"

REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDETOOLS_ROOT:-C:/claudetools}")"
VAULT_SH="$REPO/.claude/scripts/vault.sh"
ALERT_SH="$REPO/.claude/scripts/post-bot-alert.sh"

logerr() { bash "$REPO/.claude/scripts/log-skill-error.sh" "tailscale" "$1" ${2:+--context "$2"} >/dev/null 2>&1 || true; }

usage() {
  sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
  exit "${1:-0}"
}

# ---- arg parse (global) -------------------------------------------------------
VAULT_PATH="${TAILSCALE_VAULT:-tailscale/api-access.sops.yaml}"
CONFIRM=0; JSON=0; TAGS=(); REUSABLE=false; PREAUTH=false; EPHEMERAL=false
EXPIRY_DAYS=90; DESC=""; POS=()
while [ $# -gt 0 ]; do
  case "$1" in
    -h|--help|help) usage 0;;
    --vault)      VAULT_PATH="${2:?--vault needs a path}"; shift 2;;
    --confirm)    CONFIRM=1; shift;;
    --json)       JSON=1; shift;;
    --tag)        TAGS+=("${2:?--tag needs a value e.g. tag:roberts}"); shift 2;;
    --reusable)   REUSABLE=true; shift;;
    --preauth)    PREAUTH=true; shift;;
    --ephemeral)  EPHEMERAL=true; shift;;
    --expiry-days) EXPIRY_DAYS="${2:?--expiry-days needs N}"; shift 2;;
    --desc)       DESC="${2:-}"; shift 2;;
    *)            POS+=("$1"); shift;;
  esac
done
SUB="${POS[0]:-}"; [ -n "$SUB" ] || usage 2

command -v jq   >/dev/null 2>&1 || { echo "[ERROR] jq is required"; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "[ERROR] curl is required"; exit 2; }

# ---- credentials + bearer -----------------------------------------------------
CURL_CFG="$(mktemp)"; chmod 600 "$CURL_CFG" 2>/dev/null || true
trap 'rm -f "$CURL_CFG"' EXIT

vget() { bash "$VAULT_SH" get-field "$VAULT_PATH" "$1" 2>/dev/null; }

TAILNET="${TAILSCALE_TAILNET:-}"
BEARER=""
resolve_auth() {
  [ -n "$TAILNET" ] || TAILNET="$(vget credentials.tailnet)"
  [ -n "$TAILNET" ] || TAILNET="$(vget tailnet)"
  [ -n "$TAILNET" ] || TAILNET="-"

  local cid csec key
  cid="$(vget credentials.client_id)"; csec="$(vget credentials.client_secret)"
  if [ -n "$cid" ] && [ -n "$csec" ]; then
    # OAuth: secret over stdin (not argv); scopes devices:core + auth_keys.
    local out code body
    out="$(printf 'grant_type=client_credentials&client_id=%s&client_secret=%s' "$cid" "$csec" \
          | curl -sS --data @- -w $'\n%{http_code}' "$OAUTH_URL" 2>/dev/null)"
    code="${out##*$'\n'}"; body="${out%$'\n'*}"
    if [ "$code" = "200" ]; then
      BEARER="$(printf '%s' "$body" | jq -r '.access_token // empty')"
    fi
    [ -n "$BEARER" ] || { echo "[ERROR] OAuth token exchange failed (HTTP $code) at vault:$VAULT_PATH"; logerr "OAuth token exchange failed" "http=$code vault=$VAULT_PATH"; exit 2; }
    return 0
  fi
  key="$(vget credentials.api_key)"
  if [ -n "$key" ]; then BEARER="$key"; return 0; fi

  echo "[BLOCKED] no Tailscale credential at vault:$VAULT_PATH"
  echo "          need credentials.client_id + credentials.client_secret (OAuth) OR credentials.api_key (token)."
  exit 2
}
resolve_auth
printf 'header = "Authorization: Bearer %s"\n' "$BEARER" > "$CURL_CFG"

# ---- API wrapper (HTTP_CODE / HTTP_BODY) --------------------------------------
HTTP_CODE=""; HTTP_BODY=""
api() { # METHOD PATH [JSON_BODY]
  local method="$1" path="$2" data="${3:-}" out
  local args=(-sS -X "$method" -H "Accept: application/json" -K "$CURL_CFG" -w $'\n%{http_code}')
  if [ -n "$data" ]; then
    out="$(printf '%s' "$data" | curl "${args[@]}" -H "Content-Type: application/json" --data-binary @- "$API/$path" 2>/dev/null)"
  else
    out="$(curl "${args[@]}" "$API/$path" 2>/dev/null)"
  fi
  HTTP_CODE="${out##*$'\n'}"; HTTP_BODY="${out%$'\n'*}"
}
api_ok() { case "$HTTP_CODE" in 2*) return 0;; *) return 1;; esac; }
api_fail() { # context
  echo "[ERROR] Tailscale API $1 -> HTTP $HTTP_CODE"
  printf '%s\n' "$HTTP_BODY" | jq -r '.message // .' 2>/dev/null || printf '%s\n' "$HTTP_BODY"
  logerr "API $1 HTTP $HTTP_CODE" "tailnet=$TAILNET vault=$VAULT_PATH"
}

alert() { [ -f "$ALERT_SH" ] && bash "$ALERT_SH" "$1" >/dev/null 2>&1 || true; }

# ---- device list + name->id resolution ----------------------------------------
DEVLIST_JSON=""
load_devices() {
  [ -n "$DEVLIST_JSON" ] && return 0
  api GET "tailnet/$TAILNET/devices?fields=all"
  api_ok || { api_fail "list devices"; exit 2; }
  DEVLIST_JSON="$HTTP_BODY"
}

# echoes the resolved device id on success; on none/ambiguous prints to stderr, non-zero.
resolve_device() {
  local q="$1"; load_devices
  local filt='.devices[] | select(%s) | .id'
  local exact
  exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
    '.devices[] | select((.id==$q) or (.nodeId==$q) or ((.addresses//[])|index($q))
       or ((.hostname//""|ascii_downcase)==($q|ascii_downcase))
       or ((.name//""|ascii_downcase|split(".")[0])==($q|ascii_downcase))) | .id')"
  local n; n="$(printf '%s\n' "$exact" | grep -c . || true)"
  if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
  if [ "$n" -eq 0 ]; then
    # substring fallback on name/hostname
    exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
      '.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
         or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase))) | .id')"
    n="$(printf '%s\n' "$exact" | grep -c . || true)"
  fi
  if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
  if [ "$n" -eq 0 ]; then echo "[ERROR] no device matches '$q' in tailnet $TAILNET" >&2; return 3; fi
  echo "[BLOCKED] '$q' is AMBIGUOUS ($n matches) — refusing to act. Candidates:" >&2
  printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
    '.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
       or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase)))
     | "  " + .id + "  " + (.name//"?") + "  " + ((.addresses//[])|join(","))' >&2
  return 4
}

dev_label() { # id -> "name (addr)"
  printf '%s' "$DEVLIST_JSON" | jq -r --arg id "$1" \
    '.devices[] | select(.id==$id) | (.name//"?") + " (" + ((.addresses//[])|join(","))+")"'
}

# ---- subcommands --------------------------------------------------------------
case "$SUB" in
  status)
    load_devices
    local_count="$(printf '%s' "$DEVLIST_JSON" | jq -r '.devices | length')"
    echo "[OK] Tailscale API reachable"
    echo "     tailnet : $TAILNET"
    echo "     auth    : $([ -n "$(vget credentials.client_id)" ] && echo OAuth-client || echo api-token)"
    echo "     devices : $local_count"
    ;;

  devices)
    load_devices
    if [ "$JSON" = 1 ]; then printf '%s\n' "$DEVLIST_JSON" | jq '.'; exit 0; fi
    printf '%s' "$DEVLIST_JSON" | jq -r '
      .devices | sort_by(.name) | .[]
      | [ (.name//"?"), .id, ((.addresses//[])|join(",")), (.os//"?"),
          (.user//"?"), (.lastSeen//"?"), ((.tags//[])|join(",")),
          (if .updateAvailable then "update" else "-" end) ] | @tsv' \
      | { echo -e "NAME\tID\tADDRESSES\tOS\tUSER\tLASTSEEN\tTAGS\tUPDATE"; cat; } | column -t -s $'\t'
    ;;

  device)
    Q="${POS[1]:?usage: device <name|id|100.x>}"
    ID="$(resolve_device "$Q")" || exit $?
    api GET "device/$ID?fields=all"; api_ok || { api_fail "device detail"; exit 2; }
    if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; else
      printf '%s\n' "$HTTP_BODY" | jq -r '
        "name        : " + (.name//"?"),
        "id          : " + (.id//"?"),
        "nodeId      : " + (.nodeId//"?"),
        "addresses   : " + ((.addresses//[])|join(", ")),
        "os          : " + (.os//"?"),
        "user        : " + (.user//"?"),
        "tags        : " + ((.tags//[])|join(", ")),
        "authorized  : " + ((.authorized//false)|tostring),
        "lastSeen    : " + (.lastSeen//"?"),
        "clientVer   : " + (.clientVersion//"?"),
        "update      : " + ((.updateAvailable//false)|tostring)'
    fi
    ;;

  delete-device)
    Q="${POS[1]:?usage: delete-device <name|id|100.x> [--confirm]}"
    ID="$(resolve_device "$Q")" || exit $?
    LBL="$(dev_label "$ID")"
    if [ "$CONFIRM" != 1 ]; then
      echo "[BLOCKED] would DELETE device $ID  $LBL  from tailnet $TAILNET"
      echo "          re-run with --confirm to delete."
      exit 3
    fi
    api DELETE "device/$ID"; api_ok || { api_fail "delete device"; exit 2; }
    echo "[OK] deleted device $ID  $LBL"
    alert "[TAILSCALE] deleted device $LBL ($ID) from tailnet $TAILNET"
    ;;

  authorize)
    Q="${POS[1]:?usage: authorize <name|id|100.x> [--confirm]}"
    ID="$(resolve_device "$Q")" || exit $?
    LBL="$(dev_label "$ID")"
    if [ "$CONFIRM" != 1 ]; then
      echo "[BLOCKED] would AUTHORIZE device $ID  $LBL  on tailnet $TAILNET"
      echo "          re-run with --confirm to authorize."
      exit 3
    fi
    api POST "device/$ID/authorized" '{"authorized":true}'; api_ok || { api_fail "authorize device"; exit 2; }
    echo "[OK] authorized device $ID  $LBL"
    alert "[TAILSCALE] authorized device $LBL ($ID) on tailnet $TAILNET"
    ;;

  keys)
    api GET "tailnet/$TAILNET/keys"; api_ok || { api_fail "list keys"; exit 2; }
    if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; exit 0; fi
    printf '%s' "$HTTP_BODY" | jq -r '
      .keys // [] | .[]
      | [ .id, (.description//"-"),
          ((.capabilities.devices.create.tags//[])|join(",") // "-"),
          ((.capabilities.devices.create.reusable//false)|tostring),
          (.created//"?"), (.expires//"?") ] | @tsv' \
      | { echo -e "KEYID\tDESC\tTAGS\tREUSABLE\tCREATED\tEXPIRES"; cat; } | column -t -s $'\t'
    ;;

  create-key)
    TAGS_JSON="$(printf '%s\n' "${TAGS[@]:-}" | jq -R . | jq -s 'map(select(length>0))')"
    EXPIRY_SECONDS=$(( EXPIRY_DAYS * 86400 ))
    BODY="$(jq -n \
      --argjson reusable "$REUSABLE" --argjson ephemeral "$EPHEMERAL" --argjson preauth "$PREAUTH" \
      --argjson expiry "$EXPIRY_SECONDS" --arg desc "$DESC" --argjson tags "$TAGS_JSON" \
      '{capabilities:{devices:{create:{reusable:$reusable,ephemeral:$ephemeral,preauthorized:$preauth,tags:$tags}}},expirySeconds:$expiry,description:$desc}')"
    if [ "$CONFIRM" != 1 ]; then
      echo "[BLOCKED] would CREATE auth key on tailnet $TAILNET with:"
      printf '%s\n' "$BODY" | jq '.'
      echo "          re-run with --confirm to create."
      exit 3
    fi
    api POST "tailnet/$TAILNET/keys" "$BODY"; api_ok || { api_fail "create key"; exit 2; }
    KID="$(printf '%s' "$HTTP_BODY" | jq -r '.id // "?"')"
    echo "[OK] created auth key $KID (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
    echo "[WARNING] the key secret is shown ONCE below — store it in the vault, never in chat/commit:"
    printf '%s' "$HTTP_BODY" | jq -r '.key // "(no key field returned)"'
    alert "[TAILSCALE] created auth key $KID on tailnet $TAILNET (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
    ;;

  delete-key)
    KID="${POS[1]:?usage: delete-key <keyId> [--confirm]}"
    if [ "$CONFIRM" != 1 ]; then
      echo "[BLOCKED] would DELETE/revoke auth key $KID on tailnet $TAILNET"
      echo "          re-run with --confirm to revoke."
      exit 3
    fi
    api DELETE "tailnet/$TAILNET/keys/$KID"; api_ok || { api_fail "delete key"; exit 2; }
    echo "[OK] revoked auth key $KID"
    alert "[TAILSCALE] revoked auth key $KID on tailnet $TAILNET"
    ;;

  *)
    echo "[ERROR] unknown subcommand: $SUB"
    usage 2
    ;;
esac
