#!/bin/bash
#
# repo-cache.sh
# Provider-agnostic repo lister with TTL cache.
#
# Output JSON schema (per repo):
#   { "fullName":"<owner>/<name>", "name":"<name>", "owner":"<owner>",
#     "description":"...", "isFork":false, "updatedAt":"...",
#     "canPush":true|false, "provider":"github"|"bitbucket" }
#
# canPush reflects whether the active account has WRITE+ permission on the
# repo (github: viewerPermission in {ADMIN, MAINTAIN, WRITE}; bitbucket: write
# is assumed for the cached project scope). Consumers  -  notably the
# submodule detector  -  use this to decide which related repos may be edited.
#
# Usage:
#   ./repo-cache.sh github <owner> [--refresh]
#   ./repo-cache.sh bitbucket <workspace> [--refresh]
#
# Required env per-provider:
#   github    -> GH_TOKEN_KEY (keychain service)
#   bitbucket -> BB_TOKEN_KEY, BB_USER_KEY, BB_HOST (e.g. bitbucket.example.com)
#   local     -> (none)  -  scopes are treated as filesystem roots; "scope=$HOME"
#                walks $HOME with maxdepth, scope=<path> walks just that path.
#                LOCAL_SCAN_ROOTS env can override the default root list.
#                LOCAL_SCAN_MAXDEPTH env (default 4) bounds traversal cost.

set -euo pipefail

PROVIDER="${1:-}"
SCOPE="${2:-}"
REFRESH=0
[ "${3:-}" = "--refresh" ] && REFRESH=1

[ -z "$PROVIDER" ] || [ -z "$SCOPE" ] && {
  echo "usage: $0 <provider> <owner|workspace> [--refresh]" >&2
  exit 1
}

CACHE_DIR="$HOME/.claude/cache/repos"
mkdir -p "$CACHE_DIR"
# Sanitize SCOPE so it's safe as a filename component (local provider may
# receive an absolute path; provider-account names are already filesystem-safe).
SCOPE_SLUG=$(printf '%s' "$SCOPE" | tr '/ ' '__' | sed 's/^_*//')
CACHE_FILE="$CACHE_DIR/${PROVIDER}-${SCOPE_SLUG}.json"
TTL_SECONDS="${REPO_CACHE_TTL:-3600}" # default 1h

cache_fresh() {
  [ -f "$CACHE_FILE" ] || return 1
  local age now mtime
  now=$(date +%s)
  mtime=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null)
  age=$((now - mtime))
  [ "$age" -lt "$TTL_SECONDS" ]
}

if [ $REFRESH -eq 0 ] && cache_fresh; then
  cat "$CACHE_FILE"
  exit 0
fi

# Refresh staging: providers write to $CACHE_TMP, then commit_cache validates
# and mv's it into place. Writing straight to $CACHE_FILE truncated the cache
# BEFORE the fetch ran, so any failure left an empty file that cache_fresh
# then served for the whole TTL.
CACHE_TMP="${CACHE_FILE}.tmp.$$"
cleanup_tmp() { rm -f "$CACHE_TMP" "$CACHE_TMP.list" 2>/dev/null || true; }
trap cleanup_tmp EXIT

# Validate the staged file (non-empty valid JSON) and move it into place.
# On failure keep the previous cache and serve it with a warning; hard-fail
# only when there is no previous cache to fall back to.
commit_cache() {
  if [ -s "$CACHE_TMP" ] \
    && python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$CACHE_TMP" 2>/dev/null; then
    mv "$CACHE_TMP" "$CACHE_FILE"
    return 0
  fi
  rm -f "$CACHE_TMP"
  if [ -f "$CACHE_FILE" ]; then
    echo "WARN: repo-cache refresh failed for $PROVIDER/$SCOPE; serving previous cache" >&2
    return 0
  fi
  echo "ERR: repo-cache refresh failed for $PROVIDER/$SCOPE and no previous cache exists" >&2
  exit 3
}

# Basic-auth via a curl config fed through process substitution so the
# credential never appears in argv (argv is visible to `ps`).
bb_auth_cfg() { printf 'user = "%s:%s"\n' "$1" "$2"; }

