#!/usr/bin/env bash
# credential-inventory.sh  -  what the pipeline can reach right now, and what it cannot.
#
# WHY THIS EXISTS
#
# A run asked the user to paste a Crashlytics stack trace by hand while a Firebase
# service-account JSON sat in the Keychain, mapped as `firebase`, fully valid. Nothing
# was broken - the pipeline simply never asked itself "do I already hold a credential
# that answers this?" before asking the user. From the user's side that is worse than a
# failure: they had supplied the key precisely so this would not happen.
#
# One command, so there is no excuse for asking blind. Run it before any question that
# requests data an external system holds, and let the answer shape the question:
#
#   - credential present  -> ask for the *pointer* (the issue URL), not the payload,
#                            and say you will fetch it
#   - credential missing  -> say which logical key is unmapped and offer the Save Flow
#   - credential dead     -> say it is expired and offer to refresh it
#
# SAFETY
#
# Values are never printed, never stored in a variable that reaches stdout, and never
# logged. Presence is probed through `credential-store.sh get` with stdout discarded, so
# the secret goes to /dev/null and only the exit status is read.
#
# Usage:
#   credential-inventory.sh              # human-readable table
#   credential-inventory.sh --json       # machine-readable, for state files
#   credential-inventory.sh --probe      # also verify each configured service answers
#   credential-inventory.sh --key firebase   # single logical key, exit 0 present / 1 not
#
# PRESENT IS NOT THE SAME AS WORKING
#
# Without --probe this reports what is configured. With --probe each configured
# credential makes one cheap authenticated request, and the verdict distinguishes the
# three failures that need three different user actions:
#
#   reachable          the service answered and accepted the credential
#   auth-rejected      401/403 - the credential is dead, the user must refresh it
#   unreachable        no response at all - on a corporate host, almost always the VPN
#   no-host-configured a token exists but its host was never recorded in preferences
#   well-formed        structurally valid, liveness not checkable without more input
#   not-probeable      no cheap probe exists for this credential type
#
# Only configured credentials are probed. An unmapped key is a capability the user chose
# not to enable, not a problem to report.
#
# Exit codes: 0 = inventory produced (or the queried key is present), 1 = queried key
# absent, 3 = usage error.

set -uo pipefail

PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"

STORE=""
for c in "$HOME/.claude/lib/credential-store.sh" "$HOME/.copilot/lib/credential-store.sh" \
         "$HOME/.codex/lib/credential-store.sh" "$(dirname "$0")/credential-store.sh"; do
  [ -f "$c" ] && { STORE="$c"; break; }
done

MODE="table"
QUERY=""
PROBE=0
TIMEOUT="${MULTI_AGENT_PROBE_TIMEOUT:-6}"
while [ $# -gt 0 ]; do
  case "$1" in
    --json)  MODE="json"; shift ;;
    --probe) PROBE=1; shift ;;
    --key)   QUERY="${2:-}"; shift 2 || shift ;;
    -h|--help)
      echo "usage: $0 [--json] [--probe] [--key <logical-key>]" >&2; exit 3 ;;
    *) echo "ERR: unexpected arg $1" >&2; exit 3 ;;
  esac
done

pref() {  # pref <dotted.path> -> value or empty
  [ -f "$PREFS" ] || return 0
  python3 - "$PREFS" "$1" <<'PYPREF' 2>/dev/null
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
for k in sys.argv[2].split("."):
    if not isinstance(d, dict):
        sys.exit(0)
    d = d.get(k)
    if d is None:
        sys.exit(0)
print(d if not isinstance(d, (dict, list)) else "")
PYPREF
}

