# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)

import collections.abc
import contextlib
import fnmatch
import functools
import itertools
import os
import re
import sys
import traceback
import types
import typing
import warnings
from datetime import datetime, timedelta
from typing import (
    Any,
    Callable,
    Dict,
    Generic,
    Iterable,
    Iterator,
    List,
    Mapping,
    Optional,
    Tuple,
    TypeVar,
    Union,
)

# Ignore emacs backups when listing modules
ignore_modules = r"^\.#|~$"


def index_by(objects, *funcs):
    """Create a hierarchy of dictionaries by splitting the supplied
    set of objects on unique values of the supplied functions.

    Values are used as keys. For example, suppose you have four
    objects with attributes that look like this::

        a = Spec("boost %gcc target=skylake")
        b = Spec("mrnet %intel target=zen2")
        c = Spec("libelf %xlc target=skylake")
        d = Spec("libdwarf %intel target=zen2")

        list_of_specs = [a,b,c,d]
        index1 = index_by(list_of_specs, lambda s: str(s.target),
                          lambda s: s.compiler)
        index2 = index_by(list_of_specs, lambda s: s.compiler)

    ``index1`` now has two levels of dicts, with lists at the
    leaves, like this::

        { 'zen2'    : { 'gcc' : [a], 'xlc' : [c] },
          'skylake' : { 'intel' : [b, d] }
        }

    And ``index2`` is a single level dictionary of lists that looks
    like this::

        { 'gcc'    : [a],
          'intel'  : [b,d],
          'xlc'    : [c]
        }

    If any elements in funcs is a string, it is treated as the name
    of an attribute, and acts like ``getattr(object, name)``.  So
    shorthand for the above two indexes would be::

        index1 = index_by(list_of_specs, 'arch', 'compiler')
        index2 = index_by(list_of_specs, 'compiler')

    You can also index by tuples by passing tuples::

        index1 = index_by(list_of_specs, ('target', 'compiler'))

    Keys in the resulting dict will look like ``('gcc', 'skylake')``.

    """
    if not funcs:
        return objects

    f = funcs[0]
    if isinstance(f, str):
        f = lambda x: getattr(x, funcs[0])
    elif isinstance(f, tuple):
        f = lambda x: tuple(getattr(x, p, None) for p in funcs[0])

    result = {}
    for o in objects:
        key = f(o)
        result.setdefault(key, []).append(o)

    for key, objects in result.items():
        result[key] = index_by(objects, *funcs[1:])

    return result


def attr_setdefault(obj, name, value):
    """Like dict.setdefault, but for objects."""
    if not hasattr(obj, name):
        setattr(obj, name, value)
    return getattr(obj, name)


def memoized(func):
    """Decorator that caches the results of a function, storing them in
    an attribute of that function.

    Example::

        @memoized
        def expensive_computation(x):
            # Some expensive computation
            return result
    """
    return functools.lru_cache(maxsize=None)(func)


def list_modules(directory, **kwargs):
    """Lists all of the modules, excluding ``__init__.py``, in a
    particular directory.  Listed packages have no particular
    order."""
    list_directories = kwargs.setdefault("directories", True)

    ignore = re.compile(ignore_modules)

    with os.scandir(directory) as it:
        for entry in it:
            if entry.name == "__init__.py" or entry.name == "__pycache__":
                continue

            if (
                list_directories
                and entry.is_dir()
                and os.path.isfile(os.path.join(entry.path, "__init__.py"))
            ):
                yield entry.name

            elif entry.name.endswith(".py") and entry.is_file() and not ignore.search(entry.name):
                yield entry.name[:-3]  # strip .py


def decorator_with_or_without_args(decorator):
    """Allows a decorator to be used with or without arguments, e.g.::

        # Calls the decorator function some args
        @decorator(with, arguments, and=kwargs)

    or::

        # Calls the decorator function with zero arguments
        @decorator

    """

    # See https://stackoverflow.com/questions/653368 for more on this
    @functools.wraps(decorator)
    def new_dec(*args, **kwargs):
        if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
            # actual decorated function
            return decorator(args[0])
        else:
            # decorator arguments
            return lambda realf: decorator(realf, *args, **kwargs)

    return new_dec


