#!/bin/bash
#
# credential-store.sh
# Secret storage wrapper over the macOS Keychain. Every script reads and writes
# tokens through here rather than calling `security` itself, so key mapping,
# argv hygiene and the audit trail live in one place.
#
# Subcommands:
#   get <key>            Read secret value to stdout (empty + exit 1 if missing)
#   set <key> <value>    Store secret (overwrites if exists)
#   set <key> -          Same, but read the secret from stdin. Preferred:
#                        keeps the secret off this script's argv (visible to
#                        ps). A literal "-" value cannot be stored this way.
#   delete <key>         Remove secret
#   probe <key>          Is the secret readable right now, without a prompt?
#                        Prints one word - readable | missing | locked - and
#                        never the value: the backend's stdout goes to
#                        /dev/null and only its exit status and error text
#                        are read. Exit 0 readable, 1 missing, 5 locked.
#   list                 List all keys (one per line)
#   platform             Print detected platform: macos | unknown
#   doctor               Check backend availability, print remediation if missing
#
# Backend: the macOS Keychain via `security`. The pipeline is macOS only
# (ADR-0012); on any other host every subcommand except `platform` exits 2.
#
# Returns 0 on success, 1 on missing/empty value, 2 on backend missing or a
# non-macOS host, 3 on usage error, 5 when `probe` finds the store locked.

set -euo pipefail

detect_platform() {
  case "$(uname -s 2>/dev/null)" in
    Darwin) echo "macos" ;;
    *) echo "unknown" ;;
  esac
}

PLATFORM="${CREDENTIAL_STORE_PLATFORM:-$(detect_platform)}"
CMD="${1:-}"
shift || true

if [ "$PLATFORM" != "macos" ] && [ "$CMD" != "platform" ]; then
  echo "ERR: macOS only, see ADR-0012 (detected: $PLATFORM)" >&2
  exit 2
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"

# CREDENTIAL_STORE_KEYCHAIN scopes every backend call to one keychain file
# instead of the user's search list. Tests point it at a throwaway keychain so
# they never write to the login keychain. keychain.py reads the same variable.
KEYCHAIN_FILE=()
if [ -n "${CREDENTIAL_STORE_KEYCHAIN:-}" ]; then KEYCHAIN_FILE=("$CREDENTIAL_STORE_KEYCHAIN"); fi
KEYCHAIN_PY="$SCRIPT_DIR/../scripts/keychain.py"

# --- Python delegate --------------------------------------------------------
# Prefer the Python helper for deterministic behaviour:
#   - It writes BOTH -l (label) and -s (service) attributes, so items
#     are findable by either convention (personal tokens use -l, this script
#     historically used -s).
#   - It returns clean exit codes (0 ok / 1 missing / 2 backend / 4 error).
# Opt out with `KEYCHAIN_DELEGATE=0` if you need raw shell behaviour.
delegate_to_python() {
  [ "${KEYCHAIN_DELEGATE:-1}" != "0" ] || return 1
  command -v python3 >/dev/null 2>&1 || return 1
  [ -f "$KEYCHAIN_PY" ] || return 1
  [ "$PLATFORM" = "macos" ]
}

# --- Logical key -> backend key --------------------------------------------
# Callers pass a LOGICAL key ("github", "jira", "figma_pat"). The actual
# Keychain entry is named by
# prefs.global.keychainMapping, so a lookup that uses the logical key verbatim
# finds nothing whenever the two differ -  silently, because a missing entry and
# a missing mapping look identical from here. Resolve the mapping first and fall
# back to the logical key when no mapping exists (backward compatible with
# entries whose name already equals the logical key).
PREFS_FILE="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"

