#!/usr/bin/env python3
"""Tests for resolve_marketing_version.py."""

from __future__ import annotations

import io
import os
import plistlib
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest import mock

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

import resolve_marketing_version as rmv  # noqa: E402


class _Env:
    """Scoped env override that restores prior state on exit."""

    def __init__(self, mapping: dict[str, str | None]) -> None:
        self._mapping = mapping
        self._prev: dict[str, str | None] = {}

    def __enter__(self) -> "_Env":
        for k, v in self._mapping.items():
            self._prev[k] = os.environ.get(k)
            if v is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = v
        return self

    def __exit__(self, *_exc) -> None:
        for k, prev in self._prev.items():
            if prev is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = prev


def _fake_run(stdout: str = "", returncode: int = 0):
    """Build a fake subprocess.run replacement returning stdout."""
    def runner(cmd, check=False, capture_output=True, text=True, timeout=None):
        return subprocess.CompletedProcess(
            cmd, returncode=returncode, stdout=stdout, stderr="",
        )
    return runner


class GrepSettingTests(unittest.TestCase):
    def test_grep_marketing_version(self):
        text = (
            "    OTHER = foo\n"
            "    MARKETING_VERSION = 1.2.3\n"
            "    SOMETHING_ELSE = bar\n"
        )
        self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "1.2.3")

    def test_grep_returns_empty_on_miss(self):
        text = "    OTHER = foo\n"
        self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "")

    def test_grep_strips_surrounding_whitespace(self):
        text = "    MARKETING_VERSION = 2.0   \n"
        self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "2.0")


class ResolveTests(unittest.TestCase):
    """End-to-end resolution paths via main() exercise."""

    def test_marketing_version_from_build_settings(self):
        stdout = "    MARKETING_VERSION = 1.1.2\n    INFOPLIST_FILE = Info.plist\n"
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            rmv.main()
        self.assertEqual(out_buf.getvalue().strip(), "MARKETING_VERSION=1.1.2")
        self.assertIn("Resolved MARKETING_VERSION=1.1.2", err_buf.getvalue())
        self.assertIn("xcodebuild -showBuildSettings", err_buf.getvalue())

    def test_falls_back_to_info_plist(self):
        with tempfile.TemporaryDirectory() as tmp:
            tmp_dir = Path(tmp)
            plist_path = tmp_dir / "Info.plist"
            with open(plist_path, "wb") as fh:
                plistlib.dump({"CFBundleShortVersionString": "1.0.7"}, fh)
            stdout = f"    INFOPLIST_FILE = {plist_path.name}\n"
            project = str(tmp_dir / "App.xcodeproj")
            out_buf = io.StringIO()
            err_buf = io.StringIO()
            env = {"PROJECT": project, "WORKSPACE": "",
                   "SCHEME": "App", "CONFIGURATION": "Release"}
            with _Env(env), \
                    mock.patch.object(
                        rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
                    redirect_stdout(out_buf), redirect_stderr(err_buf):
                rmv.main()
            self.assertEqual(out_buf.getvalue().strip(),
                             "MARKETING_VERSION=1.0.7")
            self.assertIn("Info.plist CFBundleShortVersionString",
                          err_buf.getvalue())

    def test_empty_settings_exits_1_with_migration_hint(self):
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run("")), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            with self.assertRaises(SystemExit) as ctx:
                rmv.main()
        self.assertEqual(ctx.exception.code, 1)
        self.assertEqual(out_buf.getvalue(), "")
        stderr = err_buf.getvalue()
        self.assertIn("::error::", stderr)
        self.assertIn("MARKETING_VERSION", stderr)
        self.assertIn("MIGRATION.md", stderr)

    def test_invalid_semver_in_settings_exits_1(self):
        stdout = "    MARKETING_VERSION = banana\n"
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            with self.assertRaises(SystemExit) as ctx:
                rmv.main()
        self.assertEqual(ctx.exception.code, 1)
        self.assertIn("::error::", err_buf.getvalue())

    def test_subprocess_error_falls_through_to_error(self):
        def raiser(*_a, **_kw):
            raise OSError("xcodebuild not found")
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=raiser), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            with self.assertRaises(SystemExit) as ctx:
                rmv.main()
        self.assertEqual(ctx.exception.code, 1)
        self.assertIn("::error::", err_buf.getvalue())

    def test_project_takes_precedence_over_workspace(self):
        # MARKETING_VERSION is an app-target setting; the bare -project
        # avoids evaluating the whole CocoaPods workspace graph, which can
        # time out -showBuildSettings on CI runners.
        captured: dict = {}

        def runner(cmd, **_kw):
            captured["cmd"] = cmd
            return subprocess.CompletedProcess(
                cmd, returncode=0, stdout="    MARKETING_VERSION = 1.0\n", stderr="",
            )

        env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "App.xcodeproj",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            rmv.main()
        self.assertIn("-project", captured["cmd"])
        self.assertIn("App.xcodeproj", captured["cmd"])
        self.assertNotIn("-workspace", captured["cmd"])
        self.assertEqual(out_buf.getvalue().strip(),
                         "MARKETING_VERSION=1.0")

    def test_workspace_used_when_no_project(self):
        captured: dict = {}

        def runner(cmd, **_kw):
            captured["cmd"] = cmd
            return subprocess.CompletedProcess(
                cmd, returncode=0, stdout="    MARKETING_VERSION = 1.0\n", stderr="",
            )

        env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "",
               "SCHEME": "App", "CONFIGURATION": "Release"}
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        with _Env(env), \
                mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
                redirect_stdout(out_buf), redirect_stderr(err_buf):
            rmv.main()
        self.assertIn("-workspace", captured["cmd"])
        self.assertNotIn("-project", captured["cmd"])