def key_ordering(cls):
    """Decorates a class with extra methods that implement rich comparison
    operations and ``__hash__``.  The decorator assumes that the class
    implements a function called ``_cmp_key()``.  The rich comparison
    operations will compare objects using this key, and the ``__hash__``
    function will return the hash of this key.

    If a class already has ``__eq__``, ``__ne__``, ``__lt__``, ``__le__``,
    ``__gt__``, or ``__ge__`` defined, this decorator will overwrite them.

    Raises:
        TypeError: If the class does not have a ``_cmp_key`` method
    """

    def setter(name, value):
        value.__name__ = name
        setattr(cls, name, value)

    if not hasattr(cls, "_cmp_key"):
        raise TypeError(f"'{cls.__name__}' doesn't define _cmp_key().")

    setter("__eq__", lambda s, o: (s is o) or (o is not None and s._cmp_key() == o._cmp_key()))
    setter("__lt__", lambda s, o: o is not None and s._cmp_key() < o._cmp_key())
    setter("__le__", lambda s, o: o is not None and s._cmp_key() <= o._cmp_key())

    setter("__ne__", lambda s, o: (s is not o) and (o is None or s._cmp_key() != o._cmp_key()))
    setter("__gt__", lambda s, o: o is None or s._cmp_key() > o._cmp_key())
    setter("__ge__", lambda s, o: o is None or s._cmp_key() >= o._cmp_key())

    setter("__hash__", lambda self: hash(self._cmp_key()))

    return cls


#: sentinel for testing that iterators are done in lazy_lexicographic_ordering
done = object()


def tuplify(seq):
    """Helper for lazy_lexicographic_ordering()."""
    return tuple((tuplify(x) if callable(x) else x) for x in seq())


def lazy_eq(lseq, rseq):
    """Equality comparison for two lazily generated sequences.

    See ``lazy_lexicographic_ordering``.
    """
    liter = lseq()  # call generators
    riter = rseq()

    # zip_longest is implemented in native code, so use it for speed.
    # use zip_longest instead of zip because it allows us to tell
    # which iterator was longer.
    for left, right in itertools.zip_longest(liter, riter, fillvalue=done):
        if (left is done) or (right is done):
            return False

        # recursively enumerate any generators, otherwise compare
        equal = lazy_eq(left, right) if callable(left) else left == right
        if not equal:
            return False

    return True


def lazy_lt(lseq, rseq):
    """Less-than comparison for two lazily generated sequences.

    See ``lazy_lexicographic_ordering``.
    """
    liter = lseq()
    riter = rseq()

    for left, right in itertools.zip_longest(liter, riter, fillvalue=done):
        if (left is done) or (right is done):
            return left is done  # left was shorter than right

        sequence = callable(left)
        equal = lazy_eq(left, right) if sequence else left == right
        if equal:
            continue

        if sequence:
            return lazy_lt(left, right)
        if left is None:
            return True
        if right is None:
            return False

        return left < right

    return False  # if equal, return False


