"""
    salt.utils.versions
    ~~~~~~~~~~~~~~~~~~~

    Version parsing based on `packaging.version` and `looseversion.LooseVersion`
    which works under python 3 because on python 3 you can no longer compare
    strings against integers.
"""

import collections
import datetime
import inspect
import logging
import numbers
import os
import sys
import warnings

import looseversion
import packaging.version

import salt.version

log = logging.getLogger(__name__)


class Version(packaging.version.Version):
    def __lt__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__lt__(other)

    def __le__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__le__(other)

    def __eq__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__eq__(other)

    def __ge__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__ge__(other)

    def __gt__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__gt__(other)

    def __ne__(self, other):
        if isinstance(other, str):
            other = Version(other)
        return super().__ne__(other)


class StrictVersion(Version):
    def __init__(self, *args, **kwargs):
        warn_until(
            3009,
            f"'{__name__}.StrictVersion' is no longer a subclass of "
            "'distutils.versions.StrictVersion'. It's usage has been "
            "deprecated and should no longer be used. Please switch to "
            f"'{__name__}.Version' which is a subclass of "
            f"'packaging.version.Version'. '{__name__}.StrictVersion' will "
            "be removed in {version}.",
        )
        super().__init__(*args, **kwargs)


class LooseVersion(looseversion.LooseVersion):
    def parse(self, vstring):
        super().parse(vstring)
        # Convert every part of the version to string in order to be able to compare
        self._str_version = [
            str(vp).zfill(8) if isinstance(vp, int) else vp for vp in self.version
        ]

    def _cmp(self, other):
        if isinstance(other, str):
            other = LooseVersion(other)

        string_in_version = False
        for part in self.version + other.version:
            if not isinstance(part, int):
                string_in_version = True
                break

        if string_in_version is False:
            return super()._cmp(other)

        # If we reached this far, it means at least a part of the version contains a string
        # In python 3, strings and integers are not comparable
        if self._str_version == other._str_version:
            return 0
        if self._str_version < other._str_version:
            return -1
        if self._str_version > other._str_version:
            return 1


def _format_warning(message, category, filename, lineno, line=None):
    """
    Replacement for warnings.formatwarning that disables the echoing of
    the 'line' parameter.
    """
    return f"{filename}:{lineno}: {category.__name__}: {message}\n"


