#!/usr/bin/env python3
"""
Tests for auto_detect.py — Xcode project / scheme / bundle_id auto-detection.

All tests run offline. xcodebuild invocations are stubbed through
``unittest.mock`` so they never shell out.
"""

from __future__ import annotations

import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

# Make sibling scripts importable regardless of the pytest invocation cwd.
sys.path.insert(0, str(Path(__file__).resolve().parent))

import auto_detect  # noqa: E402


# These discovery tests fake Xcode output; the graph guard has separate actual project/CLI tests.
def setUpModule():
    global _graph_guard
    _graph_guard = mock.patch.object(auto_detect.native_dependency_guard, "check", return_value={"ok": True})
    _graph_guard.start()


def tearDownModule():
    _graph_guard.stop()


APP_TYPE = "com.apple.product-type.application"
EXT_TYPE = "com.apple.product-type.app-extension"
TUN_TYPE = "com.apple.product-type.tunnel-provider"


class AutoDetectProjectTests(unittest.TestCase):
    """auto_detect_project: find *.xcworkspace or *.xcodeproj at repo root."""

    def test_prefers_xcworkspace_over_xcodeproj(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "MyApp.xcworkspace").mkdir()
            (root / "MyApp.xcodeproj").mkdir()
            project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "")
        self.assertEqual(workspace, "MyApp.xcworkspace")

    def test_returns_single_xcodeproj_when_no_workspace(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "MyApp.xcodeproj").mkdir()
            project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "MyApp.xcodeproj")
        self.assertEqual(workspace, "")

    def test_prefers_xcodeproj_matching_repo_basename_when_multiple(self):
        with tempfile.TemporaryDirectory() as tmp:
            # Create a named subdirectory so basename is predictable.
            root = Path(tmp) / "myapp"
            root.mkdir()
            (root / "Other.xcodeproj").mkdir()
            (root / "myapp.xcodeproj").mkdir()
            project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "myapp.xcodeproj")
        self.assertEqual(workspace, "")

    def test_falls_back_alphabetical_when_no_basename_match(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp) / "ambiguous-repo"
            root.mkdir()
            (root / "Zebra.xcodeproj").mkdir()
            (root / "Alpha.xcodeproj").mkdir()
            project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "Alpha.xcodeproj")
        self.assertEqual(workspace, "")

    def test_returns_empty_when_no_project_found(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "")
        self.assertEqual(workspace, "")

    def test_runs_xcodegen_when_only_project_yml_present(self):
        """project.yml without .xcodeproj should trigger xcodegen + retry."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "project.yml").write_text("name: MyApp\n")

            def fake_xcodegen(cmd, **kw):
                # Simulate xcodegen materializing the .xcodeproj.
                self.assertEqual(cmd[:2], ["xcodegen", "generate"])
                (root / "MyApp.xcodeproj").mkdir()
                result = mock.MagicMock()
                result.returncode = 0
                result.stdout = ""
                result.stderr = ""
                return result

            with mock.patch("auto_detect.subprocess.run", side_effect=fake_xcodegen) as mrun:
                project, workspace = auto_detect.auto_detect_project(root)
            mrun.assert_called_once()
        self.assertEqual(project, "MyApp.xcodeproj")
        self.assertEqual(workspace, "")

    def test_skips_xcodegen_when_xcodeproj_already_present(self):
        """xcodegen should NOT run if an .xcodeproj is already there."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "project.yml").write_text("name: MyApp\n")
            (root / "MyApp.xcodeproj").mkdir()

            with mock.patch("auto_detect.subprocess.run") as mrun:
                project, workspace = auto_detect.auto_detect_project(root)
            mrun.assert_not_called()
        self.assertEqual(project, "MyApp.xcodeproj")

    def test_xcodegen_failure_returns_empty(self):
        """xcodegen returning non-zero leaves project/workspace empty."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "project.yml").write_text("name: Broken\n")

            def fake_xcodegen(cmd, **kw):
                result = mock.MagicMock()
                result.returncode = 1
                result.stdout = ""
                result.stderr = "boom"
                return result

            with mock.patch("auto_detect.subprocess.run", side_effect=fake_xcodegen):
                project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "")
        self.assertEqual(workspace, "")

    def test_xcodegen_missing_binary_returns_empty(self):
        """FileNotFoundError (xcodegen not installed) is handled gracefully.

        When the Homebrew install fallback also fails (brew missing or
        install rc!=0), the function returns empty without raising.
        """
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "project.yml").write_text("name: NoTool\n")

            # Raise FileNotFoundError on xcodegen, then brew install also
            # FileNotFoundErrors — simulates neither tool available.
            with mock.patch(
                "auto_detect.subprocess.run",
                side_effect=FileNotFoundError("binary"),
            ):
                project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "")
        self.assertEqual(workspace, "")

    def test_xcodegen_installed_via_brew_on_missing_binary(self):
        """When xcodegen is missing, brew install runs and we retry."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "project.yml").write_text("name: Installable\n")

            call_log: list[str] = []

            def fake_run(cmd, **kw):
                call_log.append(cmd[0])
                result = mock.MagicMock()
                result.returncode = 0
                result.stdout = ""
                result.stderr = ""
                if cmd[0] == "xcodegen" and call_log.count("xcodegen") == 1:
                    # First xcodegen call fails as FileNotFoundError
                    raise FileNotFoundError("xcodegen")
                if cmd[0] == "xcodegen":
                    # Second call succeeds after brew install
                    (root / "Installable.xcodeproj").mkdir()
                    return result
                if cmd[0] == "brew":
                    # brew install succeeds
                    return result
                return result

            with mock.patch("auto_detect.subprocess.run", side_effect=fake_run):
                project, workspace = auto_detect.auto_detect_project(root)
        self.assertEqual(project, "Installable.xcodeproj")
        self.assertEqual(call_log, ["xcodegen", "brew", "xcodegen"])

    def test_detects_alt_xcodegen_spec_names(self):
        """xcodegen also accepts Project.yml / project.yaml / Project.yaml."""
        for spec_name in ("Project.yml", "project.yaml", "Project.yaml"):
            with self.subTest(spec=spec_name):
                with tempfile.TemporaryDirectory() as tmp:
                    root = Path(tmp)
                    (root / spec_name).write_text("name: Alt\n")

                    def fake_xcodegen(cmd, **kw):
                        (root / "Alt.xcodeproj").mkdir()
                        result = mock.MagicMock()
                        result.returncode = 0
                        result.stdout = ""
                        result.stderr = ""
                        return result

                    with mock.patch(
                        "auto_detect.subprocess.run",
                        side_effect=fake_xcodegen,
                    ):
                        project, workspace = auto_detect.auto_detect_project(root)
                self.assertEqual(project, "Alt.xcodeproj")
                self.assertEqual(workspace, "")