@decorator_with_or_without_args
def lazy_lexicographic_ordering(cls, set_hash=True):
    """Decorates a class with extra methods that implement rich comparison.

    This is a lazy version of the tuple comparison used frequently to
    implement comparison in Python. Given some objects with fields, you
    might use tuple keys to implement comparison, e.g.:

    .. code-block:: python

        class Widget:
            def _cmp_key(self):
                return (self.a, self.b, (self.c, self.d), self.e)

            def __eq__(self, other):
                return self._cmp_key() == other._cmp_key()

            def __lt__(self):
                return self._cmp_key() < other._cmp_key()

            # etc.

    Python would compare ``Widgets`` lexicographically based on their
    tuples. The issue there for simple comparators is that we have to
    build the tuples *and* we have to generate all the values in them up
    front. When implementing comparisons for large data structures, this
    can be costly.

    Lazy lexicographic comparison maps the tuple comparison shown above
    to generator functions. Instead of comparing based on pre-constructed
    tuple keys, users of this decorator can compare using elements from a
    generator. So, you'd write:

    .. code-block:: python

        @lazy_lexicographic_ordering
        class Widget:
            def _cmp_iter(self):
                yield a
                yield b
                def cd_fun():
                    yield c
                    yield d
                yield cd_fun
                yield e

            # operators are added by decorator

    There are no tuples preconstructed, and the generator does not have
    to complete. Instead of tuples, we simply make functions that lazily
    yield what would've been in the tuple. The
    ``@lazy_lexicographic_ordering`` decorator handles the details of
    implementing comparison operators, and the ``Widget`` implementor
    only has to worry about writing ``_cmp_iter``, and making sure the
    elements in it are also comparable.

    In some cases, you may have a fast way to determine whether two
    objects are equal, e.g. the ``is`` function or an already-computed
    cryptographic hash. For this, you can implement your own
    ``_cmp_fast_eq`` function:

    .. code-block:: python

        @lazy_lexicographic_ordering
        class Widget:
            def _cmp_iter(self):
                yield a
                yield b

                def cd_fun():
                    yield c
                    yield d

                yield cd_fun
                yield e

            def _cmp_fast_eq(self, other):
                return self is other or None

    ``_cmp_fast_eq`` should return:

    * ``True`` if ``self`` is equal to ``other``,
    * ``False`` if ``self`` is not equal to ``other``, and
    * ``None`` if it's not known whether they are equal, and the full
      comparison should be done.

    ``lazy_lexicographic_ordering`` uses ``_cmp_fast_eq`` to short-circuit
    the comparison if the answer can be determined quickly. If you do not
    implement it, it defaults to ``self is other or None``.

    Some things to note:

    * If a class already has ``__eq__``, ``__ne__``, ``__lt__``,
      ``__le__``, ``__gt__``, ``__ge__``, or ``__hash__`` defined, this
      decorator will overwrite them.

    * If ``set_hash`` is ``False``, this will not overwrite
      ``__hash__``.

    * This class uses Python 2 None-comparison semantics. If you yield
      None and it is compared to a non-None type, None will always be
      less than the other object.

    Raises:
        TypeError: If the class does not have a ``_cmp_iter`` method

    """
    if not hasattr(cls, "_cmp_iter"):
        raise TypeError(f"'{cls.__name__}' doesn't define _cmp_iter().")

    # get an equal operation that allows us to short-circuit comparison
    # if it's not provided, default to `is`
    _cmp_fast_eq = getattr(cls, "_cmp_fast_eq", lambda x, y: x is y or None)

    # comparison operators are implemented in terms of lazy_eq and lazy_lt
    def eq(self, other):
        fast_eq = _cmp_fast_eq(self, other)
        if fast_eq is not None:
            return fast_eq
        return (other is not None) and lazy_eq(self._cmp_iter, other._cmp_iter)

    def lt(self, other):
        if _cmp_fast_eq(self, other) is True:
            return False
        return (other is not None) and lazy_lt(self._cmp_iter, other._cmp_iter)

    def gt(self, other):
        if _cmp_fast_eq(self, other) is True:
            return False
        return (other is None) or lazy_lt(other._cmp_iter, self._cmp_iter)

    def ne(self, other):
        return not (self == other)

    def le(self, other):
        return not (self > other)

    def ge(self, other):
        return not (self < other)

    def h(self):
        return hash(tuplify(self._cmp_iter))

    def add_func_to_class(name, func):
        """Add a function to a class with a particular name."""
        func.__name__ = name
        setattr(cls, name, func)

    add_func_to_class("__eq__", eq)
    add_func_to_class("__lt__", lt)
    add_func_to_class("__gt__", gt)
    add_func_to_class("__ne__", ne)
    add_func_to_class("__le__", le)
    add_func_to_class("__ge__", ge)
    if set_hash:
        add_func_to_class("__hash__", h)

    return cls


K = TypeVar("K")
V = TypeVar("V")


@lazy_lexicographic_ordering
class HashableMap(typing.MutableMapping[K, V]):
    """This is a hashable, comparable dictionary.  Hash is performed on
    a tuple of the values in the dictionary."""

    __slots__ = ("dict",)

    def __init__(self):
        self.dict: Dict[K, V] = {}

    def __getitem__(self, key: K) -> V:
        return self.dict[key]

    def __setitem__(self, key: K, value: V) -> None:
        self.dict[key] = value

    def __iter__(self) -> Iterator[K]:
        return iter(self.dict)

    def __len__(self) -> int:
        return len(self.dict)

    def __delitem__(self, key: K) -> None:
        del self.dict[key]

    def _cmp_iter(self):
        for _, v in sorted(self.items()):
            yield v


