# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

"""
This module provides a bunch of utility methods and helpers for version handling.
"""

__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"

import logging

import pkg_resources
from past.builtins import basestring

from octoprint import __version__


def get_octoprint_version_string():
    return __version__


def get_octoprint_version(cut=None, **kwargs):
    octoprint_version_string = normalize_version(get_octoprint_version_string())
    return get_comparable_version(octoprint_version_string, cut=cut, **kwargs)


def is_released_octoprint_version(version=None):
    if version is None:
        version = get_octoprint_version()
    return is_release(version)


def is_stable_octoprint_version(version=None):
    if version is None:
        version = get_octoprint_version()
    return is_stable(version)


def is_octoprint_compatible(*compatibility_entries, **kwargs):
    """
    Tests if the current ``octoprint_version`` is compatible to any of the provided ``compatibility_entries``.

    Arguments:
            compatibility_entries (str): compatibility string(s) to test against, result will be `True` if any match
                    is found
            octoprint_version (tuple or SetuptoolsVersion): optional OctoPrint version to match against, if not current
                    base version will be determined via :func:`get_octoprint_version`.

    Returns:
            (bool) ``True`` if any of the provided compatibility entries matches or there are no entries, else ``False``
    """

    logger = logging.getLogger(__name__)

    if not compatibility_entries:
        return True

    octoprint_version = kwargs.get("octoprint_version")
    if octoprint_version is None:
        octoprint_version = get_octoprint_version(base=True)

    for octo_compat in compatibility_entries:
        try:
            if not any(
                octo_compat.startswith(c)
                for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")
            ):
                octo_compat = ">={}".format(octo_compat)

            s = pkg_resources.Requirement.parse("OctoPrint" + octo_compat)
            if octoprint_version in s:
                break
        except Exception:
            logger.exception(
                "Something is wrong with this compatibility string for OctoPrint: {}".format(
                    octo_compat
                )
            )
    else:
        return False

    return True


def get_python_version_string():
    from platform import python_version

    version_string = normalize_version(python_version())

    return version_string


def get_python_version():
    return get_comparable_version(get_python_version_string())


def is_python_compatible(compat, **kwargs):
    if not compat:
        return True

    python_version = kwargs.get("python_version")
    if python_version is None:
        python_version = get_python_version_string()

    s = pkg_resources.Requirement.parse("Python" + compat)
    return python_version in s


def get_comparable_version(version_string, cut=None, **kwargs):
    """
    Args:
        version_string: The version string for which to create a comparable version instance
        cut: optional, how many version digits to remove (e.g., cut=1 will turn 1.2.3 into 1.2).
             Defaults to ``None``, meaning no further action. Settings this to 0 will remove
             anything up to the last digit, e.g. dev or rc information.

    Returns:
        A comparable version
    """

    if "base" in kwargs and kwargs.get("base", False) and cut is None:
        cut = 0
    if cut is not None and (cut < 0 or not isinstance(cut, int)):
        raise ValueError("level must be a positive integer")

    version_string = normalize_version(version_string)
    version = pkg_resources.parse_version(version_string)

    if cut is not None:
        if isinstance(version, tuple):
            # old setuptools
            base_version = []
            for part in version:
                if part.startswith("*"):
                    break
                base_version.append(part)
            if 0 < cut < len(base_version):
                base_version = base_version[:-cut]
            base_version.append("*final")
            version = tuple(base_version)
        else:
            # new setuptools
            version = pkg_resources.parse_version(version.base_version)
            if cut is not None:
                parts = version.base_version.split(".")
                if 0 < cut < len(parts):
                    reduced = parts[:-cut]
                    version = pkg_resources.parse_version(
                        ".".join(str(x) for x in reduced)
                    )

    return version


def is_stable(version):
    """
    >>> import pkg_resources
    >>> is_stable(pkg_resources.parse_version("1.3.6rc3"))
    False
    >>> is_stable(pkg_resources.parse_version("1.3.6rc3.dev2+g1234"))
    False
    >>> is_stable(pkg_resources.parse_version("1.3.6"))
    True
    >>> is_stable(pkg_resources.parse_version("1.3.6.post1+g1234"))
    True
    >>> is_stable(pkg_resources.parse_version("1.3.6.post1.dev0+g1234"))
    False
    >>> is_stable(pkg_resources.parse_version("1.3.7.dev123+g23545"))
    False
    """

    if isinstance(version, basestring):
        version = get_comparable_version(version)

    if not is_release(version):
        return False

    if isinstance(version, tuple):
        return "*a" not in version and "*b" not in version and "*c" not in version
    else:
        return not version.is_prerelease


