import inspect
from traitlets import ClassBasedTraitType, Undefined, warn

# Traitlet's 5.x includes a set of utilities for building
# description strings for objects. Traitlets 5.x does not
# support Python 3.6, but notebook does; instead
# notebook uses traitlets 4.3.x which doesn't have
# this `descriptions` submodule. This chunk in the except
# clause is a copy-and-paste from traitlets 5.0.5.
try:
    from traitlets.utils.descriptions import describe
except ImportError:
    import inspect
    import re
    import types

    def describe(article, value, name=None, verbose=False, capital=False):
        """Return string that describes a value
        Parameters
        ----------
        article : str or None
            A definite or indefinite article. If the article is
            indefinite (i.e. "a" or "an") the appropriate one
            will be infered. Thus, the arguments of ``describe``
            can themselves represent what the resulting string
            will actually look like. If None, then no article
            will be prepended to the result. For non-articled
            description, values that are instances are treated
            definitely, while classes are handled indefinitely.
        value : any
            The value which will be named.
        name : str or None (default: None)
            Only applies when ``article`` is "the" - this
            ``name`` is a definite reference to the value.
            By default one will be infered from the value's
            type and repr methods.
        verbose : bool (default: False)
            Whether the name should be concise or verbose. When
            possible, verbose names include the module, and/or
            class name where an object was defined.
        capital : bool (default: False)
            Whether the first letter of the article should
            be capitalized or not. By default it is not.
        Examples
        --------
        Indefinite description:
        >>> describe("a", object())
        'an object'
        >>> describe("a", object)
        'an object'
        >>> describe("a", type(object))
        'a type'
        Definite description:
        >>> describe("the", object())
        "the object at '0x10741f1b0'"
        >>> describe("the", object)
        "the type 'object'"
        >>> describe("the", type(object))
        "the type 'type'"
        Definitely named description:
        >>> describe("the", object(), "I made")
        'the object I made'
        >>> describe("the", object, "I will use")
        'the object I will use'
        """
        if isinstance(article, str):
            article = article.lower()

        if not inspect.isclass(value):
            typename = type(value).__name__
        else:
            typename = value.__name__
        if verbose:
            typename = _prefix(value) + typename

        if article == "the" or (article is None and not inspect.isclass(value)):
            if name is not None:
                result = "{} {}".format(typename, name)
                if article is not None:
                    return add_article(result, True, capital)
                else:
                    return result
            else:
                tick_wrap = False
                if inspect.isclass(value):
                    name = value.__name__
                elif isinstance(value, types.FunctionType):
                    name = value.__name__
                    tick_wrap = True
                elif isinstance(value, types.MethodType):
                    name = value.__func__.__name__
                    tick_wrap = True
                elif type(value).__repr__ in (object.__repr__, type.__repr__):
                    name = "at '%s'" % hex(id(value))
                    verbose = False
                else:
                    name = repr(value)
                    verbose = False
                if verbose:
                    name = _prefix(value) + name
                if tick_wrap:
                    name = name.join("''")
                return describe(article, value, name=name,
                    verbose=verbose, capital=capital)
        elif article in ("a", "an") or article is None:
            if article is None:
                return typename
            return add_article(typename, False, capital)
        else:
            raise ValueError("The 'article' argument should "
                "be 'the', 'a', 'an', or None not %r" % article)


    def add_article(name, definite=False, capital=False):
        """Returns the string with a prepended article.
        The input does not need to begin with a charater.
        Parameters
        ----------
        definite : bool (default: False)
            Whether the article is definite or not.
            Indefinite articles being 'a' and 'an',
            while 'the' is definite.
        capital : bool (default: False)
            Whether the added article should have
            its first letter capitalized or not.
        """
        if definite:
            result = "the " + name
        else:
            first_letters = re.compile(r'[\W_]+').sub('', name)
            if first_letters[:1].lower() in 'aeiou':
                result = 'an ' + name
            else:
                result = 'a ' + name
        if capital:
            return result[0].upper() + result[1:]
        else:
            return result