def warn_until(
    version,
    message,
    category=DeprecationWarning,
    stacklevel=None,
    _version_info_=None,
    _dont_call_warnings=False,
):
    """
    Helper function to raise a warning, by default, a ``DeprecationWarning``,
    until the provided ``version``, after which, a ``RuntimeError`` will
    be raised to remind the developers to remove the warning because the
    target version has been reached.

    :param version: The version info or name after which the warning becomes a ``RuntimeError``.
                    For example ``(2019, 2)``, ``3000``, ``Hydrogen`` or an instance of
                    :class:`salt.version.SaltStackVersion` or :class:`salt.version.SaltVersion`.
    :param message: The warning message to be displayed.
    :param category: The warning class to be thrown, by default
                     ``DeprecationWarning``
    :param stacklevel: There should be no need to set the value of
                       ``stacklevel``. Salt should be able to do the right thing.
    :param _version_info_: In order to reuse this function for other SaltStack
                           projects, they need to be able to provide the
                           version info to compare to.
    :param _dont_call_warnings: This parameter is used just to get the
                                functionality until the actual error is to be
                                issued. When we're only after the salt version
                                checks to raise a ``RuntimeError``.
    """
    if isinstance(version, salt.version.SaltVersion):
        version = salt.version.SaltStackVersion(*version.info)
    elif isinstance(version, int):
        version = salt.version.SaltStackVersion(version)
    elif isinstance(version, tuple):
        version = salt.version.SaltStackVersion(*version)
    elif isinstance(version, str):
        if version.lower() not in salt.version.SaltStackVersion.LNAMES:
            raise RuntimeError(
                "Incorrect spelling for the release name in the warn_utils "
                "call. Expecting one of these release names: {}".format(
                    [vs.name for vs in salt.version.SaltVersionsInfo.versions()]
                )
            )
        version = salt.version.SaltStackVersion.from_name(version)
    elif not isinstance(version, salt.version.SaltStackVersion):
        raise RuntimeError(
            "The 'version' argument should be passed as a tuple, integer, string or "
            "an instance of 'salt.version.SaltVersion' or "
            "'salt.version.SaltStackVersion'."
        )

    if stacklevel is None:
        # Attribute the warning to the calling function, not to warn_until()
        stacklevel = 2

    if _version_info_ is None:
        _version_info_ = salt.version.__version_info__

    _version_ = salt.version.SaltStackVersion(*_version_info_)

    if _version_ >= version:
        caller = inspect.getframeinfo(sys._getframe(stacklevel - 1))
        deprecated_message = (
            "The warning triggered on filename '{filename}', line number "
            "{lineno}, is supposed to be shown until version "
            "{until_version} is released. Current version is now "
            "{salt_version}. Please remove the warning.".format(
                filename=caller.filename,
                lineno=caller.lineno,
                until_version=version.formatted_version,
                salt_version=_version_.formatted_version,
            )
        )
        if os.environ.get("RAISE_DEPRECATIONS_RUNTIME_ERRORS", "0") == "1":
            # We don't raise RuntimeError by default since that can break
            # users systems. We do however want to raise them in a CI context.
            raise RuntimeError(deprecated_message)
        # Otherwise, print the deprecated message to STDERR
        sys.stderr.write(f"\n{deprecated_message}\n")
        sys.stderr.flush()

    if _dont_call_warnings is False and os.environ.get("PYTHONWARNINGS") != "ignore":
        warnings.warn(
            message.format(version=version.formatted_version),
            category,
            stacklevel=stacklevel,
        )


def warn_until_date(
    date,
    message,
    category=DeprecationWarning,
    stacklevel=None,
    _current_date=None,
    _dont_call_warnings=False,
):
    """
    Helper function to raise a warning, by default, a ``DeprecationWarning``,
    until the provided ``date``, after which, a ``RuntimeError`` will
    be raised to remind the developers to remove the warning because the
    target date has been reached.

    :param date: A ``datetime.date`` or ``datetime.datetime`` instance.
    :param message: The warning message to be displayed.
    :param category: The warning class to be thrown, by default
                     ``DeprecationWarning``
    :param stacklevel: There should be no need to set the value of
                       ``stacklevel``. Salt should be able to do the right thing.
    :param _dont_call_warnings: This parameter is used just to get the
                                functionality until the actual error is to be
                                issued. When we're only after the date
                                checks to raise a ``RuntimeError``.
    """
    _strptime_fmt = "%Y%m%d"
    if not isinstance(date, (str, datetime.date, datetime.datetime)):
        raise RuntimeError(
            "The 'date' argument should be passed as a 'datetime.date()' or "
            "'datetime.datetime()' objects or as string parserable by "
            "'datetime.datetime.strptime()' with the following format '{}'.".format(
                _strptime_fmt
            )
        )
    elif isinstance(date, str):
        date = datetime.datetime.strptime(date, _strptime_fmt)

    # We're really not interested in the time
    if isinstance(date, datetime.datetime):
        date = date.date()

    if stacklevel is None:
        # Attribute the warning to the calling function, not to warn_until_date()
        stacklevel = 2

    today = _current_date or datetime.datetime.now(datetime.timezone.utc).date()
    if today >= date:
        caller = inspect.getframeinfo(sys._getframe(stacklevel - 1))
        deprecated_message = (
            "{message} This warning(now exception) triggered on "
            "filename '{filename}', line number {lineno}, is "
            "supposed to be shown until {date}. Today is {today}. "
            "Please remove the warning.".format(
                message=message.format(date=date.isoformat(), today=today.isoformat()),
                filename=caller.filename,
                lineno=caller.lineno,
                date=date.isoformat(),
                today=today.isoformat(),
            ),
        )
        if os.environ.get("RAISE_DEPRECATIONS_RUNTIME_ERRORS", "0") == "1":
            # We don't raise RuntimeError by default since that can break
            # users systems. We do however want to raise them in a CI context.
            raise RuntimeError(deprecated_message)
        # Otherwise, print the deprecated message to STDERR
        sys.stderr.write(f"\n{deprecated_message}\n")
        sys.stderr.flush()

    if _dont_call_warnings is False and os.environ.get("PYTHONWARNINGS") != "ignore":
        warnings.warn(
            message.format(date=date.isoformat(), today=today.isoformat()),
            category,
            stacklevel=stacklevel,
        )