def is_release(version):
    """
    >>> import pkg_resources
    >>> is_release(pkg_resources.parse_version("1.3.6rc3"))
    True
    >>> is_release(pkg_resources.parse_version("1.3.6rc3.dev2+g1234"))
    False
    >>> is_release(pkg_resources.parse_version("1.3.6"))
    True
    >>> is_release(pkg_resources.parse_version("1.3.6.post1+g1234"))
    True
    >>> is_release(pkg_resources.parse_version("1.3.6.post1.dev0+g1234"))
    False
    >>> is_release(pkg_resources.parse_version("1.3.7.dev123+g23545"))
    False
    """

    if isinstance(version, basestring):
        version = get_comparable_version(version)

    if isinstance(version, tuple):
        # old setuptools
        return "*@" not in version
    else:
        # new setuptools
        return "dev" not in version.public
    pass


def is_prerelease(version):
    if isinstance(version, basestring):
        version = get_comparable_version(version)

    if isinstance(version, tuple):
        # old setuptools
        return any(map(lambda x: x in version, ("*a", "*b", "*c", "*rc")))
    else:
        # new setuptools
        return version.is_prerelease


def normalize_version(version):
    if "-" in version:
        version = version[: version.find("-")]

    # Debian has the python version set to 2.7.15+ which is not PEP440 compliant (bug 914072)
    if version.endswith("+"):
        version = version[:-1]

    if version[0].lower() == "v":
        version = version[1:]

    return version.strip()

src/octoprint/util/version.py
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

"""
This module provides a bunch of utility methods and helpers for version handling.
"""

__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"

import logging

import pkg_resources

from octoprint import __version__


def get_octoprint_version_string():
    return __version__


def get_octoprint_version(cut=None, **kwargs):
    octoprint_version_string = normalize_version(get_octoprint_version_string())
    return get_comparable_version(octoprint_version_string, cut=cut, **kwargs)


def is_released_octoprint_version(version=None):
    """
    >>> import pkg_resources
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.6rc3"))
    True
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.6rc3.dev2+g1234"))
    False
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.6"))
    True
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.6.post1+g1234"))
    True
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.6.post1.dev0+g1234"))
    False
    >>> is_released_octoprint_version(version=pkg_resources.parse_version("1.3.7.dev123+g23545"))
    False
    """

    if version is None:
        version = get_octoprint_version()

    if isinstance(version, tuple):
        # old setuptools
        return "*@" not in version
    else:
        # new setuptools
        return "dev" not in version.public


def is_stable_octoprint_version(version=None):
    """
    >>> import pkg_resources
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.6rc3"))
    False
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.6rc3.dev2+g1234"))
    False
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.6"))
    True
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.6.post1+g1234"))
    True
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.6.post1.dev0+g1234"))
    False
    >>> is_stable_octoprint_version(version=pkg_resources.parse_version("1.3.7.dev123+g23545"))
    False
    """

    if version is None:
        version = get_octoprint_version()

    if not is_released_octoprint_version(version=version):
        return False

    if isinstance(version, tuple):
        return "*a" not in version and "*b" not in version and "*c" not in version
    else:
        return not version.is_prerelease


def is_octoprint_compatible(*compatibility_entries, **kwargs):
    """
    Tests if the current ``octoprint_version`` is compatible to any of the provided ``compatibility_entries``.

    Arguments:
            compatibility_entries (str): compatibility string(s) to test against, result will be `True` if any match
                    is found
            octoprint_version (tuple or SetuptoolsVersion): optional OctoPrint version to match against, if not current
                    base version will be determined via :func:`get_octoprint_version`.

    Returns:
            (bool) ``True`` if any of the provided compatibility entries matches or there are no entries, else ``False``
    """

    logger = logging.getLogger(__name__)

    if not compatibility_entries:
        return True

    octoprint_version = kwargs.get("octoprint_version")
    if octoprint_version is None:
        octoprint_version = get_octoprint_version(base=True)

    for octo_compat in compatibility_entries:
        try:
            if not any(
                octo_compat.startswith(c)
                for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")
            ):
                octo_compat = ">={}".format(octo_compat)

            s = pkg_resources.Requirement.parse("OctoPrint" + octo_compat)
            if octoprint_version in s:
                break
        except Exception:
            logger.exception(
                "Something is wrong with this compatibility string for OctoPrint: {}".format(
                    octo_compat
                )
            )
    else:
        return False

    return True


def get_python_version_string():
    from platform import python_version

    version_string = normalize_version(python_version())

    return version_string


def get_python_version():
    return get_comparable_version(get_python_version_string())


def is_python_compatible(compat, **kwargs):
    if not compat:
        return True

    python_version = kwargs.get("python_version")
    if python_version is None:
        python_version = get_python_version_string()

    s = pkg_resources.Requirement.parse("Python" + compat)
    return python_version in s


