#!/usr/bin/env python3
"""Crashlytics dSYM upload: gating, tool discovery and failure semantics.

The upload itself cannot run here (it needs the FirebaseCrashlytics pod and
the network), so the command is captured through the injectable runner. The
wiring into action.yml is pinned at the bottom.
"""

from __future__ import annotations

import io
import re
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import crashlytics_dsyms as cd  # noqa: E402

ANDROID = "1:123456789012:android:0123456789abcdef"
IOS = "1:123456789012:ios:fedcba9876543210"


class ParseAppIdsTest(unittest.TestCase):
    def test_parse_and_select(self) -> None:
        self.assertEqual(cd.parse_app_ids(f"{ANDROID}, {IOS}"), [("android", ANDROID), ("ios", IOS)])
        self.assertEqual(cd.select_app_id(f"{ANDROID}\n{IOS}", "ios"), IOS)
        self.assertIsNone(cd.select_app_id(ANDROID, "ios"))
        self.assertEqual(cd.parse_app_ids(""), [])

    def test_garbage_is_an_error(self) -> None:
        with self.assertRaises(cd.AppIdError):
            cd.parse_app_ids("1:123:tvos:abc")


class FindUploadSymbolsTest(unittest.TestCase):
    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.ios = self.root / "ios"
        self.ios.mkdir()
        self.home = self.root / "home"
        self.home.mkdir()

    def tearDown(self) -> None:
        self.tmp.cleanup()

    def touch(self, path: Path) -> Path:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("#!/bin/sh\n")
        return path

    def test_cocoapods_location_wins(self) -> None:
        tool = self.touch(self.ios / "Pods" / "FirebaseCrashlytics" / "upload-symbols")
        self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)

    def test_a_crashlytics_tool_anywhere_under_the_root_is_found(self) -> None:
        tool = self.touch(self.ios / "Vendor" / "Crashlytics" / "upload-symbols")
        self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)

    def test_an_unrelated_upload_symbols_is_ignored(self) -> None:
        self.touch(self.ios / "Pods" / "Sentry" / "upload-symbols")
        self.assertIsNone(cd.find_upload_symbols(self.ios, self.home))

    def test_swiftpm_checkout_is_found(self) -> None:
        tool = self.touch(
            self.home / "Library" / "Developer" / "Xcode" / "DerivedData" / "Runner-abc"
            / "SourcePackages" / "checkouts" / "firebase-ios-sdk" / "Crashlytics" / "upload-symbols"
        )
        self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)

    def test_missing_root_is_not_an_exception(self) -> None:
        self.assertIsNone(cd.find_upload_symbols(self.root / "nope", self.home))

    def test_pruned_trees_are_never_searched(self) -> None:
        # A project at the repo root makes the search root the whole checkout.
        for tree in (".git", "build", ".dart_tool", "node_modules"):
            self.touch(self.ios / tree / "FirebaseCrashlytics" / "upload-symbols")
        self.assertIsNone(cd.find_upload_symbols(self.ios, self.home))