def match_predicate(*args):
    """Utility function for making string matching predicates.

    Each arg can be a:
    * regex
    * list or tuple of regexes
    * predicate that takes a string.

    This returns a predicate that is true if:
    * any arg regex matches
    * any regex in a list or tuple of regexes matches.
    * any predicate in args matches.
    """

    def match(string):
        for arg in args:
            if isinstance(arg, str):
                if re.search(arg, string):
                    return True
            elif isinstance(arg, list) or isinstance(arg, tuple):
                if any(re.search(i, string) for i in arg):
                    return True
            elif callable(arg):
                if arg(string):
                    return True
            else:
                raise ValueError(
                    "args to match_predicate must be regex, " "list of regexes, or callable."
                )
        return False

    return match


def dedupe(sequence, key=None):
    """Yields a stable de-duplication of an hashable sequence by key

    Args:
        sequence: hashable sequence to be de-duplicated
        key: callable applied on values before uniqueness test; identity
            by default.

    Returns:
        stable de-duplication of the sequence

    Examples:

        Dedupe a list of integers::

            [x for x in dedupe([1, 2, 1, 3, 2])] == [1, 2, 3]

            [x for x in spack.llnl.util.lang.dedupe([1,-2,1,3,2], key=abs)] == [1, -2, 3]
    """
    seen = set()
    for x in sequence:
        x_key = x if key is None else key(x)
        if x_key not in seen:
            yield x
            seen.add(x_key)


def pretty_date(time: Union[datetime, int], now: Optional[datetime] = None) -> str:
    """Convert a datetime or timestamp to a pretty, relative date.

    Args:
        time: date to print prettily
        now: the date the pretty date is relative to (default is ``datetime.now()``)

    Returns:
        pretty string like "an hour ago", "Yesterday", "3 months ago", "just now", etc.

    Adapted from https://stackoverflow.com/questions/1551382.

    """
    if now is None:
        now = datetime.now()

    if type(time) is int:
        diff = now - datetime.fromtimestamp(time)
    elif isinstance(time, datetime):
        diff = now - time
    else:
        raise ValueError("pretty_date requires a timestamp or datetime")

    second_diff = diff.seconds
    day_diff = diff.days

    if day_diff < 0:
        return ""

    if day_diff == 0:
        if second_diff < 10:
            return "just now"
        if second_diff < 60:
            return f"{second_diff} seconds ago"
        if second_diff < 120:
            return "a minute ago"
        if second_diff < 3600:
            return f"{second_diff // 60} minutes ago"
        if second_diff < 7200:
            return "an hour ago"
        if second_diff < 86400:
            return f"{second_diff // 3600} hours ago"
    if day_diff == 1:
        return "yesterday"
    if day_diff < 7:
        return f"{day_diff} days ago"
    if day_diff < 28:
        weeks = day_diff // 7
        if weeks == 1:
            return "a week ago"
        else:
            return f"{day_diff // 7} weeks ago"
    if day_diff < 365:
        months = day_diff // 30
        if months == 1:
            return "a month ago"
        elif months == 12:
            months -= 1
        return f"{months} months ago"

    year_diff = day_diff // 365
    if year_diff == 1:
        return "a year ago"
    return f"{year_diff} years ago"