def get_comparable_version(version_string, cut=None, **kwargs):
    """
    Args:
        version_string: The version string for which to create a comparable version instance
        cut: optional, how many version digits to remove (e.g., cut=1 will turn 1.2.3 into 1.2).
             Defaults to ``None``, meaning no further action. Settings this to 0 will remove
             anything up to the last digit, e.g. dev or rc information.

    Returns:
        A comparable version
    """

    if "base" in kwargs and kwargs.get("base", False) and cut is None:
        cut = 0
    if cut is not None and (cut < 0 or not isinstance(cut, int)):
        raise ValueError("level must be a positive integer")

    version_string = normalize_version(version_string)
    version = pkg_resources.parse_version(version_string)

    if cut is not None:
        if isinstance(version, tuple):
            # old setuptools
            base_version = []
            for part in version:
                if part.startswith("*"):
                    break
                base_version.append(part)
            if 0 < cut < len(base_version):
                base_version = base_version[:-cut]
            base_version.append("*final")
            version = tuple(base_version)
        else:
            # new setuptools
            version = pkg_resources.parse_version(version.base_version)
            if cut is not None:
                parts = version.base_version.split(".")
                if 0 < cut < len(parts):
                    reduced = parts[:-cut]
                    version = pkg_resources.parse_version(
                        ".".join(str(x) for x in reduced)
                    )

    return version


def is_prerelease(version_string):
    version = get_comparable_version(version_string)

    if isinstance(version, tuple):
        # old setuptools
        return any(map(lambda x: x in version, ("*a", "*b", "*c", "*rc")))
    else:
        # new setuptools
        return version.is_prerelease


def normalize_version(version):

    # Debian has the python version set to 2.7.15+ which is not PEP440 compliant (bug 914072)
    if version.endswith("+"):
        version = version[:-1]

    if version[0].lower() == "v":
        version = version[1:]

    return version.strip()

src/octoprint/util/version.py
diff --git a/src/octoprint/util/version.py b/src/octoprint/util/version.py
index 81a6beed06..ad0bbe08a6 100644
--- a/src/octoprint/util/version.py
+++ b/src/octoprint/util/version.py
@@ -208,8 +208,6 @@ def is_prerelease(version_string):
 
 
 def normalize_version(version):
-    if "-" in version:
-        version = version[: version.find("-")]
 
     # Debian has the python version set to 2.7.15+ which is not PEP440 compliant (bug 914072)
     if version.endswith("+"):
diff --git a/tests/util/test_version.py b/tests/util/test_version.py
index 59df747ea0..0a79756146 100644
--- a/tests/util/test_version.py
+++ b/tests/util/test_version.py
@@ -46,7 +46,6 @@ def test_get_comparable_version_base(self):
         ("V1.6.0", "1.6.0"),
         ("1.6.0+", "1.6.0"),
         ("\t1.6.0  \r\n", "1.6.0"),
-        ("1.6.0-dev123", "1.6.0"),
     )
     @ddt.unpack
     def test_normalize_version(self, version, expected):

GitHub tags are only read up to the first `-` character, breaking tags such as `1.0.0-rc1`
### The problem

The software update plugin does a load of parsing to compare the GitHub tag version to the one from the installed Python packages. I unwrapped some of it to figure out what was going on here.

We have a tag on GitHub with the name `1.0.0-rc1`, which according to [PEP440](https://www.python.org/dev/peps/pep-0440/#pre-release-separators) is allowed but would be normalized to `1.0.0rc1`, which is how it comes through if this tag is set in setup.py.

The plugin runs it through (as far as I could trace) `octoprint.plugins.softwareupdate.version_checks.github_release.get_comparable_version`. This calls some functions in `octoprint.util.version` including `normalize_version`, which only reads the tag up to and including the `-`, stripping it down to just `1.0.0` in this case, which would be seen as 'ahead' of `1.0.0rc1` of course.

Some testing to demonstrate, the first line is the problem one:
```python
>>> get_comparable_version("1.1.1-rc1")
<Version('1.1.1')>
>>> get_comparable_version("1.1.1rc1")
<Version('1.1.1rc1')>
>>> get_comparable_version("1.1.1.rc1")
<Version('1.1.1rc1')>
>>> get_comparable_version("1.1.1_rc1")
<Version('1.1.1rc1')>
```

They should all be parsed equally according to PEP440.

This resulted in an update loop where `1.0.0-rc1` was set the same in both `setup.py` and the GitHub tag, but it became GitHub tag = 1.0.0 and setup.py = 1.0.0rc1 when parsed and the comparison would always show an update available.

**TLDR**; everything after the `-` in a GitHub tag is ignored, when it should be included.

Discussion on discord: https://discord.com/channels/704958479194128507/708230829050036236/928402397435420775

### Did the issue persist even in safe mode?

Yes, it did persist

### If you could not test in safe mode, please state why

NA

### Version of OctoPrint

1.7.2

### Operating system running OctoPrint

All

### Printer model & used firmware incl. version

All

### Browser and version of browser, operating system running browser

All

### Checklist of files to include below

- [X] Systeminfo Bundle (always include!)
- [ ] Contents of the JavaScript browser console (always include in cases of issues with the user interface)
- [ ] Screenshots and/or videos showing the problem (always include in case of issues with the user interface)
- [ ] GCODE file with which to reproduce (always include in case of issues with GCODE analysis or printing behaviour)

### Additional information & file uploads

_No response_