def kwargs_warn_until(
    kwargs,
    version,
    category=DeprecationWarning,
    stacklevel=None,
    _version_info_=None,
    _dont_call_warnings=False,
):
    """
    Helper function to raise a warning (by default, a ``DeprecationWarning``)
    when unhandled keyword arguments are passed to function, until the
    provided ``version_info``, after which, a ``RuntimeError`` will be raised
    to remind the developers to remove the ``**kwargs`` because the target
    version has been reached.
    This function is used to help deprecate unused legacy ``**kwargs`` that
    were added to function parameters lists to preserve backwards compatibility
    when removing a parameter. See
    :ref:`the deprecation development docs <deprecations>`
    for the modern strategy for deprecating a function parameter.

    :param kwargs: The caller's ``**kwargs`` argument value (a ``dict``).
    :param version: The version info or name after which the warning becomes a
                    ``RuntimeError``. For example ``(0, 17)`` or ``Hydrogen``
                    or an instance of :class:`salt.version.SaltStackVersion`.
    :param category: The warning class to be thrown, by default
                     ``DeprecationWarning``
    :param stacklevel: There should be no need to set the value of
                       ``stacklevel``. Salt should be able to do the right thing.
    :param _version_info_: In order to reuse this function for other SaltStack
                           projects, they need to be able to provide the
                           version info to compare to.
    :param _dont_call_warnings: This parameter is used just to get the
                                functionality until the actual error is to be
                                issued. When we're only after the salt version
                                checks to raise a ``RuntimeError``.
    """
    if not isinstance(version, (tuple, str, salt.version.SaltStackVersion)):
        raise RuntimeError(
            "The 'version' argument should be passed as a tuple, string or "
            "an instance of 'salt.version.SaltStackVersion'."
        )
    elif isinstance(version, tuple):
        version = salt.version.SaltStackVersion(*version)
    elif isinstance(version, str):
        version = salt.version.SaltStackVersion.from_name(version)

    if stacklevel is None:
        # Attribute the warning to the calling function,
        # not to kwargs_warn_until() or warn_until()
        stacklevel = 3

    if _version_info_ is None:
        _version_info_ = salt.version.__version_info__

    _version_ = salt.version.SaltStackVersion(*_version_info_)

    if kwargs or _version_.info >= version.info:
        arg_names = ", ".join(f"'{key}'" for key in kwargs)
        warn_until(
            version,
            message=(
                "The following parameter(s) have been deprecated and "
                "will be removed in '{}': {}.".format(version.string, arg_names)
            ),
            category=category,
            stacklevel=stacklevel,
            _version_info_=_version_.info,
            _dont_call_warnings=_dont_call_warnings,
        )


