#!/bin/bash
#
# fetch-crashlytics.sh
# Fetch a Firebase Crashlytics issue (and optional session) by URL or by
# parsed components. Auths with the user's Firebase service-account JSON
# stored in the keychain (see Phase 0 Step 1b.1  -  this script is the
# implementation backing that step).
#
# Usage:
#   ./fetch-crashlytics.sh <issue-url>
#   ./fetch-crashlytics.sh --project <id> --platform <ios|android> \
#                          --bundle <id> --issue-id <id> [--session-id <id>]
#
# Optional env:
#   FIREBASE_TIMEOUT_SECONDS    default 20
#   FIREBASE_TOKEN_KEY          forces one keychain key, skipping account lookup
#
# Credential resolution, in order:
#   1. FIREBASE_TOKEN_KEY (env override)
#   2. prefs.global.firebase.accounts[] entry whose projectId matches the URL
#   3. prefs.global.keychainMapping.firebase (single-project setups)
#   4. "${USER}_Firebase_Access_Json"
#
# Output (stdout, single JSON object):
#   {
#     "fetchedAt": "<ISO8601>",
#     "source": { "url": "<url|null>", "projectId": "...", "appId": "<resolved|null>",
#                 "platform": "ios|android", "bundle": "...", "issueId": "...",
#                 "sessionId": "<id|null>" },
#     "issue": { "title": "...", "subtitle": "...", "type": "FATAL|NON_FATAL|ANR",
#                "state": "...", "consoleUri": "<url|null>",
#                "occurrences": <n>, "impactedUsers": <n>, "sessions": <n>,
#                "firstSeenVersion": "<v|null>", "lastSeenVersion": "<v|null>",
#                "topStackFrame": { "file": "<library>", "line": 0, "symbol": "..." },
#                "stackTrace": [ { "library": "...", "symbol": "..." }, ... ],
#                "topDevices": [ "<name>", ... ],
#                "topOS": [ "<version>", ... ],
#                "affectedAppVersions": [ "<version>", ... ] },
#     "session": null | { "id": "<eventId>", "time": "<ISO8601>",
#                         "events": [ { "type": "breadcrumb|log", "time": "...",
#                                       "summary": "...", "screen": "<name|null>" } ] }
#   }
#
# Exit codes:
#   0  success
#   2  missing/expired credential (orchestrator handles the Save Flow)
#   3  network or auth failure
#   4  bad usage
#   5  project mismatch  -  SA JSON project_id != URL project (configuration bug)
#
# Notes:
#   Two v1alpha calls back this, because there is no get-issue-by-id endpoint:
#   `reports/topIssues` for the summary and metrics, and `events?filter.issue.id`
#   for the stack trace, device, OS and breadcrumbs. The opaque appId is resolved
#   first through the Firebase Management API (`iosApps` / `androidApps`), since a
#   console URL only ever carries the bundle id. Any failure exits 3 with a reason
#   ("app-not-found", "crashlytics-unreachable"); the orchestrator surfaces that as
#   "fetcher unavailable, advisory only" and keeps the pipeline moving.

set -euo pipefail

URL=""
PROJECT_ID=""
PLATFORM=""
BUNDLE=""
ISSUE_ID=""
SESSION_ID=""
TIMEOUT="${FIREBASE_TIMEOUT_SECONDS:-20}"

while [ $# -gt 0 ]; do
  case "$1" in
    --project)    PROJECT_ID="$2"; shift 2 ;;
    --platform)   PLATFORM="$2";   shift 2 ;;
    --bundle)     BUNDLE="$2";     shift 2 ;;
    --issue-id)   ISSUE_ID="$2";   shift 2 ;;
    --session-id) SESSION_ID="$2"; shift 2 ;;
    -h|--help)
      echo "usage: $0 <issue-url> | $0 --project ... --platform ... --bundle ... --issue-id ... [--session-id ...]" >&2
      exit 4 ;;
    *)
      if [ -z "$URL" ]; then URL="$1"; shift; else
        echo "ERR: unexpected arg $1" >&2; exit 4
      fi ;;
  esac
done