def pretty_string_to_date(date_str: str, now: Optional[datetime] = None) -> datetime:
    """Parses a string representing a date and returns a datetime object.

    Args:
        date_str: string representing a date. This string might be
            in different format (like ``YYYY``, ``YYYY-MM``, ``YYYY-MM-DD``,
            ``YYYY-MM-DD HH:MM``, ``YYYY-MM-DD HH:MM:SS``)
            or be a *pretty date* (like ``yesterday`` or ``two months ago``)

    Returns: datetime object corresponding to ``date_str``
    """

    pattern = {}

    now = now or datetime.now()

    # datetime formats
    pattern[re.compile(r"^\d{4}$")] = lambda x: datetime.strptime(x, "%Y")
    pattern[re.compile(r"^\d{4}-\d{2}$")] = lambda x: datetime.strptime(x, "%Y-%m")
    pattern[re.compile(r"^\d{4}-\d{2}-\d{2}$")] = lambda x: datetime.strptime(x, "%Y-%m-%d")
    pattern[re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")] = lambda x: datetime.strptime(
        x, "%Y-%m-%d %H:%M"
    )
    pattern[re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")] = lambda x: datetime.strptime(
        x, "%Y-%m-%d %H:%M:%S"
    )

    pretty_regex = re.compile(r"(a|\d+)\s*(year|month|week|day|hour|minute|second)s?\s*ago")

    def _n_xxx_ago(x):
        how_many, time_period = pretty_regex.search(x).groups()

        how_many = 1 if how_many == "a" else int(how_many)

        # timedelta natively supports time periods up to 'weeks'.
        # To apply month or year we convert to 30 and 365 days
        if time_period == "month":
            how_many *= 30
            time_period = "day"
        elif time_period == "year":
            how_many *= 365
            time_period = "day"

        kwargs = {(time_period + "s"): how_many}
        return now - timedelta(**kwargs)

    pattern[pretty_regex] = _n_xxx_ago

    # yesterday
    callback = lambda x: now - timedelta(days=1)
    pattern[re.compile("^yesterday$")] = callback

    for regexp, parser in pattern.items():
        if bool(regexp.match(date_str)):
            return parser(date_str)

    raise ValueError(f'date "{date_str}" does not match any valid format')


def pretty_seconds_formatter(seconds):
    if seconds >= 1:
        multiplier, unit = 1, "s"
    elif seconds >= 1e-3:
        multiplier, unit = 1e3, "ms"
    elif seconds >= 1e-6:
        multiplier, unit = 1e6, "us"
    else:
        multiplier, unit = 1e9, "ns"
    return lambda s: "%.3f%s" % (multiplier * s, unit)


def pretty_seconds(seconds):
    """Seconds to string with appropriate units

    Arguments:
        seconds (float): Number of seconds

    Returns:
        str: Time string with units
    """
    return pretty_seconds_formatter(seconds)(seconds)


class ObjectWrapper:
    """Base class that wraps an object. Derived classes can add new behavior
    while staying undercover.

    This class is modeled after the stackoverflow answer:
    * http://stackoverflow.com/a/1445289/771663
    """

    def __init__(self, wrapped_object):
        wrapped_cls = type(wrapped_object)
        wrapped_name = wrapped_cls.__name__

        # If the wrapped object is already an ObjectWrapper, or a derived class
        # of it, adding type(self) in front of type(wrapped_object)
        # results in an inconsistent MRO.
        #
        # TODO: the implementation below doesn't account for the case where we
        # TODO: have different base classes of ObjectWrapper, say A and B, and
        # TODO: we want to wrap an instance of A with B.
        if type(self) not in wrapped_cls.__mro__:
            self.__class__ = type(wrapped_name, (type(self), wrapped_cls), {})
        else:
            self.__class__ = type(wrapped_name, (wrapped_cls,), {})

        self.__dict__ = wrapped_object.__dict__


class Singleton:
    """Wrapper for lazily initialized singleton objects."""

    def __init__(self, factory: Callable[[], object]):
        """Create a new singleton to be inited with the factory function.

        Most factories will simply create the object to be initialized and
        return it.

        In some cases, e.g. when bootstrapping some global state, the singleton
        may need to be initialized incrementally. If the factory returns a generator
        instead of a regular object, the singleton will assign each result yielded by
        the generator to the singleton instance. This allows methods called by
        the factory in later stages to refer back to the singleton.

        Args:
            factory (function): function taking no arguments that creates the
                singleton instance.

        """
        self.factory = factory
        self._instance = None

    @property
    def instance(self):
        if self._instance is None:
            instance = self.factory()

            if isinstance(instance, types.GeneratorType):
                # if it's a generator, assign every value
                for value in instance:
                    self._instance = value
            else:
                # if not, just assign the result like a normal singleton
                self._instance = instance

        return self._instance

    def __getattr__(self, name):
        # When unpickling Singleton objects, the 'instance' attribute may be
        # requested but not yet set. The final 'getattr' line here requires
        # 'instance'/'_instance' to be defined or it will enter an infinite
        # loop, so protect against that here.
        if name in ["_instance", "instance"]:
            raise AttributeError(f"cannot create {name}")
        return getattr(self.instance, name)

    def __getitem__(self, name):
        return self.instance[name]

    def __contains__(self, element):
        return element in self.instance

    def __call__(self, *args, **kwargs):
        return self.instance(*args, **kwargs)

    def __iter__(self):
        return iter(self.instance)

    def __str__(self):
        return str(self.instance)

    def __repr__(self):
        return repr(self.instance)


