# encoding: utf-8
"""A base class for objects that are configurable."""

# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.

from __future__ import print_function, absolute_import

from copy import deepcopy
import warnings

from .loader import Config, LazyConfigValue, _is_section_key
from traitlets.traitlets import HasTraits, Instance, observe, observe_compat, default
from ipython_genutils.text import indent, dedent, wrap_paragraphs


#-----------------------------------------------------------------------------
# Helper classes for Configurables
#-----------------------------------------------------------------------------


class ConfigurableError(Exception):
    pass


class MultipleInstanceError(ConfigurableError):
    pass

#-----------------------------------------------------------------------------
# Configurable implementation
#-----------------------------------------------------------------------------

class Configurable(HasTraits):

    config = Instance(Config, (), {})
    parent = Instance('traitlets.config.configurable.Configurable', allow_none=True)

    def __init__(self, **kwargs):
        """Create a configurable given a config config.

        Parameters
        ----------
        config : Config
            If this is empty, default values are used. If config is a
            :class:`Config` instance, it will be used to configure the
            instance.
        parent : Configurable instance, optional
            The parent Configurable instance of this object.

        Notes
        -----
        Subclasses of Configurable must call the :meth:`__init__` method of
        :class:`Configurable` *before* doing anything else and using
        :func:`super`::

            class MyConfigurable(Configurable):
                def __init__(self, config=None):
                    super(MyConfigurable, self).__init__(config=config)
                    # Then any other code you need to finish initialization.

        This ensures that instances will be configured properly.
        """
        parent = kwargs.pop('parent', None)
        if parent is not None:
            # config is implied from parent
            if kwargs.get('config', None) is None:
                kwargs['config'] = parent.config
            self.parent = parent

        config = kwargs.pop('config', None)

        # load kwarg traits, other than config
        super(Configurable, self).__init__(**kwargs)

        # load config
        if config is not None:
            # We used to deepcopy, but for now we are trying to just save
            # by reference.  This *could* have side effects as all components
            # will share config. In fact, I did find such a side effect in
            # _config_changed below. If a config attribute value was a mutable type
            # all instances of a component were getting the same copy, effectively
            # making that a class attribute.
            # self.config = deepcopy(config)
            self.config = config
        else:
            # allow _config_default to return something
            self._load_config(self.config)

        # Ensure explicit kwargs are applied after loading config.
        # This is usually redundant, but ensures config doesn't override
        # explicitly assigned values.
        for key, value in kwargs.items():
            setattr(self, key, value)

    #-------------------------------------------------------------------------
    # Static trait notifiations
    #-------------------------------------------------------------------------

    @classmethod
    def section_names(cls):
        """return section names as a list"""
        return  [c.__name__ for c in reversed(cls.__mro__) if
            issubclass(c, Configurable) and issubclass(cls, c)
        ]

    def _find_my_config(self, cfg):
        """extract my config from a global Config object

        will construct a Config object of only the config values that apply to me
        based on my mro(), as well as those of my parent(s) if they exist.

        If I am Bar and my parent is Foo, and their parent is Tim,
        this will return merge following config sections, in this order::

            [Bar, Foo.bar, Tim.Foo.Bar]

        With the last item being the highest priority.
        """
        cfgs = [cfg]
        if self.parent:
            cfgs.append(self.parent._find_my_config(cfg))
        my_config = Config()
        for c in cfgs:
            for sname in self.section_names():
                # Don't do a blind getattr as that would cause the config to
                # dynamically create the section with name Class.__name__.
                if c._has_section(sname):
                    my_config.merge(c[sname])
        return my_config

    def _load_config(self, cfg, section_names=None, traits=None):
        """load traits from a Config object"""

        if traits is None:
            traits = self.traits(config=True)
        if section_names is None:
            section_names = self.section_names()

        my_config = self._find_my_config(cfg)

        # hold trait notifications until after all config has been loaded
        with self.hold_trait_notifications():
            for name, config_value in my_config.items():
                if name in traits:
                    if isinstance(config_value, LazyConfigValue):
                        # ConfigValue is a wrapper for using append / update on containers
                        # without having to copy the initial value
                        initial = getattr(self, name)
                        config_value = config_value.get_value(initial)
                    # We have to do a deepcopy here if we don't deepcopy the entire
                    # config object. If we don't, a mutable config_value will be
                    # shared by all instances, effectively making it a class attribute.
                    setattr(self, name, deepcopy(config_value))
                elif not _is_section_key(name) and not isinstance(config_value, Config):
                    from difflib import get_close_matches
                    if isinstance(self, LoggingConfigurable):
                        warn = self.log.warning
                    else:
                        warn = lambda msg: warnings.warn(msg, stacklevel=9)
                    matches = get_close_matches(name, traits)
                    msg = u"Config option `{option}` not recognized by `{klass}`.".format(
                        option=name, klass=self.__class__.__name__)

                    if len(matches) == 1:
                        msg += u"  Did you mean `{matches}`?".format(matches=matches[0])
                    elif len(matches) >= 1:
                        msg +="  Did you mean one of: `{matches}`?".format(matches=', '.join(sorted(matches)))
                    warn(msg)

    @observe('config')
    @observe_compat
    def _config_changed(self, change):
        """Update all the class traits having ``config=True`` in metadata.

        For any class trait with a ``config`` metadata attribute that is
        ``True``, we update the trait with the value of the corresponding
        config entry.
        """
        # Get all traits with a config metadata entry that is True
        traits = self.traits(config=True)

        # We auto-load config section for this class as well as any parent
        # classes that are Configurable subclasses.  This starts with Configurable
        # and works down the mro loading the config for each section.
        section_names = self.section_names()
        self._load_config(change.new, traits=traits, section_names=section_names)

    def update_config(self, config):
        """Update config and load the new values"""
        # traitlets prior to 4.2 created a copy of self.config in order to trigger change events.
        # Some projects (IPython < 5) relied upon one side effect of this,
        # that self.config prior to update_config was not modified in-place.
        # For backward-compatibility, we must ensure that self.config
        # is a new object and not modified in-place,
        # but config consumers should not rely on this behavior.
        self.config = deepcopy(self.config)
        # load config
        self._load_config(config)
        # merge it into self.config
        self.config.merge(config)
        # TODO: trigger change event if/when dict-update change events take place
        # DO NOT trigger full trait-change

    @classmethod
    def class_get_help(cls, inst=None):
        """Get the help string for this class in ReST format.

        If `inst` is given, it's current trait values will be used in place of
        class defaults.
        """
        assert inst is None or isinstance(inst, cls)
        final_help = []
        final_help.append(u'%s options' % cls.__name__)
        final_help.append(len(final_help[0])*u'-')
        for k, v in sorted(cls.class_traits(config=True).items()):
            help = cls.class_get_trait_help(v, inst)
            final_help.append(help)
        return '\n'.join(final_help)

    @classmethod
    def class_get_trait_help(cls, trait, inst=None):
        """Get the help string for a single trait.

        If `inst` is given, it's current trait values will be used in place of
        the class default.
        """
        assert inst is None or isinstance(inst, cls)
        lines = []
        header = "--%s.%s=<%s>" % (cls.__name__, trait.name, trait.__class__.__name__)
        lines.append(header)
        if inst is not None:
            lines.append(indent('Current: %r' % getattr(inst, trait.name), 4))
        else:
            try:
                dvr = trait.default_value_repr()
            except Exception:
                dvr = None # ignore defaults we can't construct
            if dvr is not None:
                if len(dvr) > 64:
                    dvr = dvr[:61]+'...'
                lines.append(indent('Default: %s' % dvr, 4))
        if 'Enum' in trait.__class__.__name__:
            # include Enum choices
            lines.append(indent('Choices: %r' % (trait.values,)))

        help = trait.help
        if help != '':
            help = '\n'.join(wrap_paragraphs(help, 76))
            lines.append(indent(help, 4))
        return '\n'.join(lines)

    @classmethod
    def class_print_help(cls, inst=None):
        """Get the help string for a single trait and print it."""
        print(cls.class_get_help(inst))

    @classmethod
    def class_config_section(cls):
        """Get the config class config section"""
        def c(s):
            """return a commented, wrapped block."""
            s = '\n\n'.join(wrap_paragraphs(s, 78))

            return '## ' + s.replace('\n', '\n#  ')

        # section header
        breaker = '#' + '-'*78
        parent_classes = ','.join(p.__name__ for p in cls.__bases__)
        s = "# %s(%s) configuration" % (cls.__name__, parent_classes)
        lines = [breaker, s, breaker, '']
        # get the description trait
        desc = cls.class_traits().get('description')
        if desc:
            desc = desc.default_value
        if not desc:
            # no description from trait, use __doc__
            desc = getattr(cls, '__doc__', '')
        if desc:
            lines.append(c(desc))
            lines.append('')

        for name, trait in sorted(cls.class_own_traits(config=True).items()):
            lines.append(c(trait.help))
            lines.append('#c.%s.%s = %s' % (cls.__name__, name, trait.default_value_repr()))
            lines.append('')
        return '\n'.join(lines)

    @classmethod
    def class_config_rst_doc(cls):
        """Generate rST documentation for this class' config options.

        Excludes traits defined on parent classes.
        """
        lines = []
        classname = cls.__name__
        for k, trait in sorted(cls.class_own_traits(config=True).items()):
            ttype = trait.__class__.__name__

            termline = classname + '.' + trait.name

            # Choices or type
            if 'Enum' in ttype:
                # include Enum choices
                termline += ' : ' + '|'.join(repr(x) for x in trait.values)
            else:
                termline += ' : ' + ttype
            lines.append(termline)

            # Default value
            try:
                dvr = trait.default_value_repr()
            except Exception:
                dvr = None # ignore defaults we can't construct
            if dvr is not None:
                if len(dvr) > 64:
                    dvr = dvr[:61]+'...'
                # Double up backslashes, so they get to the rendered docs
                dvr = dvr.replace('\\n', '\\\\n')
                lines.append('    Default: ``%s``' % dvr)
                lines.append('')

            help = trait.help or 'No description'
            lines.append(indent(dedent(help), 4))

            # Blank line
            lines.append('')

        return '\n'.join(lines)



