#!/usr/bin/env python3
"""Shared Crashlytics app/tool discovery and the retired native uploader interface.

CI uses prepare_crashlytics_dsyms.py to retain an archive and a typed console-upload
receipt. Native upload-symbols uses NSURLSession without the task proxy environment,
so executing it from this legacy interface is refused. No direct fallback is allowed.
"""

from __future__ import annotations

import argparse
import os
import plistlib
import re
import subprocess
import sys
from pathlib import Path

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; garbage is an error."""
    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:
    return _pick(parse_app_ids(raw), platform)


CONFIG_NAME = "GoogleService-Info.plist"


def app_id_from_config(search_root: Path, platform: str) -> str | None:
    """Read the Firebase app ID out of the app's own GoogleService-Info.plist.

    Firebase reads this file at runtime, it is committed beside the Xcode project and it
    is already inside the archive, so requiring the same value in a separate secret adds
    a way to be wrong without adding a source of truth. An explicit FIREBASE_APP_ID still
    wins; this is only consulted when it names no app for the platform.
    """
    if not search_root.is_dir():
        return None
    root = search_root.resolve()
    found: dict[str, Path] = {}
    for path in sorted(root.rglob(CONFIG_NAME)):
        # A pod ships its own fixtures; only the app's committed config speaks for it.
        if path.is_symlink() or root not in path.resolve().parents:
            continue
        if "Pods" in path.relative_to(root).parts:
            continue
        try:
            with path.open("rb") as stream:
                raw = plistlib.load(stream).get("GOOGLE_APP_ID", "")
        except (OSError, ValueError, plistlib.InvalidFileException):
            continue
        match = APP_ID_RE.match(str(raw).strip())
        if match and match.group(3) == platform:
            found.setdefault(match.group(0), path)
    if len(found) > 1:
        listing = ", ".join(f"{path.relative_to(root)} -> {app_id}"
                            for app_id, path in sorted(found.items()))
        raise AppIdError(f"more than one {platform} Firebase app is configured ({listing}); "
                         "set FIREBASE_APP_ID to say which one owns these symbols")
    return next(iter(found), None)


def find_upload_symbols(search_root: Path, home: Path | None = None) -> Path | None:
    """Locate the FirebaseCrashlytics `upload-symbols` tool.

    CocoaPods puts it at <ios dir>/Pods/FirebaseCrashlytics/upload-symbols; a
    Swift Package Manager checkout keeps it under Xcode's DerivedData. Only a
    tool whose parent directory names Crashlytics counts — the name alone is
    too generic.
    """
    direct = search_root / "Pods" / "FirebaseCrashlytics" / "upload-symbols"
    if direct.is_file():
        return direct
    if search_root.is_dir():
        # A project at the repo root makes search_root the whole checkout, so
        # prune the trees that are big and can never hold the tool.
        prune = {".git", "build", ".dart_tool", "node_modules"}
        hits: list[Path] = []
        for dirpath, dirnames, filenames in os.walk(search_root):
            dirnames[:] = [d for d in dirnames if d not in prune]
            if "upload-symbols" in filenames and Path(dirpath).name in ("FirebaseCrashlytics", "Crashlytics"):
                candidate = Path(dirpath) / "upload-symbols"
                if candidate.is_file():
                    hits.append(candidate)
        if hits:
            return sorted(hits)[0]
    home = Path.home() if home is None else home
    derived = home / "Library" / "Developer" / "Xcode" / "DerivedData"
    for candidate in sorted(
        derived.glob("*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols")
    ):
        if candidate.is_file():
            return candidate
    return None


def upload_command(tool: Path, app_id: str, dsyms_dir: Path) -> list[str]:
    return [str(tool), "-ai", app_id, "-p", "ios", "--", str(dsyms_dir)]


def _default_run(cmd: list[str]) -> int:
    raise SystemExit("Native Crashlytics upload is refused; use prepare_crashlytics_dsyms.py "
                     "and complete the upload through the account-proxied console")


def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None,
         home: Path | None = 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("--archive", required=True, type=Path, help="the .xcarchive that was exported")
    parser.add_argument("--search-root", required=True, type=Path,
                        help="directory holding the Xcode project/workspace (where Pods/ lives)")
    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, "ios")
    if app_id is None:
        if ids:
            print(
                "::notice::FIREBASE_APP_ID names no iOS app (1:<project number>:ios:<hash>); "
                "native upload is disabled; use the proxied console delivery."
            )
        else:
            print("FIREBASE_APP_ID unset; native upload is disabled; configure proxied console delivery.")
        return 0

    dsyms_dir = args.archive / "dSYMs"
    dsyms = sorted(dsyms_dir.glob("*.dSYM")) if dsyms_dir.is_dir() else []
    if not dsyms:
        print(f"::error::no dSYMs in {dsyms_dir}; nothing to upload to Crashlytics")
        return 1

    tool = find_upload_symbols(args.search_root, home)
    if tool is None:
        # No tool means the app does not link the Crashlytics SDK, so there are
        # no Crashlytics crash reports to symbolicate — skipping loses nothing,
        # while failing would block every release of a non-Crashlytics app
        # whose FIREBASE_APP_ID happens to carry an iOS id.
        print(
            "::warning::FIREBASE_APP_ID names an iOS app but no FirebaseCrashlytics "
            f"upload-symbols tool exists under {args.search_root} or Xcode's "
            "DerivedData, so the app does not appear to embed Crashlytics; "
            "skipping the dSYM upload. (`flutterfire configure` adds the "
            "dependency and the run-script phase.)"
        )
        return 0

    cmd = upload_command(tool, app_id, dsyms_dir)
    print(f"Uploading {len(dsyms)} dSYM(s) to Crashlytics app {app_id}:")
    for dsym in dsyms:
        print(f"  {dsym.name}")
    print("  " + " ".join(cmd))
    rc = run(cmd)
    if rc != 0:
        print(
            f"::error::Crashlytics dSYM upload failed (exit {rc}). This step runs "
            "before the TestFlight upload, so nothing shipped — fix the cause and "
            "re-run the deploy."
        )
        return 1
    print(f"Uploaded dSYMs to Crashlytics app {app_id}.")
    return 0


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