def get_entry_points(*, group: str):
    """Wrapper for ``importlib.metadata.entry_points``

    Args:
        group: entry points to select

    Returns:
        EntryPoints for ``group`` or empty list if unsupported
    """

    try:
        import importlib.metadata  # type: ignore  # novermin
    except ImportError:
        return []

    try:
        return importlib.metadata.entry_points(group=group)
    except TypeError:
        # Prior to Python 3.10, entry_points accepted no parameters and always
        # returned a dictionary of entry points, keyed by group.  See
        # https://docs.python.org/3/library/importlib.metadata.html#entry-points
        return importlib.metadata.entry_points().get(group, [])


def load_module_from_file(module_name, module_path):
    """Loads a python module from the path of the corresponding file.

    If the module is already in ``sys.modules`` it will be returned as
    is and not reloaded.

    Args:
        module_name (str): namespace where the python module will be loaded,
            e.g. ``foo.bar``
        module_path (str): path of the python file containing the module

    Returns:
        A valid module object

    Raises:
        ImportError: when the module can't be loaded
        FileNotFoundError: when module_path doesn't exist
    """
    import importlib.util

    if module_name in sys.modules:
        return sys.modules[module_name]

    # This recipe is adapted from https://stackoverflow.com/a/67692/771663

    spec = importlib.util.spec_from_file_location(module_name, module_path)
    module = importlib.util.module_from_spec(spec)
    # The module object needs to exist in sys.modules before the
    # loader executes the module code.
    #
    # See https://docs.python.org/3/reference/import.html#loading
    sys.modules[spec.name] = module
    try:
        spec.loader.exec_module(module)
    except BaseException:
        try:
            del sys.modules[spec.name]
        except KeyError:
            pass
        raise
    return module


def uniq(sequence):
    """Remove strings of duplicate elements from a list.

    This works like the command-line ``uniq`` tool.  It filters strings
    of duplicate elements in a list. Adjacent matching elements are
    merged into the first occurrence.

    For example::

        uniq([1, 1, 1, 1, 2, 2, 2, 3, 3]) == [1, 2, 3]
        uniq([1, 1, 1, 1, 2, 2, 2, 1, 1]) == [1, 2, 1]

    """
    if not sequence:
        return []

    uniq_list = [sequence[0]]
    last = sequence[0]
    for element in sequence[1:]:
        if element != last:
            uniq_list.append(element)
            last = element
    return uniq_list


def elide_list(line_list: List[str], max_num: int = 10) -> List[str]:
    """Takes a long list and limits it to a smaller number of elements,
    replacing intervening elements with ``"..."``.  For example::

        elide_list(["1", "2", "3", "4", "5", "6"], 4)

    gives::

        ["1", "2", "3", "...", "6"]
    """
    if len(line_list) > max_num:
        return [*line_list[: max_num - 1], "...", line_list[-1]]
    return line_list


if sys.version_info >= (3, 9):
    PatternStr = re.Pattern[str]
    PatternBytes = re.Pattern[bytes]
else:
    PatternStr = typing.Pattern[str]
    PatternBytes = typing.Pattern[bytes]


def fnmatch_translate_multiple(named_patterns: Dict[str, str]) -> str:
    """Similar to ``fnmatch.translate``, but takes an ordered dictionary where keys are pattern
    names, and values are filename patterns. The output is a regex that matches any of the
    patterns in order, and named capture groups are used to identify which pattern matched."""
    return "|".join(f"(?P<{n}>{fnmatch.translate(p)})" for n, p in named_patterns.items())


