#!/usr/bin/env bash
# crowbot installer for opencode. Adds a `crowbot` provider to your opencode config.
#
#   curl -fsSL https://crowbot.sh/install.sh | bash
#   curl -fsSL https://crowbot.sh/install.sh | bash -s -- --global --key 4192083755612094
#
# Merges into your config with a timestamped backup; touches only `provider.crowbot`.
set -euo pipefail

SITE="https://crowbot.sh"
API="https://api.crowbot.sh"
WEB="https://chat.crowbot.sh"
GLOBAL_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"
LOCAL_CFG="$PWD/opencode.json"
# opencode globs `{plugin,plugins}/*.{ts,js}` under each config root, so a file placed here
# loads by being here.
GLOBAL_PLUGIN_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugin"
LOCAL_PLUGIN_DIR="$PWD/.opencode/plugin"

MODE=""; KEY=""; DRY=0; PAIR=0

while [ $# -gt 0 ]; do
  case "$1" in
    --global) MODE=global ;;
    --local)  MODE=local ;;
    --key)    KEY="${2:-}"; shift ;;
    --pair)   PAIR=1 ;;
    --dry-run) DRY=1 ;;
    -h|--help)
      sed -n '2,7p' "$0" 2>/dev/null || echo "See $SITE/docs/opencode.md"
      exit 0 ;;
    *) echo "unknown option: $1" >&2; exit 2 ;;
  esac
  shift
done

say()  { printf '%s\n' "$*"; }
warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; }
die()  { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; }

# Under `curl | bash` stdin is the script itself, so every prompt must come from the terminal.
TTY=""
[ -r /dev/tty ] && TTY=/dev/tty
ask() {
  [ -n "$TTY" ] || die "No terminal available. Re-run with flags, e.g.:
  curl -fsSL $SITE/install.sh | bash -s -- --global --key <your-account-number>"
  local __v; read -r -p "$1" __v < "$TTY"; printf '%s' "$__v"
}

[ "$(id -u)" != 0 ] || die "Do not run this as root — it writes into your home directory."
command -v python3 >/dev/null || die "python3 is required (used to merge your config safely)."
command -v curl    >/dev/null || die "curl is required."

# ── opencode present?
find_opencode() {
  local c
  for c in "$(command -v opencode 2>/dev/null || true)" \
           "$HOME/.opencode/bin/opencode" \
           "${XDG_DATA_HOME:-$HOME/.local/share}/opencode/bin/opencode" \
           "$HOME/.local/bin/opencode" \
           "$HOME/.bun/bin/opencode" \
           "$HOME/.npm-global/bin/opencode" \
           /opt/homebrew/bin/opencode \
           /usr/local/bin/opencode; do
    [ -n "$c" ] && [ -x "$c" ] && { printf '%s' "$c"; return 0; }
  done
  return 1
}
# Bounded, so a wedged binary cannot hang the installer.
bounded() { if command -v timeout >/dev/null; then timeout 20 "$@"; else "$@"; fi; }

OPENCODE="$(find_opencode)" || {
  warn "opencode is not installed: not on your PATH, and not where its installers put it."
  say  "Install it first, then re-run this:"
  say  "    curl -fsSL https://opencode.ai/install | bash"
  say  ""
  say  "(Not installed for you: it is another project's software.)"
  exit 1
}
if ! OPENCODE_VERSION="$(bounded "$OPENCODE" --version 2>/dev/null)"; then
  die "Found $OPENCODE, but it does not run (opencode --version failed). Reinstall it with:  curl -fsSL https://opencode.ai/install | bash"
fi
OPENCODE_VERSION="${OPENCODE_VERSION%%$'\n'*}"
say "Found opencode ${OPENCODE_VERSION:-(unknown version)} at $OPENCODE"
case ":$PATH:" in
  *":$(dirname "$OPENCODE"):"*) : ;;
  *) warn "Note: $(dirname "$OPENCODE") is not on this shell's PATH yet. Open a new terminal before running opencode." ;;
esac

# ── where does the config go? ────────────────────────────────────────────────
if [ -z "$MODE" ]; then
  say "Where should the crowbot provider go?"
  say "  1) globally, for every project   $GLOBAL_CFG"
  say "  2) this project only             $LOCAL_CFG"
  case "$(ask 'Choice [1]: ')" in
    2) MODE=local ;;
    *) MODE=global ;;
  esac
