# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.

import collections
import itertools
import os
import platform
import sys
from typing import cast
from unittest import mock

import pytest

from packaging._parser import Node
from packaging.markers import (
    InvalidMarker,
    Marker,
    UndefinedComparison,
    default_environment,
    format_full_version,
)

VARIABLES = [
    "extra",
    "implementation_name",
    "implementation_version",
    "os_name",
    "platform_machine",
    "platform_release",
    "platform_system",
    "platform_version",
    "python_full_version",
    "python_version",
    "platform_python_implementation",
    "sys_platform",
]

PEP_345_VARIABLES = [
    "os.name",
    "sys.platform",
    "platform.version",
    "platform.machine",
    "platform.python_implementation",
]

SETUPTOOLS_VARIABLES = ["python_implementation"]

OPERATORS = ["===", "==", ">=", "<=", "!=", "~=", ">", "<", "in", "not in"]

VALUES = [
    "1.0",
    "5.6a0",
    "dog",
    "freebsd",
    "literally any string can go here",
    "things @#4 dsfd (((",
]


class TestNode:
    @pytest.mark.parametrize("value", ["one", "two", None, 3, 5, []])
    def test_accepts_value(self, value):
        assert Node(value).value == value

    @pytest.mark.parametrize("value", ["one", "two"])
    def test_str(self, value):
        assert str(Node(value)) == str(value)

    @pytest.mark.parametrize("value", ["one", "two"])
    def test_repr(self, value):
        assert repr(Node(value)) == f"<Node({str(value)!r})>"

    def test_base_class(self):
        with pytest.raises(NotImplementedError):
            Node("cover all the code").serialize()


class TestOperatorEvaluation:
    def test_prefers_pep440(self):
        assert Marker('"2.7.9" < "foo"').evaluate(dict(foo="2.7.10"))

    def test_falls_back_to_python(self):
        assert Marker('"b" > "a"').evaluate(dict(a="a"))

    def test_fails_when_undefined(self):
        with pytest.raises(UndefinedComparison):
            Marker("'2.7.0' ~= os_name").evaluate()

    def test_allows_prerelease(self):
        assert Marker('python_full_version > "3.6.2"').evaluate(
            {"python_full_version": "3.11.0a5"}
        )


FakeVersionInfo = collections.namedtuple(
    "FakeVersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]
)


class TestDefaultEnvironment:
    def test_matches_expected(self):
        environment = default_environment()

        iver = (
            f"{sys.implementation.version.major}."
            f"{sys.implementation.version.minor}."
            f"{sys.implementation.version.micro}"
        )
        if sys.implementation.version.releaselevel != "final":
            iver = (
                f"{iver}{sys.implementation.version.releaselevel[0]}"
                f"{sys.implementation.version.serial}"
            )

        assert environment == {
            "implementation_name": sys.implementation.name,
            "implementation_version": iver,
            "os_name": os.name,
            "platform_machine": platform.machine(),
            "platform_release": platform.release(),
            "platform_system": platform.system(),
            "platform_version": platform.version(),
            "python_full_version": platform.python_version(),
            "platform_python_implementation": platform.python_implementation(),
            "python_version": ".".join(platform.python_version_tuple()[:2]),
            "sys_platform": sys.platform,
        }

    def test_multidigit_minor_version(self, monkeypatch):
        version_info = (3, 10, 0, "final", 0)
        monkeypatch.setattr(sys, "version_info", version_info, raising=False)

        monkeypatch.setattr(platform, "python_version", lambda: "3.10.0", raising=False)
        monkeypatch.setattr(
            platform, "python_version_tuple", lambda: ("3", "10", "0"), raising=False
        )

        environment = default_environment()
        assert environment["python_version"] == "3.10"

    def tests_when_releaselevel_final(self):
        v = FakeVersionInfo(3, 4, 2, "final", 0)
        assert format_full_version(v) == "3.4.2"

    def tests_when_releaselevel_not_final(self):
        v = FakeVersionInfo(3, 4, 2, "beta", 4)
        assert format_full_version(v) == "3.4.2b4"


