# Copyright 2019-2020 Portmod Authors
# Distributed under the terms of the GNU General Public License v3

import re
from collections import namedtuple
from typing import AbstractSet, Any, Dict, Optional, Set, TypeVar

"""
Module for handling package atoms

All atom classes defined in this module should be considered read-only.
Modification of an Atom object may have unexpected side-effects
"""

flag_re = r"[A-Za-z0-9][A-Za-z0-9+_-]*"
useflag_re = re.compile(r"^" + flag_re + r"$")
usedep_re = (
    r"(?P<prefix>[!-]?)(?P<flag>"
    + flag_re
    + r")(?P<default>(\(\+\)|\(\-\))?)(?P<suffix>[?=]?)"
)
_usedep_re = re.compile("^" + usedep_re + "$")

op_re = r"(?P<B>(!!))?(?P<OP>([<>]=?|[<>=~]))?"
cat_re = r"((?P<C>[A-Za-z0-9][A-Za-z0-9\-]*)/)?"
ver_re = r"(\d+)((\.\d+)*)([a-z]?)((_(pre|p|beta|alpha|rc)\d*)*)"
rev_re = r"(-(?P<PR>r[0-9]+))?"
repo_re = r"(::(?P<R>[A-Za-z0-9_][A-Za-z0-9_-]*(::installed)?))?"
_atom_re = re.compile(
    op_re
    + cat_re
    + r"(?P<P>(?P<PN>[A-Za-z0-9+_-]+?)(-(?P<PV>"
    + ver_re
    + r"))?)"
    + rev_re
    + repo_re
    + r"(\[(?P<USE>.*)\])?$"
)


class InvalidAtom(Exception):
    "Exception indicating an atom has invalid syntax"


class UnqualifiedAtom(Exception):
    """
    Exception indicating an atom, which was expected to be
    qualified with a category, has no category
    """

    def __init__(self, atom):
        self.atom = atom

    def __str__(self):
        return f"Atom {self.atom} was expected to have a category!"


T = TypeVar("T", bound="Atom")


class Atom(str):
    CP: Optional[str]
    CPN: Optional[str]
    USE: Set[str] = set()
    P: str
    PN: str
    PF: str
    PV: Optional[str]
    PR: Optional[str]
    C: Optional[str]
    R: Optional[str]
    OP: Optional[str]
    BLOCK: bool
    PVR: Optional[str]
    CPF: str

    _CACHE: Dict[str, Any] = {}

    def __init__(self, atom: str):
        if atom in self._CACHE:
            self.__dict__ = self._CACHE[atom]
            return

        match = _atom_re.match(atom)
        if not match:
            raise InvalidAtom("Invalid atom %s. Cannot parse" % (atom))

        if match.group("P") and match.group("C"):
            self.CP = match.group("C") + "/" + match.group("P")
            self.CPN = match.group("C") + "/" + match.group("PN")
        else:
            self.CP = None
            self.CPN = None

        if match.group("USE"):
            self.USE = set(match.group("USE").split(","))
            for x in self.USE:
                m = _usedep_re.match(x)
                if not m:
                    raise InvalidAtom(
                        "Invalid use dependency {} in atom {}".format(atom, x)
                    )

        if match.group("PR"):
            self.PF = match.group("P") + "-" + match.group("PR")
        else:
            self.PF = match.group("P")

        self.P = match.group("P")
        self.PN = match.group("PN")
        self.PV = match.group("PV")
        self.PR = match.group("PR")
        self.C = match.group("C")
        self.R = match.group("R")
        self.OP = match.group("OP")
        self.BLOCK = match.group("B") is not None
        self.PVR = self.PV
        if self.PR:
            self.PVR += "-" + self.PR

        if self.C:
            self.CPF = self.C + "/" + self.PF
        else:
            self.CPF = self.PF

        if self.OP is not None and self.PV is None:
            raise InvalidAtom(
                "Atom %s has a comparison operator but no version!" % (atom)
            )

        self._CACHE[atom] = self.__dict__

    def evaluate_conditionals(self, use: AbstractSet[str]) -> "Atom":
        """
        Create an atom instance with any USE conditionals evaluated.
        @param use: The set of enabled USE flags
        @return: an atom instance with any USE conditionals evaluated
        """
        tokens = set()

        for x in self.USE:
            m = _usedep_re.match(x)

            if m is not None:
                operator = m.group("prefix") + m.group("suffix")
                flag = m.group("flag")
                default = m.group("default")
                if default is None:
                    default = ""

                if operator == "?":
                    if flag in use:
                        tokens.add(flag + default)
                elif operator == "=":
                    if flag in use:
                        tokens.add(flag + default)
                    else:
                        tokens.add("-" + flag + default)
                elif operator == "!=":
                    if flag in use:
                        tokens.add("-" + flag + default)
                    else:
                        tokens.add(flag + default)
                elif operator == "!?":
                    if flag not in use:
                        tokens.add("-" + flag + default)
                else:
                    tokens.add(x)
            else:
                raise Exception("Internal Error when processing atom conditionals")

        atom = Atom(self)
        atom.USE = tokens
        return atom

    def strip_use(self: T) -> T:
        """Returns the equivalent of this atom with the USE dependencies removed"""
        return self.__class__(re.sub(r"\[.*\]", "", str(self)))

    def use(self, *flags: str):
        """returns atom with use flag dependency"""
        return Atom(f'{self}[{",".join(flags)}]')