fi
[ "$MODE" = global ] && CFG="$GLOBAL_CFG" || CFG="$LOCAL_CFG"

# ── credential ───────────────────────────────────────────────────────────────
pair_flow() {
  local start hostname device user ttl url code body deadline
  hostname="$(hostname 2>/dev/null || echo unknown)"
  # Status apart from the body: `curl -f` would report a 429 as unreachable.
  local sfile scode
  sfile="$(mktemp)"
  scode="$(curl -s -o "$sfile" -w '%{http_code}' -X POST "$API/api/pair/start" \
            -H 'content-type: application/json' \
            -d "{\"hostname\":\"$hostname\"}")" || scode=000
  start="$(cat "$sfile")"; rm -f "$sfile"
  case "$scode" in
    200) : ;;
    429) die "Too many pairing attempts from this address. Wait a few minutes, or re-run
with --key <your-account-number> to skip pairing." ;;
    000) die "Could not reach $API. Check the address and your connection." ;;
    *)   die "$API returned HTTP $scode starting the pairing." ;;
  esac
  read -r device user ttl < <(printf '%s' "$start" | python3 -c \
    'import json,sys;d=json.load(sys.stdin);print(d["device_code"],d["user_code"],d["ttl"])')
  # The page ignores a code in the URL, so a shared link never lands on a one-click Approve.
  url="$WEB/pair"

  say ""
  say "Open this, sign in, and approve the pairing:"
  say "    $url"
  say ""
  say "Type this code:  $user"
  say ""
  say "No browser here? Open $url on any device."
  # Best effort.
  { command -v xdg-open >/dev/null && xdg-open "$url" >/dev/null 2>&1 & } \
    || { command -v open >/dev/null && open "$url" >/dev/null 2>&1 & } || true
  say "Waiting for approval ($((ttl / 60)) minutes)..."

  # Polled for as long as the server keeps the code.
  deadline=$((SECONDS + ttl))
  while [ "$SECONDS" -lt "$deadline" ]; do
    body="$(mktemp)"; code="$(curl -s -o "$body" -w '%{http_code}' "$API/api/pair/$device")"
    case "$code" in
      200) KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["api_key"])' "$body")"
           rm -f "$body"; say "Paired."; return 0 ;;
      202) rm -f "$body"; sleep 2 ;;
      410) rm -f "$body"
           # Denied, or the code ran out.
           say ""
           if [ -n "$TTY" ] && [ "$(ask 'Pairing expired or was denied. Try again? [y/N]: ')" = y ]; then
             pair_flow; return $?
           fi
           die "Pairing not completed." ;;
      429) rm -f "$body"; sleep 5 ;;
      *)   rm -f "$body"; sleep 2 ;;
    esac
  done
  die "Timed out waiting for approval."
}

if [ -z "$KEY" ]; then
  if [ "$PAIR" = 1 ]; then
    pair_flow
  else
    say ""
    say "How do you want to authenticate?"
    say "  1) approve in your browser  (creates a key just for this machine)"
    say "  2) paste your account number"
    if [ "$(ask 'Choice [1]: ')" = 2 ]; then
      KEY="$(ask 'Account number: ')"
      KEY="$(printf '%s' "$KEY" | tr -d '[:space:]')"
      [ -n "$KEY" ] || die "No key entered."
    else
      pair_flow
    fi
  fi
fi

# ── fetch the provider block ─────────────────────────────────────────────────
NEW="$(mktemp)"; trap 'rm -f "$NEW"' EXIT
curl -fsS "$API/docs/opencode.json" -o "$NEW" \
  || die "Could not fetch $API/docs/opencode.json"

# ── merge: provider.crowbot is replaced, not deep-merged
INLINE=0; [ "$MODE" = global ] && INLINE=1