class TestMarker:
    @pytest.mark.parametrize(
        "marker_string",
        [
            "{} {} {!r}".format(*i)
            for i in itertools.product(VARIABLES, OPERATORS, VALUES)
        ]
        + [
            "{2!r} {1} {0}".format(*i)
            for i in itertools.product(VARIABLES, OPERATORS, VALUES)
        ],
    )
    def test_parses_valid(self, marker_string):
        Marker(marker_string)

    @pytest.mark.parametrize(
        "marker_string",
        [
            "this_isnt_a_real_variable >= '1.0'",
            "python_version",
            "(python_version)",
            "python_version >= 1.0 and (python_version)",
            '(python_version == "2.7" and os_name == "linux"',
            '(python_version == "2.7") with random text',
        ],
    )
    def test_parses_invalid(self, marker_string):
        with pytest.raises(InvalidMarker):
            Marker(marker_string)

    @pytest.mark.parametrize(
        ("marker_string", "expected"),
        [
            # Test the different quoting rules
            ("python_version == '2.7'", 'python_version == "2.7"'),
            ('python_version == "2.7"', 'python_version == "2.7"'),
            # Test and/or expressions
            (
                'python_version == "2.7" and os_name == "linux"',
                'python_version == "2.7" and os_name == "linux"',
            ),
            (
                'python_version == "2.7" or os_name == "linux"',
                'python_version == "2.7" or os_name == "linux"',
            ),
            (
                'python_version == "2.7" and os_name == "linux" or '
                'sys_platform == "win32"',
                'python_version == "2.7" and os_name == "linux" or '
                'sys_platform == "win32"',
            ),
            # Test nested expressions and grouping with ()
            ('(python_version == "2.7")', 'python_version == "2.7"'),
            (
                '(python_version == "2.7" and sys_platform == "win32")',
                'python_version == "2.7" and sys_platform == "win32"',
            ),
            (
                'python_version == "2.7" and (sys_platform == "win32" or '
                'sys_platform == "linux")',
                'python_version == "2.7" and (sys_platform == "win32" or '
                'sys_platform == "linux")',
            ),
        ],
    )
    def test_str_repr_eq_hash(self, marker_string, expected):
        m = Marker(marker_string)
        assert str(m) == expected
        assert repr(m) == f"<Marker({str(m)!r})>"
        # Objects created from the same string should be equal.
        assert m == Marker(marker_string)
        # Objects created from the equivalent strings should also be equal.
        assert m == Marker(expected)
        # Objects created from the same string should have the same hash.
        assert hash(Marker(marker_string)) == hash(Marker(marker_string))
        # Objects created from equivalent strings should also have the same hash.
        assert hash(Marker(marker_string)) == hash(Marker(expected))

    @pytest.mark.parametrize(
        ("example1", "example2"),
        [
            # Test trivial comparisons.
            ('python_version == "2.7"', 'python_version == "3.7"'),
            (
                'python_version == "2.7"',
                'python_version == "2.7" and os_name == "linux"',
            ),
            (
                'python_version == "2.7"',
                '(python_version == "2.7" and os_name == "linux")',
            ),
            # Test different precedence.
            (
                'python_version == "2.7" and (os_name == "linux" or '
                'sys_platform == "win32")',
                'python_version == "2.7" and os_name == "linux" or '
                'sys_platform == "win32"',
            ),
        ],
    )
    def test_different_markers_different_hashes(self, example1, example2):
        marker1, marker2 = Marker(example1), Marker(example2)
        # Markers created from strings that are not equivalent should differ.
        assert marker1 != marker2
        # Different Marker objects should have different hashes.
        assert hash(marker1) != hash(marker2)

    def test_compare_markers_to_other_objects(self):
        # Markers should not be comparable to other kinds of objects.
        assert Marker("os_name == 'nt'") != "os_name == 'nt'"

    def test_environment_assumes_empty_extra(self):
        assert Marker('extra == "im_valid"').evaluate() is False

    def test_environment_with_extra_none(self):
        # GIVEN
        marker_str = 'extra == "im_valid"'

        # Pretend that this is dict[str, str], even though it's not. This is a
        # test for being bug-for-bug compatible with the older implementation.
        environment = cast("dict[str, str]", {"extra": None})

        # WHEN
        marker = Marker(marker_str)

        # THEN
        assert marker.evaluate(environment) is False

    @pytest.mark.parametrize(
        ("marker_string", "environment", "expected"),
        [
            (f"os_name == '{os.name}'", None, True),
            ("os_name == 'foo'", {"os_name": "foo"}, True),
            ("os_name == 'foo'", {"os_name": "bar"}, False),
            ("'2.7' in python_version", {"python_version": "2.7.5"}, True),
            ("'2.7' not in python_version", {"python_version": "2.7"}, False),
            (
                "os_name == 'foo' and python_version ~= '2.7.0'",
                {"os_name": "foo", "python_version": "2.7.6"},
                True,
            ),
            (
                "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')",
                {"os_name": "foo", "python_version": "2.7.4"},
                True,
            ),
            (
                "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')",
                {"os_name": "bar", "python_version": "2.7.4"},
                True,
            ),
            (
                "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')",
                {"os_name": "other", "python_version": "2.7.4"},
                False,
            ),
            ("extra == 'security'", {"extra": "quux"}, False),
            ("extra == 'security'", {"extra": "security"}, True),
            ("extra == 'SECURITY'", {"extra": "security"}, True),
            ("extra == 'security'", {"extra": "SECURITY"}, True),
            ("extra == 'pep-685-norm'", {"extra": "PEP_685...norm"}, True),
            (
                "extra == 'Different.punctuation..is...equal'",
                {"extra": "different__punctuation_is_EQUAL"},
                True,
            ),
        ],
    )
    def test_evaluates(self, marker_string, environment, expected):
        args = [] if environment is None else [environment]
        assert Marker(marker_string).evaluate(*args) == expected

    @pytest.mark.parametrize(
        "marker_string",
        [
            "{} {} {!r}".format(*i)
            for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES)
        ]
        + [
            "{2!r} {1} {0}".format(*i)
            for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES)
        ],
    )
    def test_parses_pep345_valid(self, marker_string):
        Marker(marker_string)

    @pytest.mark.parametrize(
        ("marker_string", "environment", "expected"),
        [
            (f"os.name == '{os.name}'", None, True),
            ("sys.platform == 'win32'", {"sys_platform": "linux2"}, False),
            ("platform.version in 'Ubuntu'", {"platform_version": "#39"}, False),
            ("platform.machine=='x86_64'", {"platform_machine": "x86_64"}, True),
            (
                "platform.python_implementation=='Jython'",
                {"platform_python_implementation": "CPython"},
                False,
            ),
            (
                "python_version == '2.5' and platform.python_implementation!= 'Jython'",
                {"python_version": "2.7"},
                False,
            ),
        ],
    )
    def test_evaluate_pep345_markers(self, marker_string, environment, expected):
        args = [] if environment is None else [environment]
        assert Marker(marker_string).evaluate(*args) == expected

    @pytest.mark.parametrize(
        "marker_string",
        [
            "{} {} {!r}".format(*i)
            for i in itertools.product(SETUPTOOLS_VARIABLES, OPERATORS, VALUES)
        ]
        + [
            "{2!r} {1} {0}".format(*i)
            for i in itertools.product(SETUPTOOLS_VARIABLES, OPERATORS, VALUES)
        ],
    )
    def test_parses_setuptools_legacy_valid(self, marker_string):
        Marker(marker_string)

    def test_evaluate_setuptools_legacy_markers(self):
        marker_string = "python_implementation=='Jython'"
        args = [{"platform_python_implementation": "CPython"}]
        assert Marker(marker_string).evaluate(*args) is False

    def test_extra_str_normalization(self):
        raw_name = "S_P__A_M"
        normalized_name = "s-p-a-m"
        lhs = f"{raw_name!r} == extra"
        rhs = f"extra == {raw_name!r}"

        assert str(Marker(lhs)) == f'"{normalized_name}" == extra'
        assert str(Marker(rhs)) == f'extra == "{normalized_name}"'

    def test_python_full_version_untagged_user_provided(self):
        """A user-provided python_full_version ending with a + is also repaired."""
        assert Marker("python_full_version < '3.12'").evaluate(
            {"python_full_version": "3.11.1+"}
        )

    def test_python_full_version_untagged(self):
        with mock.patch("platform.python_version", return_value="3.11.1+"):
            assert Marker("python_full_version < '3.12'").evaluate()

    @pytest.mark.parametrize("variable", ["extras", "dependency_groups"])
    @pytest.mark.parametrize(
        "expression,result",
        [
            pytest.param('"foo" in {0}', True, id="value-in-foo"),
            pytest.param('"bar" in {0}', True, id="value-in-bar"),
            pytest.param('"baz" in {0}', False, id="value-not-in"),
            pytest.param('"baz" not in {0}', True, id="value-not-in-negated"),
            pytest.param('"foo" in {0} and "bar" in {0}', True, id="and-in"),
            pytest.param('"foo" in {0} or "bar" in {0}', True, id="or-in"),
            pytest.param(
                '"baz" in {0} and "foo" in {0}', False, id="short-circuit-and"
            ),
            pytest.param('"foo" in {0} or "baz" in {0}', True, id="short-circuit-or"),
            pytest.param('"Foo" in {0}', True, id="case-sensitive"),
        ],
    )
    def test_extras_and_dependency_groups(self, variable, expression, result):
        environment = {variable: {"foo", "bar"}}
        assert Marker(expression.format(variable)).evaluate(environment) == result

    @pytest.mark.parametrize("variable", ["extras", "dependency_groups"])
    def test_extras_and_dependency_groups_disallowed(self, variable):
        marker = Marker(f'"foo" in {variable}')
        assert not marker.evaluate(context="lock_file")

        with pytest.raises(KeyError):
            marker.evaluate()

        with pytest.raises(KeyError):
            marker.evaluate(context="requirement")
