#!/usr/bin/env python

# ###########################################################################
#
# This file is part of Taurus
#
# http://taurus-scada.org
#
# Copyright 2011 CELLS / ALBA Synchrotron, Bellaterra, Spain
#
# Taurus is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Taurus is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Taurus.  If not, see <http://www.gnu.org/licenses/>.
#
# ###########################################################################

"""This module provides the set of base classes designed to provide
configuration features to the classes that inherit from them
"""


__docformat__ = "restructuredtext"


class ConfigurationError(Exception):
    pass


class configurableProperty(object):
    """A dummy class used to handle properties with the configuration API

    .. warning:: this class is intended for internal use by the configuration
                 package. Do not instantiate it directly in your code.
                 Use :meth:`BaseConfigurableClass.registerConfigProperty`
                 instead.
    """

    def __init__(self, name, fget, fset, obj=None):
        self.name = name
        self.fget = fget  # this may either be a method or a method name
        self.fset = fset  # this may either be a method or a method name
        self._obj = obj  # obj is only needed if fset or fget are method names

    def createConfig(self, allowUnpickable=False):
        """returns value returned by the fget function of this property. the
        allowUnpickable parameter is ignored
        """
        if isinstance(
            self.fget, str
        ):  # fget is not a method but a method name...
            result = getattr(self._obj, self.fget)()
        else:
            result = self.fget()
        return result

    def applyConfig(self, value, depth=-1):
        """calls the fset function for this property with the given value.
        The depth parameter is ignored
        """
        if isinstance(
            self.fget, str
        ):  # fget is not a method but a method name...
            getattr(self._obj, self.fset)(value)
        else:
            self.fset(value)

    def objectName(self):
        """returns the name of this property"""
        return self.name