@contextlib.contextmanager
def nullcontext(*args, **kwargs):
    """Empty context manager.
    TODO: replace with contextlib.nullcontext() if we ever require python 3.7.
    """
    yield


class UnhashableArguments(TypeError):
    """Raise when an @memoized function receives unhashable arg or kwarg values."""


T = TypeVar("T")


def stable_partition(
    input_iterable: Iterable[T], predicate_fn: Callable[[T], bool]
) -> Tuple[List[T], List[T]]:
    """Partition the input iterable according to a custom predicate.

    Args:
        input_iterable: input iterable to be partitioned.
        predicate_fn: predicate function accepting an iterable item
            as argument.

    Return:
        Tuple of the list of elements evaluating to True, and
        list of elements evaluating to False.
    """
    true_items: List[T] = []
    false_items: List[T] = []
    for item in input_iterable:
        if predicate_fn(item):
            true_items.append(item)
        else:
            false_items.append(item)
    return true_items, false_items


def ensure_last(lst, *elements):
    """Performs a stable partition of lst, ensuring that ``elements``
    occur at the end of ``lst`` in specified order. Mutates ``lst``.
    Raises ``ValueError`` if any ``elements`` are not already in ``lst``."""
    for elt in elements:
        lst.append(lst.pop(lst.index(elt)))


class Const:
    """Class level constant, raises when trying to set the attribute"""

    __slots__ = ["value"]

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

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        raise TypeError(f"Const value does not support assignment [value={self.value}]")


class TypedMutableSequence(collections.abc.MutableSequence):
    """Base class that behaves like a list, just with a different type.

    Client code can inherit from this base class::

        class Foo(TypedMutableSequence):
            pass

    and later perform checks based on types::

        if isinstance(l, Foo):
            # do something
    """

    def __init__(self, iterable):
        self.data = list(iterable)

    def __getitem__(self, item):
        return self.data[item]

    def __setitem__(self, key, value):
        self.data[key] = value

    def __delitem__(self, key):
        del self.data[key]

    def __len__(self):
        return len(self.data)

    def insert(self, index, item):
        self.data.insert(index, item)

    def __repr__(self):
        return repr(self.data)

    def __str__(self):
        return str(self.data)


class GroupedExceptionHandler:
    """A generic mechanism to coalesce multiple exceptions and preserve tracebacks."""

    def __init__(self):
        self.exceptions: List[Tuple[str, Exception, List[str]]] = []

    def __bool__(self):
        """Whether any exceptions were handled."""
        return bool(self.exceptions)

    def forward(self, context: str, base: type = BaseException) -> "GroupedExceptionForwarder":
        """Return a contextmanager which extracts tracebacks and prefixes a message."""
        return GroupedExceptionForwarder(context, self, base)

    def _receive_forwarded(self, context: str, exc: Exception, tb: List[str]):
        self.exceptions.append((context, exc, tb))

    def grouped_message(self, with_tracebacks: bool = True) -> str:
        """Print out an error message coalescing all the forwarded errors."""
        each_exception_message = [
            "\n\t{0} raised {1}: {2}\n{3}".format(
                context, exc.__class__.__name__, exc, f"\n{''.join(tb)}" if with_tracebacks else ""
            )
            for context, exc, tb in self.exceptions
        ]
        return "due to the following failures:\n{0}".format("\n".join(each_exception_message))


class GroupedExceptionForwarder:
    """A contextmanager to capture exceptions and forward them to a
    GroupedExceptionHandler."""

    def __init__(self, context: str, handler: GroupedExceptionHandler, base: type):
        self._context = context
        self._handler = handler
        self._base = base

    def __enter__(self):
        return None

    def __exit__(self, exc_type, exc_value, tb):
        if exc_value is not None:
            if not issubclass(exc_type, self._base):
                return False
            self._handler._receive_forwarded(self._context, exc_value, traceback.format_tb(tb))

        # Suppress any exception from being re-raised:
        # https://docs.python.org/3/reference/datamodel.html#object.__exit__.
        return True


ClassPropertyType = TypeVar("ClassPropertyType")