# An authenticated request whose credential never appears in argv.
#
# `curl -H "Authorization: Bearer $TOK"` puts the secret in the process command line,
# where any `ps` on the machine can read it. `--config -` takes both the header and the
# URL from stdin instead, so the token exists only in the pipe. Same reason the Vercel
# wrapper refuses a `--token=` argv.
#
# probe_http <url> <header-name> <header-value> -> prints the HTTP status
probe_http() {
  local url="$1" hname="$2" hval="$3" out
  # No `|| echo 000` here: curl already writes `000` through write-out when it never
  # got a response, and exits non-zero as well. Appending produced `000000`, which fell
  # through classify_status into a bogus `unexpected-000000` verdict for what was simply
  # a closed VPN.
  out=$(printf 'url = "%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
    "$url" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
  printf '%s' "${out:-000}"
}

# probe_basic <url> <user> <pass> -> prints the HTTP status
# probe_basic_hdr <url> <user> <pass> <header-name> <header-value> -> HTTP status
probe_basic_hdr() {
  local url="$1" u="$2" pw="$3" hname="$4" hval="$5" out
  out=$(printf 'url = "%s"\nuser = "%s:%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
    "$url" "$u" "$pw" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
  printf '%s' "${out:-000}"
}

probe_basic() {
  local url="$1" u="$2" p="$3" out
  out=$(printf 'url = "%s"\nuser = "%s:%s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
    "$url" "$u" "$p" "$TIMEOUT" | curl --config - 2>/dev/null)
  printf '%s' "${out:-000}"
}

# Map an HTTP status onto the vocabulary the pipeline reasons about. `000` is curl's
# "never got a response" - DNS failure, refused connection, timeout - which on a
# corporate host is almost always a closed VPN, and is a completely different user
# action from a rejected credential.
classify_status() {
  case "$1" in
    2??)      echo "reachable" ;;
    # A redirect means the host answered. Corporate services commonly bounce an
    # unauthenticated API call to an SSO login page, which classify as auth-rejected
    # rather than reachable: the request did not get its data.
    30?)      echo "auth-rejected" ;;
    401|403)  echo "auth-rejected" ;;
    404)      echo "reachable" ;;   # endpoint answered; the probe path may just not exist
    000|"")   echo "unreachable" ;;
    5??)      echo "server-error" ;;
    # Anything that is not a three-digit status means the probe itself misbehaved.
    # Saying so beats inventing a service verdict from a malformed value.
    *)        echo "probe-error" ;;
  esac
}

