# Copyright (C) 2015 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Unittests for the wrapper.py module."""

import io
import os
import re
import subprocess
import sys
from unittest import mock

import pytest
import utils_for_test

import main
import wrapper


@pytest.fixture(autouse=True)
def reset_wrapper() -> None:
    """Reset the wrapper module every time."""
    wrapper.Wrapper.cache_clear()


@pytest.fixture
def repo_wrapper() -> wrapper.Wrapper:
    """Fixture for the wrapper module."""
    return wrapper.Wrapper()


class GitCheckout:
    """Class to hold git checkout info for tests."""

    def __init__(self, git_dir, rev_list):
        self.git_dir = git_dir
        self.rev_list = rev_list


@pytest.fixture(scope="module")
def git_checkout(tmp_path_factory) -> GitCheckout:
    """Fixture for tests that use a real/small git checkout.

    Create a repo to operate on, but do it once per-test-run.
    """
    tempdir = tmp_path_factory.mktemp("repo-rev-tests")
    run_git = wrapper.Wrapper().run_git

    remote = os.path.join(tempdir, "remote")
    os.mkdir(remote)

    utils_for_test.init_git_tree(remote)
    run_git("commit", "--allow-empty", "-minit", cwd=remote)
    run_git("branch", "stable", cwd=remote)
    run_git("tag", "v1.0", cwd=remote)
    run_git("commit", "--allow-empty", "-m2nd commit", cwd=remote)
    rev_list = run_git("rev-list", "HEAD", cwd=remote).stdout.splitlines()

    run_git("init", cwd=tempdir)
    run_git(
        "fetch",
        remote,
        "+refs/heads/*:refs/remotes/origin/*",
        cwd=tempdir,
    )
    yield GitCheckout(tempdir, rev_list)