case "$PROVIDER" in
  github)
    if ! command -v gh >/dev/null 2>&1; then
      echo "ERR: gh CLI not found" >&2
      exit 2
    fi
    # Multi-account: when GH_USER is set (passed by the picker from
    # account-resolver.sh users.github), switch the active gh account so
    # `gh repo list` queries the right user. No-op if already active.
    if [ -n "${GH_USER:-}" ]; then
      gh_active=$(gh auth status 2>&1 | awk '/active account: true/{f=1} f && /Logged in to github.com account/{sub(/.*account /, ""); sub(/ .*/, ""); print; exit}' || true)
      if [ -n "$gh_active" ] && [ "$gh_active" != "$GH_USER" ]; then
        gh auth switch --user "$GH_USER" >/dev/null 2>&1 \
          || echo "WARN: gh auth switch to $GH_USER failed; continuing with $gh_active" >&2
      fi
    fi
    gh repo list "$SCOPE" --limit 200 \
      --json name,owner,description,isFork,updatedAt,sshUrl,url,viewerPermission 2>/dev/null \
      | python3 -c '
import json,sys
WRITE_PERMS = {"ADMIN", "MAINTAIN", "WRITE"}
data = json.load(sys.stdin)
out = []
for r in data:
    owner = r["owner"]["login"]
    name = r["name"]
    perm = (r.get("viewerPermission") or "").upper()
    out.append({
      "fullName": owner + "/" + name,
      "name": name,
      "owner": owner,
      "description": r.get("description") or "",
      "isFork": r.get("isFork", False),
      "updatedAt": r.get("updatedAt",""),
      "cloneUrl": r.get("sshUrl") or (r.get("url","") + ".git"),
      "httpsCloneUrl": (r.get("url","") + ".git") if r.get("url") else "",
      "viewerPermission": perm or None,
      "canPush": perm in WRITE_PERMS,
      "provider": "github"
    })
print(json.dumps(out))
' > "$CACHE_TMP" || true
    commit_cache
    ;;

  bitbucket)
    : "${BB_TOKEN_KEY:?BB_TOKEN_KEY required for bitbucket}"
    : "${BB_HOST:?BB_HOST required (e.g. bitbucket.example.com)}"
    # Resolve the credential helper via the shared resolver (works from the
    # repo checkout and from both install trees)  -  never hardcode the path.
    # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
    if [ -z "${CRED_STORE:-}" ]; then
      # shellcheck disable=SC1090,SC1091
      # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
      # `||` included, so a `.`-chain reaches neither its later candidates nor its error
      # branch. The loop also covers all three hosts - the chain it replaced knew only
      # .claude and .copilot, so a Codex-only install could not resolve at all.
      for _cred_resolver in \
        "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
        "$HOME/.claude/lib/credential-store-resolver.sh" \
        "$HOME/.copilot/lib/credential-store-resolver.sh" \
        "$HOME/.codex/lib/credential-store-resolver.sh"; do
        [ -f "$_cred_resolver" ] || continue
        # shellcheck source=/dev/null
        . "$_cred_resolver" 2>/dev/null || true
        if [ -n "${CRED_STORE:-}" ]; then break; fi
      done
      unset _cred_resolver
      if [ -z "${CRED_STORE:-}" ]; then
        echo "ERR: credential helper not found" >&2
        exit 3
      fi
    fi
    BB_USER="${BB_USER:-${BB_USER_KEY:+$("$CRED_STORE" get "$BB_USER_KEY" 2>/dev/null || true)}}"
    BB_TOKEN=$("$CRED_STORE" get "$BB_TOKEN_KEY" 2>/dev/null || true)
    if [ -z "$BB_TOKEN" ]; then
      echo "ERR: bitbucket token not found in credential store ($BB_TOKEN_KEY)" >&2
      exit 3
    fi
    # Bitbucket Server REST: /rest/api/1.0/projects/{key}/repos?limit=200
    # Auth via curl config file (-K) so the token never appears on argv.
    curl -sf -K <(bb_auth_cfg "${BB_USER:-bot}" "$BB_TOKEN") \
      "https://${BB_HOST}/rest/api/1.0/projects/${SCOPE}/repos?limit=200" \
      | python3 -c '