class classproperty(Generic[ClassPropertyType]):
    """Non-data descriptor to evaluate a class-level property. The function that performs
    the evaluation is injected at creation time and takes an owner (i.e., the class that
    originated the instance).
    """

    def __init__(self, callback: Callable[[Any], ClassPropertyType]) -> None:
        self.callback = callback
        self.__doc__ = callback.__doc__

    def __get__(self, instance, owner) -> ClassPropertyType:
        return self.callback(owner)


#: A type alias that represents either a classproperty descriptor or a constant value of the same
#: type. This allows derived classes to override a computed class-level property with a constant
#: value while retaining type compatibility.
ClassProperty = Union[ClassPropertyType, classproperty[ClassPropertyType]]


class DeprecatedProperty:
    """Data descriptor to error or warn when a deprecated property is accessed.

    Derived classes must define a factory method to return an adaptor for the deprecated
    property, if the descriptor is not set to error.
    """

    __slots__ = ["name"]

    #: 0 - Nothing
    #: 1 - Warning
    #: 2 - Error
    error_lvl = 0

    def __init__(self, name: str) -> None:
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        if self.error_lvl == 1:
            warnings.warn(
                f"accessing the '{self.name}' property of '{instance}', which is deprecated"
            )
        elif self.error_lvl == 2:
            raise AttributeError(f"cannot access the '{self.name}' attribute of '{instance}'")

        return self.factory(instance, owner)

    def __set__(self, instance, value):
        raise TypeError(
            f"the deprecated property '{self.name}' of '{instance}' does not support assignment"
        )

    def factory(self, instance, owner):
        raise NotImplementedError("must be implemented by derived classes")


KT = TypeVar("KT")
VT = TypeVar("VT")


class PriorityOrderedMapping(Mapping[KT, VT]):
    """Mapping that iterates over key according to an integer priority. If the priority is
    the same for two keys, insertion order is what matters.

    The priority is set when the key/value pair is added. If not set, the highest current priority
    is used.
    """

    _data: Dict[KT, VT]
    _priorities: List[Tuple[int, KT]]

    def __init__(self) -> None:
        self._data = {}
        # Tuple of (priority, key)
        self._priorities = []

    def __getitem__(self, key: KT) -> VT:
        return self._data[key]

    def __len__(self) -> int:
        return len(self._data)

    def __iter__(self):
        yield from (key for _, key in self._priorities)

    def __reversed__(self):
        yield from (key for _, key in reversed(self._priorities))

    def reversed_keys(self):
        """Iterates over keys from the highest priority, to the lowest."""
        return reversed(self)

    def reversed_values(self):
        """Iterates over values from the highest priority, to the lowest."""
        yield from (self._data[key] for _, key in reversed(self._priorities))

    def priority_values(self, priority: int):
        """Iterate over values of a given priority."""
        if not any(p == priority for p, _ in self._priorities):
            raise KeyError(f"No such priority in PriorityOrderedMapping: {priority}")
        yield from (self._data[k] for p, k in self._priorities if p == priority)

    def _highest_priority(self) -> int:
        if not self._priorities:
            return 0
        result, _ = self._priorities[-1]
        return result

    def add(self, key: KT, *, value: VT, priority: Optional[int] = None) -> None:
        """Adds a key/value pair to the mapping, with a specific priority.

        If the priority is None, then it is assumed to be the highest priority value currently
        in the container.

        Raises:
              ValueError: when the same priority is already in the mapping
        """
        if priority is None:
            priority = self._highest_priority()

        if key in self._data:
            self.remove(key)

        self._priorities.append((priority, key))
        # We rely on sort being stable
        self._priorities.sort(key=lambda x: x[0])
        self._data[key] = value
        assert len(self._data) == len(self._priorities)

    def remove(self, key: KT) -> VT:
        """Removes a key from the mapping.

        Returns:
            The value associated with the key being removed

        Raises:
            KeyError: if the key is not in the mapping
        """
        if key not in self._data:
            raise KeyError(f"cannot find {key}")

        popped_item = self._data.pop(key)
        self._priorities = [(p, k) for p, k in self._priorities if k != key]
        assert len(self._data) == len(self._priorities)
        return popped_item