def version_cmp(pkg1, pkg2, ignore_epoch=False):
    """
    Compares two version strings using `LooseVersion`. This
    is a fallback for providers which don't have a version comparison utility
    built into them.  Return -1 if version1 < version2, 0 if version1 ==
    version2, and 1 if version1 > version2. Return None if there was a problem
    making the comparison.
    """

    def normalize(x):
        return str(x).split(":", 1)[-1] if ignore_epoch else str(x)

    pkg1 = normalize(pkg1)
    pkg2 = normalize(pkg2)

    try:
        # pylint: disable=no-member
        if LooseVersion(pkg1) < LooseVersion(pkg2):
            return -1
        elif LooseVersion(pkg1) == LooseVersion(pkg2):
            return 0
        elif LooseVersion(pkg1) > LooseVersion(pkg2):
            return 1
    except Exception as exc:  # pylint: disable=broad-except
        log.exception(exc)
    return None


def compare(ver1="", oper="==", ver2="", cmp_func=None, ignore_epoch=False):
    """
    Compares two version numbers. Accepts a custom function to perform the
    cmp-style version comparison, otherwise uses version_cmp().
    """
    cmp_map = {"<": (-1,), "<=": (-1, 0), "==": (0,), ">=": (0, 1), ">": (1,)}
    if oper not in ("!=",) and oper not in cmp_map:
        log.error("Invalid operator '%s' for version comparison", oper)
        return False

    if cmp_func is None:
        cmp_func = version_cmp

    cmp_result = cmp_func(ver1, ver2, ignore_epoch=ignore_epoch)
    if cmp_result is None:
        return False

    # Check if integer/long
    if not isinstance(cmp_result, numbers.Integral):
        log.error("The version comparison function did not return an integer/long.")
        return False

    if oper == "!=":
        return cmp_result not in cmp_map["=="]
    else:
        # Gracefully handle cmp_result not in (-1, 0, 1).
        if cmp_result < -1:
            cmp_result = -1
        elif cmp_result > 1:
            cmp_result = 1

        return cmp_result in cmp_map[oper]


def check_boto_reqs(
    boto_ver=None, boto3_ver=None, botocore_ver=None, check_boto=True, check_boto3=True
):
    """
    Checks for the version of various required boto libs in one central location. Most
    boto states and modules rely on a single version of the boto, boto3, or botocore libs.
    However, some require newer versions of any of these dependencies. This function allows
    the module to pass in a version to override the default minimum required version.

    This function is useful in centralizing checks for ``__virtual__()`` functions in the
    various, and many, boto modules and states.

    boto_ver
        The minimum required version of the boto library. Defaults to ``2.0.0``.

    boto3_ver
        The minimum required version of the boto3 library. Defaults to ``1.2.6``.

    botocore_ver
        The minimum required version of the botocore library. Defaults to ``1.3.23``.

    check_boto
        Boolean defining whether or not to check for boto deps. This defaults to ``True`` as
        most boto modules/states rely on boto, but some do not.

    check_boto3
        Boolean defining whether or not to check for boto3 (and therefore botocore) deps.
        This defaults to ``True`` as most boto modules/states rely on boto3/botocore, but
        some do not.
    """
    if check_boto is True:
        try:
            # Late import so we can only load these for this function
            import boto

            has_boto = True
        except ImportError:
            has_boto = False

        if boto_ver is None:
            boto_ver = "2.0.0"

        if not has_boto or version_cmp(boto.__version__, boto_ver) == -1:
            return False, f"A minimum version of boto {boto_ver} is required."

    if check_boto3 is True:
        try:
            # Late import so we can only load these for this function
            import boto3
            import botocore

            has_boto3 = True
        except ImportError:
            has_boto3 = False

        # boto_s3_bucket module requires boto3 1.2.6 and botocore 1.3.23 for
        # idempotent ACL operations via the fix in https://github.com/boto/boto3/issues/390
        if boto3_ver is None:
            boto3_ver = "1.2.6"
        if botocore_ver is None:
            botocore_ver = "1.3.23"

        if not has_boto3 or version_cmp(boto3.__version__, boto3_ver) == -1:
            return (
                False,
                f"A minimum version of boto3 {boto3_ver} is required.",
            )
        elif version_cmp(botocore.__version__, botocore_ver) == -1:
            return (
                False,
                f"A minimum version of botocore {botocore_ver} is required",
            )

    return True


