#!/usr/bin/env python3
"""Defer dedicated native Crashlytics upload phases to the proxied console delivery."""
from __future__ import annotations

import argparse
import json
from pathlib import Path
import plistlib
import re
import shlex
import subprocess

MARKER = "echo 'Crashlytics dSYMs are retained by CI for the account-proxied console upload'"
UPLOADS = ("upload-crashlytics-symbols", "upload-symbols", "Crashlytics/run")
# Shell words a symbol-upload phase may legitimately run besides the upload itself.
# `dart pub global activate flutterfire_cli` is how the FlutterFire CLI installs the
# uploader it is about to call; the rest are the inert built-ins the generated guards use
# to skip the upload when the tool is absent (Firebase via SwiftPM, for instance).
INERT = frozenset({"echo", "exit", "true", "false", ":", "[", "[[", "test", "return"})
KEYWORDS = frozenset({"if", "then", "else", "elif", "fi", "do", "done", "while", "until"})
ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z_0-9]*=")
OPERATOR = set(";&|<>")
REDIRECT = set("<>")


def continued(script: str) -> list[str]:
    """Split into logical lines, joining only a REAL backslash-newline continuation.

    `echo done\\` ends in an escaped backslash, so the newline still separates commands and
    the next line runs. Joining unconditionally turned that next command into an argument
    of the echo and hid it, so count the trailing backslashes: an odd number continues.
    """
    lines: list[str] = []
    pending = ""
    for line in script.split("\n"):
        line = pending + line
        if (len(line) - len(line.rstrip("\\"))) % 2:
            pending = line[:-1] + " "
            continue
        lines.append(line)
        pending = ""
    if pending:
        lines.append(pending)
    return lines


def commands(script: str) -> list[list[str]] | None:
    """Every command in `script` as a token list, or None when it cannot be read safely.

    A newline separates commands just as `;` does, and shlex treats it as plain
    whitespace, so each line is tokenized on its own after continuations are joined.
    """
    if "$(" in script or "`" in script:
        return None
    parsed = []
    for line in continued(script):
        lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|<>")
        lexer.whitespace_split = True
        # shlex would treat a '#' ANYWHERE outside quotes as starting a comment, but the
        # shell only does so at the start of a word: in `upload-symbols#; rm -rf /` the
        # rm really runs. Read comments the way the shell does instead.
        lexer.commenters = ""
        try:
            tokens = list(lexer)
        except ValueError:
            return None
        current = []
        target = False
        for token in tokens:
            if target:
                target = False
                continue
            if token.startswith("#"):
                break
            if token and set(token) <= OPERATOR:
                if set(token) & REDIRECT:
                    # Only the deferred uploader's effects belong to the removed operation.
                    # A redirected builtin/assignment can produce another required build input.
                    if not uploads_symbols(current):
                        return None
                    target = True
                    continue
                if current:
                    parsed.append(current)
                current = []
            elif token in KEYWORDS:
                if current:
                    parsed.append(current)
                current = []
            else:
                current.append(token)
        if current:
            parsed.append(current)
    return parsed


def invoked(tokens: list[str]) -> list[str]:
    """What the command actually runs, after any leading VAR=value prefixes.

    `CONFIG=release rm -rf build` runs rm; treating the assignment as the whole command
    would approve anything hiding behind one.
    """
    index = 0
    while index < len(tokens) and ASSIGNMENT.match(tokens[index]):
        index += 1
    return tokens[index:]


def uploads_symbols(tokens: list[str]) -> bool:
    words = invoked(tokens)
    if not words:
        return False
    return (words[0].endswith(("upload-symbols", "Crashlytics/run"))
            or (words[0] == "flutterfire" and words[1:2] == ["upload-crashlytics-symbols"]))


def permitted(tokens: list[str]) -> bool:
    words = invoked(tokens)
    if not words:
        return True
    if words[0] == "export" and all(ASSIGNMENT.match(word) for word in words[1:]):
        return True                          # `export PATH=...` is the same inert prologue
    return (uploads_symbols(words)
            or words[0] in INERT
            or words[:5] == ["dart", "pub", "global", "activate", "flutterfire_cli"])


def dedicated(script: str) -> bool:
    """True when the phase uploads symbols and does nothing else the archive needs.

    The uploader is generated, not hand-written: `flutterfire configure` emits a guard
    clause, a PATH export, a `dart pub global activate` and the upload, and the Firebase
    documentation emits an if/else around `$PODS_ROOT/FirebaseCrashlytics/upload-symbols`.
    Demanding a single command rejected both, so the phase every Flutter app actually has
    could never be deferred. What matters is that nothing ELSE in the phase builds
    anything, since the whole script is replaced.
    """
    parsed = commands(script)
    if parsed is None:
        return False
    return any(uploads_symbols(tokens) for tokens in parsed) and all(map(permitted, parsed))


def rewrite(document: dict) -> list[str]:
    changed = []
    for identity, phase in document.get("objects", {}).items():
        if phase.get("isa") != "PBXShellScriptBuildPhase":
            continue
        script = phase.get("shellScript", "")
        active = "\n".join(line for line in script.splitlines() if not line.lstrip().startswith("#"))
        if not any(token in active for token in UPLOADS):
            continue
        if not dedicated(script):
            raise ValueError(
                f"Crashlytics upload shares the build phase {phase.get('name') or identity!r} with other "
                "work; move the upload into its own phase before archiving")
        phase["shellScript"] = MARKER
        changed.append(identity)
    return changed


def prepare(root: Path) -> int:
    root = root.resolve()
    projects = sorted(root.rglob("project.pbxproj"))
    prepared = []
    for project in projects:
        if project.is_symlink() or root not in project.resolve().parents:
            raise ValueError("Xcode project leaves the CI workspace")
        raw = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(project)], timeout=30)
        document = json.loads(raw)
        changed = rewrite(document)
        if changed:
            prepared.append((project, document, changed))
    # Validate every project before changing one; a mixed phase cannot produce
    # a partially rewritten workspace that a caller accidentally archives.
    for project, document, changed in prepared:
        temporary = project.with_suffix(".proxy-new")
        try:
            temporary.write_bytes(plistlib.dumps(document, sort_keys=False))
            temporary.replace(project)
        finally:
            temporary.unlink(missing_ok=True)
        print(f"Deferred {len(changed)} Crashlytics upload phase(s) in {project.relative_to(root)}")
    return sum(len(changed) for _, _, changed in prepared)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--search-root", required=True, type=Path)
    args = parser.parse_args()
    prepare(args.search_root)
