#!/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>]
#   ./fetch-crashlytics.sh --probe [--project <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)
#
# --probe answers one question - can this service account read Crashlytics right
# now - and answers it on stdout as {"tier":...,"reason":...} with exit 0 on every
# outcome, because "no" is an answer, not a failure. It lives here rather than in
# credential-inventory.sh so the JWT exchange has exactly one implementation; a
# second copy would be the one that drifts. Verdicts:
#
#   tier-1-ready     the SA holds firebasecrashlytics.issues.get
#   tier-1-no-grant  the SA authenticates but lacks that permission - a role away
#   malformed        the stored value is not service-account JSON
#   unreachable      no credential, no helper, or the exchange never answered
#
# 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=""
PROBE=0
TIMEOUT="${FIREBASE_TIMEOUT_SECONDS:-20}"

# Probe verdicts go to stdout and exit 0 - the caller reads the tier, it does not
# read an exit status. Outside probe mode this is never called.
probe_out() {
  printf '{"tier":"%s","reason":"%s","projectId":"%s"}\n' "$1" "$2" "${3:-}"
  exit 0
}

need_value() { [ $# -ge 2 ] || { echo "ERR: $1 needs a value" >&2; exit 4; }; }

while [ $# -gt 0 ]; do
  case "$1" in
    --project)    need_value "$@"; PROJECT_ID="$2"; shift 2 ;;
    --platform)   need_value "$@"; PLATFORM="$2";   shift 2 ;;
    --bundle)     need_value "$@"; BUNDLE="$2";     shift 2 ;;
    --issue-id)   need_value "$@"; ISSUE_ID="$2";   shift 2 ;;
    --session-id) need_value "$@"; SESSION_ID="$2"; shift 2 ;;
    --probe)      PROBE=1; shift ;;
    -h|--help)
      echo "usage: $0 <issue-url> | $0 --project ... --platform ... --bundle ... --issue-id ... [--session-id ...] | $0 --probe [--project <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 [ "$PROBE" -eq 0 ] \
   && { [ -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
  [ "$PROBE" -eq 1 ] && probe_out unreachable no-credential-helper "$PROJECT_ID"
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

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

# The credential store returns the JSON as stored - encoding is its internal
# business, not this script's. The base64 attempt that used to live here was
# guesswork that never fired: what actually broke the fetcher was the store
# returning bare hex for any multi-line value, which is neither base64-of-JSON
# nor JSON, so both branches failed and the failure surfaced three layers later
# as a token-exchange error.
#
# With no decode step there is nothing to stage on disk, so the temp file and its
# cleanup traps went with it: the service-account JSON now only ever exists in a
# shell variable, which is one fewer place a GCP private key can be left behind.
SA_JSON="$SA_JSON_RAW"

# 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 [ "$PROBE" -eq 1 ]; then
  # A stored value that is not service-account JSON has no project_id, and no
  # amount of network access makes it usable. Say malformed here rather than
  # letting the exchange fail and reporting it as unreachable - the two need
  # different fixes, and conflating them is what sent the last diagnosis three
  # layers away from the defect.
  if [ -z "$SA_PROJECT" ]; then
    probe_out malformed not-service-account-json "$PROJECT_ID"
  fi
  [ -z "$PROJECT_ID" ] && PROJECT_ID="$SA_PROJECT"
fi
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
TOKEN_RC=0
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
) || TOKEN_RC=$?

if [ "$TOKEN_RC" -ne 0 ] || [ -z "$ACCESS_TOKEN" ]; then
  [ "$PROBE" -eq 1 ] && probe_out unreachable token-exchange-failed "$PROJECT_ID"
  echo "ERR: Firebase token exchange failed (exchange rc=$TOKEN_RC)" >&2
  exit 3
fi

if [ "$PROBE" -eq 1 ]; then
  # Ask IAM what this service account may do rather than calling Crashlytics and
  # reading the error: a 403 from topIssues means "no permission", but so does a
  # 403 from a disabled API or a project holding no crash data, and those need
  # different fixes. testIamPermissions answers only the question asked, and
  # answers it without touching crash data.
  PERM_VERDICT=$(PROBE_TOKEN="$ACCESS_TOKEN" PROBE_PROJECT="$PROJECT_ID" python3 - <<'IAM'
import json, os, sys, urllib.error, urllib.request

WANT = "firebasecrashlytics.issues.get"
body = json.dumps({"permissions": [WANT, "firebasecrashlytics.issues.list"]}).encode()
req = urllib.request.Request(
    "https://cloudresourcemanager.googleapis.com/v1/projects/%s:testIamPermissions"
    % os.environ["PROBE_PROJECT"],
    data=body,
    headers={"Authorization": "Bearer %s" % os.environ["PROBE_TOKEN"],
             "Content-Type": "application/json"},
)
try:
    with urllib.request.urlopen(req, timeout=int(os.environ.get("FIREBASE_TIMEOUT_SECONDS", "20"))) as r:
        held = json.loads(r.read().decode()).get("permissions") or []
except urllib.error.HTTPError as e:
    # The token was minted, so the account is real; a refusal here is still a
    # grant question, not a reachability one.
    print("tier-1-no-grant" if e.code in (401, 403) else "unreachable")
    sys.exit(0)
except Exception:
    print("unreachable")
    sys.exit(0)
print("tier-1-ready" if WANT in held else "tier-1-no-grant")
IAM
) || PERM_VERDICT="unreachable"
  case "$PERM_VERDICT" in
    tier-1-ready)    probe_out tier-1-ready crashlytics-readable "$PROJECT_ID" ;;
    tier-1-no-grant) probe_out tier-1-no-grant missing-crashlytics-role "$PROJECT_ID" ;;
    *)               probe_out unreachable iam-check-failed "$PROJECT_ID" ;;
  esac
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, so it has to come from somewhere. Preferences first: the repo's own
# GoogleService-Info*.plist / google-services.json already name the pair, and
# firebase-app-discovery.sh writes them into
# prefs.global.firebase.accounts[].apps[] at setup. A hit there costs nothing; a
# miss falls through to the Management API exactly as before, so an install that
# never ran discovery behaves identically.
APP_ID=""
if [ -f "$PREFS" ]; then
  APP_ID=$(WANT_PROJECT="$PROJECT_ID" WANT_BUNDLE="$BUNDLE" WANT_PLATFORM="$PLATFORM" \
    python3 -c "
import json, os
try:
    g = json.load(open('$PREFS')).get('global', {})
except Exception:
    g = {}
want_p = os.environ.get('WANT_PROJECT') or ''
want_b = os.environ.get('WANT_BUNDLE') or ''
want_pl = os.environ.get('WANT_PLATFORM') or ''
for a in (g.get('firebase', {}) or {}).get('accounts') or []:
    if not isinstance(a, dict) or str(a.get('projectId') or '') != want_p:
        continue
    for app in a.get('apps') or []:
        if not isinstance(app, dict):
            continue
        if str(app.get('bundleId') or '') == want_b and str(app.get('platform') or '') == want_pl:
            print(app.get('appId') or '')
            raise SystemExit(0)
print('')
" 2>/dev/null || printf '')
fi

FB_MGMT="https://firebase.googleapis.com/v1beta1/projects/$PROJECT_ID"
if [ -z "$APP_ID" ]; then
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)
fi

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