class TypeFromClasses(ClassBasedTraitType):
    """A trait whose value must be a subclass of a class in a specified list of classes."""

    def __init__(self, default_value=Undefined, klasses=None, **kwargs):
        """Construct a Type trait
        A Type trait specifies that its values must be subclasses of
        a class in a list of possible classes.
        If only ``default_value`` is given, it is used for the ``klasses`` as
        well. If neither are given, both default to ``object``.
        Parameters
        ----------
        default_value : class, str or None
            The default value must be a subclass of klass.  If an str,
            the str must be a fully specified class name, like 'foo.bar.Bah'.
            The string is resolved into real class, when the parent
            :class:`HasTraits` class is instantiated.
        klasses : list of class, str [ default object ]
            Values of this trait must be a subclass of klass.  The klass
            may be specified in a string like: 'foo.bar.MyClass'.
            The string is resolved into real class, when the parent
            :class:`HasTraits` class is instantiated.
        allow_none : bool [ default False ]
            Indicates whether None is allowed as an assignable value.
        """
        if default_value is Undefined:
            new_default_value = object if (klasses is None) else klasses
        else:
            new_default_value = default_value

        if klasses is None:
            if (default_value is None) or (default_value is Undefined):
                klasses = [object]
            else:
                klasses = [default_value]

        # OneOfType requires a list of klasses to be specified (different than Type).
        if not isinstance(klasses, (list, tuple, set)):
            raise TraitError("`klasses` must be a list of class names (type is str) or classes.")

        for klass in klasses:
            if not (inspect.isclass(klass) or isinstance(klass, str)):
                raise TraitError("A OneOfType trait must specify a list of classes.")

        # Store classes.
        self.klasses = klasses

        super().__init__(new_default_value, **kwargs)

    def subclass_from_klasses(self, value):
        "Check that a given class is a subclasses found in the klasses list."
        return any(issubclass(value, klass) for klass in self.importable_klasses)

    def validate(self, obj, value):
        """Validates that the value is a valid object instance."""
        if isinstance(value, str):
            try:
                value = self._resolve_string(value)
            except ImportError:
                raise TraitError("The '%s' trait of %s instance must be a type, but "
                                 "%r could not be imported" % (self.name, obj, value))
        try:
            if self.subclass_from_klasses(value):
                return value
        except Exception:
            pass

        self.error(obj, value)

    def info(self):
        """Returns a description of the trait."""
        result = "a subclass of "
        for klass in self.klasses:
            if not isinstance(klass, str):
                klass = klass.__module__ + '.' + klass.__name__
            result += f"{klass} or "
        # Strip the last "or"
        result = result.strip(" or ")
        if self.allow_none:
            return result + ' or None'
        return result

    def instance_init(self, obj):
        self._resolve_classes()
        super().instance_init(obj)

    def _resolve_classes(self):
        # Resolve all string names to actual classes.
        self.importable_klasses = []
        for klass in self.klasses:
            if isinstance(klass, str):
                try:
                    klass = self._resolve_string(klass)
                    self.importable_klasses.append(klass)
                except:
                    warn(f"{klass} is not importable. Is it installed?", ImportWarning)
            else:
                self.importable_klasses.append(klass)

        if isinstance(self.default_value, str):
            self.default_value = self._resolve_string(self.default_value)

    def default_value_repr(self):
        value = self.default_value
        if isinstance(value, str):
            return repr(value)
        else:
            return repr(f'{value.__module__}.{value.__name__}')


class InstanceFromClasses(ClassBasedTraitType):
    """A trait whose value must be an instance of a class in a specified list of classes.
    The value can also be an instance of a subclass of the specified classes.
    Subclasses can declare default classes by overriding the klass attribute
    """
    def __init__(self, klasses=None, args=None, kw=None, **kwargs):
        """Construct an Instance trait.
        This trait allows values that are instances of a particular
        class or its subclasses.  Our implementation is quite different
        from that of enthough.traits as we don't allow instances to be used
        for klass and we handle the ``args`` and ``kw`` arguments differently.
        Parameters
        ----------
        klasses : list of classes or class_names (str)
            The class that forms the basis for the trait.  Class names
            can also be specified as strings, like 'foo.bar.Bar'.
        args : tuple
            Positional arguments for generating the default value.
        kw : dict
            Keyword arguments for generating the default value.
        allow_none : bool [ default False ]
            Indicates whether None is allowed as a value.
        Notes
        -----
        If both ``args`` and ``kw`` are None, then the default value is None.
        If ``args`` is a tuple and ``kw`` is a dict, then the default is
        created as ``klass(*args, **kw)``.  If exactly one of ``args`` or ``kw`` is
        None, the None is replaced by ``()`` or ``{}``, respectively.
        """
        # If class
        if klasses is None:
            self.klasses = klasses
        # Verify all elements are either classes or strings.
        elif all(inspect.isclass(k) or isinstance(k, str) for k in klasses):
            self.klasses = klasses
        else:
            raise TraitError('The klasses attribute must be a list of class names or classes'
                                ' not: %r' % klass)

        if (kw is not None) and not isinstance(kw, dict):
            raise TraitError("The 'kw' argument must be a dict or None.")
        if (args is not None) and not isinstance(args, tuple):
            raise TraitError("The 'args' argument must be a tuple or None.")

        self.default_args = args
        self.default_kwargs = kw

        super(InstanceFromClasses, self).__init__(**kwargs)

    def instance_from_importable_klasses(self, value):
        "Check that a given class is a subclasses found in the klasses list."
        return any(isinstance(value, klass) for klass in self.importable_klasses)

    def validate(self, obj, value):
        if self.instance_from_importable_klasses(value):
            return value
        else:
            self.error(obj, value)

    def info(self):
        result = "an instance of "
        for klass in self.klasses:
            if isinstance(klass, str):
                result += klass
            else:
                result += describe("a", klass)
            result += " or "
        result = result.strip(" or ")
        if self.allow_none:
            result += ' or None'
        return result

    def instance_init(self, obj):
        self._resolve_classes()
        super().instance_init(obj)

    def _resolve_classes(self):
        # Resolve all string names to actual classes.
        self.importable_klasses = []
        for klass in self.klasses:
            if isinstance(klass, str):
                try:
                    klass = self._resolve_string(klass)
                    self.importable_klasses.append(klass)
                except:
                    warn(f"{klass} is not importable. Is it installed?", ImportWarning)
            else:
                self.importable_klasses.append(klass)

    def make_dynamic_default(self):
        if (self.default_args is None) and (self.default_kwargs is None):
            return None
        return self.klass(*(self.default_args or ()),
                          **(self.default_kwargs or {}))

    def default_value_repr(self):
        return repr(self.make_dynamic_default())

    def from_string(self, s):
        return _safe_literal_eval(s)
