#!/usr/bin/env python3
"""Crashlytics symbol upload: gating, id selection and failure semantics.

The upload itself cannot run here (it needs the Firebase CLI, Java and the
network), so the command is captured through the injectable runner and the
decision logic is exercised directly. The wiring into action.yml is pinned at
the bottom, next to the other Flutter obfuscation wiring tests.
"""

from __future__ import annotations

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

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

import crashlytics_symbols as cs  # noqa: E402

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


class ParseAppIdsTest(unittest.TestCase):
    def test_single_id(self) -> None:
        self.assertEqual(cs.parse_app_ids(ANDROID), [("android", ANDROID)])

    def test_several_ids_separated_by_commas_or_whitespace(self) -> None:
        for raw in (f"{ANDROID},{IOS}", f"{ANDROID}, {IOS}", f"{ANDROID}\n{IOS}", f" {ANDROID}  {IOS} "):
            with self.subTest(raw=raw):
                self.assertEqual(cs.parse_app_ids(raw), [("android", ANDROID), ("ios", IOS)])

    def test_empty_is_no_ids(self) -> None:
        self.assertEqual(cs.parse_app_ids(""), [])
        self.assertEqual(cs.parse_app_ids("  \n "), [])

    def test_web_ids_parse_but_are_never_selected_here(self) -> None:
        web = "1:123456789012:web:abcdef0123456789"
        self.assertEqual(cs.parse_app_ids(web), [("web", web)])
        self.assertIsNone(cs.select_app_id(web, "android"))

    def test_garbage_is_an_error_not_a_skip(self) -> None:
        for raw in ("my-app", "1:123:android", "1:123:desktop:abc", f"{ANDROID},oops"):
            with self.subTest(raw=raw):
                with self.assertRaises(cs.AppIdError):
                    cs.parse_app_ids(raw)

    def test_select_picks_the_platform(self) -> None:
        self.assertEqual(cs.select_app_id(f"{IOS},{ANDROID}", "android"), ANDROID)
        self.assertEqual(cs.select_app_id(f"{IOS},{ANDROID}", "ios"), IOS)
        self.assertIsNone(cs.select_app_id(IOS, "android"))


class MainTest(unittest.TestCase):
    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.symbols = self.root / "symbols"
        self.symbols.mkdir()
        for arch in ("arm", "arm64", "x64"):
            (self.symbols / f"app.android-{arch}.symbols").write_bytes(b"\x7fELF")
        self.pubspec = self.root / "pubspec.yaml"
        self.pubspec.write_text("name: app\ndependencies:\n  flutter:\n    sdk: flutter\n")
        self.calls: list[list[str]] = []

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

    def run_main(self, env: dict[str, str], rc: int = 0, symbols_dir: 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 = cs.main(
                ["--symbols-dir", str(symbols_dir or self.symbols), "--pubspec", str(self.pubspec)],
                environ=env,
                run=runner,
            )
        return code, out.getvalue()

    def test_unset_without_crashlytics_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_unset_with_crashlytics_warns_but_does_not_fail(self) -> None:
        self.pubspec.write_text("dependencies:\n  firebase_crashlytics: ^5.0.0\n")
        code, out = self.run_main({"FIREBASE_APP_ID": ""})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [])
        self.assertIn("::warning::", out)
        self.assertIn("FIREBASE_APP_ID", out)
        self.assertIn("***", out)
        # The action cannot tell an unset variable from a deploy.yml that never
        # passes the input; the message must name both causes.
        self.assertIn("gowalk-cicd", out)

    def test_a_commented_out_dependency_does_not_count(self) -> None:
        self.pubspec.write_text("dependencies:\n  # firebase_crashlytics: ^5.0.0\n")
        code, out = self.run_main({})
        self.assertEqual(code, 0)
        self.assertNotIn("::warning::", out)

    def test_ids_without_an_android_one_warn_and_skip(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": IOS})
        self.assertEqual(code, 0)
        self.assertEqual(self.calls, [])
        self.assertIn("::warning::", out)
        self.assertIn("no Android app", out)

    def test_malformed_id_fails(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": "not-an-id"})
        self.assertEqual(code, 1)
        self.assertEqual(self.calls, [])
        self.assertIn("::error::", out)

    def test_android_id_uploads_the_symbol_directory(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": f"{IOS}, {ANDROID}"})
        self.assertEqual(code, 0)
        self.assertEqual(
            self.calls,
            [["npx", "--yes", cs.FIREBASE_TOOLS, "crashlytics:symbols:upload",
              f"--app={ANDROID}", str(self.symbols.resolve())]],
        )
        self.assertIn("Uploaded", out)
        self.assertNotIn("::error::", out)

    def test_firebase_tools_is_pinned_exactly(self) -> None:
        # This job holds the upload keystore and the Play service account;
        # floating third-party code has no place in it.
        self.assertRegex(cs.FIREBASE_TOOLS, r"^firebase-tools@\d+\.\d+\.\d+$")

    def test_a_missing_npx_is_a_named_error_not_a_traceback(self) -> None:
        out = io.StringIO()
        with redirect_stdout(out):
            rc = cs._default_run(["gowalk-cicd-no-such-binary-xyz"])
        self.assertNotEqual(rc, 0)
        self.assertIn("::error::", out.getvalue())

    def test_a_failed_upload_fails_the_step(self) -> None:
        code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, rc=2)
        self.assertEqual(code, 1)
        self.assertIn("::error::", out)
        # The operator is told how to recover from the retained artifact.
        self.assertIn("android-symbols", out)
        self.assertIn(f"--app={ANDROID}", out)

    def test_missing_symbols_fail_before_any_upload(self) -> None:
        empty = self.root / "empty"
        empty.mkdir()
        code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, symbols_dir=empty)
        self.assertEqual(code, 1)
        self.assertEqual(self.calls, [])
        self.assertIn("::error::", out)
        code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, symbols_dir=self.root / "nope")
        self.assertEqual(code, 1)
        self.assertEqual(self.calls, [])


ACTION_YML = Path(__file__).resolve().parent.parent / "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_flutter_apps_upload_after_retaining_and_before_the_gradle_path(self) -> None:
        upload = step("Upload Dart symbols to Crashlytics")
        self.assertIn("project_kind == 'flutter'", upload)
        self.assertIn("FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}", upload)
        self.assertIn("DART_SYMBOLS_DIR: ${{ steps.flutter_build.outputs.symbols_path }}", upload)
        self.assertIn("scripts/crashlytics_symbols.py", upload)
        text = ACTION_YML.read_text()
        self.assertLess(text.index("- name: Retain Dart symbol files"),
                        text.index("- name: Upload Dart symbols to Crashlytics"))
        self.assertLess(text.index("- name: Upload Dart symbols to Crashlytics"),
                        text.index("# --- Native Gradle"))

    def test_the_upload_runs_even_when_unset_so_it_can_warn(self) -> None:
        upload = step("Upload Dart symbols to Crashlytics")
        self.assertNotIn("firebase-app-id != ''", upload)

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


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