class QualifiedAtom(Atom):
    """Atoms that include categories"""

    CP: str
    CPN: str
    CPF: str
    C: str

    def __init__(self, atom: str):
        super().__init__(atom)

        if not self.C:
            raise UnqualifiedAtom(atom)


class VAtom(QualifiedAtom):
    """Atoms that include version information"""

    PV: str
    PVR: str

    def __init__(self, atom: str):
        super().__init__(atom)
        assert self.PV


class FQAtom(VAtom):
    """Atoms that include all possible non-optional fields"""

    R: str

    def __init__(self, atom: str):
        super().__init__(atom)
        assert self.R


VersionMatch = namedtuple(
    "VersionMatch", ["version", "numeric", "letter", "suffix", "revision"]
)


def suffix_gt(a_suffix: str, b_suffix: str) -> bool:
    """Returns true iff a_suffix > b_suffix"""
    suffixes = ["alpha", "beta", "pre", "rc", "p"]
    return suffixes.index(a_suffix) > suffixes.index(b_suffix)


def version_gt(version1: str, version2: str) -> bool:
    """
    Version comparision function

    args:
        version1: A version string
        version2: Another version string

    returns:
        True if and only if version1 is greater than version2
    """
    vre = re.compile(
        r"(?P<NUMERIC>[\d\.]+)"
        r"(?P<LETTER>[a-z])?"
        r"(?P<SUFFIX>(_[a-z]+\d*)*)"
        r"(-r(?P<REV>\d+))?"
    )
    match1 = vre.match(version1)
    match2 = vre.match(version2)

    assert match1 is not None
    assert match2 is not None
    v_match1 = VersionMatch(
        version=version1,
        numeric=match1.group("NUMERIC").split("."),
        letter=match1.group("LETTER") or "",
        suffix=match1.group("SUFFIX") or "",
        revision=int(match1.group("REV") or "0"),
    )
    v_match2 = VersionMatch(
        version=version2,
        numeric=match2.group("NUMERIC").split("."),
        letter=match2.group("LETTER") or "",
        suffix=match2.group("SUFFIX") or "",
        revision=int(match2.group("REV") or "0"),
    )

    # Compare numeric components
    for index, val in enumerate(v_match1.numeric):
        if index >= len(v_match2.numeric):
            return True
        if int(val) > int(v_match2.numeric[index]):
            return True
        if int(val) < int(v_match2.numeric[index]):
            return False
    if len(v_match2.numeric) > len(v_match1.numeric):
        return False

    # Compare letter components
    if v_match1.letter > v_match2.letter:
        return True
    if v_match1.letter < v_match2.letter:
        return False

    # Compare suffixes
    if v_match1.suffix:
        a_suffixes = v_match1.suffix.lstrip("_").split("_")
    else:
        a_suffixes = []
    if v_match2.suffix:
        b_suffixes = v_match2.suffix.lstrip("_").split("_")
    else:
        b_suffixes = []
    for a_s, b_s in zip(a_suffixes, b_suffixes):
        asm = re.match(r"(?P<S>[a-z]+)(?P<N>\d+)?", a_s)
        bsm = re.match(r"(?P<S>[a-z]+)(?P<N>\d+)?", b_s)
        assert asm
        assert bsm
        a_suffix = asm.group("S")
        b_suffix = bsm.group("S")
        a_suffix_num = int(asm.group("N") or "0")
        b_suffix_num = int(bsm.group("N") or "0")
        if a_suffix == b_suffix:
            if b_suffix_num > a_suffix_num:
                return False
            if a_suffix_num > b_suffix_num:
                return True
        elif suffix_gt(a_suffix, b_suffix):
            return True
        else:
            return False
    # More suffixes implies an earlier version,
    # except when the suffix is _p
    if len(a_suffixes) > len(b_suffixes):
        if a_suffixes[len(b_suffixes)].startswith("p"):
            return True
        return False
    if len(a_suffixes) < len(b_suffixes):
        if b_suffixes[len(a_suffixes)].startswith("p"):
            return False
        return True

    # Compare revisions
    if v_match1.revision > v_match2.revision:
        return True
    if v_match1.revision < v_match2.revision:
        return False

    # Equal
    return False


def atom_sat(specific: Atom, generic: Atom, *, ignore_name: bool = False) -> bool:
    """
    Determines if a fully qualified atom (can only refer to a single package)
    satisfies a generic atom
    """

    if not ignore_name:
        if specific.PN != generic.PN:
            # Mods must have the same name
            return False

        if generic.C and (generic.C != specific.C):
            # If para defines category, it must match
            return False

    if generic.R and (generic.R != specific.R):
        # If para defines repo, it must match
        return False

    if not generic.OP:
        # Simple atom, either one version or all versions will satisfy

        # Check if version is correct
        if generic.PV and (specific.PV != generic.PV):
            return False

        # Check if revision is correct
        if generic.PR and (specific.PR != generic.PR):
            return False
    elif generic.PV and specific.PV:
        assert generic.PVR and specific.PVR
        equal = specific.PVR == generic.PVR
        verequal = specific.PV == generic.PV
        lessthan = version_gt(generic.PVR, specific.PVR)
        greaterthan = version_gt(specific.PVR, generic.PVR)

        if generic.OP == "=":
            return equal
        if generic.OP == "~":
            return verequal
        if generic.OP == "<":
            return lessthan
        if generic.OP == "<=":
            return equal or lessthan
        if generic.OP == ">":
            return greaterthan
        if generic.OP == ">=":
            return equal or greaterthan

    return True