class AutoDetectSchemeTests(unittest.TestCase):
    """auto_detect_scheme: pick the single application-type scheme."""

    def setUp(self):
        auto_detect._clear_caches()

    def _list_output(self, schemes: list[str], project_name: str = "MyApp") -> str:
        return json.dumps({"project": {"name": project_name, "schemes": schemes}})

    def _show_settings(self, product_type: str, bundle_id: str = "com.x.y") -> str:
        return json.dumps(
            [
                {
                    "action": "build",
                    "buildSettings": {
                        "PRODUCT_TYPE": product_type,
                        "PRODUCT_BUNDLE_IDENTIFIER": bundle_id,
                    },
                }
            ]
        )

    def test_returns_single_application_scheme(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                result.stdout = self._list_output(["MyApp"])
            else:
                result.stdout = self._show_settings(APP_TYPE)
            return result

        root = Path("/tmp/x")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "MyApp.xcodeproj", "")
        self.assertEqual(scheme, "MyApp")

    def test_filters_out_extension_and_tunnel_schemes(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                result.stdout = self._list_output(["Tunnel", "VPNProj2"])
            else:
                # Find the scheme name in the command
                idx = cmd.index("-scheme") + 1
                scheme_name = cmd[idx]
                if scheme_name == "Tunnel":
                    result.stdout = self._show_settings(TUN_TYPE)
                else:
                    result.stdout = self._show_settings(APP_TYPE)
            return result

        root = Path("/tmp/mirage")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "VPNProj2.xcodeproj", "")
        self.assertEqual(scheme, "VPNProj2")

    def test_prefers_basename_match_when_multiple_app_schemes(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                result.stdout = self._list_output(["AppA", "myapp"])
            else:
                result.stdout = self._show_settings(APP_TYPE)
            return result

        root = Path("/tmp/myapp")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertEqual(scheme, "myapp")

    def test_falls_back_alphabetical_when_ambiguous(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                result.stdout = self._list_output(["Zebra", "Alpha"])
            else:
                result.stdout = self._show_settings(APP_TYPE)
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertEqual(scheme, "Alpha")

    def test_returns_none_when_no_application_scheme(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                result.stdout = self._list_output(["Tunnel"])
            else:
                result.stdout = self._show_settings(TUN_TYPE)
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertIsNone(scheme)

    def test_returns_none_when_xcodebuild_list_fails(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 1
            result.stdout = ""
            result.stderr = "xcodebuild error"
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertIsNone(scheme)

    def test_resolves_packages_and_retries_when_list_times_out(self):
        # `-list` implicitly resolves the SPM graph, so a cold runner cloning
        # firebase-ios-sdk blows the deadline on the first call. Treating that
        # as "no schemes" is what left SCHEME empty and failed the build much
        # later, on an unrelated-looking MARKETING_VERSION error.
        calls: list[str] = []

        def fake_run(cmd, **kw):
            if "-resolvePackageDependencies" in cmd:
                calls.append("resolve")
                result = mock.MagicMock()
                result.returncode = 0
                result.stdout = ""
                return result
            if "-list" in cmd:
                calls.append("list")
                if calls.count("list") == 1:
                    raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)
                result = mock.MagicMock()
                result.returncode = 0
                result.stdout = self._list_output(["MyApp"])
                return result
            result = mock.MagicMock()
            result.returncode = 0
            result.stdout = self._show_settings(APP_TYPE)
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")

        self.assertEqual(scheme, "MyApp")
        self.assertEqual(calls[:3], ["list", "resolve", "list"])

    def test_gives_up_when_list_times_out_again_after_resolving(self):
        def fake_run(cmd, **kw):
            if "-resolvePackageDependencies" in cmd:
                result = mock.MagicMock()
                result.returncode = 0
                result.stdout = ""
                return result
            raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertIsNone(scheme)


class AutoDetectBundleIdTests(unittest.TestCase):
    """auto_detect_bundle_id: extract PRODUCT_BUNDLE_IDENTIFIER from showBuildSettings."""

    def setUp(self):
        auto_detect._clear_caches()

    def _show_settings(self, bundle_id: str) -> str:
        return json.dumps(
            [
                {
                    "action": "build",
                    "buildSettings": {
                        "PRODUCT_TYPE": APP_TYPE,
                        "PRODUCT_BUNDLE_IDENTIFIER": bundle_id,
                    },
                }
            ]
        )

    def test_returns_bundle_id_from_showBuildSettings(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            result.stdout = self._show_settings("com.example.myapp")
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            bundle = auto_detect.auto_detect_bundle_id(
                root, "Proj.xcodeproj", "", "MyApp", "Release"
            )
        self.assertEqual(bundle, "com.example.myapp")

    def test_returns_none_when_bundle_id_empty(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            result.stdout = self._show_settings("")
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            bundle = auto_detect.auto_detect_bundle_id(
                root, "Proj.xcodeproj", "", "MyApp", "Release"
            )
        self.assertIsNone(bundle)

    def test_rejects_unresolved_variable_reference(self):
        # $(…) or $… patterns mean xcodebuild couldn't evaluate — reject.
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            result.stdout = self._show_settings("$(PRODUCT_NAME)")
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            bundle = auto_detect.auto_detect_bundle_id(
                root, "Proj.xcodeproj", "", "MyApp", "Release"
            )
        self.assertIsNone(bundle)

    def test_returns_none_when_xcodebuild_fails(self):
        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 65
            result.stdout = ""
            result.stderr = "xcodebuild error: scheme not found"
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            bundle = auto_detect.auto_detect_bundle_id(
                root, "Proj.xcodeproj", "", "MyApp", "Release"
            )
        self.assertIsNone(bundle)


class CachingTests(unittest.TestCase):
    """xcodebuild -list output and showBuildSettings output must be cached."""

    def setUp(self):
        auto_detect._clear_caches()

    def test_list_schemes_cached_across_calls(self):
        calls = {"count": 0}

        def fake_run(cmd, **kw):
            result = mock.MagicMock()
            result.returncode = 0
            if "-list" in cmd:
                calls["count"] += 1
                result.stdout = json.dumps(
                    {"project": {"name": "MyApp", "schemes": ["MyApp"]}}
                )
            else:
                result.stdout = json.dumps(
                    [{"buildSettings": {"PRODUCT_TYPE": APP_TYPE,
                                          "PRODUCT_BUNDLE_IDENTIFIER": "com.x"}}]
                )
            return result

        root = Path("/tmp/repo")
        with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
            auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
            auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
        self.assertEqual(calls["count"], 1, "xcodebuild -list should be cached")


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