#!/usr/bin/env bash
set -euo pipefail

# Safe BobRouter configuration helper for Claude Code.
# No remote scripts are executed. The BobRouter key is read only from the
# BOBROUTER_API_KEY environment variable and is never written to disk.

BASE_URL="${BOBROUTER_BASE_URL:-https://api.bobrouter.com}"
MODEL="${BOBROUTER_MODEL:-claude-sonnet-4-6}"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
SETTINGS_FILE="${BOBROUTER_CLAUDE_SETTINGS:-$CONFIG_DIR/settings.json}"
STATE_DIR="$CONFIG_DIR/bobrouter-helper"
STATE_FILE="$STATE_DIR/state.json"
BACKUP_DIR="$STATE_DIR/backups"
LOCK_DIR="$STATE_DIR/target.lock"
MARKER="bobrouter-claude-code-helper-v1"

usage() {
  cat <<'EOF'
Usage: bobrouter-claude-code.sh COMMAND [OPTIONS]

Commands:
  status       Show local prerequisites and configuration (no secrets).
  probe        Query /v1/models and /v1/messages; writes no files.
  dry-run      Show the settings merge that apply would perform.
  apply        Back up and merge BobRouter routing into Claude settings.
  run          Probe, then launch Claude Code with ephemeral environment.
  rollback     Restore the exact pre-apply settings backup.
  uninstall    Remove only helper-managed keys, preserving other settings.

Options:
  --model ID   BobRouter model ID to verify and configure.
  --yes        Skip the apply confirmation prompt.
  -h, --help   Show this help.

Environment:
  BOBROUTER_API_KEY          Required for probe, apply, and run. Never stored.
  BOBROUTER_MODEL            Default model (default: claude-sonnet-4-6).
  BOBROUTER_BASE_URL         Default: https://api.bobrouter.com
  BOBROUTER_CLAUDE_SETTINGS  Settings path (default: ~/.claude/settings.json).
  CLAUDE_CONFIG_DIR          Honored for Claude's config directory.

Examples:
  BOBROUTER_API_KEY='...' ./bobrouter-claude-code.sh probe
  BOBROUTER_API_KEY='...' ./bobrouter-claude-code.sh run --model claude-sonnet-4-6
  BOBROUTER_API_KEY='...' ./bobrouter-claude-code.sh dry-run
  BOBROUTER_API_KEY='...' ./bobrouter-claude-code.sh apply
  ./bobrouter-claude-code.sh rollback
  ./bobrouter-claude-code.sh uninstall
EOF
}

die() { printf 'error: %s\n' "$*" >&2; exit 1; }
note() { printf '%s\n' "$*" >&2; }
need() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; }

validate_path() {
  need python3
  python3 - "$1" "${2:-}" <<'PY'
import os, sys
path = os.path.abspath(os.path.expanduser(sys.argv[1]))
current = os.sep
for part in path.split(os.sep)[1:]:
    if not part:
        continue
    current = os.path.join(current, part)
    if os.path.lexists(current) and os.path.islink(current):
        raise SystemExit(f"refusing symlink path component: {current}")
if sys.argv[2] == "file" and os.path.lexists(path) and not os.path.isfile(path):
    raise SystemExit(f"settings path is not a regular file: {path}")
PY
}

acquire_lock() {
  validate_path "$CONFIG_DIR"
  mkdir -p "$CONFIG_DIR"
  validate_path "$SETTINGS_FILE" file
  validate_path "$STATE_DIR"
  validate_path "$BACKUP_DIR"
  mkdir -p "$STATE_DIR" "$BACKUP_DIR"
  validate_path "$STATE_DIR"
  validate_path "$BACKUP_DIR"
  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    die "another operation is active for settings target: $SETTINGS_FILE"
  fi
  trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
}

