# -*- coding: utf-8 -*-

# --------------------------------------------------------------------
# The MIT License (MIT)
#
# Copyright (c) 2014 Jonathan Labéjof <jonathan.labejof@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# --------------------------------------------------------------------

"""Specification of the class Configurable."""

__all__ = ['Configurable']

from six import string_types
from six.moves import reload_module

from inspect import getargspec, isclass

from traceback import format_exc

from b3j0f.utils.path import lookup
from b3j0f.utils.version import getcallargs
from b3j0f.annotation import PrivateInterceptor, Annotation

from ..model.conf import Configuration, configuration
from ..model.cat import Category, category
from ..model.param import Parameter
from ..driver.file.json import JSONFileConfDriver
from ..driver.file.ini import INIFileConfDriver
from ..driver.file.xml import XMLFileConfDriver
from ..parser.resolver.core import (
    DEFAULT_SAFE, DEFAULT_SCOPE, DEFAULT_BESTEFFORT
)

from random import random


class Configurable(PrivateInterceptor):
    """Handle object configuration from configuration resources such as files.

    Based on a configuration model and configuration drivers, it is able to:

    - be used such as a decorator (see the b3j0f.annotation.Annotation). resources to configure are accessible from the
        ``targets`` property.
    - inject configuration parameters at call time (for function/method calls).
    - preload required modules.
    - be used with several objects.

    Usage :

    .. code-block:: python

        from b3j0f.conf import category
        from b3j0f.conf import Configurable
        from b3j0f.conf getconfigurables, applyconfiguration

        # class configuration
        @Configurable(conf=category('land', Parameter('country', value='fr')))
        class World(object):
            pass

        world = World()
        assert world.country == 'fr'

        # reuse the configuration on several objects
        land = getconfigurables(world)[0]

        @land
        class World2(object):
            pass

        assert World2().country == 'fr'

        land.conf['land']['country'].value = 'en'

        # update all configuration on an object
        applyconfiguration([world])

        assert world.country == 'en'

        # inject configuration in the constructor and on object
        class World3(object):
            def __init__(self, country=None):
                self.safecountry = country

        world3 = World3()
        land(world3)

        assert world3.country == 'en'
        assert world3.safecountry = 'en'

        # inject configuration in function parameters
        @land
        def getcountry(country=None):
            print(country)
            return country

        assert getcountry() == 'en'
    """

    CATEGORY = 'CONFIGURABLE'  #: configuration category name.

    CONF = 'conf'  #: self configuration attribute name.
    PATHS = 'paths'  #: paths attribute name.
    DRIVERS = 'drivers'  #: drivers attribute name.
    FOREIGNS = 'foreigns'  #: not specified params setting attribute name.
    AUTOCONF = 'autoconf'  #: auto conf attribute name.
    SAFE = 'safe'  #: safe attribute name.
    SCOPE = 'scope'  #: scope attribute name.
    BESTEFFORT = 'besteffort'  #: best effort attribute name.
    MODULES = 'modules'  #: modules attribute name.
    CALLPARAMS = 'callparams'  #: call params attribute name.
    KEEPSTATE = 'keepstate'  #: reconfiguration keepstate level.

    EXEC_CTX = '__CONFIGURABLE_EXEC_CTX' #: configurable execution context.

    DEFAULT_FOREIGNS = True  #: default value for setting not specified params.
    # default drivers which are json and ini.
    DEFAULT_DRIVERS = (
        JSONFileConfDriver(), INIFileConfDriver(), XMLFileConfDriver()
    )
    DEFAULT_AUTOCONF = True  #: default value for auto configuration.
    DEFAULT_CALLPARAMS = True  #: default value for callparams.
    DEFAULT_MODULES = None  #: default value for modules.
    DEFAULT_SAFE = DEFAULT_SAFE  #: default value for safe.
    DEFAULT_SCOPE = DEFAULT_SCOPE  #: default value for scope.
    DEFAULT_BESTEFFORT = DEFAULT_BESTEFFORT  #: default value for besteffort.
    DEFAULT_KEEPSTATE = True  #: default keepstate value.

    SUB_CONF_PREFIX = ':'  #: sub conf prefix.

    def __init__(
            self,
            conf=None, paths=None, drivers=DEFAULT_DRIVERS,
            foreigns=DEFAULT_FOREIGNS, autoconf=DEFAULT_AUTOCONF, targets=None,
            keepstate=DEFAULT_KEEPSTATE,
            safe=DEFAULT_SAFE, scope=DEFAULT_SCOPE,
            besteffort=DEFAULT_BESTEFFORT, modules=DEFAULT_MODULES,
            callparams=DEFAULT_CALLPARAMS, logger=None,
            *args, **kwargs
    ):
        """
        :param conf: conf to use at instance level.
        :type conf: Configuration, Category or Parameter.
        :param Iterable paths: paths to parse.
        :param targets: object(s) to reconfigure. Such object may
            implement the methods configure applyconfiguration and configure.
        :param bool keepstate: if True (default), do not instanciate sub objects
            if they already exist.
        :type targets: list or instance.
        :param bool foreigns: if True (default), set parameters not specified by
            this conf but given by conf resources.
        :param ConfDriver(s) drivers: list of drivers to use. Default
            Configurable.DEFAULT_DRIVERS.
        :param list targets: objects to configure.
        :param bool autoconf: if autoconf, configurate this `targets`
            objects as soon as possible. (at targets instanciation or after
            updating this paths/conf/targets).
        :param bool safe: if True (default), expression parser are used in a
            safe context to resolve python object. For example, if safe,
            builtins function such as `open` are not resolvable.
        :param dict scope: expression resolver scope.

        :param bool besteffort: expression resolver best effort flag.
        :param list modules: required modules.
        :param bool callparams: if True (default), use parameters in the
            configured callable function.
        :param bool autoresolve: if True (default), call auto resolve on
            retrieved configurations.
        :param Logger logger: this logger."""

        super(Configurable, self).__init__(*args, **kwargs)

        # init protected attributes
        self._paths = None
        self._conf = None
        self._targets = set()
        self._modules = []

        # init public attributes

        # dirty hack: falsify autoconf in order to avoid auto applyconfiguration
        self.autoconf = False

        self.drivers = drivers
        self.foreigns = foreigns
        self.targets = set() if targets is None else targets
        self.keepstate = keepstate
        self.conf = conf
        self.paths = paths
        self.safe = safe
        self.scope = scope
        self.besteffort = besteffort
        self.modules = modules
        self.callparams = callparams
        self.logger = logger

        # generate an execution context name
        self.exec_ctx = '{0}{1}'.format(Configurable.EXEC_CTX, random())

        self.autoconf = autoconf  # end of dirty hack

        if self.autoconf:
            self.applyconfiguration()

    def _interception(self, joinpoint):

        target = joinpoint.target

        args, kwargs = joinpoint.args, joinpoint.kwargs

        if self.callparams:

            conf = self.getconf()

            args, kwargs = self.getcallparams(
                conf=conf, target=target, args=list(args), kwargs=kwargs,
                exec_ctx=joinpoint.exec_ctx.setdefault(self.exec_ctx, set())
            )

            joinpoint.args = list(args)
            joinpoint.kwargs = kwargs

        result = joinpoint.proceed()

        if self.autoconf:

            targets = [result]

            cls = joinpoint.ctx if isclass(joinpoint.ctx) else target

            if result is None:

                if isclass(cls):
                    # if target is the method __init__
                    if target.__name__ == '__init__':
                        if 'self' in kwargs:
                            targets = [kwargs['self']]

                        else:
                            targets = [args[0]]

            if not isclass(cls) or isinstance(targets[0], cls):
                self.applyconfiguration(targets=targets, callconf=False)

        return result

    def getcallparams(
            self, target, conf=None, args=None, kwargs=None, exec_ctx=None
    ):
        """Get target call parameters.

        :param list args: target call arguments.
        :param dict kwargs: target call keywords.
        :return: args, kwargs
        :rtype: tuple"""

        if args is None:
            args = []

        if kwargs is None:
            kwargs = {}

        if conf is None:
            conf = self.conf

        params = conf.params

        try:
            argspec = getargspec(target)

        except TypeError as tex:
            argspec = None
            callargs = {}

        else:
            try:
                callargs = getcallargs(target, *args, **kwargs)

            except TypeError as tex:
                if tex.args[0].endswith('\'{0}\''.format(argspec.args[0])):
                    args = [None]

                else:
                    raise

                callargs = getcallargs(target, *args, **kwargs)

                args = []

        pnames = set(params)

        for pname in pnames:

            if argspec and pname in argspec.args and (
                    (exec_ctx is not None and pname in exec_ctx)
                    or
                    callargs.get(pname) is None
            ):

                kwargs[pname] = params[pname].value

        if exec_ctx is not None:
            exec_ctx |= pnames

        return args, kwargs

    @property
    def modules(self):
        """Get this required modules."""

        return self._modules

    @modules.setter
    def modules(self, value):
        """Change required modules.

        Reload modules given in the value.

        :param list value: new modules to use."""

        self._modules = value

        if value:
            for module in value:
                reload_module(lookup(module))

    @property
    def conf(self):
        """Get conf with parsers and self property values.

        :rtype: Configuration.
        """

        return self._conf

    @conf.setter
    def conf(self, value):
        """Change of configuration.

        :param value: new configuration to use.
        :type value: Category or Configuration
        """

        self._conf = self._toconf(value)

        if self.autoconf:
            self.applyconfiguration()

    def _toconf(self, conf):
        """Convert input parameter to a Configuration.

        :param conf: configuration to convert to a Configuration object.
        :type conf: Configuration, Category or Parameter.

        :rtype: Configuration"""

        result = conf

        if result is None:
            result = Configuration()

        elif isinstance(result, Category):
            result = configuration(result)

        elif isinstance(result, Parameter):
            result = configuration(category('', result))

        return result

    @property
    def paths(self):
        """Get all type conf files and user files.

        :return: self conf files
        :rtype: tuple
        """

        return tuple(self._paths)

    @paths.setter
    def paths(self, value):
        """Change of paths in adding it in watching list."""

        if value is None:
            value = ()

        elif isinstance(value, string_types):
            value = (value, )

        self._paths = tuple(value)

        if self.autoconf:
            self.applyconfiguration()

    def applyconfiguration(
            self, conf=None, paths=None, drivers=None, logger=None,
            targets=None, scope=None, safe=None, besteffort=None,
            callconf=False, keepstate=None
    ):
        """Apply conf on a destination in those phases:

        1. identify the right driver to use with paths to parse.
        2. for all paths, get conf which matches input conf.
        3. apply parsing rules on path parameters.
        4. fill input conf with resolved parameters.
        5. apply filled conf on targets.

        :param conf: conf from where get conf.
        :type conf: Configuration, Category or Parameter
        :param paths: conf files to parse. If paths is a str, it is
            automatically putted into a list.
        :type paths: list of str
        :param Iterable targets: objects to configure. this targets by default.
        :param dict scope: local variables to use for expression resolution.
        :param bool callconf: if True (False by default), the configuration is
            used in the callable target parameters while calling it.
        :param bool keepstate: if True (default), do not instanciate sub objects
            if they already exist.
        :return: configured targets.
        :rtype: list
        """

        result = []

        if scope is None:
            scope = self.scope

        if safe is None:
            safe = self.safe

        if besteffort is None:
            besteffort = self.besteffort

        if targets is None:
            targets = self.targets

        if logger is None:
            logger = self.logger

        if keepstate is None:
            keepstate = self.keepstate

        conf = self._toconf(conf)

        # get conf from drivers and paths
        conf = self.getconf(
            conf=conf, paths=paths, logger=logger, drivers=drivers
        )
        if conf is not None:
            # resolve all values
            conf.resolve(
                configurable=self,
                scope=scope, safe=safe, besteffort=besteffort
            )
            # configure resolved configuration
            configured = self.configure(
                conf=conf, targets=targets, callconf=callconf,
                keepstate=keepstate
            )

            result += configured

        return result

    def getconf(self, conf=None, paths=None, drivers=None, logger=None):
        """Get a configuration from paths.

        :param conf: conf to update. Default this conf.
        :type conf: Configuration, Category or Parameter
        :param str(s) paths: list of conf files. Default this paths.
        :param Logger logger: logger to use for logging info/error messages.
        :param list drivers: ConfDriver to use. Default this drivers.
        :return: not resolved configuration.
        :rtype: Configuration
        """

        result = None

        conf = self._toconf(conf)

        # start to initialize input params
        if conf is None:
            conf = self.conf.copy()

        else:
            selfconf = self.conf.copy()
            selfconf.update(conf)
            conf = selfconf

        if paths is None:
            paths = self.paths

        if isinstance(paths, string_types):
            paths = [paths]

        if drivers is None:
            drivers = self.drivers

        if logger is None:
            logger = self.logger

        # iterate on all paths
        for path in paths:

            for driver in drivers:  # find the best driver

                rscconf = driver.getconf(
                    path=path, conf=conf, logger=logger
                )

                if rscconf is None:
                    continue

                if result is None:
                    result = rscconf

                else:
                    result.update(rscconf)

            if result is None:
                # if no conf found, display a warning log message
                if logger is not None:
                    logger.warning(
                        'No driver found among {0} for processing {1}'.format(
                            drivers, path
                        )
                    )

        return result

    def configure(
            self, conf=None, targets=None, logger=None, callconf=False,
            keepstate=None
    ):
        """Apply input conf on targets objects.

        Specialization of this method is done in the _configure method.

        :param conf: configuration model to configure. Default is this conf.
        :type conf: Configuration, Category or Parameter
        :param Iterable targets: objects to configure. self targets by default.
        :param Logger logger: specific logger to use.
        :param bool callconf: if True (False by default), the configuration is
            used in the callable target parameters while calling it.
        :param bool keepstate: if True (default), do not instanciate sub objects
            if they already exist.
        :return: configured targets.
        :rtype: list
        :raises: Parameter.Error for any raised exception.
        """

        result = []

        conf = self._toconf(conf)

        if conf is None:
            conf = self.conf

        if targets is None:
            targets = self.targets

        if logger is None:
            logger = self.logger

        if keepstate is None:
            keepstate = self.keepstate

        for target in targets:

            try:
                configured = self._configure(
                    conf=conf, logger=logger, target=target, callconf=callconf,
                    keepstate=keepstate
                )

            except:
                if logger is not None:
                    logger.error(
                        'Error {0} raised while configuring {1}/{2}'.format(
                            format_exc(), self, targets
                        )
                    )

            else:
                result.append(configured)

        return result

    def _configure(
            self, target, conf=None, logger=None, callconf=None, keepstate=None
    ):
        """Configure this class with input conf only if auto_conf or
        configure is true.

        This method should be overriden for specific conf

        :param target: object to configure. self targets by default.
        :param Configuration conf: configuration model to configure. Default is
            this conf.
        :param Logger logger: logger to use.
        :param bool callconf: if True, use conf in target __call__ parameters.
        :param bool keepstate: if True recreate sub objects if they already
            exist.
        """

        result = target

        if conf is None:
            conf = self.conf

        if logger is None:
            logger = self.logger

        if callconf is None:
            callconf = self.callparams

        if keepstate is None:
            keepstate = self.keepstate

        subcats = {}  # store sub configurable categories
        params = []  # self parameters

        sub_conf_prefix = Configurable.SUB_CONF_PREFIX

        for cat in conf.values():  # separate sub params and params
            cname = cat.name

            if cname.startswith(sub_conf_prefix):

                subcnames = cname.split(sub_conf_prefix)

                pname = subcnames[1]
                fcname = cname[1 + len(pname):]

                if not fcname:
                    fcname = str(random())

                fcat = cat.copy(name=fcname)

                if pname in subcats:
                    subcats[pname].append(fcat)

                else:
                    subcats[pname] = [fcat]

            else:
                cparams = cat.params
                params += cparams.values()

        if callconf and callable(target):
            conf = configuration(category('', *params))

            args, kwargs = self.getcallparams(conf=conf, target=target)

            result = target = target(*args, **kwargs)

        for param in params:

            value, pname = param.value, param.name

            if pname in subcats:  # if sub param

                subcallconf = True

                if keepstate and hasattr(target, pname):
                    subcallconf = False
                    value = getattr(target, pname)

                cats = subcats[pname]

                subconf = configuration(*cats)

                value = applyconfiguration(
                    targets=[value], conf=subconf, callconf=subcallconf,
                    keepstate=keepstate
                )[0]

            if param.error:
                continue

            elif self.foreigns or param.local:
                try:
                    setattr(target, pname, value)

                except Exception:
                    if logger is not None:
                        logger.error(
                           'Error while setting {0}({1}) on {2}: {3}'.format(
                            pname, value, target, format_exc()
                        )
                    )

        return result

    def _bind_target(self, target, ctx, *args, **kwargs):

        if callable(target):

            try:
                result = super(Configurable, self)._bind_target(
                    target=target, ctx=ctx, *args, **kwargs
                )

            except AttributeError:
                self.remove_from(target=target, ctx=ctx)
                def __init__(*args, **kwargs):
                    return object.__init__(*args, **kwargs)
                target.__init__ = __init__
                result = super(Configurable, self)._bind_target(
                    target=target, ctx=ctx, *args, **kwargs
                )

        else:
            result = Annotation._bind_target(
                self, target=target, ctx=ctx, *args, **kwargs
            )

        return result