class FlutterVarRefExpansionTests(unittest.TestCase):
    """Flutter templates put $(FLUTTER_BUILD_NAME) in the plist; the real
    value comes from Generated.xcconfig and shows up in build settings."""

    def test_expands_flutter_build_name_from_settings(self):
        with tempfile.TemporaryDirectory() as tmp:
            plist_path = Path(tmp) / "Info.plist"
            with open(plist_path, "wb") as fh:
                plistlib.dump(
                    {"CFBundleShortVersionString": "$(FLUTTER_BUILD_NAME)"}, fh)
            stdout = (
                f"    INFOPLIST_FILE = {plist_path.name}\n"
                "    FLUTTER_BUILD_NAME = 1.2.0\n"
            )
            project = str(Path(tmp) / "Runner.xcodeproj")
            out_buf = io.StringIO()
            err_buf = io.StringIO()
            env = {"PROJECT": project, "WORKSPACE": "",
                   "SCHEME": "Runner", "CONFIGURATION": "Release"}
            with _Env(env), \
                    mock.patch.object(
                        rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
                    redirect_stdout(out_buf), redirect_stderr(err_buf):
                rmv.main()
            self.assertEqual(out_buf.getvalue().strip(),
                             "MARKETING_VERSION=1.2.0")
            self.assertIn("expanded via build settings", err_buf.getvalue())

    def test_unresolvable_reference_exits_1(self):
        with tempfile.TemporaryDirectory() as tmp:
            plist_path = Path(tmp) / "Info.plist"
            with open(plist_path, "wb") as fh:
                plistlib.dump(
                    {"CFBundleShortVersionString": "$(NOT_DEFINED_ANYWHERE)"}, fh)
            stdout = f"    INFOPLIST_FILE = {plist_path.name}\n"
            project = str(Path(tmp) / "Runner.xcodeproj")
            env = {"PROJECT": project, "WORKSPACE": "",
                   "SCHEME": "Runner", "CONFIGURATION": "Release"}
            out_buf = io.StringIO()
            err_buf = io.StringIO()
            with _Env(env), \
                    mock.patch.object(
                        rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
                    redirect_stdout(out_buf), redirect_stderr(err_buf):
                with self.assertRaises(SystemExit) as ctx:
                    rmv.main()
            self.assertEqual(ctx.exception.code, 1)
            self.assertIn("::error::", err_buf.getvalue())

    def test_expands_braced_and_modifier_forms(self):
        settings = "    FLUTTER_BUILD_NAME = 2.1.0\n"
        self.assertEqual(
            rmv._expand_setting_refs("${FLUTTER_BUILD_NAME}", settings),
            "2.1.0")
        self.assertEqual(
            rmv._expand_setting_refs(
                "$(FLUTTER_BUILD_NAME:default=1.0)", settings),
            "2.1.0")


class PlistEdgeCasesTests(unittest.TestCase):
    def test_missing_short_version_string_returns_empty(self):
        with tempfile.TemporaryDirectory() as tmp:
            plist_path = Path(tmp) / "Info.plist"
            with open(plist_path, "wb") as fh:
                plistlib.dump({"CFBundleName": "App"}, fh)  # no version key
            stdout = f"    INFOPLIST_FILE = {plist_path.name}\n"
            project = str(Path(tmp) / "App.xcodeproj")
            env = {"PROJECT": project, "WORKSPACE": "",
                   "SCHEME": "App", "CONFIGURATION": "Release"}
            with _Env(env), \
                    mock.patch.object(
                        rmv.subprocess, "run", side_effect=_fake_run(stdout)):
                value, source = rmv._resolve()
            self.assertEqual(value, "")
            self.assertEqual(source, "")

    def test_corrupt_plist_falls_through_safely(self):
        with tempfile.TemporaryDirectory() as tmp:
            plist_path = Path(tmp) / "Info.plist"
            plist_path.write_bytes(b"not a plist")
            stdout = f"    INFOPLIST_FILE = {plist_path.name}\n"
            project = str(Path(tmp) / "App.xcodeproj")
            env = {"PROJECT": project, "WORKSPACE": "",
                   "SCHEME": "App", "CONFIGURATION": "Release"}
            with _Env(env), \
                    mock.patch.object(
                        rmv.subprocess, "run", side_effect=_fake_run(stdout)):
                value, _source = rmv._resolve()
            self.assertEqual(value, "")


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