class TestRepoWrapper:
    """Tests helper functions in the repo wrapper"""

    def test_version(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Make sure _Version works."""
        with pytest.raises(SystemExit) as e:
            with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
                with mock.patch(
                    "sys.stderr", new_callable=io.StringIO
                ) as stderr:
                    repo_wrapper._Version()
        assert e.value.code == 0
        assert stderr.getvalue() == ""
        assert "repo launcher version" in stdout.getvalue()

    def test_python_constraints(self, repo_wrapper: wrapper.Wrapper) -> None:
        """The launcher should never require newer than main.py."""
        assert (
            main.MIN_PYTHON_VERSION_HARD >= repo_wrapper.MIN_PYTHON_VERSION_HARD
        )
        assert (
            main.MIN_PYTHON_VERSION_SOFT >= repo_wrapper.MIN_PYTHON_VERSION_SOFT
        )
        # Make sure the versions are themselves in sync.
        assert (
            repo_wrapper.MIN_PYTHON_VERSION_SOFT
            >= repo_wrapper.MIN_PYTHON_VERSION_HARD
        )

    def test_repo_script_is_executable(self) -> None:
        """The repo launcher script should be executable."""
        repo_path = utils_for_test.THIS_DIR.parent / "repo"
        assert os.access(repo_path, os.X_OK), f"{repo_path} is not executable"

    def test_init_parser(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Make sure 'init' GetParser works."""
        parser = repo_wrapper.GetParser()
        opts, args = parser.parse_args([])
        assert args == []
        assert opts.manifest_url is None


class TestSetGitTrace2ParentSid:
    """Check SetGitTrace2ParentSid behavior."""

    KEY = "GIT_TRACE2_PARENT_SID"
    VALID_FORMAT = re.compile(r"^repo-[0-9]{8}T[0-9]{6}Z-P[0-9a-f]{8}$")

    def test_first_set(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Test env var not yet set."""
        env = {}
        repo_wrapper.SetGitTrace2ParentSid(env)
        assert self.KEY in env
        value = env[self.KEY]
        assert self.VALID_FORMAT.match(value)

    def test_append(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Test env var is appended."""
        env = {self.KEY: "pfx"}
        repo_wrapper.SetGitTrace2ParentSid(env)
        assert self.KEY in env
        value = env[self.KEY]
        assert value.startswith("pfx/")
        assert self.VALID_FORMAT.match(value[4:])

    def test_global_context(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check os.environ gets updated by default."""
        os.environ.pop(self.KEY, None)
        repo_wrapper.SetGitTrace2ParentSid()
        assert self.KEY in os.environ
        value = os.environ[self.KEY]
        assert self.VALID_FORMAT.match(value)


class TestRunCommand:
    """Check run_command behavior."""

    def test_capture(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check capture_output handling."""
        ret = repo_wrapper.run_command(["echo", "hi"], capture_output=True)
        # echo command appends OS specific linesep, but on Windows + Git Bash
        # we get UNIX ending, so we allow both.
        assert ret.stdout in ["hi" + os.linesep, "hi\n"]

    def test_check(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check check handling."""
        repo_wrapper.run_command(["true"], check=False)
        repo_wrapper.run_command(["true"], check=True)
        repo_wrapper.run_command(["false"], check=False)
        with pytest.raises(subprocess.CalledProcessError):
            repo_wrapper.run_command(["false"], check=True)


class TestRunGit:
    """Check run_git behavior."""

    def test_capture(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check capture_output handling."""
        ret = repo_wrapper.run_git("--version")
        assert "git" in ret.stdout

    def test_check(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check check handling."""
        with pytest.raises(repo_wrapper.CloneFailure):
            repo_wrapper.run_git("--version-asdfasdf")
        repo_wrapper.run_git("--version-asdfasdf", check=False)


class TestParseGitVersion:
    """Check ParseGitVersion behavior."""

    def test_autoload(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check we can load the version from the live git."""
        assert repo_wrapper.ParseGitVersion() is not None

    def test_bad_ver(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check handling of bad git versions."""
        assert repo_wrapper.ParseGitVersion(ver_str="asdf") is None

    def test_normal_ver(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check handling of normal git versions."""
        ret = repo_wrapper.ParseGitVersion(ver_str="git version 2.25.1")
        assert ret.major == 2
        assert ret.minor == 25
        assert ret.micro == 1
        assert ret.full == "2.25.1"

    def test_extended_ver(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check handling of extended distro git versions."""
        ret = repo_wrapper.ParseGitVersion(
            ver_str="git version 1.30.50.696.g5e7596f4ac-goog"
        )
        assert ret.major == 1
        assert ret.minor == 30
        assert ret.micro == 50
        assert ret.full == "1.30.50.696.g5e7596f4ac-goog"


class TestCheckGitVersion:
    """Check _CheckGitVersion behavior."""

    def test_unknown(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Unknown versions should abort."""
        with mock.patch.object(
            repo_wrapper, "ParseGitVersion", return_value=None
        ):
            with pytest.raises(repo_wrapper.CloneFailure):
                repo_wrapper._CheckGitVersion()

    def test_old(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Old versions should abort."""
        with mock.patch.object(
            repo_wrapper,
            "ParseGitVersion",
            return_value=repo_wrapper.GitVersion(1, 0, 0, "1.0.0"),
        ):
            with pytest.raises(repo_wrapper.CloneFailure):
                repo_wrapper._CheckGitVersion()

    def test_new(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Newer versions should run fine."""
        with mock.patch.object(
            repo_wrapper,
            "ParseGitVersion",
            return_value=repo_wrapper.GitVersion(100, 0, 0, "100.0.0"),
        ):
            repo_wrapper._CheckGitVersion()


class TestRequirements:
    """Check Requirements handling."""

    def test_missing_file(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Don't crash if the file is missing (old version)."""
        assert (
            repo_wrapper.Requirements.from_dir(utils_for_test.THIS_DIR) is None
        )
        assert (
            repo_wrapper.Requirements.from_file(
                utils_for_test.THIS_DIR / "xxxxxxxxxxxxxxxxxxxxxxxx"
            )
            is None
        )

    def test_corrupt_data(self, repo_wrapper: wrapper.Wrapper) -> None:
        """If the file can't be parsed, don't blow up."""
        assert repo_wrapper.Requirements.from_file(__file__) is None
        assert repo_wrapper.Requirements.from_data(b"x") is None

    def test_valid_data(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Make sure we can parse the file we ship."""
        assert repo_wrapper.Requirements.from_data(b"{}") is not None
        rootdir = utils_for_test.THIS_DIR.parent
        assert repo_wrapper.Requirements.from_dir(rootdir) is not None
        assert (
            repo_wrapper.Requirements.from_file(rootdir / "requirements.json")
            is not None
        )

    def test_format_ver(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check format_ver can format."""
        assert repo_wrapper.Requirements._format_ver((1, 2, 3)) == "1.2.3"
        assert repo_wrapper.Requirements._format_ver([1]) == "1"

    def test_assert_all_unknown(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_all works with incompatible file."""
        reqs = repo_wrapper.Requirements({})
        reqs.assert_all()

    def test_assert_all_new_repo(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_all accepts new enough repo."""
        reqs = repo_wrapper.Requirements({"repo": {"hard": [1, 0]}})
        reqs.assert_all()

    def test_assert_all_old_repo(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_all rejects old repo."""
        reqs = repo_wrapper.Requirements({"repo": {"hard": [99999, 0]}})
        with pytest.raises(SystemExit):
            reqs.assert_all()

    def test_assert_all_new_python(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_all accepts new enough python."""
        reqs = repo_wrapper.Requirements({"python": {"hard": sys.version_info}})
        reqs.assert_all()

    def test_assert_all_old_python(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_all rejects old python."""
        reqs = repo_wrapper.Requirements({"python": {"hard": [99999, 0]}})
        with pytest.raises(SystemExit):
            reqs.assert_all()

    def test_assert_ver_unknown(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_ver works with incompatible file."""
        reqs = repo_wrapper.Requirements({})
        reqs.assert_ver("xxx", (1, 0))

    def test_assert_ver_new(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_ver allows new enough versions."""
        reqs = repo_wrapper.Requirements(
            {"git": {"hard": [1, 0], "soft": [2, 0]}}
        )
        reqs.assert_ver("git", (1, 0))
        reqs.assert_ver("git", (1, 5))
        reqs.assert_ver("git", (2, 0))
        reqs.assert_ver("git", (2, 5))

    def test_assert_ver_old(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check assert_ver rejects old versions."""
        reqs = repo_wrapper.Requirements(
            {"git": {"hard": [1, 0], "soft": [2, 0]}}
        )
        with pytest.raises(SystemExit):
            reqs.assert_ver("git", (0, 5))


class TestNeedSetupGnuPG:
    """Check NeedSetupGnuPG behavior."""

    def test_missing_dir(self, tmp_path, repo_wrapper: wrapper.Wrapper) -> None:
        """The ~/.repoconfig tree doesn't exist yet."""
        repo_wrapper.home_dot_repo = str(tmp_path / "foo")
        assert repo_wrapper.NeedSetupGnuPG()

    def test_missing_keyring(
        self, tmp_path, repo_wrapper: wrapper.Wrapper
    ) -> None:
        """The keyring-version file doesn't exist yet."""
        repo_wrapper.home_dot_repo = str(tmp_path)
        assert repo_wrapper.NeedSetupGnuPG()

    def test_empty_keyring(
        self, tmp_path, repo_wrapper: wrapper.Wrapper
    ) -> None:
        """The keyring-version file exists, but is empty."""
        repo_wrapper.home_dot_repo = str(tmp_path)
        (tmp_path / "keyring-version").write_text("")
        assert repo_wrapper.NeedSetupGnuPG()

    def test_old_keyring(self, tmp_path, repo_wrapper: wrapper.Wrapper) -> None:
        """The keyring-version file exists, but it's old."""
        repo_wrapper.home_dot_repo = str(tmp_path)
        (tmp_path / "keyring-version").write_text("1.0\n")
        assert repo_wrapper.NeedSetupGnuPG()

    def test_new_keyring(self, tmp_path, repo_wrapper: wrapper.Wrapper) -> None:
        """The keyring-version file exists, and is up-to-date."""
        repo_wrapper.home_dot_repo = str(tmp_path)
        (tmp_path / "keyring-version").write_text("1000.0\n")
        assert not repo_wrapper.NeedSetupGnuPG()


class TestSetupGnuPG:
    """Check SetupGnuPG behavior."""

    def test_full(self, tmp_path, repo_wrapper: wrapper.Wrapper) -> None:
        """Make sure it works completely."""
        repo_wrapper.home_dot_repo = str(tmp_path)
        repo_wrapper.gpg_dir = str(tmp_path / "gnupg")
        assert repo_wrapper.SetupGnuPG(True)
        data = (tmp_path / "keyring-version").read_text()
        assert (
            ".".join(str(x) for x in repo_wrapper.KEYRING_VERSION)
            == data.strip()
        )


class TestVerifyRev:
    """Check verify_rev behavior."""

    def test_verify_passes(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check when we have a valid signed tag."""
        desc_result = subprocess.CompletedProcess([], 0, "v1.0\n", "")
        gpg_result = subprocess.CompletedProcess([], 0, "", "")
        with mock.patch.object(
            repo_wrapper, "run_git", side_effect=(desc_result, gpg_result)
        ):
            ret = repo_wrapper.verify_rev(
                "/", "refs/heads/stable", "1234", True
            )
            assert ret == "v1.0^0"

    def test_unsigned_commit(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check we fall back to signed tag when we have an unsigned commit."""
        desc_result = subprocess.CompletedProcess([], 0, "v1.0-10-g1234\n", "")
        gpg_result = subprocess.CompletedProcess([], 0, "", "")
        with mock.patch.object(
            repo_wrapper, "run_git", side_effect=(desc_result, gpg_result)
        ):
            ret = repo_wrapper.verify_rev(
                "/", "refs/heads/stable", "1234", True
            )
            assert ret == "v1.0^0"

    def test_verify_fails(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Check we fall back to signed tag when we have an unsigned commit."""
        desc_result = subprocess.CompletedProcess([], 0, "v1.0-10-g1234\n", "")
        gpg_result = RuntimeError
        with mock.patch.object(
            repo_wrapper, "run_git", side_effect=(desc_result, gpg_result)
        ):
            with pytest.raises(RuntimeError):
                repo_wrapper.verify_rev("/", "refs/heads/stable", "1234", True)


class TestResolveRepoRev:
    """Check resolve_repo_rev behavior."""

    def test_explicit_branch(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check refs/heads/branch argument."""
        rrev, lrev = repo_wrapper.resolve_repo_rev(
            git_checkout.git_dir, "refs/heads/stable"
        )
        assert rrev == "refs/heads/stable"
        assert lrev == git_checkout.rev_list[1]

        with pytest.raises(repo_wrapper.CloneFailure):
            repo_wrapper.resolve_repo_rev(
                git_checkout.git_dir, "refs/heads/unknown"
            )

    def test_explicit_tag(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check refs/tags/tag argument."""
        rrev, lrev = repo_wrapper.resolve_repo_rev(
            git_checkout.git_dir, "refs/tags/v1.0"
        )
        assert rrev == "refs/tags/v1.0"
        assert lrev == git_checkout.rev_list[1]

        with pytest.raises(repo_wrapper.CloneFailure):
            repo_wrapper.resolve_repo_rev(
                git_checkout.git_dir, "refs/tags/unknown"
            )

    def test_branch_name(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check branch argument."""
        rrev, lrev = repo_wrapper.resolve_repo_rev(
            git_checkout.git_dir, "stable"
        )
        assert rrev == "refs/heads/stable"
        assert lrev == git_checkout.rev_list[1]

        rrev, lrev = repo_wrapper.resolve_repo_rev(git_checkout.git_dir, "main")
        assert rrev == "refs/heads/main"
        assert lrev == git_checkout.rev_list[0]

    def test_tag_name(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check tag argument."""
        rrev, lrev = repo_wrapper.resolve_repo_rev(git_checkout.git_dir, "v1.0")
        assert rrev == "refs/tags/v1.0"
        assert lrev == git_checkout.rev_list[1]

    def test_full_commit(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check specific commit argument."""
        commit = git_checkout.rev_list[0]
        rrev, lrev = repo_wrapper.resolve_repo_rev(git_checkout.git_dir, commit)
        assert rrev == commit
        assert lrev == commit

    def test_partial_commit(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check specific (partial) commit argument."""
        commit = git_checkout.rev_list[0][0:20]
        rrev, lrev = repo_wrapper.resolve_repo_rev(git_checkout.git_dir, commit)
        assert rrev == git_checkout.rev_list[0]
        assert lrev == git_checkout.rev_list[0]

    def test_unknown(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Check unknown ref/commit argument."""
        with pytest.raises(repo_wrapper.CloneFailure):
            repo_wrapper.resolve_repo_rev(git_checkout.git_dir, "boooooooya")


class TestCheckRepoVerify:
    """Check check_repo_verify behavior."""

    def test_no_verify(self, repo_wrapper: wrapper.Wrapper) -> None:
        """Always fail with --no-repo-verify."""
        assert not repo_wrapper.check_repo_verify(False)

    def test_gpg_initialized(
        self,
        repo_wrapper: wrapper.Wrapper,
    ) -> None:
        """Should pass if gpg is setup already."""
        with mock.patch.object(
            repo_wrapper, "NeedSetupGnuPG", return_value=False
        ):
            assert repo_wrapper.check_repo_verify(True)

    def test_need_gpg_setup(
        self,
        repo_wrapper: wrapper.Wrapper,
    ) -> None:
        """Should pass/fail based on gpg setup."""
        with mock.patch.object(
            repo_wrapper, "NeedSetupGnuPG", return_value=True
        ):
            with mock.patch.object(repo_wrapper, "SetupGnuPG") as m:
                m.return_value = True
                assert repo_wrapper.check_repo_verify(True)

                m.return_value = False
                assert not repo_wrapper.check_repo_verify(True)


class TestCheckRepoRev:
    """Check check_repo_rev behavior."""

    def test_verify_works(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Should pass when verification passes."""
        with mock.patch.object(
            repo_wrapper, "check_repo_verify", return_value=True
        ):
            with mock.patch.object(
                repo_wrapper, "verify_rev", return_value="12345"
            ):
                rrev, lrev = repo_wrapper.check_repo_rev(
                    git_checkout.git_dir, "stable"
                )
        assert rrev == "refs/heads/stable"
        assert lrev == "12345"

    def test_verify_fails(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Should fail when verification fails."""
        with mock.patch.object(
            repo_wrapper, "check_repo_verify", return_value=True
        ):
            with mock.patch.object(
                repo_wrapper, "verify_rev", side_effect=RuntimeError
            ):
                with pytest.raises(RuntimeError):
                    repo_wrapper.check_repo_rev(git_checkout.git_dir, "stable")

    def test_verify_ignore(
        self,
        repo_wrapper: wrapper.Wrapper,
        git_checkout: GitCheckout,
    ) -> None:
        """Should pass when verification is disabled."""
        with mock.patch.object(
            repo_wrapper, "verify_rev", side_effect=RuntimeError
        ):
            rrev, lrev = repo_wrapper.check_repo_rev(
                git_checkout.git_dir, "stable", repo_verify=False
            )
        assert rrev == "refs/heads/stable"
        assert lrev == git_checkout.rev_list[1]
