from __future__ import annotations

import json
from functools import lru_cache
from operator import attrgetter
from pathlib import Path
from typing import Any, Iterable, List, Set, Tuple, Union, cast

from packaging.specifiers import SpecifierSet

from pdm.exceptions import InvalidPyVersion
from pdm.models.versions import Version

MAX_VERSIONS_FILE = Path(__file__).with_name("python_max_versions.json")


@lru_cache()
def get_specifier(version_str: Union[SpecifierSet, str]) -> SpecifierSet:
    if isinstance(version_str, SpecifierSet):
        return version_str
    if not version_str or version_str == "*":
        return SpecifierSet()
    return SpecifierSet(version_str)


def _normalize_op_specifier(op: str, version_str: str) -> Tuple[str, Version]:
    version = Version(version_str)
    if version.is_wildcard:
        if op == "==":
            op = "~="
            version[-1] = 0
        elif op == ">":  # >X.Y.* => >=X.Y+1.0
            op = ">="
            version = version.bump(-2)
        elif op in ("<", ">=", "<="):
            # <X.Y.* => <X.Y.0
            # >=X.Y.* => >=X.Y.0
            # <=X.Y.* => <X.Y.0
            version[-1] = 0
            if op == "<=":
                op = "<"
        elif op != "!=":
            raise InvalidPyVersion(f"Unsupported version specifier: {op}{version}")

    if op != "~=" and not (op == "!=" and version.is_wildcard):
        # Don't complete with .0 for ~=3.5 and !=3.4.*:
        version = version.complete()
    return op, version