file_digest() {
  python3 - "$1" <<'PY'
import hashlib, sys
h = hashlib.sha256()
try:
    with open(sys.argv[1], "rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
except FileNotFoundError:
    print("")
else:
    print(h.hexdigest())
PY
}

file_identity() {
  python3 - "$1" <<'PY'
import os, sys
try:
    st = os.stat(sys.argv[1], follow_symlinks=False)
except FileNotFoundError:
    print("")
else:
    print(f"{st.st_dev}:{st.st_ino}:{st.st_size}:{st.st_mtime_ns}")
PY
}

atomic_copy() {
  local source="$1" destination="$2" mode="${3:-600}" candidate
  candidate="$(mktemp "$(dirname "$destination")/.bobrouter-copy.XXXXXX")"
  if ! python3 - "$source" "$candidate" <<'PY'
import os, shutil, sys
shutil.copyfile(sys.argv[1], sys.argv[2])
with open(sys.argv[2], "rb") as fh:
    os.fsync(fh.fileno())
PY
  then rm -f -- "$candidate"; return 1; fi
  chmod "$mode" "$candidate"
  if ! mv -f -- "$candidate" "$destination"; then rm -f -- "$candidate"; return 1; fi
}

atomic_write_state() {
  local candidate
  candidate="$(mktemp "$(dirname "$STATE_FILE")/.state.XXXXXX")"
  python3 - "$candidate" "$SETTINGS_FILE" "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" <<'PY'
import json, os, sys
path, settings, backup, existed, base, model, marker, pre_digest, pre_identity, post_digest = sys.argv[1:]
doc = {"settings": settings, "backup": backup, "existed": existed == "true", "base_url": base, "model": model, "marker": marker, "pre_digest": pre_digest, "pre_identity": pre_identity, "post_digest": post_digest}
with open(path, "w", encoding="utf-8") as fh:
    json.dump(doc, fh, indent=2, sort_keys=True); fh.write("\n"); fh.flush(); os.fsync(fh.fileno())
PY
  if ! chmod 600 "$candidate" || ! mv -f -- "$candidate" "$STATE_FILE"; then
    rm -f -- "$candidate"; return 1
  fi
}

require_key() {
  [ -n "${BOBROUTER_API_KEY:-}" ] || die 'set BOBROUTER_API_KEY in the current environment; the helper will not prompt for or persist it'
}

validate_inputs() {
  case "$BASE_URL" in
    https://api.bobrouter.com|https://api.bobrouter.com/) ;;
    *) die "refusing unapproved base URL: $BASE_URL" ;;
  esac
  BASE_URL="${BASE_URL%/}"
  [ -n "$MODEL" ] || die 'model ID must not be empty'
  case "$MODEL" in *[!A-Za-z0-9._:/-]*) die 'model ID contains unsupported characters' ;; esac
}

http_json() {
  local method="$1" url="$2" body="${3:-}" output="$4"
  local status
  case "$BOBROUTER_API_KEY" in *$'\n'*|*$'\r'*) die 'BOBROUTER_API_KEY contains a newline' ;; esac
  if [ "$method" = GET ]; then
    status="$(printf 'Authorization: Bearer %s\n' "$BOBROUTER_API_KEY" | \
      env -u BOBROUTER_API_KEY curl --silent --show-error --location --max-redirs 0 \
      --connect-timeout 10 --max-time 60 --output "$output" --write-out '%{http_code}' \
      -H @- -H 'Accept: application/json' "$url")" || return 1
  else
    status="$(printf 'Authorization: Bearer %s\n' "$BOBROUTER_API_KEY" | \
      env -u BOBROUTER_API_KEY curl --silent --show-error --location --max-redirs 0 \
      --connect-timeout 10 --max-time 90 --output "$output" --write-out '%{http_code}' \
      -X POST -H @- -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' \
      --data "$body" "$url")" || return 1
  fi
  printf '%s' "$status"
}

probe() {
  need curl
  need python3
  require_key
  validate_inputs

  local models_body messages_body models_status messages_status payload
  models_body="$(mktemp)"; messages_body="$(mktemp)"
  trap 'rm -f "${models_body:-}" "${messages_body:-}"' RETURN

  note "Checking account model catalog at $BASE_URL/v1/models ..."
  models_status="$(http_json GET "$BASE_URL/v1/models" '' "$models_body")" || die 'model catalog request failed'
  [ "$models_status" = 200 ] || die "model catalog returned HTTP $models_status"
  python3 - "$models_body" "$MODEL" <<'PY'
import json, sys
path, wanted = sys.argv[1:]
try:
    doc = json.load(open(path, encoding="utf-8"))
except Exception as exc:
    raise SystemExit(f"error: model catalog was not valid JSON: {exc}")
models = doc.get("data")
if not isinstance(models, list):
    raise SystemExit("error: model catalog has no data array")
ids = {m.get("id") for m in models if isinstance(m, dict)}
if wanted not in ids:
    raise SystemExit(f"error: model {wanted!r} is not in this account's current catalog")
print(f"Model verified in current account catalog: {wanted}")
PY

  payload="$(python3 - "$MODEL" <<'PY'
import json, sys
print(json.dumps({
    "model": sys.argv[1],
    "max_tokens": 8,
    "stream": True,
    "messages": [{"role": "user", "content": "Reply with exactly probe-ok"}],
}, separators=(",", ":")))
PY
)"
  note "Checking required Anthropic Messages endpoint at $BASE_URL/v1/messages ..."
  messages_status="$(http_json POST "$BASE_URL/v1/messages" "$payload" "$messages_body")" || die 'Messages API request failed'
  if [ "$messages_status" != 200 ]; then
    note "Messages endpoint returned HTTP $messages_status. Response summary:"
    python3 - "$messages_body" <<'PY' >&2
