"""Native upload fencing and durable symbols preserve the exact archive without provider calls."""
import hashlib
import json
import os
import plistlib
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
import zipfile

sys.path.insert(0, str(Path(__file__).resolve().parent))
import crashlytics_dsyms
import prepare_crashlytics_build as build
import prepare_crashlytics_dsyms as symbols


class BuildPhaseTests(unittest.TestCase):
    def test_dedicated_phase_is_deferred_without_changing_build_objects(self):
        other = {"isa": "PBXSourcesBuildPhase", "files": ["source"]}
        doc = {"objects": {"code": other, "symbols": {"isa": "PBXShellScriptBuildPhase",
               "name": "Crashlytics Upload Symbols", "shellScript": '"$PODS_ROOT/FirebaseCrashlytics/run"'}}}
        self.assertEqual(build.rewrite(doc), ["symbols"])
        self.assertEqual(doc["objects"]["code"], other)
        self.assertEqual(doc["objects"]["symbols"]["shellScript"], build.MARKER)
        self.assertEqual(build.rewrite(doc), [])

    def test_unidentified_upload_phase_refuses_the_archive(self):
        doc = {"objects": {"mixed": {"isa": "PBXShellScriptBuildPhase", "name": "Build everything",
                                   "shellScript": "compile-app; upload-symbols"}}}
        with self.assertRaises(ValueError):
            build.rewrite(doc)

    def test_crashlytics_name_cannot_hide_other_build_work(self):
        doc = {"objects": {"mixed": {"isa": "PBXShellScriptBuildPhase", "name": "Crashlytics",
                                   "shellScript": "compile-app; upload-symbols"}}}
        with self.assertRaises(ValueError):
            build.rewrite(doc)
        self.assertTrue(build.dedicated('#!/bin/sh\nPATH="$PATH:$HOME/bin"\n'
                                       'flutterfire upload-crashlytics-symbols --platform=ios'))

    # Verbatim from gowalk-public/obd_scanner_app @3aa60211, whose iOS leg this refused
    # after pods, signing and the App Store version slot had all already succeeded.
    FLUTTERFIRE = (
        '\n#!/bin/bash\n'
        'if [ ! -f "$PODS_ROOT/FirebaseCrashlytics/upload-symbols" ]; then '
        'echo "warning: FirebaseCrashlytics upload-symbols not found at '
        '$PODS_ROOT/FirebaseCrashlytics/upload-symbols; skipping Crashlytics symbol upload '
        '(Firebase via SwiftPM)"; exit 0; fi\n'
        'PATH="${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin"\n'
        'dart pub global activate flutterfire_cli\n'
        'flutterfire upload-crashlytics-symbols '
        '--upload-symbols-script-path="$PODS_ROOT/FirebaseCrashlytics/upload-symbols" '
        '--platform=ios --apple-project-path="${SRCROOT}" '
        '--env-platform-name="${PLATFORM_NAME}" --env-configuration="${CONFIGURATION}"\n')
    FIREBASE_DOCS = (
        'if [ -f "$PODS_ROOT/FirebaseCrashlytics/upload-symbols" ]; then '
        '$PODS_ROOT/FirebaseCrashlytics/upload-symbols '
        '-gsp $PROJECT_DIR/Runner/GoogleService-Info.plist -p ios '
        '$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME; '
        'else echo "warning: FirebaseCrashlytics upload-symbols not found; '
        'skipping dSYM upload (Firebase via SwiftPM)"; fi\n')

    def test_the_phases_flutterfire_and_firebase_actually_generate_are_deferred(self):
        doc = {"objects": {
            "flutterfire": {"isa": "PBXShellScriptBuildPhase",
                            "name": 'FlutterFire: "flutterfire upload-crashlytics-symbols"',
                            "shellScript": self.FLUTTERFIRE},
            "docs": {"isa": "PBXShellScriptBuildPhase", "name": "Upload dSYMs",
                     "shellScript": self.FIREBASE_DOCS}}}
        self.assertEqual(sorted(build.rewrite(doc)), ["docs", "flutterfire"])
        for identity in ("flutterfire", "docs"):
            self.assertEqual(doc["objects"][identity]["shellScript"], build.MARKER)

    def test_an_upload_phase_is_deferred_whatever_the_phase_is_called(self):
        # "Upload dSYMs" is what the Firebase documentation tells you to name it, and a
        # name has never been evidence about what a script does.
        doc = {"objects": {"phase": {"isa": "PBXShellScriptBuildPhase", "name": "Upload dSYMs",
                                     "shellScript": self.FIREBASE_DOCS}}}
        self.assertEqual(build.rewrite(doc), ["phase"])

    def test_build_work_beside_the_upload_still_refuses_and_names_the_phase(self):
        for script in ('/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build\n'
                       '"$PODS_ROOT/FirebaseCrashlytics/run"',
                       'upload-symbols && rm -rf "$SRCROOT/build"',
                       'upload-symbols; curl https://example.invalid/x'):
            with self.subTest(script=script):
                doc = {"objects": {"p": {"isa": "PBXShellScriptBuildPhase",
                                         "name": "Crashlytics", "shellScript": script}}}
                with self.assertRaises(ValueError) as caught:
                    build.rewrite(doc)
                self.assertIn("Crashlytics", str(caught.exception))
                self.assertEqual(doc["objects"]["p"]["shellScript"], script)

    def test_a_hidden_command_is_never_treated_as_an_upload_only_phase(self):
        for script in ('upload-symbols "$(curl https://example.invalid/x)"',
                       'upload-symbols `id`',
                       '$RUNNER upload-symbols',
                       'echo hi; "unbalanced'):
            with self.subTest(script=script):
                self.assertFalse(build.dedicated(script))

    def test_a_command_hiding_behind_an_assignment_prefix_is_still_a_command(self):
        # `CONFIG=release rm -rf build` RUNS rm. Reading only the first token saw an
        # assignment and approved the phase, which would have deleted the build tree.
        for script in ('upload-symbols\nCONFIG=release rm -rf "$SRCROOT/build"',
                       'A=1 B=2 curl https://example.invalid/x\nupload-symbols'):
            with self.subTest(script=script):
                self.assertFalse(build.dedicated(script))
        self.assertTrue(build.dedicated('FOO=bar upload-symbols -p ios'))
        self.assertEqual(build.invoked(["A=1", "B=2", "rm", "-rf", "/"]), ["rm", "-rf", "/"])
        self.assertEqual(build.invoked(["A=1"]), [])

    def test_a_hash_only_starts_a_comment_at_the_start_of_a_word(self):
        # shlex would treat the '#' as a comment and never see the rm; bash runs it.
        self.assertFalse(build.dedicated('upload-symbols#; rm -rf /'))
        self.assertTrue(build.dedicated('upload-symbols -p ios  # upload the dSYMs'))
        self.assertTrue(build.dedicated('#!/bin/bash\nupload-symbols'))

    def test_an_escaped_backslash_does_not_swallow_the_next_command(self):
        # `echo done\\` prints a backslash and ENDS; joining every backslash-newline made
        # the rm an argument of the echo and hid it from the check.
        self.assertFalse(build.dedicated('"$PODS_ROOT/FirebaseCrashlytics/run"\n'
                                        'echo done\\\\\nrm -rf /'))
        self.assertTrue(build.dedicated('upload-symbols \\\n  -p ios'))
        self.assertEqual(build.continued('a\\\nb'), ["a b"])
        self.assertEqual(build.continued('a\\\\\nb'), ["a\\\\", "b"])

    def test_inert_prologues_and_redirects_do_not_refuse_a_real_upload_phase(self):
        for script in ('export PATH=/x\nupload-symbols',
                       'upload-symbols > /dev/null 2>&1',
                       'export FLUTTER_ROOT=/f\nflutterfire upload-crashlytics-symbols --platform=ios'):
            with self.subTest(script=script):
                self.assertTrue(build.dedicated(script))

    def test_a_destructive_redirect_is_removed_rather_than_refused(self):
        # The whole script is replaced, so a phase whose only extra effect is a redirect
        # stops doing it. Refusing would fail the archive and LEAVE the write in place.
        self.assertTrue(build.dedicated('upload-symbols > "$HOME/.ssh/authorized_keys"'))

    def test_a_redirect_never_hides_a_following_command(self):
        self.assertFalse(build.dedicated('upload-symbols > f; rm -rf /'))
        self.assertFalse(build.dedicated('export EVIL=1 rm -rf /\nupload-symbols'))

    def test_a_phase_that_only_mentions_the_uploader_is_left_alone(self):
        # No upload actually happens, so there is nothing to defer and nothing to refuse.
        self.assertFalse(build.dedicated('echo "run upload-symbols by hand"'))
        doc = {"objects": {"p": {"isa": "PBXShellScriptBuildPhase", "name": "Notes",
                                 "shellScript": "# upload-symbols runs in CI\necho done"}}}
        self.assertEqual(build.rewrite(doc), [])

    def test_legacy_native_entrypoint_never_starts_the_binary(self):
        with mock.patch.object(crashlytics_dsyms.subprocess, "run") as run:
            with self.assertRaises(SystemExit):
                crashlytics_dsyms._default_run(["upload-symbols"])
            run.assert_not_called()

    def test_every_project_is_validated_before_any_file_changes(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            first, second = root / "A.xcodeproj", root / "B.xcodeproj"
            first.mkdir(); second.mkdir()
            for parent in (first, second):
                (parent / "project.pbxproj").write_text("original")
            documents = [json.dumps({"objects": {"phase": {"isa": "PBXShellScriptBuildPhase",
                         "name": "Crashlytics", "shellScript": script}}}).encode()
                         for script in ("upload-symbols", "upload-symbols; compile-app")]
            with mock.patch.object(build.subprocess, "check_output", side_effect=documents):
                with self.assertRaises(ValueError):
                    build.prepare(root)
            self.assertEqual((first / "project.pbxproj").read_text(), "original")



class FirebaseAppIdTests(unittest.TestCase):
    """The iOS app ID is committed beside the Xcode project; CI need not be told it twice."""

    # Verbatim from gowalk-public/obd_scanner_app, whose archive and IPA both succeeded
    # and which then failed on "FIREBASE_APP_ID names no iOS app" with the value below
    # sitting in ios/Runner/GoogleService-Info.plist.
    IOS_ID = "1:361949697659:ios:493e38c05202a939d9f4de"
    ANDROID_ID = "1:361949697659:android:0d9dbd2b1c1f4a2b9f4de1"

    def config(self, directory: Path, app_id: str) -> Path:
        directory.mkdir(parents=True, exist_ok=True)
        target = directory / crashlytics_dsyms.CONFIG_NAME
        with target.open("wb") as stream:
            plistlib.dump({"BUNDLE_ID": "com.gowalk.obdscanner", "GOOGLE_APP_ID": app_id}, stream)
        return target

    def test_the_committed_config_names_the_app_when_the_secret_does_not(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            self.config(root / "Runner", self.IOS_ID)
            self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)
            self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "android"))

    def test_an_explicit_secret_still_wins_over_the_committed_config(self):
        override = "1:999999999999:ios:aaaabbbbccccdddd"
        self.assertEqual(crashlytics_dsyms.select_app_id(override, "ios"), override)

    def test_a_pod_fixture_never_speaks_for_the_app(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            self.config(root / "Pods" / "SomePod" / "Example", self.IOS_ID)
            self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))

    def test_two_ios_apps_refuse_rather_than_guess(self):
        other = "1:361949697659:ios:ffffffffffffffffffffff"
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            self.config(root / "Runner", self.IOS_ID)
            self.config(root / "Watch", other)
            with self.assertRaises(crashlytics_dsyms.AppIdError) as caught:
                crashlytics_dsyms.app_id_from_config(root, "ios")
            for expected in (self.IOS_ID, other, "FIREBASE_APP_ID"):
                self.assertIn(expected, str(caught.exception))

    def test_the_same_app_configured_twice_is_not_a_conflict(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            self.config(root / "Runner", self.IOS_ID)
            self.config(root / "Runner" / "Resources", self.IOS_ID)
            self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)

    def test_an_android_only_config_never_answers_for_ios(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            self.config(root / "Runner", self.ANDROID_ID)
            self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))

    def test_unreadable_or_escaping_configs_are_skipped_not_fatal(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            (root / "Broken").mkdir()
            (root / "Broken" / crashlytics_dsyms.CONFIG_NAME).write_text("not a plist")
            outside = Path(temporary) / "outside.plist"
            with outside.open("wb") as stream:
                plistlib.dump({"GOOGLE_APP_ID": self.IOS_ID}, stream)
            (root / "Linked").mkdir()
            (root / "Linked" / crashlytics_dsyms.CONFIG_NAME).symlink_to(outside)
            self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))
            self.config(root / "Runner", self.IOS_ID)
            self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)

    def test_a_missing_directory_is_simply_no_answer(self):
        self.assertIsNone(crashlytics_dsyms.app_id_from_config(Path("/nonexistent-xyz"), "ios"))