import json,sys
data = json.load(sys.stdin)
vals = data.get("values", [])
out = []
for r in vals:
    proj = r.get("project",{}).get("key","")
    name = r.get("slug") or r.get("name")
    clone = ""
    for link in (r.get("links",{}).get("clone",[]) or []):
        if link.get("name") == "ssh":
            clone = link.get("href","")
            break
    if not clone:
        for link in (r.get("links",{}).get("clone",[]) or []):
            if link.get("name") == "http":
                clone = link.get("href","")
                break
    out.append({
      "fullName": proj + "/" + str(name),
      "name": name,
      "owner": proj,
      "description": r.get("description") or "",
      "isFork": False,
      "updatedAt": "",
      "cloneUrl": clone,
      "httpsCloneUrl": clone,
      "viewerPermission": None,
      "canPush": True,
      "provider": "bitbucket"
    })
print(json.dumps(out))
' > "$CACHE_TMP" || true
    commit_cache
    ;;

  local)
    # Scan filesystem roots for git repos. Default roots cover the common
    # places personal/work checkouts live; override via LOCAL_SCAN_ROOTS.
    DEFAULT_ROOTS="$HOME $HOME/Documents $HOME/Developer $HOME/projects $HOME/code $HOME/work"
    ROOTS="${LOCAL_SCAN_ROOTS:-$DEFAULT_ROOTS}"
    MAXDEPTH="${LOCAL_SCAN_MAXDEPTH:-4}"
    # When SCOPE is an absolute path use it directly; otherwise treat SCOPE
    # as a label and walk the configured roots.
    if [ -d "$SCOPE" ]; then
      ROOTS="$SCOPE"
    fi
    : > "$CACHE_TMP.list"
    for root in $ROOTS; do
      [ -d "$root" ] || continue
      # -prune avoids descending into already-found .git trees (nested submodules
      # show up via submodule-detector, not the top-level picker).
      find "$root" -maxdepth "$MAXDEPTH" -type d -name ".git" \
        \( -path "*/node_modules/*" -prune -o -path "*/.build/*" -prune -o \
           -path "*/DerivedData/*" -prune -o -path "*/Pods/*" -prune -o \
           -path "*/.worktrees/*" -prune -o \
           -path "*/.next/*" -prune -o -print \) 2>/dev/null \
        | while read -r gitdir; do
            [ -n "$gitdir" ] || continue
            repo_dir=$(dirname "$gitdir")
            name=$(basename "$repo_dir")
            # Cheap remote probe so the picker can show whether this is
            # truly offline-only or a clone with an upstream we just chose
            # to treat as local.
            remote=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
            printf '%s\t%s\t%s\n' "$name" "$repo_dir" "$remote" >> "$CACHE_TMP.list"
          done
    done
    python3 -c '
import json, sys, re
# Redact any embedded credentials from remote URLs before caching.
# Patterns covered: https://user:token@host, https://token@host, oauth://...
CRED_RE = re.compile(r"(://)([^/@:\s]+(?::[^/@\s]*)?)@")
def redact(url):
    if not url:
        return url
    return CRED_RE.sub(r"\1<redacted>@", url)

seen = set()
out = []
for raw in open(sys.argv[1]):
    parts = raw.rstrip("\n").split("\t")
    if len(parts) < 2:
        continue
    name, path = parts[0], parts[1]
    remote = parts[2] if len(parts) > 2 else ""
    if path in seen:
        continue
    seen.add(path)
    out.append({
        "fullName": "local/" + name,
        "name": name,
        "owner": "local",
        "description": "",
        "isFork": False,
        "updatedAt": "",
        "cloneUrl": "file://" + path,
        "httpsCloneUrl": "file://" + path,
        "localPath": path,
        "remoteUrl": redact(remote) or None,
        "viewerPermission": None,
        "canPush": True,
        "provider": "local"
    })
print(json.dumps(out))
' "$CACHE_TMP.list" > "$CACHE_TMP" || true
    rm -f "$CACHE_TMP.list"
    commit_cache
    ;;

  *)
    echo "ERR: unknown provider $PROVIDER" >&2
    exit 4
    ;;
esac

cat "$CACHE_FILE"