import json, sys
raw = open(sys.argv[1], "rb").read(4096).decode("utf-8", "replace")
try:
    obj = json.loads(raw)
    if isinstance(obj, dict) and isinstance(obj.get("error"), dict):
        obj = {"error": {k: obj["error"].get(k) for k in ("type", "code", "message") if obj["error"].get(k) is not None}}
    print(json.dumps(obj, ensure_ascii=True))
except Exception:
    print(raw[:500].replace("\n", " "))
PY
    die 'BobRouter does not currently satisfy the Claude Code Anthropic Messages contract; no configuration was written'
  fi
  grep -Eq '(^|[[:space:]])event: (message_start|content_block_delta)|"type"[[:space:]]*:[[:space:]]*"message"' "$messages_body" \
    || die 'HTTP 200 response did not look like an Anthropic Messages stream; no configuration was written'
  note 'Compatibility probe passed: current model exists and /v1/messages returned an Anthropic-style response.'
}

render_merge() {
  local source="$1" output="$2"
  python3 - "$source" "$output" "$BASE_URL" "$MODEL" "$MARKER" <<'PY'
import json, os, sys
source, output, base, model, marker = sys.argv[1:]
if os.path.exists(source):
    with open(source, encoding="utf-8") as fh:
        doc = json.load(fh)
else:
    doc = {}
if not isinstance(doc, dict):
    raise SystemExit("settings root must be a JSON object")
env = doc.get("env", {})
if not isinstance(env, dict):
    raise SystemExit("settings env value must be a JSON object")
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"):
    if key in env and env[key] not in (base, model):
        raise SystemExit(f"refusing to overwrite existing env.{key}; remove it deliberately or use ephemeral run")
if "ANTHROPIC_AUTH_TOKEN" in env or "ANTHROPIC_API_KEY" in env:
    raise SystemExit("refusing to touch settings containing a literal Anthropic credential; use ephemeral run or remove the secret deliberately")
env["ANTHROPIC_BASE_URL"] = base
env["ANTHROPIC_MODEL"] = model
env["BOBROUTER_CLAUDE_HELPER"] = marker
doc["env"] = env
with open(output, "w", encoding="utf-8") as fh:
    json.dump(doc, fh, indent=2, sort_keys=True)
    fh.write("\n")
PY
}

dry_run() {
  need python3
  validate_inputs
  validate_path "$SETTINGS_FILE" file
  local candidate
  mkdir -p "$(dirname "$SETTINGS_FILE")"
  candidate="$(mktemp "$(dirname "$SETTINGS_FILE")/.bobrouter-dry.XXXXXX")"; trap 'rm -f "${candidate:-}"' RETURN
  render_merge "$SETTINGS_FILE" "$candidate"
  printf 'Would merge into %s (credential remains in the process environment only):\n' "$SETTINGS_FILE"
  python3 -m json.tool "$candidate"
}