# One merge program for both runs, so the dry run prints exactly what the real run writes. Its
# argument is the output file, or `-` for stdout.
merge() {
  KEY="$KEY" INLINE="$INLINE" python3 - "$CFG" "$NEW" "$1" <<'PY'
import json, os, sys
cfg, new, out = sys.argv[1:4]
incoming = json.load(open(new))
try:
    with open(cfg) as fh:
        current = json.load(fh)
except FileNotFoundError:
    current = {}
except json.JSONDecodeError as e:
    sys.exit(f"{cfg} is not valid JSON ({e}); refusing to touch it. Fix or move it, then re-run.")
if not isinstance(current, dict):
    sys.exit(f"{cfg} is not a JSON object; refusing to touch it.")

block = incoming["provider"]["crowbot"]
if os.environ.get("INLINE") == "1":
    block["options"]["apiKey"] = os.environ["KEY"]
# else: the generated block already carries {env:CROWBOT_API_KEY}, and a project config is a
# committed file — a literal key must never land in it.

current.setdefault("$schema", incoming["$schema"])
providers = current.setdefault("provider", {})
if not isinstance(providers, dict):
    sys.exit("`provider` in your config is not an object; refusing to touch it.")
providers["crowbot"] = block

text = json.dumps(current, indent=2) + "\n"
if out == "-":
    sys.stdout.write(text)
else:
    with open(out, "w") as fh:
        fh.write(text)
PY
}

if [ "$DRY" = 1 ]; then
  say ""; say "--dry-run: would write $CFG"
  merge -
  [ "$MODE" = global ] && DPD="$GLOBAL_PLUGIN_DIR" || DPD="$LOCAL_PLUGIN_DIR"
  say ""
  say "--dry-run: would also write $DPD/crowbot.js"
  say "  (the model-refresh plugin — keeps this model list current without re-running)"
  exit 0
fi

mkdir -p "$(dirname "$CFG")"
if [ -f "$CFG" ]; then
  BACKUP="$CFG.bak.$(date +%Y%m%d%H%M%S)"
  cp "$CFG" "$BACKUP"
  say "Backed up your config to $BACKUP"
fi

TMP="$(mktemp "$(dirname "$CFG")/.opencode.json.XXXXXX")"
trap 'rm -f "$NEW" "$TMP"' EXIT
merge "$TMP"

mv "$TMP" "$CFG"
# mktemp creates at 0600 and `mv` preserves it, so the mode must be set explicitly either way.
if [ "$INLINE" = 1 ]; then
  chmod 600 "$CFG"
else
  chmod "$(printf '%03o' "$(( 0666 & ~0$(umask) ))")" "$CFG"
fi
say "Wrote $CFG"

# ── the model-refresh plugin: the config above is a snapshot
[ "$MODE" = global ] && PLUGIN_DIR="$GLOBAL_PLUGIN_DIR" || PLUGIN_DIR="$LOCAL_PLUGIN_DIR"
PLUGIN="$PLUGIN_DIR/crowbot.js"

if mkdir -p "$PLUGIN_DIR" 2>/dev/null; then
  PTMP="$(mktemp "$PLUGIN_DIR/.crowbot.js.XXXXXX")" || PTMP=""
  if [ -n "$PTMP" ] && curl -fsS "$API/docs/opencode-plugin.js" -o "$PTMP"; then
    # It is code, not a credential — it carries no key.
    chmod "$(printf '%03o' "$(( 0666 & ~0$(umask) ))")" "$PTMP"
    mv "$PTMP" "$PLUGIN"
    say "Wrote $PLUGIN — your model list now refreshes itself."
  else
    rm -f "$PTMP"
    warn "Could not install the model-refresh plugin from $API/docs/opencode-plugin.js.
  Your config still works; re-run to retry."
  fi
else
  warn "Could not create $PLUGIN_DIR; skipped the model-refresh plugin.
  Re-run this script when models change."
fi

# ── verify, rather than assume ───────────────────────────────────────────────
ME="$(mktemp)"; CODE="$(curl -s -o "$ME" -w '%{http_code}' \
        -H "authorization: Bearer $KEY" "$API/api/me")"
if [ "$CODE" = 200 ]; then
  python3 - "$ME" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
paid = d.get("balance_microdollars", 0) or 0
print(f"Account {d.get('tenant_id')}: ${paid/1e6:.2f}")
if paid <= 0:
    # The single most common first-run failure, and it is invisible until a call fails.
    print("\n\033[33mWARNING: no balance.\033[0m The API returns 402 insufficient_balance")
    print("until you top up. Fund the account first.")
PY
else
  warn "Could not verify the key against $API/api/me (HTTP $CODE)."
  warn "The config is written; check your account number if calls fail."
fi
rm -f "$ME"

if [ "$INLINE" != 1 ]; then
  say ""
  say "Your project config references an environment variable, so add this to your shell:"
  say "    export CROWBOT_API_KEY=$KEY"
  say "(Project configs are usually committed, so the key stays out.)"
fi

say ""
say "Done. Run 'opencode', then /models, and pick a crowbot model."