class LoggingConfigurable(Configurable):
    """A parent class for Configurables that log.

    Subclasses have a log trait, and the default behavior
    is to get the logger from the currently running Application.
    """

    log = Instance('logging.Logger')
    @default('log')
    def _log_default(self):
        from traitlets import log
        return log.get_logger()


class SingletonConfigurable(LoggingConfigurable):
    """A configurable that only allows one instance.

    This class is for classes that should only have one instance of itself
    or *any* subclass. To create and retrieve such a class use the
    :meth:`SingletonConfigurable.instance` method.
    """

    _instance = None

    @classmethod
    def _walk_mro(cls):
        """Walk the cls.mro() for parent classes that are also singletons

        For use in instance()
        """

        for subclass in cls.mro():
            if issubclass(cls, subclass) and \
                    issubclass(subclass, SingletonConfigurable) and \
                    subclass != SingletonConfigurable:
                yield subclass

    @classmethod
    def clear_instance(cls):
        """unset _instance for this class and singleton parents.
        """
        if not cls.initialized():
            return
        for subclass in cls._walk_mro():
            if isinstance(subclass._instance, cls):
                # only clear instances that are instances
                # of the calling class
                subclass._instance = None

    @classmethod
    def instance(cls, *args, **kwargs):
        """Returns a global instance of this class.

        This method create a new instance if none have previously been created
        and returns a previously created instance is one already exists.

        The arguments and keyword arguments passed to this method are passed
        on to the :meth:`__init__` method of the class upon instantiation.

        Examples
        --------

        Create a singleton class using instance, and retrieve it::

            >>> from traitlets.config.configurable import SingletonConfigurable
            >>> class Foo(SingletonConfigurable): pass
            >>> foo = Foo.instance()
            >>> foo == Foo.instance()
            True

        Create a subclass that is retrived using the base class instance::

            >>> class Bar(SingletonConfigurable): pass
            >>> class Bam(Bar): pass
            >>> bam = Bam.instance()
            >>> bam == Bar.instance()
            True
        """
        # Create and save the instance
        if cls._instance is None:
            inst = cls(*args, **kwargs)
            # Now make sure that the instance will also be returned by
            # parent classes' _instance attribute.
            for subclass in cls._walk_mro():
                subclass._instance = inst

        if isinstance(cls._instance, cls):
            return cls._instance
        else:
            raise MultipleInstanceError(
                'Multiple incompatible subclass instances of '
                '%s are being created.' % cls.__name__
            )

    @classmethod
    def initialized(cls):
        """Has an instance been created?"""
        return hasattr(cls, "_instance") and cls._instance is not None