import os
from contextlib import contextmanager
from optparse import Values
from tempfile import NamedTemporaryFile
from typing import Any, Dict, Iterator, List, Tuple, Union, cast

import pytest

import pip._internal.configuration
from pip._internal.cli.main import main
from pip._internal.commands import create_command
from pip._internal.commands.configuration import ConfigurationCommand
from pip._internal.exceptions import PipError
from tests.lib.options_helpers import AddFakeCommandMixin


@contextmanager
def assert_option_error(
    capsys: pytest.CaptureFixture[str], expected: str
) -> Iterator[None]:
    """
    Assert that a SystemExit occurred because of a parsing error.

    Args:
      expected: an expected substring of stderr.
    """
    with pytest.raises(SystemExit) as excinfo:
        yield

    assert excinfo.value.code == 2
    stderr = capsys.readouterr().err
    assert expected in stderr


def assert_is_default_cache_dir(value: str) -> None:
    # This path looks different on different platforms, but the path always
    # has the substring "pip".
    assert "pip" in value


class TestOptionPrecedence(AddFakeCommandMixin):
    """
    Tests for confirming our option precedence:
        cli -> environment -> subcommand config -> global config -> option
        defaults
    """

    def get_config_section(self, section: str) -> List[Tuple[str, str]]:
        config = {
            "global": [("timeout", "-3")],
            "fake": [("timeout", "-2")],
        }
        return config[section]

    def get_config_section_global(self, section: str) -> List[Tuple[str, str]]:
        config: Dict[str, List[Tuple[str, str]]] = {
            "global": [("timeout", "-3")],
            "fake": [],
        }
        return config[section]

    def test_env_override_default_int(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """
        Test that environment variable overrides an int option default.
        """
        monkeypatch.setenv("PIP_TIMEOUT", "-1")
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert options.timeout == -1

    @pytest.mark.parametrize("values", (["F1"], ["F1", "F2"]))
    def test_env_override_default_append(
        self, values: List[str], monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test that environment variable overrides an append option default.
        """
        monkeypatch.setenv("PIP_FIND_LINKS", " ".join(values))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert options.find_links == values

    @pytest.mark.parametrize("choices", (["w"], ["s", "w"]))
    def test_env_override_default_choice(
        self, choices: List[str], monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test that environment variable overrides a choice option default.
        """
        monkeypatch.setenv("PIP_EXISTS_ACTION", " ".join(choices))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert options.exists_action == choices

    @pytest.mark.parametrize("name", ("PIP_LOG_FILE", "PIP_LOCAL_LOG"))
    def test_env_alias_override_default(
        self, name: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        When an option has multiple long forms, test that the technique of
        using the env variable, "PIP_<long form>" works for all cases.
        (e.g. PIP_LOG_FILE and PIP_LOCAL_LOG should all work)
        """
        monkeypatch.setenv(name, "override.log")
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert options.log == "override.log"

    def test_cli_override_environment(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """
        Test the cli overrides and environment variable
        """
        monkeypatch.setenv("PIP_TIMEOUT", "-1")
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["fake", "--timeout", "-2"])
        )
        assert options.timeout == -2

    @pytest.mark.parametrize(
        "pip_no_cache_dir",
        [
            # Enabling --no-cache-dir means no cache directory.
            "1",
            "true",
            "on",
            "yes",
            # For historical / backwards compatibility reasons, we also disable
            # the cache directory if provided a value that translates to 0.
            "0",
            "false",
            "off",
            "no",
        ],
    )
    def test_cache_dir__PIP_NO_CACHE_DIR(
        self, pip_no_cache_dir: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test setting the PIP_NO_CACHE_DIR environment variable without
        passing any command-line flags.
        """
        monkeypatch.setenv("PIP_NO_CACHE_DIR", pip_no_cache_dir)
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert options.cache_dir is False

    @pytest.mark.parametrize("pip_no_cache_dir", ["yes", "no"])
    def test_cache_dir__PIP_NO_CACHE_DIR__with_cache_dir(
        self,
        pip_no_cache_dir: str,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test setting PIP_NO_CACHE_DIR while also passing an explicit
        --cache-dir value.
        """
        monkeypatch.setenv("PIP_NO_CACHE_DIR", pip_no_cache_dir)
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--cache-dir", "/cache/dir", "fake"])
        )
        # The command-line flag takes precedence.
        assert options.cache_dir == "/cache/dir"

    @pytest.mark.parametrize("pip_no_cache_dir", ["yes", "no"])
    def test_cache_dir__PIP_NO_CACHE_DIR__with_no_cache_dir(
        self,
        pip_no_cache_dir: str,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test setting PIP_NO_CACHE_DIR while also passing --no-cache-dir.
        """
        monkeypatch.setenv("PIP_NO_CACHE_DIR", pip_no_cache_dir)
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["--no-cache-dir", "fake"]))
        # The command-line flag should take precedence (which has the same
        # value in this case).
        assert options.cache_dir is False

    def test_cache_dir__PIP_NO_CACHE_DIR_invalid__with_no_cache_dir(
        self,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """
        Test setting PIP_NO_CACHE_DIR to an invalid value while also passing
        --no-cache-dir.
        """
        monkeypatch.setenv("PIP_NO_CACHE_DIR", "maybe")
        expected_err = "--no-cache-dir error: invalid truth value 'maybe'"
        with assert_option_error(capsys, expected=expected_err):
            main(["--no-cache-dir", "fake"])


class TestUsePEP517Options:

    """
    Test options related to using --use-pep517.
    """

    def parse_args(self, args: List[str]) -> Values:
        # We use DownloadCommand since that is one of the few Command
        # classes with the use_pep517 options.
        command = create_command("download")
        options, args = command.parse_args(args)

        return options

    def test_no_option(self) -> None:
        """
        Test passing no option.
        """
        options = self.parse_args([])
        assert options.use_pep517 is None

    def test_use_pep517(self) -> None:
        """
        Test passing --use-pep517.
        """
        options = self.parse_args(["--use-pep517"])
        assert options.use_pep517 is True

    def test_no_use_pep517(self) -> None:
        """
        Test passing --no-use-pep517.
        """
        options = self.parse_args(["--no-use-pep517"])
        assert options.use_pep517 is False

    def test_PIP_USE_PEP517_true(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """
        Test setting PIP_USE_PEP517 to "true".
        """
        monkeypatch.setenv("PIP_USE_PEP517", "true")
        options = self.parse_args([])
        # This is an int rather than a boolean because strtobool() in pip's
        # configuration code returns an int.
        assert options.use_pep517 == 1

    def test_PIP_USE_PEP517_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """
        Test setting PIP_USE_PEP517 to "false".
        """
        monkeypatch.setenv("PIP_USE_PEP517", "false")
        options = self.parse_args([])
        # This is an int rather than a boolean because strtobool() in pip's
        # configuration code returns an int.
        assert options.use_pep517 == 0

    def test_use_pep517_and_PIP_USE_PEP517_false(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test passing --use-pep517 and setting PIP_USE_PEP517 to "false".
        """
        monkeypatch.setenv("PIP_USE_PEP517", "false")
        options = self.parse_args(["--use-pep517"])
        assert options.use_pep517 is True

    def test_no_use_pep517_and_PIP_USE_PEP517_true(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test passing --no-use-pep517 and setting PIP_USE_PEP517 to "true".
        """
        monkeypatch.setenv("PIP_USE_PEP517", "true")
        options = self.parse_args(["--no-use-pep517"])
        assert options.use_pep517 is False

    def test_PIP_NO_USE_PEP517(
        self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """
        Test setting PIP_NO_USE_PEP517, which isn't allowed.
        """
        monkeypatch.setenv("PIP_NO_USE_PEP517", "true")
        with assert_option_error(capsys, expected="--no-use-pep517 error"):
            self.parse_args([])


class TestOptionsInterspersed(AddFakeCommandMixin):
    def test_general_option_after_subcommand(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["fake", "--timeout", "-1"])
        )
        assert options.timeout == -1

    def test_option_after_subcommand_arg(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["fake", "arg", "--timeout", "-1"])
        )
        assert options.timeout == -1

    def test_additive_before_after_subcommand(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["-v", "fake", "-v"]))
        assert options.verbose == 2

    def test_subcommand_option_before_subcommand_fails(self) -> None:
        with pytest.raises(SystemExit):
            main(["--find-links", "F1", "fake"])


@contextmanager
def tmpconfig(option: str, value: Any, section: str = "global") -> Iterator[str]:
    with NamedTemporaryFile(mode="w", delete=False) as f:
        f.write(f"[{section}]\n{option}={value}\n")
        name = f.name
    try:
        yield name
    finally:
        os.unlink(name)


class TestCountOptions(AddFakeCommandMixin):
    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(4))
    def test_cli_long(self, option: str, value: int) -> None:
        flags = [f"--{option}"] * value
        # FakeCommand intentionally returns the wrong type.
        opt1, args1 = cast(Tuple[Values, List[str]], main(flags + ["fake"]))
        opt2, args2 = cast(Tuple[Values, List[str]], main(["fake"] + flags))
        assert getattr(opt1, option) == getattr(opt2, option) == value

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(1, 4))
    def test_cli_short(self, option: str, value: int) -> None:
        flag = "-" + option[0] * value
        # FakeCommand intentionally returns the wrong type.
        opt1, args1 = cast(Tuple[Values, List[str]], main([flag, "fake"]))
        opt2, args2 = cast(Tuple[Values, List[str]], main(["fake", flag]))
        assert getattr(opt1, option) == getattr(opt2, option) == value

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(4))
    def test_env_var(
        self, option: str, value: int, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("PIP_" + option.upper(), str(value))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert getattr(options, option) == value

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(3))
    def test_env_var_integrate_cli(
        self, option: str, value: int, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("PIP_" + option.upper(), str(value))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake", "--" + option]))
        assert getattr(options, option) == value + 1

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", (-1, "foobar"))
    def test_env_var_invalid(
        self,
        option: str,
        value: Any,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        monkeypatch.setenv("PIP_" + option.upper(), str(value))
        with assert_option_error(capsys, expected="a non-negative integer"):
            main(["fake"])

    # Undocumented, support for backward compatibility
    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", ("no", "false"))
    def test_env_var_false(
        self, option: str, value: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("PIP_" + option.upper(), str(value))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert getattr(options, option) == 0

    # Undocumented, support for backward compatibility
    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", ("yes", "true"))
    def test_env_var_true(
        self, option: str, value: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("PIP_" + option.upper(), str(value))
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        assert getattr(options, option) == 1

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(4))
    def test_config_file(
        self, option: str, value: int, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        with tmpconfig(option, value) as name:
            monkeypatch.setenv("PIP_CONFIG_FILE", name)
            # FakeCommand intentionally returns the wrong type.
            options, args = cast(Tuple[Values, List[str]], main(["fake"]))
            assert getattr(options, option) == value

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", range(3))
    def test_config_file_integrate_cli(
        self, option: str, value: int, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        with tmpconfig(option, value) as name:
            monkeypatch.setenv("PIP_CONFIG_FILE", name)
            # FakeCommand intentionally returns the wrong type.
            options, args = cast(
                Tuple[Values, List[str]], main(["fake", "--" + option])
            )
            assert getattr(options, option) == value + 1

    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", (-1, "foobar"))
    def test_config_file_invalid(
        self,
        option: str,
        value: Any,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        with tmpconfig(option, value) as name:
            monkeypatch.setenv("PIP_CONFIG_FILE", name)
            with assert_option_error(capsys, expected="non-negative integer"):
                main(["fake"])

    # Undocumented, support for backward compatibility
    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", ("no", "false"))
    def test_config_file_false(
        self, option: str, value: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        with tmpconfig(option, value) as name:
            monkeypatch.setenv("PIP_CONFIG_FILE", name)
            # FakeCommand intentionally returns the wrong type.
            options, args = cast(Tuple[Values, List[str]], main(["fake"]))
            assert getattr(options, option) == 0

    # Undocumented, support for backward compatibility
    @pytest.mark.parametrize("option", ("verbose", "quiet"))
    @pytest.mark.parametrize("value", ("yes", "true"))
    def test_config_file_true(
        self, option: str, value: str, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        with tmpconfig(option, value) as name:
            monkeypatch.setenv("PIP_CONFIG_FILE", name)
            # FakeCommand intentionally returns the wrong type.
            options, args = cast(Tuple[Values, List[str]], main(["fake"]))
            assert getattr(options, option) == 1


class TestGeneralOptions(AddFakeCommandMixin):

    # the reason to specifically test general options is due to the
    # extra processing they receive, and the number of bugs we've had

    def test_cache_dir__default(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["fake"]))
        # With no options the default cache dir should be used.
        assert_is_default_cache_dir(options.cache_dir)

    def test_cache_dir__provided(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--cache-dir", "/cache/dir", "fake"])
        )
        assert options.cache_dir == "/cache/dir"

    def test_no_cache_dir__provided(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(Tuple[Values, List[str]], main(["--no-cache-dir", "fake"]))
        assert options.cache_dir is False

    def test_require_virtualenv(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--require-virtualenv", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--require-virtualenv"])
        )
        assert options1.require_venv
        assert options2.require_venv

    def test_log(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--log", "path", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--log", "path"])
        )
        assert options1.log == options2.log == "path"

    def test_local_log(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--local-log", "path", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--local-log", "path"])
        )
        assert options1.log == options2.log == "path"

    def test_no_input(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(Tuple[Values, List[str]], main(["--no-input", "fake"]))
        options2, args2 = cast(Tuple[Values, List[str]], main(["fake", "--no-input"]))
        assert options1.no_input
        assert options2.no_input

    def test_proxy(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--proxy", "path", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--proxy", "path"])
        )
        assert options1.proxy == options2.proxy == "path"

    def test_retries(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--retries", "-1", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--retries", "-1"])
        )
        assert options1.retries == options2.retries == -1

    def test_timeout(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--timeout", "-1", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--timeout", "-1"])
        )
        assert options1.timeout == options2.timeout == -1

    def test_exists_action(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--exists-action", "w", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--exists-action", "w"])
        )
        assert options1.exists_action == options2.exists_action == ["w"]

    def test_cert(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--cert", "path", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--cert", "path"])
        )
        assert options1.cert == options2.cert == "path"

    def test_client_cert(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options1, args1 = cast(
            Tuple[Values, List[str]], main(["--client-cert", "path", "fake"])
        )
        options2, args2 = cast(
            Tuple[Values, List[str]], main(["fake", "--client-cert", "path"])
        )
        assert options1.client_cert == options2.client_cert == "path"


class TestOptionsConfigFiles:
    def test_venv_config_file_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
        # strict limit on the global config files list
        monkeypatch.setattr(
            pip._internal.utils.appdirs, "site_config_dirs", lambda _: ["/a/place"]
        )

        cp = pip._internal.configuration.Configuration(isolated=False)

        files = []
        for _, val in cp.iter_config_files():
            files.extend(val)

        assert len(files) == 4

    @pytest.mark.parametrize(
        "args, expect",
        (
            ([], None),
            (["--global"], "global"),
            (["--site"], "site"),
            (["--user"], "user"),
            (["--global", "--user"], PipError),
            (["--global", "--site"], PipError),
            (["--global", "--site", "--user"], PipError),
        ),
    )
    def test_config_file_options(
        self,
        monkeypatch: pytest.MonkeyPatch,
        args: List[str],
        expect: Union[None, str, PipError],
    ) -> None:
        cmd = cast(ConfigurationCommand, create_command("config"))
        # Replace a handler with a no-op to avoid side effects
        monkeypatch.setattr(cmd, "get_name", lambda *a: None)

        options, args = cmd.parser.parse_args(args + ["get", "name"])
        if expect is PipError:
            with pytest.raises(PipError):
                cmd._determine_file(options, need_value=False)
        else:
            assert expect == cmd._determine_file(options, need_value=False)


class TestOptionsExpandUser(AddFakeCommandMixin):
    def test_cache_dir(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--cache-dir", "~/cache/dir", "fake"])
        )
        assert options.cache_dir == os.path.expanduser("~/cache/dir")

    def test_log(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--log", "~/path", "fake"])
        )
        assert options.log == os.path.expanduser("~/path")

    def test_local_log(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--local-log", "~/path", "fake"])
        )
        assert options.log == os.path.expanduser("~/path")

    def test_cert(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--cert", "~/path", "fake"])
        )
        assert options.cert == os.path.expanduser("~/path")

    def test_client_cert(self) -> None:
        # FakeCommand intentionally returns the wrong type.
        options, args = cast(
            Tuple[Values, List[str]], main(["--client-cert", "~/path", "fake"])
        )
        assert options.client_cert == os.path.expanduser("~/path")