def applyconfiguration(targets, conf=None, *args, **kwargs):
    """Apply configuration on input targets.

    If targets are not annotated by a Configurable, a new one is instanciated.

    :param Iterable targets: targets to configurate.
    :param tuple args: applyconfiguration var args.
    :param dict kwargs: applyconfiguration keywords.
    :return: configured targets.
    :rtype: list
    """

    result = []

    for target in targets:

        configurables = Configurable.get_annotations(target)

        if not configurables:
            configurables = [Configurable()]

        for configurable in configurables:

            configuredtargets = configurable.applyconfiguration(
                targets=[target], conf=conf, *args, **kwargs
            )

            result += configuredtargets

    return result

#: Default Configurable conf.
_CONF = configuration(
    category(
        Configurable.CATEGORY,
        Parameter(
            name=Configurable.DRIVERS, ptype=tuple,
            value=Configurable.DEFAULT_DRIVERS
        ),
        Parameter(
            name=Configurable.PATHS, ptype=tuple,
            value=('b3j0fconf-configurable.conf', )
        ),
        Parameter(
            name=Configurable.SCOPE, ptype=dict,
            value=Configurable.DEFAULT_SCOPE
        ),
        Parameter(
            name=Configurable.SAFE, ptype=bool,
            value=Configurable.DEFAULT_SAFE
        ),
        Parameter(
            name=Configurable.BESTEFFORT, ptype=bool,
            value=Configurable.DEFAULT_BESTEFFORT
        ),
        Parameter(
            name=Configurable.CALLPARAMS, ptype=bool,
            value=Configurable.DEFAULT_CALLPARAMS
        ),
        Parameter(
            name=Configurable.MODULES, ptype=tuple,
            value=Configurable.DEFAULT_MODULES
        ),
        Parameter(
            name=Configurable.AUTOCONF, ptype=bool,
            value=Configurable.DEFAULT_AUTOCONF
        ),
        Parameter(
            name=Configurable.FOREIGNS, ptype=bool,
            value=Configurable.DEFAULT_FOREIGNS
        ),
        Parameter(
            name=Configurable.KEEPSTATE, ptype=bool,
            value=Configurable.DEFAULT_KEEPSTATE
        )
    )
)

# apply the configurable on itself
Configurable(conf=_CONF)(Configurable)
