"""
    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 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(
            3008,
            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.utcnow().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