def parse(version):
    """
    A replacement for `pkg_resources.parse_version` which is being deprecated.
    """
    return packaging.version.parse(version)


class RequirementNotRegistered(AttributeError):
    pass


Getters = collections.namedtuple("Getters", "module_getter, version_getter")


def default_version_getter(module):
    """
    Module version getter.
    """
    ver = None
    if hasattr(module, "__version__"):
        ver = module.__version__
    if hasattr(module, "version"):
        ver = module.version
    if ver is None:
        raise Exception("Version info not found")
    elif isinstance(ver, tuple):
        return ".".join([str(_) for _ in ver])
    else:
        return ver


def default_module_getter(name):
    """
    Module getter.
    """
    try:
        return __import__(name)
    except ImportError:
        pass


class Requirement:
    def __init__(
        self,
        name,
        module_getter=default_module_getter,
        version_getter=default_version_getter,
        has_depend=None,
        version=None,
    ):
        self.name = name
        self.module_getter = module_getter
        self.version_getter = version_getter
        self.has_depend = has_depend
        self.version = version
        self.populate()

    @property
    def module(self):
        return self.module_getter(self.name)

    def populate(self):
        if self.has_depend is None:
            mod = self.module_getter(self.name)
            if mod:
                self.has_depend = True
                self.version = self.version_getter(mod)
            else:
                self.has_depend = False

    def __nonzero__(self):
        return self.has_depend

    def __bool__(self):
        return self.has_depend

    def _get_version(self, ver):
        if isinstance(ver, (list, tuple)):
            return packaging.version.Version(".".join([str(_) for _ in ver]))
        if isinstance(ver, packaging.version.Version):
            return ver
        return packaging.version.Version(str(ver))

    def __eq__(self, other):
        if not self.has_depend:
            return False
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver == other_ver

    def __ne__(self, other):
        if not self.has_depend:
            return True
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver != other_ver

    def __lt__(self, other):
        if not self.has_depend:
            return False
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver < other_ver

    def __le__(self, other):
        if not self.has_depend:
            return False
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver <= other_ver

    def __gt__(self, other):
        if not self.has_depend:
            return False
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver > other_ver

    def __ge__(self, other):
        if not self.has_depend:
            return False
        other_ver = self._get_version(other)
        dep_ver = self._get_version(self.version)
        return dep_ver >= other_ver


def msgpack_module_getter(name):
    """
    Custom msgpack module getter
    """
    msgpack = None
    try:
        import msgpack

        if msgpack.version >= (0, 4, 0):
            if (
                msgpack.loads(
                    msgpack.dumps([1, 2, 3], use_bin_type=False), use_list=True
                )
                is None
            ):
                raise ImportError
        else:
            if msgpack.loads(msgpack.dumps([1, 2, 3]), use_list=True) is None:
                raise ImportError
    except ImportError:
        try:
            import msgpack_pure as msgpack  # pylint: disable=import-error
        except ImportError:
            return
    return msgpack


# To use a custom module or version getter for the depenency, map them here.
DEPS_MAP = {
    "msgpack": Getters(msgpack_module_getter, None),
    "gnupg": Getters(None, None),
}


class Requirements:
    def __init__(self, deps_map=None):
        if deps_map is None:
            self.deps_map = DEPS_MAP
        else:
            self.deps_map = deps_map
        self._cached_reqs = {}

    def clear(self):
        """
        Clear the cached requirements.
        """
        self._cached_reqs = {}

    def __getattr__(self, val):
        if val not in self.deps_map:
            raise RequirementNotRegistered(f"Unknown dependency: {val}")

        if val not in self._cached_reqs:
            module_getter, version_getter = self.deps_map[val]
            self._cached_reqs[val] = Requirement(
                val,
                module_getter or default_module_getter,
                version_getter or default_version_getter,
            )
        return self._cached_reqs[val]


reqs = Requirements()