# Reachability for one logical key. Never prints a credential; the value is piped
# straight into curl's stdin config and discarded.
#
# Hosts always come from preferences - a self-hosted Jira / Bitbucket / Confluence /
# Fortify / Graylog address is deployment-specific and must never be baked in. Only
# genuinely global public APIs are named literally. Host keys live at
# `global.hosts.<service>` per prefs.schema.json - an earlier version of this probe
# guessed `global.<service>Host` and reported no-host-configured for every
# self-hosted service the user had actually configured.
probe_one() {
  local key="$1" tok host status
  tok=$(bash "$STORE" get "$key" 2>/dev/null) || { echo "no-credential"; return; }
  [ -n "$tok" ] || { echo "no-credential"; return; }

  case "$key" in
    jira)
      host=$(pref global.hosts.jira)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      status=$(probe_http "https://${host}/rest/api/2/myself" "Authorization" "Bearer $tok") ;;
    confluence)
      host=$(pref global.hosts.confluence)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      status=$(probe_http "https://${host}/rest/api/user/current" "Authorization" "Bearer $tok") ;;
    bitbucket_token)
      host=$(pref global.hosts.bitbucket)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      local bbuser bbkey
      bbkey=$(pref global.keychainMapping.bitbucket_user)
      bbuser=$([ -n "$bbkey" ] && bash "$STORE" get bitbucket_user 2>/dev/null || echo "")
      if [ -n "$bbuser" ]; then
        status=$(probe_basic "https://${host}/rest/api/1.0/repos?limit=1" "$bbuser" "$tok")
      else
        status=$(probe_http "https://${host}/rest/api/1.0/repos?limit=1" "Authorization" "Bearer $tok")
      fi ;;
    github)
      status=$(probe_http "https://api.github.com/user" "Authorization" "Bearer $tok") ;;
    figma)
      status=$(probe_http "https://api.figma.com/v1/me" "X-Figma-Token" "$tok") ;;
    figma_mcp)
      # An OAuth token for the Figma MCP server, not a REST PAT. Sending it to
      # api.figma.com returns 403 for a perfectly healthy token, which is worse than
      # not probing: it would send the user to regenerate something that works.
      # Liveness for this one belongs to figma-mcp-refresh.sh, which owns the grant.
      printf '%s' "$tok" | grep -q . && echo "not-probeable" || echo "malformed"
      return ;;
    npm)
      status=$(probe_http "https://registry.npmjs.org/-/whoami" "Authorization" "Bearer $tok") ;;
    fortify)
      host=$(pref global.hosts.fortify)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      # Shapes copied from fetch-fortify.sh, which already works against SSC: the API
      # lives under /ssc/, and SSC accepts either a Bearer token or its own
      # `FortifyToken <base64>` scheme. Probing the wrong base path returned a 302 to
      # the login page and read as a broken credential.
      status=$(probe_http "https://${host}/ssc/api/v1/projects?limit=1" "Authorization" "Bearer $tok")
      if [ "$(classify_status "$status")" != "reachable" ]; then
        status=$(probe_http "https://${host}/ssc/api/v1/projects?limit=1" \
          "Authorization" "FortifyToken $(printf '%s' "$tok" | base64 | tr -d '\n')")
      fi ;;
    graylog)
      host=$(pref global.hosts.graylog)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      # Graylog PATs authenticate as basic auth with the literal password "token", and
      # the API rejects requests without X-Requested-By. Both per fetch-graylog.sh.
      status=$(probe_basic_hdr "https://${host}/api/system" "$tok" "token" \
        "X-Requested-By" "multi-agent-pipeline") ;;
    jenkins)
      host=$(pref global.hosts.jenkins)
      [ -n "$host" ] || { echo "no-host-configured"; return; }
      status=$(probe_http "https://${host}/api/json" "Authorization" "Bearer $tok") ;;
    firebase)
      # A service-account JSON, not a bearer token: an OAuth exchange would be needed
      # to reach Crashlytics, and the fetcher does that per issue. Verifying the shape
      # is honest about what is known - well-formed, not proven reachable.
      if printf '%s' "$tok" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("project_id") and d.get("private_key") else 1)' 2>/dev/null; then
        echo "well-formed"; return
      fi
      echo "malformed"; return ;;
    appstore_connect_private_key)
      printf '%s' "$tok" | grep -q "BEGIN PRIVATE KEY" && echo "well-formed" || echo "malformed"
      return ;;
    *)
      echo "not-probeable"; return ;;
  esac
  classify_status "$status"
}

# What each logical key unlocks, in the pipeline's own terms. This is the column that
# turns an inventory into an actionable question: "I hold `firebase`, so give me the
# issue URL and I will pull the stack trace" is only sayable if the capability is
# written down somewhere.
capability_of() {
  case "$1" in
    jira)              echo "read the ticket, its comments and its linked issues; post the Phase 7 comment" ;;
    bitbucket_token)   echo "read the repo, open and update pull requests" ;;
    bitbucket_user)    echo "identify the PR author (paired with bitbucket_token)" ;;
    github)            echo "read issues, open pull requests, read Actions runs" ;;
    confluence)        echo "read linked pages and publish the analysis document" ;;
    firebase)          echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL)" ;;
    fortify)           echo "pull a static-analysis finding and its remediation guidance" ;;
    graylog)           echo "pull request logs by transaction or conversation id (advisory)" ;;
    figma|figma_mcp)   echo "fetch design context, screenshots and Code Connect mappings" ;;
    jenkins)           echo "read build results" ;;
    npm)               echo "publish to the npm registry" ;;
    appstore_connect_key_id|appstore_connect_issuer_id|appstore_connect_private_key)
                       echo "validate an archive against App Store rules before submission" ;;
    claude_oauth_token|claude_oauth_token_fallback)
                       echo "run headless review and analysis passes" ;;
    *)                 echo "(no capability recorded for this key)" ;;
  esac
}

mapping_keys() {
  [ -f "$PREFS" ] || return 0
  python3 - "$PREFS" <<'PY' 2>/dev/null
import json, sys
try:
    p = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
m = (p.get("global") or {}).get("keychainMapping") or {}
for k, v in m.items():
    print(f"{k}\t{'1' if v else '0'}")
PY
}