class PySpecSet(SpecifierSet):
    """A custom SpecifierSet that supports merging with logic operators (&, |)."""

    PY_MAX_MINOR_VERSION = {
        Version(key): value
        for key, value in json.loads(MAX_VERSIONS_FILE.read_text()).items()
    }
    MAX_MAJOR_VERSION = max(PY_MAX_MINOR_VERSION)[:1].bump()

    def __init__(self, version_str: str = "", analyze: bool = True) -> None:
        if version_str == "*":
            version_str = ""
        super().__init__(version_str)
        self._lower_bound = Version.MIN
        self._upper_bound = Version.MAX
        self._excludes: List[Version] = []
        if version_str and analyze:
            self._analyze_specifiers()

    def _analyze_specifiers(self) -> None:
        lower_bound, upper_bound = Version.MIN, Version.MAX
        excludes: Set[Version] = set()
        for spec in self:
            op, version = _normalize_op_specifier(spec.operator, spec.version)

            if op in ("==", "==="):
                lower_bound = version
                upper_bound = version.bump()
                break
            if op == "!=":
                excludes.add(version)
            elif op[0] == ">":
                lower_bound = max(
                    lower_bound, version if op == ">=" else version.bump()
                )
            elif op[0] == "<":
                upper_bound = min(
                    upper_bound, version.bump() if op == "<=" else version
                )
            elif op == "~=":
                new_lower = version.complete()
                new_upper = version.bump(-2)
                if new_upper < upper_bound:
                    upper_bound = new_upper
                if new_lower > lower_bound:
                    lower_bound = new_lower
            else:
                raise InvalidPyVersion(f"Unsupported version specifier: {op}{version}")
        self._rearrange(lower_bound, upper_bound, excludes)

    @classmethod
    def _merge_bounds_and_excludes(
        cls,
        lower: Version,
        upper: Version,
        excludes: Iterable[Version],
    ) -> Tuple[Version, Version, List[Version]]:
        sorted_excludes = sorted(excludes)
        wildcard_excludes = {
            version[:-1] for version in sorted_excludes if version.is_wildcard
        }
        # Remove versions that are already excluded by another wildcard exclude.
        sorted_excludes = [
            version
            for version in sorted_excludes
            if version.is_wildcard
            or not any(version.startswith(wv) for wv in wildcard_excludes)
        ]

        if lower == Version.MIN and upper == Version.MAX:
            # Nothing we can do here, it is a non-constraint.
            return lower, upper, sorted_excludes

        for version in list(sorted_excludes):  # from to low to high
            if version >= upper:
                sorted_excludes[:] = []
                break

            if version.is_wildcard:
                valid_length = len(version._version) - 1
                valid_version = version[:valid_length]

                if valid_version < lower[:valid_length]:
                    # Useless excludes
                    sorted_excludes.remove(version)
                elif lower.startswith(valid_version):
                    # The lower bound is excluded, e.g: >=3.7.3,!=3.7.*
                    # bump the lower version in the last common bit: >=3.8.0
                    lower = version.bump(-2)
                    sorted_excludes.remove(version)
                else:
                    break
            else:
                if version < lower:
                    sorted_excludes.remove(version)
                elif version == lower:
                    lower = version.bump()
                    sorted_excludes.remove(version)
                else:
                    break
        for version in reversed(sorted_excludes):  # from high to low
            if version >= upper:
                sorted_excludes.remove(version)
                continue

            if not version.is_wildcard:
                break
            valid_length = len(version._version) - 1
            valid_version = version[:valid_length]

            if upper.startswith(valid_version) or version.bump(-2) == upper:
                # Case 1: The upper bound is excluded, e.g: <3.7.3,!=3.7.*
                # set the upper to the zero version: <3.7.0
                # Case 2: The upper bound is adjacent to the excluded one,
                # e.g: <3.7.0,!=3.6.*
                # Move the upper bound to below the excluded: <3.6.0
                upper = valid_version.complete()
                sorted_excludes.remove(version)
            else:
                break

        return lower, upper, sorted_excludes

    def _rearrange(
        self, lower_bound: Version, upper_bound: Version, excludes: Iterable[Version]
    ) -> None:
        """Rearrange the version bounds with the given inputs."""
        (
            self._lower_bound,
            self._upper_bound,
            self._excludes,
        ) = self._merge_bounds_and_excludes(lower_bound, upper_bound, excludes)
        if not self.is_impossible:
            super().__init__(str(self))

    def _comp_key(self) -> Tuple[Version, Version, Tuple[Version, ...]]:
        return (self._lower_bound, self._upper_bound, tuple(self._excludes))

    def __hash__(self) -> int:
        return hash(self._comp_key())

    def __eq__(self, other: Any) -> bool:
        if not isinstance(other, PySpecSet):
            return False
        return self._comp_key() == other._comp_key()

    @property
    def is_impossible(self) -> bool:
        """Check whether the specifierset contains any valid versions."""
        if self._lower_bound == Version.MIN or self._upper_bound == Version.MAX:
            return False
        return self._lower_bound >= self._upper_bound

    @property
    def is_allow_all(self) -> bool:
        """Return True if the specifierset accepts all versions."""
        if self.is_impossible:
            return False
        return (
            self._lower_bound == Version.MIN
            and self._upper_bound == Version.MAX
            and not self._excludes
        )

    def __bool__(self) -> bool:
        return not self.is_allow_all

    def __str__(self) -> str:
        if self.is_impossible:
            return "impossible"
        if self.is_allow_all:
            return ""
        lower = self._lower_bound
        upper = self._upper_bound
        if lower[-1] == 0 and not lower.is_prerelease:
            lower = lower[:-1]
        if upper[-1] == 0 and not upper.is_prerelease:
            upper = upper[:-1]
        lower_str = "" if lower == Version.MIN else f">={lower}"
        upper_str = "" if upper == Version.MAX else f"<{upper}"
        excludes_str = ",".join(f"!={version}" for version in self._excludes)

        return ",".join(filter(None, [lower_str, upper_str, excludes_str]))

    def __repr__(self) -> str:
        return f"<PySpecSet {self}>"

    def copy(self) -> "PySpecSet":
        """Create a new specifierset that is same as the original one."""
        if self.is_impossible:
            return ImpossiblePySpecSet()
        instance = self.__class__(str(self), False)
        instance._lower_bound = self._lower_bound
        instance._upper_bound = self._upper_bound
        instance._excludes = self._excludes[:]
        return instance

    @lru_cache()
    def __and__(self, other: "PySpecSet") -> "PySpecSet":
        if any(s.is_impossible for s in (self, other)):
            return ImpossiblePySpecSet()
        if self.is_allow_all:
            return other.copy()
        elif other.is_allow_all:
            return self.copy()
        rv = self.copy()
        excludes = set(rv._excludes) | set(other._excludes)
        lower = max(rv._lower_bound, other._lower_bound)
        upper = min(rv._upper_bound, other._upper_bound)
        rv._rearrange(lower, upper, excludes)
        return rv

    @lru_cache()
    def __or__(self, other: "PySpecSet") -> "PySpecSet":
        if self.is_impossible:
            return other.copy()
        elif other.is_impossible:
            return self.copy()
        if self.is_allow_all:
            return self.copy()
        elif other.is_allow_all:
            return other.copy()
        rv = self.copy()
        left, right = sorted([rv, other], key=lambda x: x._lower_bound)
        excludes = set(left._excludes) & set(right._excludes)
        lower = left._lower_bound
        upper = max(left._upper_bound, right._upper_bound)
        if right._lower_bound > left._upper_bound:  # two ranges has no overlap
            excludes.update(
                self._populate_version_range(left._upper_bound, right._lower_bound)
            )
        rv._rearrange(lower, upper, excludes)
        return rv

    def _populate_version_range(
        self, lower: Version, upper: Version
    ) -> Iterable[Version]:
        """Expand the version range to a collection of versions to exclude,
        taking the released python versions into consideration.
        """
        assert lower < upper
        prev = lower
        while prev < upper:
            if prev[-2:] == Version((0, 0)):  # X.0.0
                cur = prev.bump(0)  # X+1.0.0
                if cur <= upper:  # It is still within the range
                    yield Version((prev[0], "*"))  # Exclude the whole major series: X.*
                    prev = cur
                    continue
            if prev[-1] == 0:  # X.Y.0
                cur = prev.bump(1)  # X.Y+1.0
                if cur <= upper:  # It is still within the range
                    yield prev[:2].complete("*")  # Exclude X.Y.*
                    prev = (
                        prev.bump(0)
                        if cur.is_py2
                        and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]]
                        else cur
                    )  # If prev is 2.7, next is 3.0, otherwise next is X.Y+1.0
                    continue
                while prev < upper:
                    # Iterate each version from X.Y.0(prev) to X.Y.Z(upper)
                    yield prev
                    prev = prev.bump()
                break
            # Can't produce any wildcard versions
            cur = prev.bump(1)
            if cur <= upper:  # X.Y+1.0 is still within the range
                current_max = self.PY_MAX_MINOR_VERSION[prev[:2]]
                for z in range(cast(int, prev[2]), current_max + 1):
                    yield prev[:2].complete(z)
                prev = (
                    prev.bump(0)
                    if cur.is_py2
                    and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]]
                    else cur
                )
            else:  # Produce each version from X.Y.Z to X.Y.W
                while prev < upper:
                    yield prev
                    prev = prev.bump()
                break

    @lru_cache()
    def is_superset(self, other: Union[str, SpecifierSet]) -> bool:
        if self.is_impossible:
            return False
        if self.is_allow_all:
            return True
        other = type(self)(str(other))
        if other._upper_bound >= self.MAX_MAJOR_VERSION:
            # XXX: narrow down the upper bound to ``MAX_MAJOR_VERSION``
            # So that `>=3.6,<4.0` is considered a superset of `>=3.7`, see issues/66
            other._upper_bound = self.MAX_MAJOR_VERSION
        lower, upper, excludes = self._merge_bounds_and_excludes(
            other._lower_bound, other._upper_bound, self._excludes
        )
        if (
            self._lower_bound > other._lower_bound
            or self._upper_bound < other._upper_bound
        ):
            return False
        return (
            lower <= other._lower_bound
            and upper >= other._upper_bound
            and set(excludes) <= set(other._excludes)
        )

    @lru_cache()
    def is_subset(self, other: Union[str, SpecifierSet]) -> bool:
        if self.is_impossible:
            return False
        other = type(self)(str(other))
        if other._upper_bound >= self.MAX_MAJOR_VERSION:
            # Relax the upper bound to max version
            other._upper_bound = Version.MAX
        if other.is_allow_all:
            return True
        lower, upper, excludes = self._merge_bounds_and_excludes(
            self._lower_bound, self._upper_bound, other._excludes
        )
        if (
            self._lower_bound < other._lower_bound
            or self._upper_bound > other._upper_bound
        ):
            return False
        return (
            lower <= self._lower_bound
            and upper >= self._upper_bound
            and set(self._excludes) >= set(excludes)
        )

    def as_marker_string(self) -> str:
        if self.is_allow_all:
            return ""
        result = []
        excludes = []
        full_excludes = []
        for spec in sorted(self, key=attrgetter("version")):
            op, version = spec.operator, spec.version
            if len(version.split(".")) < 3:
                key = "python_version"
            else:
                key = "python_full_version"
                if version[-2:] == ".*":
                    version = version[:-2]
                    key = "python_version"
            if op == "!=":
                if key == "python_version":
                    excludes.append(version)
                else:
                    full_excludes.append(version)
            else:
                result.append(f"{key}{op}{version!r}")
        if excludes:
            result.append(
                "python_version not in {!r}".format(", ".join(sorted(excludes)))
            )
        if full_excludes:
            result.append(
                "python_full_version not in {!r}".format(
                    ", ".join(sorted(full_excludes))
                )
            )
        return " and ".join(result)

    def supports_py2(self) -> bool:
        return self._lower_bound.is_py2


class ImpossiblePySpecSet(PySpecSet):
    """
    A special subclass of PySpecSet that references to an impossible specifier set.
    """

    def __init__(self, version_str: str = "", analyze: bool = True) -> None:
        super().__init__(version_str=version_str, analyze=False)
        # Make sure the spec set is impossible
        self._lower_bound = Version.MAX
        self._upper_bound = Version.MIN

    @property
    def is_impossible(self) -> bool:
        return True
