#!/usr/bin/env python3
"""Upload a Flutter app's Dart symbol files to Firebase Crashlytics.

Every Flutter release build is obfuscated (see action.yml), so Crashlytics
shows `***` for every Dart frame until it has the symbol files that
--split-debug-info wrote. On Android nothing uploads them automatically — the
Crashlytics Gradle plugin only handles native NDK symbols — so this script
does, with the command Firebase documents for Flutter:

    firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID> <symbols dir>

It needs no credentials: that Firebase CLI command has no login requirement
and authenticates by app id alone. It does need Node (npx) and Java (the CLI
drives the Crashlytics buildtools jar), both of which the Android job has.

Gating: the FIREBASE_APP_ID environment variable holds the app's Firebase App
ID(s), `1:<project number>:<platform>:<hash>`, comma- or whitespace-separated
when an app ships both platforms; the Android one is used. Unset, nothing is
uploaded and the script only warns when the app depends on
firebase_crashlytics. Set, an upload failure is a failure: the action runs
before the workflow ships the bundle to Play, so a deploy never goes out with
crashes nobody can read.
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path

import subprocess

from crashlytics_bootstrap import BootstrapError, prepare as prepare_buildtools

# Exact pin, not a range: this job holds the upload keystore and the Play
# service account, so no floating third-party code runs in it. Bump on purpose.
FIREBASE_TOOLS = "firebase-tools@15.28.2"

APP_ID_RE = re.compile(r"^(\d+):(\d+):(android|ios|web):([0-9A-Fa-f]+)$")


class AppIdError(ValueError):
    """FIREBASE_APP_ID contains something that is not a Firebase App ID."""


def parse_app_ids(raw: str) -> list[tuple[str, str]]:
    """Split FIREBASE_APP_ID into (platform, app id) pairs.

    Accepts one id or several separated by commas and/or whitespace. Anything
    that does not look like `1:<project number>:<android|ios|web>:<hash>` is
    an error rather than a silent skip — a typo must not turn into a fleet
    that quietly stops uploading symbols.
    """
    ids: list[tuple[str, str]] = []
    for token in re.split(r"[,\s]+", raw.strip()):
        if not token:
            continue
        match = APP_ID_RE.match(token)
        if match is None:
            raise AppIdError(
                f"{token!r} is not a Firebase App ID "
                "(expected 1:<project number>:<android|ios|web>:<hash>)"
            )
        ids.append((match.group(3), token))
    return ids


def _pick(ids: list[tuple[str, str]], platform: str) -> str | None:
    return next((app_id for found_platform, app_id in ids if found_platform == platform), None)


def select_app_id(raw: str, platform: str) -> str | None:
    """The app id for *platform* out of FIREBASE_APP_ID, or None."""
    return _pick(parse_app_ids(raw), platform)


def uses_crashlytics(pubspec: Path) -> bool:
    try:
        text = pubspec.read_text(encoding="utf-8")
    except OSError:
        return False
    return re.search(r"^\s*firebase_crashlytics\s*:", text, re.MULTILINE) is not None


def upload_command(app_id: str, symbols_dir: Path, firebase_tools: str = FIREBASE_TOOLS) -> list[str]:
    return [
        "npx",
        "--yes",
        firebase_tools,
        "crashlytics:symbols:upload",
        f"--app={app_id}",
        str(symbols_dir),
    ]


def _default_run(cmd: list[str]) -> int:
    # A scratch cwd: the Crashlytics buildtools drop a .crashlytics/ directory
    # (dump_syms.bin, ~4 MB) into the working directory, which must not be the
    # consumer's checkout.
    try:
        if cmd and shutil.which(cmd[0]) is None:
            raise FileNotFoundError(cmd[0])
        jar = prepare_buildtools()
        if not cmd:
            print("Verified Crashlytics buildtools are ready before compilation.")
            return 0
        with tempfile.TemporaryDirectory(prefix="crashlytics-upload-") as workdir:
            return subprocess.run(["env", f"CRASHLYTICS_LOCAL_JAR={jar}", *cmd],
                                  cwd=workdir, check=False).returncode
    except BootstrapError as error:
        error.annotate()
        return 1
    except FileNotFoundError:
        print(
            "::error::npx is not on PATH, so the Firebase CLI cannot run. "
            "If a disk-cleanup step removes /usr/local/lib/node_modules, npm "
            "and npx go with it — deploy.yml deliberately leaves that path alone."
        )
        return 127


def _missing_app(ids: list[tuple[str, str]], pubspec: Path) -> None:
    if ids:
        print("::warning::FIREBASE_APP_ID names no Android app "
              "(1:<project number>:android:<hash>); the Dart symbols were not uploaded to Crashlytics.")
    elif uses_crashlytics(pubspec):
        print("::warning::This app depends on firebase_crashlytics but no Firebase App ID reached this build, "
              "so every Dart frame in Crashlytics will read as ***. Either the FIREBASE_APP_ID repository "
              "variable is unset, or this repo's deploy.yml predates the firebase-app-id input — a one-off "
              "`npx --yes gowalk-cicd` refreshes it.")
    else:
        print("FIREBASE_APP_ID unset; not uploading Dart symbols to Crashlytics.")


def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None) -> int:
    environ = os.environ if environ is None else environ
    run = _default_run if run is None else run

    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--symbols-dir", type=Path,
                        help="the directory passed to flutter build --split-debug-info")
    parser.add_argument("--prepare-only", action="store_true", help="verify the buildtools before compilation")
    parser.add_argument("--pubspec", type=Path, default=Path("pubspec.yaml"),
                        help="pubspec.yaml, used only to decide whether to warn when unset")
    parser.add_argument("--firebase-tools", default=FIREBASE_TOOLS,
                        help="npm spec of the Firebase CLI to run through npx")
    args = parser.parse_args(argv)

    raw = environ.get("FIREBASE_APP_ID", "")
    try:
        ids = parse_app_ids(raw)
    except AppIdError as exc:
        print(f"::error::FIREBASE_APP_ID: {exc}")
        return 1

    app_id = _pick(ids, "android")
    if app_id is None:
        _missing_app(ids, args.pubspec)
        return 0

    if args.prepare_only:
        return 0 if run([]) == 0 else 1
    if args.symbols_dir is None:
        parser.error("--symbols-dir is required unless --prepare-only is used")
    symbols = sorted(args.symbols_dir.glob("*.symbols")) if args.symbols_dir.is_dir() else []
    if not symbols:
        print(f"::error::no Dart symbol files in {args.symbols_dir}; nothing to upload to Crashlytics")
        return 1

    cmd = upload_command(app_id, args.symbols_dir.resolve(), args.firebase_tools)
    print(f"Uploading {len(symbols)} Dart symbol file(s) to Crashlytics app {app_id}:")
    print("  " + " ".join(cmd))
    rc = run(cmd)
    if rc != 0:
        print(
            f"::error::Crashlytics symbol upload failed (exit {rc}). The symbols are "
            "retained as the android-symbols artifact; after fixing the cause, upload "
            "them after diagnosing the failed phase."
        )
        return 1
    print(f"Uploaded Dart symbols to Crashlytics app {app_id}.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