# When given a URL, parse it into components using the same regex Phase 0 uses.
if [ -n "$URL" ] && [ -z "$ISSUE_ID" ]; then
  PARSED=$(URL_IN="$URL" python3 - <<'PY'
import os, re, sys, urllib.parse as up
url = os.environ["URL_IN"]
p = up.urlparse(url)
if p.netloc != "console.firebase.google.com":
    print("\t\t\t\t")
    sys.exit(0)
m = re.search(
    r"/project/(?P<project>[^/]+)/crashlytics/app/(?P<platform>ios|android)(?::|%3A)(?P<bundle>[^/]+)/issues/(?P<issue>[^/?#]+)(?:/sessions/(?P<session>[^/?#]+))?",
    p.path,
)
if not m:
    print("\t\t\t\t")
    sys.exit(0)
print("\t".join([
    m.group("project") or "",
    m.group("platform") or "",
    up.unquote(m.group("bundle") or ""),
    m.group("issue") or "",
    m.group("session") or "",
]))
PY
)
  PROJECT_ID=$(printf '%s' "$PARSED" | cut -f1)
  PLATFORM=$(printf '%s' "$PARSED" | cut -f2)
  BUNDLE=$(printf '%s' "$PARSED" | cut -f3)
  ISSUE_ID=$(printf '%s' "$PARSED" | cut -f4)
  SESSION_ID=$(printf '%s' "$PARSED" | cut -f5)
fi

if [ -z "$PROJECT_ID" ] || [ -z "$PLATFORM" ] || [ -z "$BUNDLE" ] || [ -z "$ISSUE_ID" ]; then
  echo "ERR: missing required components  -  need project, platform, bundle, issue-id" >&2
  exit 4
fi