class BaseConfigurableClass(object):
    """
    A base class defining the API for configurable objects.

    .. note:: One implicit requisite is that a configurable object must
              also provide a `meth:`objectName` method which returns the
              object name. This is typically fulfilled by inheriting from
              QObject.

    Using objects that inherit from :class:`BaseConfigurableClass` automates
    saving and restoring of application settings and also enables the use of
    perspectives in Taurus GUIs.

    The basic idea is that each object/widget in your application is
    responsible for providing a dictionary containing information on its
    properties (see :meth:`createConfig`). The same object/widget is also
    responsible for restoring such properties when provided with a
    configuration dictionary (see :meth:`applyConfig`).

    For a certain property to be saved/restored it is usually enough to
    *register* it using :meth:`registerConfigProperty`. When the objects are
    structured in a hierarchical way (e.g. as the widgets in a Qt application),
    the parent widget can (should) delegate the save/restore of its children to
    the children themselves. This delegation is done by registering the
    children using :meth:`registerConfigDelegate`.

    Consider the following example: I am creating a groupbox container which
    contains a :class:`TaurusForm` and I want to save/restore the state of the
    checkbox and the properties of the form::

        # The class looks like this:
        class MyBox(Qt.QGroupBox, BaseConfigurableClass):
            def __init__(self):
                ...
                self.form = TaurusForm()
                ...
                self.registerConfigProperty(
                    self.isChecked,
                    self.setChecked,
                    'checked'
                )
                # the TaurusForm already handles its own configuration!
                self.registerConfigDelegate(self.form)
                ...

        # and we can retrieve the configuration doing:
        b1 = MyBox()
        # checked is a registered property of MyBox class
        b1.setChecked(True)
        # modifiableByUser is a registered property of a TaurusForm
        b1.form.setModifiableByUser(True)
        # we get the configuration as a dictionary
        cfg = b1.createConfig()
        ...
        b2 = MyBox()
        # now b2 has the same configuration as b1 when cfg was created
        b2.applyConfig(cfg)

    :meth:`createConfig` and :meth:`applyConfig` methods use a dictionary for
    passing the configuration, but :class:`BaseConfigurableClass` also provides
    some other convenience methods for working with files
    (:meth:`saveConfigFile` and :meth:`loadConfigFile`) or as QByteArrays
    (:meth:`createQConfig` and :meth:`applyQConfig`)

    Finally, we recommend to use :class:`TaurusMainWindow` for all Taurus GUIs
    since it automates all the steps for *saving properties when closing* and
    *restoring the settings on startup*. It also provides a mechanism for
    implementing "perspectives" in your application.
    """

    defaultConfigRecursionDepth = -1
    # the latest element of this list is considered the current version
    _supportedConfigVersions = tuple()

    def __init__(self, **kwargs):
        self.resetConfigurableItems()

    @staticmethod
    def isTaurusConfig(x):
        """Checks if the given argument has the structure of a configdict

        :param x: object to test
        :type x: object
        :return: True if it is a configdict, False otherwise.
        :rtype: bool
        """
        if not isinstance(x, dict):
            return False
        for k in (
            "__orderedConfigNames__",
            "__itemConfigurations__",
            "ConfigVersion",
            "__pickable__",
        ):
            if k not in x:
                return False

        for k in x["__orderedConfigNames__"]:
            if k not in x["__itemConfigurations__"]:
                print('missing configuration for "%s" in %s' % (k, repr(x)))
        return True

    def createConfig(self, allowUnpickable=False):
        """
        Returns a dictionary containing configuration information about the
        current state of the object.

        In most usual situations, using :meth:`registerConfigProperty` and
        :meth:`registerConfigDelegate`, should be enough to cover all needs
        using this method, although it can be reimplemented in children
        classes to support very specific configurations.

        By default, meth:`createQConfig` and meth:`saveConfigFile` call to this
        method for obtaining the data.

        Hint: The following code allows you to serialize the configuration
        dictionary as a string (which you can store as a QSetting, or as a
        Tango Attribute, provided that allowUnpickable==False)::

            import pickle
            s = pickle.dumps(widget.createConfig())

        :param alllowUnpickable: if False the returned dict is guaranteed to be
            a pickable object. This is the default and preferred option because
            it allows the serialization as a string that can be directly stored
            in a QSetting. If True, this limitation is not enforced, which
            allows to use more complex objects as values (but limits its
            persistence).
        :type alllowUnpickable: bool
        :return: configurations (which can be loaded with :meth:`applyConfig`).
        :rtype: dict<str,object>


        .. seealso: :meth:`applyConfig` , :meth:`registerConfigurableItem`,
            meth:`createQConfig`, meth:`saveConfigFile`
        """
        if len(self._supportedConfigVersions) > 0:
            version = self._supportedConfigVersions[-1]
        else:
            version = "{}.0".format(self.__class__.__name__)
        configdict = {
            "ConfigVersion": version,
            "__pickable__": True,
        }
        # store the configurations for all registered configurable items as
        # well
        itemcfgs = {}
        for k, v in self.__configurableItems.items():
            itemcfgs[k] = v.createConfig(allowUnpickable=allowUnpickable)
        configdict["__itemConfigurations__"] = itemcfgs
        configdict["__orderedConfigNames__"] = self.__configurableItemNames
        return configdict

    def applyConfig(self, configdict, depth=None):
        """applies the settings stored in a configdict to the current object.

        In most usual situations, using :meth:`registerConfigProperty` and
        :meth:`registerConfigDelegate`, should be enough to cover all needs
        using this method, although it can be reimplemented in children classes
        to support very specific configurations.

        :param configdict:
        :type configdict: dict
        :param depth: If depth = 0, applyConfig will only be called for this
            object, and not for any other object registered via
            :meth:`registerConfigurableItem`. If depth > 0, applyConfig will be
            called recursively as many times as the depth value. If depth < 0
            (default, see note), no limit is imposed to recursion (i.e., it
            will recurse for as deep as there are registered items).
        :type depth: int


        .. note:: the default recursion depth can be tweaked in derived classes
                  by changing the class property `defaultConfigRecursionDepth`

        .. seealso:: :meth:`createConfig`
        """
        if depth is None:
            depth = self.defaultConfigRecursionDepth
        if not self.checkConfigVersion(configdict):
            raise ConfigurationError("configuration version not supported")
        # delegate restoring the configuration of any registered configurable
        # item
        if depth != 0:
            apply_config_failed = False
            itemcfgs = configdict["__itemConfigurations__"]
            # we use the sorted item names that was stored in the configdict
            for key in configdict["__orderedConfigNames__"]:
                if key in self.__configurableItems:
                    try:
                        self.__configurableItems[key].applyConfig(
                            itemcfgs[key], depth=depth - 1
                        )
                    except Exception:
                        self.warning(
                            "can not apply configuration of {}".format(key),
                            exc_info=True,
                        )
                        apply_config_failed = True

            if apply_config_failed:
                raise ConfigurationError(
                    "can not apply configuration of some of configurable item"
                )

    def getConfigurableItemNames(self):
        """returns an ordered list of the names of currently registered
        configuration items (delegates and properties)

        :return:
        :rtype: list<unicode>
        """
        return self.__configurableItemNames

    def resetConfigurableItems(self):
        """clears the record of configurable items depending of this object

        .. seealso:: :meth:`registerConfigurableItem`
        """
        self.__configurableItemNames = []
        self.__configurableItems = {}

    def registerConfigurableItem(self, item, name=None):
        print(
            (
                "Deprecation WARNING: %s.registerConfigurableItem() "
                + "has been deprecated. Use registerConfigDelegate() instead"
            )
            % repr(self)
        )
        self._registerConfigurableItem(item, name=name)

    def registerConfigDelegate(self, delegate, name=None):
        """
        Registers the given object as a delegate for configuration. Delegates
        are typically other objects inheriting from BaseConfigurableClass (or
        at least they must provide the following methods:

          - `createConfig` (as provided by, e.g., BaseConfigurableClass)
          - `applyConfig` (as provided by, e.g., BaseConfigurableClass)
          - `objectName` (as provided by, e.g., QObject)

        :param delegate: The delegate object to be registered.
        :type delegate: BaseConfigurableClass
        :param name: The name to be used as a key for this item in the
            configuration dictionary. If None given, the object name is used by
            default.
        :type name: str


        .. note:: the registration order will be used when restoring
            configurations

        .. seealso:: :meth:`unregisterConfigurableItem`,
            :meth:`registerConfigProperty`, :meth:`createConfig`
        """
        return self._registerConfigurableItem(delegate, name=name)

    def registerConfigProperty(self, fget, fset, name):
        """
        Registers a certain property to be included in the config dictionary.

        In this context a "property" is some named value that can be obtained
        via a getter method and can be set via a setter method.

        :param fget: method (or name of a method) that gets no arguments and
            returns the value of a property.
        :type fget: method or str
        :param fset: method (or name of a method) that gets as an argument the
            value of a property, and sets it
        :type fset: method or str
        :param name: The name to be used as a key for this property in the
            configuration dictionary
        :type name: str


        .. note:: the registration order will be used when
            restoring configurations

        .. seealso:: :meth:`unregisterConfigurableItem`,
            :meth:`registerConfigDelegate`, :meth:`createConfig`
        """
        if isinstance(fget, str) or isinstance(fset, str):
            import weakref

            obj = weakref.proxy(self)
        else:
            obj = None
        p = configurableProperty(name, fget, fset, obj=obj)
        return self._registerConfigurableItem(p, name=name)

    def _registerConfigurableItem(self, item, name=None):
        """
        Registers the given item as a configurable item which depends of this
        Taurus widget.

        .. note:: This method is not meant to be called directly. Use
                  :meth:`registerConfigProperty`,
                  :meth:`registerConfigDelegate`
                  instead

        Registered items are expected to implement the
        following methods:
          - `createConfig` (as provided by, e.g., BaseConfigurableClass)
          - `applyConfig` (as provided by, e.g., BaseConfigurableClass)
          - `objectName` (as provided by, e.g., QObject)

        :param item: The object that should be registered.
        :type item: object
        :param name: The name to be used as a key for this item in the
            configuration dictionary. If None given, the object name is used by
            default.
        :type name: str


        .. note:: the registration order will be used when restoring
            configurations

        .. seealso:: :meth:`unregisterConfigurableItem`, :meth:`createConfig`
        """
        if name is None:
            name = item.objectName()
        name = str(name)
        if name in self.__configurableItemNames:
            # abort if duplicated names
            raise ValueError(
                (
                    "_registerConfigurableItem: "
                    + 'An object with name "%s" is already registered'
                )
                % name
            )
        self.__configurableItemNames.append(name)
        self.__configurableItems[name] = item

    def unregisterConfigurableItem(self, item, raiseOnError=True):
        """
        unregisters the given item (either a delegate or a property) from the
        configurable items record. It raises an exception if the item is not
        registered

        :param item: The object that should be unregistered. Alternatively, the
            name under which the object was registered can be passed as a
            python string.
        :type item: object or str
        :param raiseOnError: If True (default), it raises a KeyError exception
            if item was not registered. If False, it just logs a debug message
        :type raiseOnError: bool


        .. seealso:: :meth:`registerConfigProperty`,
            :meth:`registerConfigDelegate`

        """
        if isinstance(item, str):
            name = str(item)
        else:
            name = str(item.objectName())
        if (
            name in self.__configurableItemNames
            and name in self.__configurableItems
        ):
            self.__configurableItemNames.remove(name)
            self.__configurableItems.pop(name)
            return True
        elif raiseOnError:
            raise KeyError('"%s" was not registered.' % name)
        else:
            self.debug('"%s" was not registered. Skipping' % name)
            return False

    def checkConfigVersion(
        self, configdict, showDialog=False, supportedVersions=None
    ):
        """Check if the version of configdict is supported. By default, the
        BaseConfigurableClass objects have ["__UNVERSIONED__"] as their list of
        supported versions, so unversioned config dicts will be accepted.

        :param configdict: configuration dictionary to check
        :type configdict: dict
        :param showDialog: whether to show a QtWarning dialog if check failed
            (false by default)
        :type showDialog: bool
        :param supportedVersions: supported version numbers, if None given, the
            versions supported by this widget will be used (i.e., those defined
            in self._supportedConfigVersions)
        :type supportedVersions: sequence<str>, or None
        :return: returns True if the configdict is of the right version
        :rtype: bool
        """
        if supportedVersions is None:
            supportedVersions = self._supportedConfigVersions
        if len(supportedVersions) == 0:
            supportedVersions = ["{}.0".format(self.__class__.__name__)]
        version = configdict.get("ConfigVersion", None)
        if version == "__UNVERSIONED__":
            msg = (
                "Deprecated Config Version __UNVERSIONED__. "
                + "Will be auto-fixed when saving settings."
            )
            try:
                self.deprecated(msg=msg)
            except AttributeError:
                from taurus import deprecated

                msg += " Class using Deprecated Config Version: {}.".format(
                    self.__class__.__name__
                )
                deprecated(msg=msg)
            return True
        if version not in supportedVersions:
            msg = "Unsupported Config Version %s. (Supported: %s)" % (
                version,
                repr(supportedVersions),
            )
            self.warning(msg)
            if showDialog:
                from taurus.external.qt import Qt

                Qt.QMessageBox.warning(
                    self, "Wrong Configuration Version", msg, Qt.QMessageBox.Ok
                )
            return False
        return True

    def createQConfig(self):
        """
        returns the current configuration status encoded as a QByteArray. This
        state can therefore be easily stored using QSettings

        :return: (in the current implementation this is just a pickled
            configdict encoded as a QByteArray
        :rtype: QByteArray


        .. seealso:: :meth:`restoreQConfig`
        """
        from taurus.external.qt import Qt
        import pickle

        configdict = self.createConfig(allowUnpickable=False)
        return Qt.QByteArray(pickle.dumps(configdict))

    def applyQConfig(self, qstate):
        """
        restores the configuration from a qstate generated by
        :meth:`getQState`.

        :param qstate:
        :type qstate: QByteArray


        .. seealso:: :meth:`createQConfig`
        """
        if qstate.isNull():
            return
        import pickle

        configdict = pickle.loads(qstate.data())
        self.applyConfig(configdict)

    def saveConfigFile(self, ofile=None):
        """Stores the current configuration on a file

        :param ofile: file or filename to store the configuration
        :type ofile: file or string
        :return: file name used
        :rtype: str
        """
        import pickle

        if ofile is None:
            from taurus.external.qt import compat

            ofile, _ = compat.getSaveFileName(
                self,
                "Save Configuration",
                "%s.pck" % self.__class__.__name__,
                "Configuration File (*.pck)",
            )
            if not ofile:
                return
        self.info(
            "Saving current settings in '%s'", getattr(ofile, "name", ofile)
        )
        configdict = self.createConfig(allowUnpickable=False)
        if isinstance(ofile, str):
            with open(ofile, "wb") as ofile:
                pickle.dump(configdict, ofile)
        else:
            pickle.dump(configdict, ofile)
        return ofile.name

    def loadConfigFile(self, ifile=None):
        """Reads a file stored by :meth:`saveConfig` and applies the settings

        :param ifile: file or filename from where to read the configuration
        :type ifile: file or string
        :return: file name used
        :rtype: str
        """
        import pickle

        if ifile is None:
            from taurus.external.qt import compat

            ifile, _ = compat.getOpenFileName(
                self, "Load Configuration", "", "Configuration File (*.pck)"
            )
            if not ifile:
                return
        if isinstance(ifile, str):
            with open(ifile, "rb") as ifile:
                configdict = pickle.load(ifile)
        else:
            configdict = pickle.load(ifile)
        self.applyConfig(configdict)
        return ifile.name