# Reader order is python3 then node, and both are here on purpose. python3 is not
# guaranteed on a mac (it arrives with the Command Line Tools or Homebrew); node is
# guaranteed wherever this pipeline is installed at all, because the installer IS a
# node program. With a python3-only reader a host without python3 would skip the
# mapping in silence, look up the LOGICAL key, and report a token as missing while it
# sits under its mapped name. A missing interpreter must not look like a missing token.
resolve_key() {
  local logical="$1" mapped=""
  if [ -f "$PREFS_FILE" ]; then
    if command -v python3 >/dev/null 2>&1; then
      mapped=$(python3 -c '
import json, sys
try:
    with open(sys.argv[1], encoding="utf-8") as fh:
        prefs = json.load(fh)
except Exception:
    sys.exit(0)
name = prefs.get("global", {}).get("keychainMapping", {}).get(sys.argv[2])
if isinstance(name, str) and name.strip():
    sys.stdout.write(name.strip())
' "$PREFS_FILE" "$logical" 2>/dev/null || true)
    fi
    if [ -z "$mapped" ] && command -v node >/dev/null 2>&1; then
      mapped=$(node -e '
try {
  const prefs = JSON.parse(require("fs").readFileSync(process.argv[1], "utf-8"));
  const name = ((prefs.global || {}).keychainMapping || {})[process.argv[2]];
  if (typeof name === "string" && name.trim()) process.stdout.write(name.trim());
} catch { /* no mapping readable - caller falls back to the logical key */ }
' "$PREFS_FILE" "$logical" 2>/dev/null || true)
    fi
    if [ -z "$mapped" ] && ! command -v python3 >/dev/null 2>&1 && ! command -v node >/dev/null 2>&1; then
      echo "WARN: neither python3 nor node found; keychainMapping ignored, looking up '$logical' verbatim." >&2
    fi
  fi
  printf '%s' "${mapped:-$logical}"
}

# --- Subcommands ------------------------------------------------------------
# Append one PAT-lookup audit event. Every `get` goes through here, which is the
# point of the control: the audit trail described in the .gitignore hardening and
# managed by `/multi-agent:prune-logs` was written by audit-log.sh, but nothing
# ever called it, so the trail was always empty and the control was inert.
#
# Never fails the caller and never echoes a secret: audit-log.sh hashes the repo
# URL, logs the logical key (not the value), and is silent on error by design.
audit_lookup() {
  local logical="$1" success="$2"
  local audit="$SCRIPT_DIR/../scripts/audit-log.sh"
  [ -f "$audit" ] || return 0
  bash "$audit" pat_lookup "$logical" "${USER:-unknown}" "${MULTI_AGENT_REPO_URL:-}" "$success" \
    >/dev/null 2>&1 || true
}

do_get() {
  local key="${1:-}"
  [ -z "$key" ] && { echo "usage: $0 get <key>" >&2; exit 3; }
  local logical="$key"
  key=$(resolve_key "$key")
  if delegate_to_python; then
    local val rc
    val=$(python3 "$KEYCHAIN_PY" get "$key" 2>/dev/null) || rc=$?
    rc=${rc:-0}
    if [ "$rc" -eq 0 ] && [ -n "$val" ]; then
      audit_lookup "$logical" true
      printf '%s' "$val"
      return 0
    fi
    audit_lookup "$logical" false
    case "$rc" in
      2) echo "ERR: credential backend unavailable on $PLATFORM" >&2; return 2 ;;
      4) echo "ERR: credential backend error while reading '$logical'" >&2; return 4 ;;
    esac
    return 1
  fi
  local val=""
  # `-w` prints a value it considers non-printable (a newline included) as
  # bare hex with no marker. `-g` prints `password: 0x<HEX>` in that case,
  # and the marker is what makes the decoding unambiguous.
  local attrs hex
  attrs=$(security find-generic-password -s "$key" -g ${KEYCHAIN_FILE[@]+"${KEYCHAIN_FILE[@]}"} 2>&1 >/dev/null || true)
  hex=$(printf '%s\n' "$attrs" | sed -n 's/^password: 0x\([0-9A-Fa-f]*\).*/\1/p' | head -n 1)
  if [ -n "$hex" ]; then
    val=$(printf '%s' "$hex" | xxd -r -p; printf x); val=${val%x}
  else
    val=$(security find-generic-password -s "$key" -w ${KEYCHAIN_FILE[@]+"${KEYCHAIN_FILE[@]}"} 2>/dev/null || true)
  fi
  if [ -z "$val" ]; then
    audit_lookup "$logical" false
    return 1
  fi
  audit_lookup "$logical" true
  printf '%s' "$val"
}