# Resolve the Firebase SA JSON via keychain mapping (matches Phase 0 contract).
#
# One team routinely owns several Firebase projects - a legacy app and its
# redesign, or staging next to production - and each has its own service-account
# key. A single `keychainMapping.firebase` slot forced a choice, and a crash URL
# from the other project then failed the project_id check as if it were a
# configuration error. `global.firebase.accounts[]` maps projectId -> keychain
# key, and the URL's projectId picks the key. The single slot stays the fallback,
# so a one-project setup needs no config at all.
PREFS="$HOME/.claude/multi-agent-preferences.json"
TOKEN_KEY="${FIREBASE_TOKEN_KEY:-}"
FIREBASE_ACCOUNT_LABEL=""
if [ -z "$TOKEN_KEY" ] && [ -f "$PREFS" ]; then
  ACCOUNT_HIT=$(WANT_PROJECT="$PROJECT_ID" python3 -c "
import json, os
want = os.environ.get('WANT_PROJECT') or ''
try:
    g = json.load(open('$PREFS')).get('global', {})
except Exception:
    g = {}
for a in (g.get('firebase', {}) or {}).get('accounts') or []:
    if isinstance(a, dict) and str(a.get('projectId') or '') == want and a.get('keychainKey'):
        print('%s\t%s' % (a['keychainKey'], a.get('label') or a['projectId']))
        break
else:
    print('%s\t' % ((g.get('keychainMapping', {}) or {}).get('firebase') or ''))
" 2>/dev/null || printf '\t')
  TOKEN_KEY=$(printf '%s' "$ACCOUNT_HIT" | cut -f1)
  FIREBASE_ACCOUNT_LABEL=$(printf '%s' "$ACCOUNT_HIT" | cut -f2)
fi
[ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Firebase_Access_Json"

# Locate the resolver with an existence check, not a `.`-chain.
#
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
# message. Reordering does not help: whichever candidate is absent aborts at that point.
# Checking for the file before sourcing it is the only safe form.
for _cred_resolver in \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && 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`, not `[ ... ] && break`: the latter is the loop body's last command and returns
  # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
  # candidate that does not resolve - the very case the loop exists to survive.
  if [ -n "${CRED_STORE:-}" ]; then break; fi
done
unset _cred_resolver
if [ -z "${CRED_STORE:-}" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

SA_JSON_B64=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
if [ -z "$SA_JSON_B64" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

# The credential store may return raw JSON or base64-encoded JSON (the user's
# setup convention). Try base64-decode first, fall back to raw. The decoded
# payload contains a GCP private key, so it goes into a 0600 mktemp file with
# cleanup traps registered BEFORE the secret is written.
SA_TMP=""
cleanup_sa_tmp() {
  if [ -n "$SA_TMP" ]; then rm -f "$SA_TMP"; fi
}
trap cleanup_sa_tmp EXIT
trap 'cleanup_sa_tmp; exit 130' INT
trap 'cleanup_sa_tmp; exit 143' TERM
SA_TMP=$(umask 077; mktemp "${TMPDIR:-/tmp}/fc-sa.XXXXXX")

SA_JSON=""
if printf '%s' "$SA_JSON_B64" | base64 -d > "$SA_TMP" 2>/dev/null \
   && python3 -c "import json, sys; json.load(open(sys.argv[1]))" "$SA_TMP" 2>/dev/null; then
  SA_JSON=$(cat "$SA_TMP")
else
  SA_JSON="$SA_JSON_B64"
fi
rm -f "$SA_TMP"
SA_TMP=""

# Verify project_id from SA JSON matches the URL  -  protects against pasting a
# crash URL from another GCP project than the keychain's SA covers.
SA_PROJECT=$(printf '%s' "$SA_JSON" | python3 -c "
import sys, json
try:
    p = json.load(sys.stdin)
    print(p.get('project_id') or '')
except Exception:
    print('')
")
if [ -n "$SA_PROJECT" ] && [ "$SA_PROJECT" != "$PROJECT_ID" ]; then
  # Still a hard error, but say which key was used and how to map the right one:
  # with several Firebase projects in play, "project mismatch" alone does not
  # tell you whether the URL is wrong or the mapping is missing an account.
  echo "ERR: SA project_id ($SA_PROJECT) != URL project ($PROJECT_ID)" >&2
  echo "     key used: $TOKEN_KEY${FIREBASE_ACCOUNT_LABEL:+ (account: $FIREBASE_ACCOUNT_LABEL)}" >&2
  echo "     map this project: prefs.global.firebase.accounts[] += {\"projectId\":\"$PROJECT_ID\",\"keychainKey\":\"<keychain-key>\"}" >&2
  exit 5
fi

# Exchange the SA JSON for a short-lived access token. JWT exchange:
#   1. Build header + claim, base64url
#   2. Sign with the SA private key
#   3. POST to oauth2.googleapis.com/token
ACCESS_TOKEN=$(SA_JSON_IN="$SA_JSON" python3 - <<'PY'
import base64, json, os, sys, time, urllib.parse, urllib.request

sa = json.loads(os.environ["SA_JSON_IN"])
private_key = sa.get("private_key")
client_email = sa.get("client_email")
if not (private_key and client_email):
    print("", file=sys.stderr)
    sys.exit(1)

def b64u(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

header = {"alg": "RS256", "typ": "JWT"}
now = int(time.time())
claim = {
    "iss": client_email,
    "scope": "https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/cloud-platform",
    "aud": "https://oauth2.googleapis.com/token",
    "iat": now,
    "exp": now + 3600,
}

signing_input = b64u(json.dumps(header, separators=(",",":")).encode()) + "." \
              + b64u(json.dumps(claim,  separators=(",",":")).encode())

# Sign via cryptography if available, fall back to openssl pipe.
signature = None
try:
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import padding
    key = serialization.load_pem_private_key(private_key.encode(), password=None)
    sig = key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
    signature = b64u(sig)
except ImportError:
    import subprocess, tempfile
    with tempfile.NamedTemporaryFile("w", delete=False) as f:
        f.write(private_key)
        keyf = f.name
    try:
        p = subprocess.run(
            ["openssl", "dgst", "-sha256", "-sign", keyf],
            input=signing_input.encode(),
            capture_output=True, check=True,
        )
        signature = b64u(p.stdout)
    finally:
        os.unlink(keyf)

jwt = signing_input + "." + signature

body = urllib.parse.urlencode({
    "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "assertion": jwt,
}).encode()
req = urllib.request.Request(
    "https://oauth2.googleapis.com/token",
    data=body,
    headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
    with urllib.request.urlopen(req, timeout=int(os.environ.get("FIREBASE_TIMEOUT_SECONDS", "20"))) as resp:
        out = json.loads(resp.read().decode())
        print(out.get("access_token") or "")
except Exception as e:
    print("", file=sys.stderr)
    sys.exit(2)
PY
)

if [ -z "$ACCESS_TOKEN" ]; then
  echo "ERR: Firebase token exchange failed" >&2
  exit 3
fi

# Bearer auth via a curl config fed through process substitution so the token
# never appears in argv (argv is visible to ps).
crashlytics_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$ACCESS_TOKEN"; }

fb_get() {
  curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
    -K <(crashlytics_auth_cfg) -H "Accept: application/json" "$1" 2>/dev/null || true
}

# The appId is opaque (1:1234567890:ios:abcdef) and cannot be derived from the
# bundle id. Ask the Firebase Management API which app owns this bundle; the
# console URL only ever carries the bundle.
FB_MGMT="https://firebase.googleapis.com/v1beta1/projects/$PROJECT_ID"
if [ "$PLATFORM" = "ios" ]; then
  APPS_JSON=$(fb_get "$FB_MGMT/iosApps?pageSize=200")
  MATCH_FIELD="bundleId"
else
  APPS_JSON=$(fb_get "$FB_MGMT/androidApps?pageSize=200")
  MATCH_FIELD="packageName"
fi

APP_ID=$(APPS_IN="$APPS_JSON" WANT="$BUNDLE" FIELD="$MATCH_FIELD" python3 -c "
import json, os
try:
    apps = (json.loads(os.environ['APPS_IN'] or '{}').get('apps') or [])
except Exception:
    apps = []
want = (os.environ['WANT'] or '').lower()
field = os.environ['FIELD']
for a in apps:
    if str(a.get(field) or '').lower() == want:
        print(a.get('appId') or '')
        break
else:
    # A single-app project needs no disambiguation; anything else stays unresolved
    # rather than guessing at the wrong app.
    print(apps[0].get('appId') or '' if len(apps) == 1 else '')
" 2>/dev/null || true)

if [ -z "$APP_ID" ]; then
  printf '{"status":"failed","reason":"app-not-found","platform":"%s","bundle":"%s","projectId":"%s"}\n' \
    "$PLATFORM" "$BUNDLE" "$PROJECT_ID" >&2
  exit 3
fi

CRASHLYTICS_APP="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_ID"

# There is no get-issue-by-id endpoint on v1alpha. The issue summary comes from
# the topIssues report (filtered to the id we want) and the stack trace, device,
# OS and breadcrumbs come from the issue's most recent event. An earlier version
# of this script called /issues/<id> with a guessed "<platform>:<bundle>" app
# reference; neither the path nor the reference exists, so every fetch failed and
# reported it as "api-not-enabled".
#
# Two partial answers are treated as answers, not failures. A long-tail issue
# outside the top 200 leaves the summary empty and the event still carries the
# stack trace, and an event fetch that comes back empty still leaves the summary.
# Only both being empty is a failure. A URL naming a specific session gets that
# issue's most recent event instead: v1alpha exposes no per-session read, and
# `source.sessionId` is passed through so the caller can see what was asked for.
ISSUE_ID_ENC=$(ISSUE_IN="$ISSUE_ID" python3 -c "
import os, urllib.parse
print(urllib.parse.quote(os.environ['ISSUE_IN'], safe=''))
" 2>/dev/null || printf '%s' "$ISSUE_ID")
TOP_ISSUES_JSON=$(fb_get "$CRASHLYTICS_APP/reports/topIssues?pageSize=200")
EVENT_JSON=$(fb_get "$CRASHLYTICS_APP/events?filter.issue.id=$ISSUE_ID_ENC&pageSize=1")

if [ -z "$TOP_ISSUES_JSON" ] && [ -z "$EVENT_JSON" ]; then
  printf '{"status":"failed","reason":"crashlytics-unreachable","appId":"%s","issueId":"%s"}\n' "$APP_ID" "$ISSUE_ID" >&2
  exit 3
fi

ISSUE_JSON="$TOP_ISSUES_JSON"
SESSION_JSON="$EVENT_JSON"

SRC_URL="$URL" SRC_PROJECT="$PROJECT_ID" SRC_PLATFORM="$PLATFORM" SRC_BUNDLE="$BUNDLE" \
SRC_ISSUE="$ISSUE_ID" SRC_SESSION="$SESSION_ID" SRC_APPID="$APP_ID" \
ISSUE_JSON_IN="$ISSUE_JSON" SESSION_JSON_IN="$SESSION_JSON" \
python3 - <<'PY'
import json, os, datetime

def load(name):
    raw = os.environ.get(name) or ""
    try:
        return json.loads(raw) if raw else {}
    except Exception:
        return {}

issue_id = os.environ["SRC_ISSUE"]

# topIssues returns groups: {"issue": {...}, "metrics": [{...}]}. Find ours.
groups = load("ISSUE_JSON_IN").get("topIssues") or load("ISSUE_JSON_IN").get("issues") or []
group = {}
for g in groups:
    if str(((g or {}).get("issue") or {}).get("id") or "") == issue_id:
        group = g
        break
issue = (group.get("issue") or {}) if isinstance(group, dict) else {}
metrics_list = (group.get("metrics") or []) if isinstance(group, dict) else []
metrics = metrics_list[0] if metrics_list else {}

def count(value):
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0

events_body = load("SESSION_JSON_IN")
events = events_body.get("events") or events_body.get("errorEvents") or []
event = events[0] if events else {}

exceptions = event.get("exceptions") or []
top_exception = exceptions[0] if exceptions else {}
frames = top_exception.get("frames") or []
top_frame = frames[0] if frames else {}

# v1alpha frames carry library + symbol, not file + line. Keeping the documented
# key names with the library in "file" beats inventing a second shape for the
# one consumer (Phase 1 prompt injection) that reads them.
stack = [
    {"library": f.get("library") or "", "symbol": f.get("symbol") or f.get("rawSymbol") or ""}
    for f in frames[:40]
]

versions = []
for v in (issue.get("firstSeenVersion"), issue.get("lastSeenVersion"),
          ((event.get("version") or {}).get("displayName"))):
    if v and v not in versions:
        versions.append(v)

timeline = []
for crumb in (event.get("breadcrumbs") or [])[:25]:
    screen = ((crumb.get("params") or {}).get("firebase_screen_class")) or None
    timeline.append({
        "type": "breadcrumb",
        "time": crumb.get("eventTime") or "",
        "summary": crumb.get("title") or "",
        "screen": screen,
    })
for log in (event.get("logs") or [])[:25]:
    message = log.get("message") or ""
    if message:
        timeline.append({"type": "log", "time": "", "summary": message, "screen": None})

result = {
    "fetchedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
    "source": {
        "url": os.environ.get("SRC_URL") or None,
        "projectId": os.environ["SRC_PROJECT"],
        "appId": os.environ.get("SRC_APPID") or None,
        "platform": os.environ["SRC_PLATFORM"],
        "bundle": os.environ["SRC_BUNDLE"],
        "issueId": issue_id,
        "sessionId": os.environ.get("SRC_SESSION") or None,
    },
    "issue": {
        "title": issue.get("title") or top_exception.get("title") or "",
        "subtitle": issue.get("subtitle") or "",
        "type": issue.get("errorType") or "unknown",
        "state": issue.get("state") or "",
        "consoleUri": issue.get("uri") or None,
        "occurrences": count(metrics.get("eventsCount")),
        "impactedUsers": count(metrics.get("impactedUsersCount")),
        "sessions": count(metrics.get("sessionsCount")),
        "firstSeenVersion": issue.get("firstSeenVersion") or None,
        "lastSeenVersion": issue.get("lastSeenVersion") or None,
        "topStackFrame": {
            "file": top_frame.get("library") or "",
            "line": 0,
            "symbol": top_frame.get("symbol") or top_frame.get("rawSymbol") or "",
        },
        "stackTrace": stack,
        "topDevices": [d for d in [((event.get("device") or {}).get("displayName"))] if d],
        "topOS": [o for o in [((event.get("operatingSystem") or {}).get("displayName"))] if o],
        "affectedAppVersions": versions,
    },
    "session": ({
        "id": event.get("eventId") or os.environ.get("SRC_SESSION") or "",
        "time": event.get("eventTime") or "",
        "events": timeline,
    } if event else None),
}
print(json.dumps(result, ensure_ascii=False))
PY