@mock.patch.dict(os.environ, {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "2", "GITHUB_SHA": "source-sha"})
class ArchiveTests(unittest.TestCase):
    def test_zip_manifest_and_notice_identify_the_same_build(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            dwarf = root / "app.xcarchive/dSYMs/App.framework.dSYM/Contents/Resources/DWARF/App"
            dwarf.parent.mkdir(parents=True); dwarf.write_bytes(b"actual debug symbols")
            output = root / "output"
            with mock.patch.object(symbols.subprocess, "check_output", return_value="UUID: expected (arm64) App\n"):
                receipt = symbols.prepare(root / "app.xcarchive", output, "1:2:ios:abc")
            manifest = json.loads((output / "firebase-symbols.json").read_text())
            self.assertEqual(receipt["source_sha"], "source-sha")
            self.assertEqual(receipt["artifact"], "ios-crashlytics-symbols-123-2")
            self.assertEqual(receipt["sha256"], hashlib.sha256((output / "ios-dsyms.zip").read_bytes()).hexdigest())
            self.assertEqual(manifest["uuids"], ["UUID: expected (arm64) App"])
            with zipfile.ZipFile(output / "ios-dsyms.zip") as zipped:
                self.assertEqual(zipped.read(zipped.namelist()[0]), b"actual debug symbols")

    def test_symlink_to_another_owner_cannot_enter_the_artifact(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            other = root / "other"; other.write_bytes(b"private")
            dsym = root / "app.xcarchive/dSYMs/App.dSYM"; dsym.mkdir(parents=True)
            (dsym / "outside").symlink_to(other)
            with self.assertRaises(ValueError):
                symbols.prepare(root / "app.xcarchive", root / "output", "1:2:ios:abc")
            self.assertFalse((root / "output").exists())


if __name__ == "__main__":
    unittest.main()