class MainTest(unittest.TestCase):
    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.ios = self.root / "ios"
        self.tool = self.ios / "Pods" / "FirebaseCrashlytics" / "upload-symbols"
        self.tool.parent.mkdir(parents=True)
        self.tool.write_text("#!/bin/sh\n")
        self.archive = self.root / "app.xcarchive"
        self.dsyms = self.archive / "dSYMs"
        for name in ("Runner.app.dSYM", "App.framework.dSYM", "Flutter.framework.dSYM"):
            (self.dsyms / name / "Contents").mkdir(parents=True)
        self.home = self.root / "home"
        self.home.mkdir()
        self.calls: list[list[str]] = []

    def tearDown(self) -> None:
        self.tmp.cleanup()

    def run_main(self, env: dict[str, str], rc: int = 0, archive: Path | None = None,
                 search_root: Path | None = None) -> tuple[int, str]:
        def runner(cmd: list[str]) -> int:
            self.calls.append(cmd)
            return rc

        out = io.StringIO()
        with redirect_stdout(out):
            code = cd.main(
                ["--archive", str(archive or self.archive), "--search-root", str(search_root or self.ios)],
                environ=env, run=runner, home=self.home,
            )
        return code, out.getvalue()

    def test_unset_is_a_quiet_no_op(self) -> None:
        code, out = self.run_main({})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [])
        self.assertNotIn("::warning::", out)
        self.assertNotIn("::error::", out)

    def test_ids_without_an_ios_one_are_a_notice_not_a_failure(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": ANDROID})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [])
        self.assertIn("::notice::", out)

    def test_malformed_id_fails(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": "nope"})
        self.assertEqual(code, 1)
        self.assertIn("::error::", out)

    def test_ios_id_uploads_the_whole_dsyms_directory(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": f"{ANDROID},{IOS}"})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [[str(self.tool), "-ai", IOS, "-p", "ios", "--", str(self.dsyms)]])
        self.assertIn("App.framework.dSYM", out)
        self.assertIn("Uploaded", out)

    def test_missing_tool_warns_and_skips(self) -> None:
        # No tool => the app does not link the Crashlytics SDK, so there is
        # nothing to symbolicate; blocking the release would be pure harm.
        self.tool.unlink()
        code, out = self.run_main({"FIREBASE_APP_ID": IOS})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [])
        self.assertIn("::warning::", out)
        self.assertNotIn("::error::", out)
        self.assertIn("upload-symbols", out)

    def test_native_tool_is_refused_without_executing_it(self) -> None:
        with self.assertRaisesRegex(SystemExit, "account-proxied console"):
            cd._default_run([str(self.root / "upload-symbols")])

    def test_missing_dsyms_fail_before_any_upload(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": IOS}, archive=self.root / "missing.xcarchive")
        self.assertEqual(code, 1)
        self.assertEqual(self.calls, [])
        self.assertIn("::error::", out)

    def test_a_failed_upload_fails_the_step(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": IOS}, rc=3)
        self.assertEqual(code, 1)
        self.assertIn("::error::", out)
        self.assertIn(f"-ai {IOS}", out)


ACTION_YML = Path(__file__).resolve().parents[1] / "action.yml"


def step(name: str) -> str:
    text = ACTION_YML.read_text()
    pattern = rf"    - name: {re.escape(name)}\n(?P<body>.*?)(?=\n    - name:|\Z)"
    match = re.search(pattern, text, re.DOTALL)
    if match is None:
        raise AssertionError(f"missing action step: {name}")
    return match.group("body")


class CrashlyticsWiringTest(unittest.TestCase):
    def test_the_action_takes_the_app_id_as_an_input(self) -> None:
        # An input, not `vars.FIREBASE_APP_ID` read inside the action: the
        # workflow passes the repository variable, and an unavailable context
        # in a composite action would invalidate every consumer's workflow.
        text = ACTION_YML.read_text()
        self.assertRegex(text, r"(?m)^  firebase-app-id:\n(?:    .*\n)*?    default: \"\"$")
        self.assertNotIn("vars.", text)

    def test_dsyms_go_up_after_export_and_before_testflight(self) -> None:
        upload = step("Retain dSYMs for the proxied Firebase console")
        self.assertIn("inputs.archive == 'true'", upload)
        # Only builds that actually ship: an archive-only run (upload=false)
        # must not be failed over a symbols upload.
        self.assertIn("inputs.upload == 'true'", upload)
        self.assertNotIn("inputs.firebase-app-id != ''", upload)
        self.assertIn("FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}", upload)
        self.assertIn("scripts/prepare_crashlytics_dsyms.py", upload)
        self.assertIn('--archive "$RUNNER_TEMP/app.xcarchive"', upload)
        text = ACTION_YML.read_text()
        self.assertLess(text.index("- name: Export IPA"), text.index("- name: Retain dSYMs for the proxied Firebase console"))
        self.assertLess(text.index("- name: Retain dSYMs for the proxied Firebase console"), text.index("- name: Upload to TestFlight"))

    def test_the_script_ships_with_the_action(self) -> None:
        self.assertTrue((ACTION_YML.parent / "scripts" / "crashlytics_dsyms.py").is_file())


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