apply_config() {
  local assume_yes="$1"
  need python3
  acquire_lock
  [ ! -f "$STATE_FILE" ] || die 'a previous apply is still active; run rollback or uninstall before applying again'
  probe
  chmod 700 "$STATE_DIR" "$BACKUP_DIR"
  local candidate backup timestamp existed mode pre_digest pre_identity
  candidate="$(mktemp "$(dirname "$SETTINGS_FILE")/.bobrouter-apply.XXXXXX")"; trap 'rm -f "${candidate:-}"' RETURN
  render_merge "$SETTINGS_FILE" "$candidate"
  printf 'Proposed settings:\n'; python3 -m json.tool "$candidate"
  if [ "$assume_yes" != yes ]; then
    [ -t 0 ] || die 'apply requires an interactive terminal or --yes after reviewing dry-run'
    printf 'Apply this merge to %s? [y/N] ' "$SETTINGS_FILE" >&2
    read -r answer
    case "$answer" in y|Y|yes|YES|Yes) ;; *) die 'cancelled' ;; esac
  fi
  timestamp="$(date -u +%Y%m%dT%H%M%SZ)"; existed=false; backup=""
  if [ -e "$SETTINGS_FILE" ]; then
    validate_path "$SETTINGS_FILE" file
    backup="$BACKUP_DIR/settings.json.$timestamp.bak"
    atomic_copy "$SETTINGS_FILE" "$backup" 600 || die 'could not create backup'
    existed=true
  fi
  mode=600
  if [ "$existed" = true ]; then mode="$(python3 - "$SETTINGS_FILE" <<'PY'
import os, sys
print(format(os.stat(sys.argv[1], follow_symlinks=False).st_mode & 0o777, 'o'))
PY
)"; fi
  pre_digest="$(file_digest "$SETTINGS_FILE")"; pre_identity="$(file_identity "$SETTINGS_FILE")"
  if [ "$existed" = true ] && [ "$pre_digest" != "$(file_digest "$backup")" ]; then die 'settings changed while preparing apply'; fi
  atomic_write_state "$backup" "$existed" "$BASE_URL" "$MODEL" "$MARKER" "$pre_digest" "$pre_identity" ""
  if ! atomic_copy "$candidate" "$SETTINGS_FILE" "$mode"; then
    note 'apply write failed; restoring the pre-apply settings automatically'
    if [ "$existed" = true ]; then atomic_copy "$backup" "$SETTINGS_FILE" "$mode" || die 'automatic recovery failed'; else rm -f -- "$SETTINGS_FILE"; fi
    rm -f -- "$STATE_FILE"; die 'could not write settings; pre-apply state was restored'
  fi
  if ! atomic_write_state "$backup" "$existed" "$BASE_URL" "$MODEL" "$MARKER" "$pre_digest" "$pre_identity" "$(file_digest "$SETTINGS_FILE")"; then
    note 'state commit failed; restoring the pre-apply settings automatically'
    if [ "$existed" = true ]; then atomic_copy "$backup" "$SETTINGS_FILE" "$mode" || die 'automatic recovery failed'; else rm -f -- "$SETTINGS_FILE"; fi
    rm -f -- "$STATE_FILE"; die 'could not commit state; pre-apply settings were restored'
  fi
  note "Applied routing settings to $SETTINGS_FILE. The API key was not stored."
  note "Launch with BOBROUTER_API_KEY set, or use: $0 run"
}

load_state_field() {
  python3 - "$STATE_FILE" "$1" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh: doc = json.load(fh)
value = doc.get(sys.argv[2], "")
print("true" if value is True else "false" if value is False else value)
PY
}

rollback_config() {
  need python3
  acquire_lock
  [ -f "$STATE_FILE" ] || die 'no helper state found; nothing to roll back'
  local settings backup existed pre_digest pre_identity post_digest
  settings="$(load_state_field settings)"; backup="$(load_state_field backup)"; existed="$(load_state_field existed)"
  pre_digest="$(load_state_field pre_digest)"; pre_identity="$(load_state_field pre_identity)"; post_digest="$(load_state_field post_digest)"
  [ "$settings" = "$SETTINGS_FILE" ] || die "state belongs to a different settings path: $settings"
  [ "$post_digest" = "$(file_digest "$SETTINGS_FILE")" ] || die 'settings changed since apply; refusing rollback'
  if [ "$existed" = true ]; then
    [ -f "$backup" ] || die "backup is missing: $backup"
    [ "$pre_digest" = "$(file_digest "$backup")" ] || die 'backup digest does not match pre-apply state'
    atomic_copy "$backup" "$SETTINGS_FILE" 600 || die 'atomic rollback failed'
    note "Restored pre-apply settings from $backup"
  else
    rm -f -- "$SETTINGS_FILE"; note "Removed settings file created by apply: $SETTINGS_FILE"
  fi
  rm -f -- "$STATE_FILE"
}

