#!/usr/bin/env python

# This file is part of colorevo, but was adapted from the Taurus project
#
# 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"""

__all__ = ["configurableProperty", "BaseConfigurableClass"]

__docformat__ = "restructuredtext"


import logging


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.

    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()
        # we get the configuration as a dictionary
        cfg = b1.createConfig()
        # checked is a registered property of MyBox class
        b1.setChecked(True)
        # modifiableByUser is a registered property of a TaurusForm
        b1.form.setModifiableByUser(True)
        ...
        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 = ("__UNVERSIONED__",)

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

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

        :param x: (object) object to test

        :return: (bool) True if it is a configdict, False otherwise.
        """
        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__"]:
                logging.warning('missing configuration for "%s" in %r', k, 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())  #s is a string that can be stored

        :param alllowUnpickable:  (bool) 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).

        :return: (dict<str,object>) configurations (which can be loaded with :meth:`applyConfig`).

        .. seealso: :meth:`applyConfig` , :meth:`registerConfigurableItem`,  meth:`createQConfig`, meth:`saveConfigFile`
        """
        configdict = {
            "ConfigVersion": self._supportedConfigVersions[-1],
            "__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: (dict)
        :param depth: (int)  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).

        .. 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 ValueError("the given configuration is of unsupported version")
        # delegate restoring the configuration of any registered configurable
        # item
        if depth != 0:
            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:
                    self.__configurableItems[key].applyConfig(
                        itemcfgs[key], depth=depth - 1
                    )

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

        :return: (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 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: (BaseConfigurableClass) The delegate object to be
                         registered.
        :param name: (str) 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.

        .. 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 str) method (or name of a method) that gets no
                     arguments and returns the value of a property.
        :param fset: (method or str) method (or name of a method) that gets as
                     an argument the value of a property, and sets it
        :param name: (str) The name to be used as a key for this property in
                     the configuration dictionary

        .. 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
        class.

        .. 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: (object) The object that should be registered.
        :param name: (str) 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.

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

        .. seealso:: :meth:`registerConfigProperty`,
                     :meth:`registerConfigDelegate`
        """
        if isinstance(item, str):
            name = 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:
            logging.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: (dict) configuration dictionary to check
        :param showDialog: (bool) whether to show a QtWarning dialog if check
                           failed (false by default)
        :param supportedVersions: (sequence<str>, or None) supported version
                                  numbers, if None given, the versions
                                  supported by this widget will be used
                                  (i.e., those defined in
                                  self._supportedConfigVersions)

        :return: (bool) returns True if the configdict is of the right version
        """
        if supportedVersions is None:
            supportedVersions = self._supportedConfigVersions
        version = configdict.get("ConfigVersion", "__UNVERSIONED__")
        if version not in supportedVersions:
            msg = "Unsupported Config Version %s. (Supported: %s)" % (
                version,
                repr(supportedVersions),
            )
            logging.warning(msg)
            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: (QByteArray) (in the current implementation this is just a
                 pickled configdict encoded as a QByteArray

        .. seealso:: :meth:`restoreQConfig`
        """
        from PyQt6 import QtCore
        import pickle

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

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

        :param qstate: (QByteArray)

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

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

    def saveConfigFile(self, fname):
        """Stores the current configuration on a file

        :param ofile: (file) filename  or file descriptorto store the
                      configuration

        :return: (str) file name used
        """
        import pickle

        configdict = self.createConfig(allowUnpickable=False)
        logging.info("Saving current settings in '%s'" % fname)

        with open(fname, "wb") as f:
            pickle.dump(configdict, f)
        return fname

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

        :param ifile: (file or string) file or filename from where to read the
                      configuration

        :return: (str) file name used
        """
        import pickle

        with open(fname, "rb") as f:
            configdict = pickle.load(f)
        self.applyConfig(configdict)
        return fname