# Presence probe. The value lands in /dev/null; only the exit status is observed.
probe() {
  [ -n "$STORE" ] || return 2
  bash "$STORE" get "$1" >/dev/null 2>&1
}

if [ -n "$QUERY" ]; then
  probe "$QUERY"
  rc=$?
  case "$rc" in
    0)
      if [ "$PROBE" = "1" ]; then
        echo "$QUERY: present, $(probe_one "$QUERY")  -  can $(capability_of "$QUERY")"
      else
        echo "$QUERY: present  -  can $(capability_of "$QUERY")"
      fi
      exit 0 ;;
    2) echo "$QUERY: no credential helper on this host" >&2; exit 1 ;;
    *) echo "$QUERY: NOT AVAILABLE  -  onboard it via /multi-agent:setup before relying on it" >&2; exit 1 ;;
  esac
fi

ROWS=""
while IFS=$'\t' read -r key mapped; do
  [ -z "$key" ] && continue
  if [ "$mapped" != "1" ]; then
    state="unmapped"
    reach="not-probed"
  elif probe "$key"; then
    state="present"
    # Only configured credentials are probed. A key the user never onboarded is not a
    # problem to report - it is a capability they chose not to enable.
    reach=$([ "$PROBE" = "1" ] && probe_one "$key" || echo "not-probed")
  else
    state="mapped-but-missing"
    reach="not-probed"
  fi
  ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key")\n"
done < <(mapping_keys)

if [ -z "$ROWS" ]; then
  if [ "$MODE" = "json" ]; then
    echo '{"status":"empty","reason":"no keychainMapping in preferences","credentials":[]}'
  else
    echo "no keychainMapping found in $PREFS  -  run /multi-agent:setup" >&2
  fi
  exit 0
fi

if [ "$MODE" = "json" ]; then
  # The rows travel through a temp file, not a pipe. `python3 - <<EOF` takes its SCRIPT
  # from stdin, so a piped payload never reaches sys.stdin: --json came back with empty
  # arrays while the table mode looked correct. Caught by smoke-credential-awareness.
  ROWS_FILE="$(mktemp)"
  trap 'rm -f "$ROWS_FILE"' EXIT
  printf '%b' "$ROWS" > "$ROWS_FILE"
  python3 - "$ROWS_FILE" <<'PYJSON'
import json, sys
rows = []
with open(sys.argv[1]) as fh:
    for line in fh:
        line = line.rstrip("\n")
        if not line:
            continue
        parts = line.split("\t")
        if len(parts) < 4:
            continue
        rows.append({
            "logical": parts[0],
            "state": parts[1],
            "reachability": parts[2],
            "capability": parts[3],
        })

# `usable` is presence only, so a caller that never probed still gets a meaningful
# answer. `reachable` is the stronger claim and exists only after --probe.
OK_REACH = {"reachable", "well-formed"}
BLOCKED = {"auth-rejected", "malformed"}
probed = [r for r in rows if r["reachability"] != "not-probed"]
print(json.dumps({
    "status": "ok",
    "probed": bool(probed),
    "usable": [r["logical"] for r in rows if r["state"] == "present"],
    "needsAttention": [r["logical"] for r in rows if r["state"] != "present"],
    # Separated on purpose: a dead token is the user's to refresh, an unreachable host
    # is usually a closed VPN, and a missing host is a setup gap. Collapsing them into
    # one "failed" bucket is what made the pipeline ask blind questions.
    "reachable": [r["logical"] for r in probed if r["reachability"] in OK_REACH],
    "authRejected": [r["logical"] for r in probed if r["reachability"] in BLOCKED],
    "unreachable": [r["logical"] for r in probed if r["reachability"] == "unreachable"],
    "noHostConfigured": [r["logical"] for r in probed if r["reachability"] == "no-host-configured"],
    "credentials": rows,
}, indent=2))
PYJSON
else
  printf '%b' "$ROWS" | awk -F'\t' '
    BEGIN { printf "%-28s %-20s %-20s %s\n", "LOGICAL KEY", "STATE", "REACHABILITY", "UNLOCKS" }
    { printf "%-28s %-20s %-20s %s\n", $1, $2, $3, $4 }'
fi