uninstall_config() {
  need python3
  acquire_lock
  [ -f "$SETTINGS_FILE" ] || { note 'Settings file does not exist; nothing to remove.'; return; }
  local candidate
  candidate="$(mktemp "$(dirname "$SETTINGS_FILE")/.bobrouter-uninstall.XXXXXX")"; trap 'rm -f "${candidate:-}"' RETURN
  python3 - "$SETTINGS_FILE" "$candidate" "$BASE_URL" "$MARKER" <<'PY'
import json, sys
source, output, base, marker = sys.argv[1:]
with open(source, encoding="utf-8") as fh: doc = json.load(fh)
if not isinstance(doc, dict) or not isinstance(doc.get("env", {}), dict):
    raise SystemExit("settings JSON has an invalid env object")
env = doc.get("env", {})
if env.get("BOBROUTER_CLAUDE_HELPER") != marker:
    raise SystemExit("helper marker is absent; refusing to remove unowned settings")
if env.get("ANTHROPIC_BASE_URL") != base:
    raise SystemExit("base URL changed since helper configuration; use rollback or edit deliberately")
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "BOBROUTER_CLAUDE_HELPER"):
    env.pop(key, None)
if env: doc["env"] = env
else: doc.pop("env", None)
with open(output, "w", encoding="utf-8") as fh:
    json.dump(doc, fh, indent=2, sort_keys=True)
    fh.write("\n")
PY
  atomic_copy "$candidate" "$SETTINGS_FILE" 600 || die 'atomic uninstall failed'
  rm -f -- "$STATE_FILE"
  note "Removed only helper-managed BobRouter keys from $SETTINGS_FILE"
}

show_status() {
  validate_inputs
  printf 'BobRouter base URL: %s\nModel candidate: %s\nSettings file: %s\n' "$BASE_URL" "$MODEL" "$SETTINGS_FILE"
  if command -v claude >/dev/null 2>&1; then printf 'Claude Code: '; claude --version 2>/dev/null || command -v claude; else printf 'Claude Code: not installed\n'; fi
  if command -v curl >/dev/null 2>&1; then printf 'curl: available\n'; else printf 'curl: missing\n'; fi
  if command -v python3 >/dev/null 2>&1; then printf 'python3: available\n'; else printf 'python3: missing\n'; fi
  if [ -n "${BOBROUTER_API_KEY:-}" ]; then printf 'BOBROUTER_API_KEY: set (value not shown)\n'; else printf 'BOBROUTER_API_KEY: not set\n'; fi
  if [ -f "$SETTINGS_FILE" ]; then
    python3 - "$SETTINGS_FILE" "$MARKER" <<'PY'
import json, sys
try:
    doc = json.load(open(sys.argv[1], encoding="utf-8"))
    env = doc.get("env", {}) if isinstance(doc, dict) else {}
    print("Helper-managed settings: " + ("present" if isinstance(env, dict) and env.get("BOBROUTER_CLAUDE_HELPER") == sys.argv[2] else "absent"))
except Exception as exc:
    print(f"Settings JSON: invalid ({exc})")
PY
  else
    printf 'Settings file: absent\n'
  fi
}

run_claude() {
  need claude
  probe
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
  note 'Launching Claude Code with ephemeral BobRouter routing. No key is written to disk.'
  exec env ANTHROPIC_BASE_URL="$BASE_URL" ANTHROPIC_AUTH_TOKEN="$BOBROUTER_API_KEY" ANTHROPIC_MODEL="$MODEL" claude
}

command_name="${1:-status}"; [ "$#" -gt 0 ] && shift || true
assume_yes=no
while [ "$#" -gt 0 ]; do
  case "$1" in
    --model) [ "$#" -ge 2 ] || die '--model requires a value'; MODEL="$2"; shift 2 ;;
    --yes) assume_yes=yes; shift ;;
    -h|--help) usage; exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
done

case "$command_name" in
  status) show_status ;;
  probe) probe ;;
  dry-run) dry_run ;;
  apply) apply_config "$assume_yes" ;;
  run) run_claude ;;
  rollback) rollback_config ;;
  uninstall) uninstall_config ;;
  -h|--help|help) usage ;;
  *) usage >&2; die "unknown command: $command_name" ;;
esac