do_set() {
  local key="${1:-}" val="${2:-}"
  [ -z "$key" ] && { echo "usage: $0 set <key> <value|->" >&2; exit 3; }
  # Resolve through the same mapping as do_get so set/get stay symmetric.
  key=$(resolve_key "$key")
  # "-" means: read the secret from stdin (keeps it off argv).
  if [ "$val" = "-" ]; then
    val=$(cat)
  fi
  # The delegate receives the secret on stdin and hands it to `security -i` on
  # stdin as well, so no argv ever carries it, and it writes both the -l and -s
  # attributes that lookups by either convention expect.
  if delegate_to_python; then
    printf '%s' "$val" | python3 "$KEYCHAIN_PY" set "$key" -
    return $?
  fi
  # `security -i` reads the add command from stdin, so the secret never
  # lands on the `security` argv (visible to ps). printf is a bash
  # builtin, so no argv exposure there either. The value goes in as
  # `-X <hex>`: the interactive tokenizer reads one command per line, so a
  # newline inside a quoted `-w` value would end the command early.
  local key_sec val_hex target=""
  key_sec=${key//\\/\\\\}; key_sec=${key_sec//\"/\\\"}
  val_hex=$(printf '%s' "$val" | od -An -v -tx1 | tr -d ' \n')
  if [ -n "${CREDENTIAL_STORE_KEYCHAIN:-}" ]; then
    target=${CREDENTIAL_STORE_KEYCHAIN//\\/\\\\}; target=" \"${target//\"/\\\"}\""
  fi
  printf 'add-generic-password -U -a "%s" -l "%s" -s "%s" -X %s%s\n' \
    "${USER:-claude}" "$key_sec" "$key_sec" "$val_hex" "$target" | security -i >/dev/null 2>&1
}

do_delete() {
  local key="${1:-}"
  [ -z "$key" ] && { echo "usage: $0 delete <key>" >&2; exit 3; }
  key=$(resolve_key "$key")
  if delegate_to_python; then
    python3 "$KEYCHAIN_PY" delete "$key" >/dev/null 2>&1 || true
    return 0
  fi
  security delete-generic-password -s "$key" ${KEYCHAIN_FILE[@]+"${KEYCHAIN_FILE[@]}"} >/dev/null 2>&1 || true
}

# Readable without a prompt, answered without the value ever leaving the backend.
#
# The data is really read - an attribute-only lookup would say "present" for an
# item whose access control still asks before handing the value over, which is
# exactly the item a background tick cannot use. `-w` prints the value on
# stdout, and stdout goes to /dev/null; stderr carries only the backend's error
# text, and that is what separates a locked keychain (errSecInteractionNotAllowed,
# exit 36) from a missing item (errSecItemNotFound, exit 44). Both conventions are
# tried, label then service, as keychain.py reads them.
do_probe() {
  local key="${1:-}"
  [ -z "$key" ] && { echo "usage: $0 probe <key>" >&2; exit 3; }
  key=$(resolve_key "$key")
  local locked=0 rc err
  local sec="${CREDENTIAL_STORE_SECURITY_BIN:-security}" attr
  for attr in -l -s; do
    rc=0
    err=$("$sec" find-generic-password "$attr" "$key" -w ${KEYCHAIN_FILE[@]+"${KEYCHAIN_FILE[@]}"} 2>&1 >/dev/null) || rc=$?
    if [ "$rc" -eq 0 ]; then echo "readable"; return 0; fi
    case "$rc:$err" in
      36:* | *"nteraction is not allowed"*) locked=1 ;;
    esac
  done
  if [ "$locked" -eq 1 ]; then echo "locked"; return 5; fi
  echo "missing"
  return 1
}

do_list() {
  # Read `svce` - the SERVICE attribute, which is what `set` writes with -s and
  # `get` looks up with -s. This used to read `0x00000007`, which is the LABEL:
  # it happens to match for items written by keychain.py (which stamps both -l and
  # -s), but it is the wrong key in principle and diverges on any item whose label
  # was set independently, listing a name `get` cannot resolve.
  #
  # `dump-keychain` prints the first few attributes under numeric tags and the rest
  # under named ones, and `svce` is in the named group - so the tag to match is
  # `"svce"`, not `0x00000008`.
  #
  # Without `-d` this reads attributes only and does not prompt for access; only
  # the data-dumping form does.
  #
  # A trailing `\000` appears when security prints the value in its hex+C-string
  # form; it is not part of the service name.
  security dump-keychain ${KEYCHAIN_FILE[@]+"${KEYCHAIN_FILE[@]}"} 2>/dev/null \
    | awk -F'"' '/"svce"<blob>=/ && NF >= 5 { v = $4; sub(/\\000$/, "", v); if (v != "") print v }' \
    | sort -u
}

do_doctor() {
  echo "Platform:        $PLATFORM"
  command -v security >/dev/null 2>&1 \
    && echo "Backend:         security ✓ (built-in)" \
    || echo "Backend:         security ✗ MISSING (impossible on macOS)"
}

case "$CMD" in
  get)      do_get "$@" ;;
  set)      do_set "$@" ;;
  delete)   do_delete "$@" ;;
  probe)    do_probe "$@" ;;
  list)     do_list ;;
  platform) echo "$PLATFORM" ;;
  doctor)   do_doctor ;;
  *) echo "usage: $0 {get|set|delete|probe|list|platform|doctor} [args]" >&2; exit 3 ;;
esac
