import collections
import gettext
import importlib
import itertools
import logging
import os
import time


LOGGER = logging.getLogger(__name__)

MINIMUM_CONFIG_PLUGINS = [
    # You also have to provide a plugin providing the interface 'app'
    'openpaperwork_core.archives',
    'openpaperwork_core.cmd.config',
    'openpaperwork_core.cmd.plugins',
    'openpaperwork_core.config',
    'openpaperwork_core.config.automatic_plugin_reset',
    'openpaperwork_core.config.backend.configparser',
    'openpaperwork_core.display.print',
    'openpaperwork_core.frozen',
    'openpaperwork_core.fs.python',
    'openpaperwork_core.logs.archives',
    'openpaperwork_core.logs.print',
    'openpaperwork_core.mainloop.asyncio',
    'openpaperwork_core.paths.xdg',
    'openpaperwork_core.uncaught_exception',
]


RECOMMENDED_PLUGINS = [
    'openpaperwork_core.cmd.chkdeps',
    'openpaperwork_core.external_apps.dbus',
    'openpaperwork_core.external_apps.windows',
    'openpaperwork_core.external_apps.xdg',
    'openpaperwork_core.flatpak',
    'openpaperwork_core.fs.memory',
    'openpaperwork_core.http',
    'openpaperwork_core.i18n.python',
    'openpaperwork_core.l10n.python',
    'openpaperwork_core.perfcheck.log',
    'openpaperwork_core.resources.frozen',
    'openpaperwork_core.resources.setuptools',
    'openpaperwork_core.thread.pool',
    'openpaperwork_core.work_queue.default',
]


def _(s):
    return gettext.dgettext('openpaperwork_core', s)


class DependencyException(Exception):
    """
    Failed to satisfy dependencies.
    """
    pass


class PluginBase(object):
    """
    Indicates all the methods that must be implemented by any plugin
    managed by OpenPaperwork core. Also provides default implementations
    for each method.
    """

    # Priority defines in which order callbacks will be called.
    # Plugins with higher priorities will have their callbacks called first.
    PRIORITY = 0

    # Convenience for the applications: Indicates if users should be able
    # to enable/disable this plugin in the UI.
    USER_VISIBLE = False

    def __init__(self):
        """
        Called as soon as the module is loaded. Should be as minimal as
        possible. Most of the work should be done in `init()`.
        You *must* *not* rely on any dependencies here.
        """
        self.core = None

    def get_interfaces(self):
        """
        Indicates the list of interfaces implemented by this plugin.
        Interface names are arbitrarily defined. Methods provided by each
        interface are arbitrarily defined (and no checks are done).

        Returns a list of string.
        """
        return []

    def get_deps(self):
        """
        Return the dependencies required by this plugin.

        Example:

        .. code-block:: python

          [
            {
              "interface": "some_interface_name",  # required
              "defaults": ['plugin_a', 'plugin_b'],  # required
              "expected_already_satisfied": False,  # optional, default: True
            },
          ]
        """
        return []

    def init(self, core):
        """
        Plugins can initialize whatever they want here. When called, all
        dependencies have been loaded and initialized, so using them is safe.
        Does nothing by default.
        """
        self.core = core


class Core(object):
    """
    Manage plugins and their callbacks.
    """
    def __init__(self, auto_load_dependencies=False):
        """
        `auto_load_dependencies=True` means that missing dependencies will be
        loaded automatically based on the default plugin list provided by
        plugins. This should be only used for testing.
        """
        self.plugins = {}
        self.initialized = False
        self._to_initialize = set()
        self._initialized = set()  # avoid double-init
        self.interfaces = collections.defaultdict(list)
        self.callbacks = collections.defaultdict(list)
        self.auto_load_dependencies = auto_load_dependencies

        self.log_all = bool(os.getenv("CORE_LOG_ALL", 0))
        self.count_limit_per_second = int(os.getenv("CORE_CALL_LIMIT", 0))
        self.counters_last_reset = 0
        self.counters = collections.defaultdict(lambda: 0)

    def load(self, module_name):
        """
        - Load the specified module
        - Instantiate the class 'Plugin()' of this module
        - Register all the methods of this plugin object (except those starting
          by '_' and those from the class PluginBase) as callbacks

        BEWARE of dependency loops !

        Arguments:
            - module_name: name of the Python module to load
        """
        if module_name in self.plugins:
            return self.plugins[module_name]

        LOGGER.info("Loading plugin '%s' ...", module_name)
        try:
            module = importlib.import_module(module_name)
            return self._load_module(module_name, module)
        except Exception as exc:
            LOGGER.error("Failed to load '%s'", module_name, exc_info=exc)
            return None

    def _load_module(self, module_name, module):
        """
        should be called from outside for testing only
        """
        if module_name in self.plugins:
            LOGGER.debug("Module %s already loaded", module_name)
            return self.plugins[module_name]

        self.initialized = False

        plugin = module.Plugin()
        self.plugins[module_name] = plugin

        for interface in plugin.get_interfaces():
            LOGGER.debug("- '%s' provides '%s'", str(type(plugin)), interface)
            self.interfaces[interface].append(plugin)

        self._to_initialize.add(plugin)

        LOGGER.info("Plugin '%s' loaded", module_name)
        return plugin

    def _check_deps(self):
        to_examine = [
            (plugin_name, plugin)
            for (plugin_name, plugin) in self.plugins.items()
        ]

        while len(to_examine) > 0:
            (plugin_name, plugin) = to_examine[0]
            to_examine = to_examine[1:]

            LOGGER.info("Examining dependencies of '%s' ...", plugin_name)

            deps = plugin.get_deps()
            for dep in deps:
                interface = dep['interface']
                if len(self.interfaces[interface]) > 0:
                    LOGGER.debug(
                        "- Interface '%s' already provided by %d plugins",
                        interface, len(self.interfaces[interface])
                    )
                    continue

                defaults = dep['defaults']
                if len(defaults) <= 0:
                    continue

                if (not self.auto_load_dependencies
                        and (
                            'expected_already_satisfied' not in dep
                            or dep['expected_already_satisfied']
                        )):
                    LOGGER.warning(
                        "Plugin '{}' requires interface '{}' but no plugins"
                        " provide this interface (suggested: {}). Plugin '{}'"
                        " will not be initialized.".format(
                            plugin_name, interface, defaults, plugin_name
                        )
                    )
                    plugin = self.plugins.pop(plugin_name)
                    self._to_initialize.remove(plugin)
                    # return False to indicate we actually dropped a plugin
                    # and need to reevaluate all the dependencies again.
                    return False
                else:
                    LOGGER.info(
                        "Loading plugins %s to satisfy dependency."
                        " Required by '%s' for interface '%s'",
                        defaults, type(plugin), interface
                    )
                    for default in defaults:
                        to_examine.append((default, self.load(default)))

        return True

    def _register_plugin(self, plugin):
        for attr_name in dir(plugin):
            if attr_name[0] == "_":
                continue
            if attr_name in dir(PluginBase):  # ignore base methods of plugins
                continue
            callback = getattr(plugin, attr_name)
            if not hasattr(callback, '__call__'):
                continue
            LOGGER.debug("- %s.%s()", str(type(plugin)), attr_name)
            self.callbacks[attr_name].append((
                plugin.PRIORITY, str(type(plugin)), callback
            ))
            self.callbacks[attr_name].sort(reverse=True)

    def _init(self, plugin, stack=list()):
        nb = 0
        if plugin in self._initialized:
            return nb

        if plugin in stack:
            LOGGER.error("Dependency loop:")
            for p in itertools.chain(stack, [plugin]):
                LOGGER.error(
                    "- %s %s depends on %s",
                    p, p.get_interfaces(),
                    [d['interface'] for d in p.get_deps()]
                )
            raise DependencyException("Dependency loop: %s" % str(stack))

        stack.append(plugin)

        self.initialized = True

        deps = plugin.get_deps()
        for dep in deps:
            dep_plugins = self.interfaces[dep['interface']]
            for dep_plugin in dep_plugins:
                nb += self._init(dep_plugin, stack)

        LOGGER.info("Initializing plugin '%s' ...", type(plugin))
        stack.remove(plugin)
        try:
            plugin.init(self)
        except Exception as exc:
            LOGGER.error(
                "Failed to initialized plugin '%s'",
                type(plugin), exc_info=exc
            )
            return nb

        self._register_plugin(plugin)
        nb += 1

        self._initialized.add(plugin)
        return nb

    def init(self):
        """
        - Make sure all the dependencies of all the plugins are satisfied.
        - Call the method init() of each plugin following the dependency
          order (those without dependencies are called first).

        BEWARE of dependency loops !
        """
        LOGGER.info("Initializing all plugins")
        while not self._check_deps():
            pass
        nb = 0
        for plugin in self._to_initialize:
            nb += self._init(plugin)
        self._to_initialize = set()
        LOGGER.info("%d plugins initialized", nb)

    def get_by_name(self, module_name):
        """
        Returns a Plugin instance based on the corresponding module name
        (assuming it has been loaded).

        You shouldn't use this function, except for:
        - unit tests
        - configuration (see cmd.plugins)
        """
        return self.plugins[module_name]

    def get_by_interface(self, interface_name):
        return self.interfaces[interface_name]

    def get_plugins(self):
        """
        You shouldn't use this function, except for:
        - unit tests
        - configuration (see cmd.plugins)
        """
        return dict(self.plugins)

    def _check_call_limit(self, callback_name):
        if self.count_limit_per_second <= 0:
            return
        now = time.time()
        if now - self.counters_last_reset >= 1.0:
            self.counters = collections.defaultdict(lambda: 0)
            self.counters_last_reset = now
        self.counters[callback_name] += 1
        if self.counters[callback_name] >= self.count_limit_per_second:
            raise Exception(
                "Too many calls to '{}' (>= {}) in one second".format(
                    callback_name, self.count_limit_per_second
                )
            )

    def call_all(self, callback_name, *args, **kwargs):
        """
        Call all the methods of all the plugins that have `callback_name`
        as name. Arguments are passed as is. Returned values are dropped
        (use callbacks for return values if required)

        Method call order is defined by the plugin priorities: Plugins with
        a higher priority get their methods called first.

        When we need a return value from callbacks called with `call_all()`,
        we need a way to get the results from all of them. The usual way to do
        that is to instantiate an empty `list` or `set`, and pass it as first
        argument of the callbacks (argument `out`). Callbacks can then
        complete this list or set using `list.append()` or `set.add()`.

        .. uml::

           Caller -> Core: call "func"
           Core -> "Plugin A": plugin.func()
           Core <- "Plugin A": returns "something_a"
           Core -> "Plugin B": plugin.func()
           Core <- "Plugin B": returns "something_b"
           Core -> "Plugin C": plugin.func()
           Core <- "Plugin C": returns "something_c"
           Caller <- Core: returns 3
        """
        if self.log_all:
            print(
                "[{}] call_all({}, args={}, kwargs={})".format(
                    time.time(), callback_name, args, kwargs
                )
            )

        assert \
            self.initialized, \
            "A plugin has been loaded without being initialized." \
            " Call core.init() first"

        self._check_call_limit(callback_name)

        callbacks = self.callbacks[callback_name]
        if len(callbacks) <= 0:
            if callback_name.startswith("on_"):
                # those are 'observer' callback. If nobody is observing,
                # it's usually fine.
                log_method = LOGGER.debug
            else:
                log_method = LOGGER.warning
            log_method("No method '%s' found", callback_name)
            return 0
        for (priority, plugin, callback) in callbacks:
            if self.log_all:
                print(
                    "[{}] call_all({}, args={}, kwargs={}) -> {}:{}".format(
                        time.time(), callback_name, args, kwargs,
                        priority, callback
                    )
                )
            callback(*args, **kwargs)
        return len(callbacks)

    def call_one(self, callback_name, *args, **kwargs):
        """
        Look for a plugin method called `callback_name` and calls it.
        Raises an error if no such method exists. If many exists,
        call one at random. Returns the value return by the callback.

        Method call order is defined by the plugin priorities: Plugins with
        a higher priority get their methods called first.

        .. uml::

           Caller -> Core: call "func"
           Core -> "Plugin A": plugin.func()
           Core <- "Plugin A": returns X
           Caller <- Core: returns X

        You're advised to use `call_all()` or `call_success()` instead
        whenever possible. This method is only provided as convenience for
        when you're fairly sure there should be only one plugin with such
        callback (example: mainloop plugins).
        """
        assert \
            self.initialized, \
            "A plugin has been loaded without being initialized." \
            " Call core.init() first"

        self._check_call_limit(callback_name)
        if self.log_all:
            print(
                "[{}] call_one({}, args={}, kwargs={})".format(
                    time.time(), callback_name, args, kwargs
                )
            )
        callbacks = self.callbacks[callback_name]
        if len(callbacks) <= 0:
            raise IndexError(
                "No method '{}' found !".format(callback_name)
            )
        if self.log_all:
            print(
                "[{}] call_all({}, args={}, kwargs={}) -> {}:{}".format(
                    time.time(), callback_name, args, kwargs,
                    callbacks[0][0], callbacks[0][2]
                )
            )
        return callbacks[0][2](*args, **kwargs)

    def call_success(self, callback_name, *args, **kwargs):
        """
        Call methods of all the plugins that have `callback_name`
        as name until one of them return a value that is not None.
        Arguments are passed as is. First value to be different
        from None is returned. If none of the callbacks returned
        a value different from None or if no callback has the
        specified name, this method will return None.

        Method call order is defined by the plugin priorities: Plugins with
        a higher priority get their methods called first.

        Callbacks should never raise any exception.

        .. uml::

           Caller -> Core: call "func"
           Core -> "Plugin A": plugin.func()
           Core <- "Plugin A": returns None
           Core -> "Plugin B": plugin.func()
           Core <- "Plugin B": returns None
           Core -> "Plugin C": plugin.func()
           Core <- "Plugin C": returns "something"
           Caller <- Core: returns "something"
        """
        assert \
            self.initialized, \
            "A plugin has been loaded without being initialized." \
            " Call core.init() first"

        self._check_call_limit(callback_name)
        if self.log_all:
            print(
                "[{}] call_one({}, args={}, kwargs={})".format(
                    time.time(), callback_name, args, kwargs
                )
            )
        callbacks = self.callbacks[callback_name]
        if len(callbacks) <= 0:
            LOGGER.warning("No method '%s' found", callback_name)
        for (priority, plugin, callback) in callbacks:
            if self.log_all:
                print(
                    "[{}] call_all({}, args={}, kwargs={}) -> {}:{}".format(
                        time.time(), callback_name, args, kwargs,
                        priority, callback
                    )
                )
            try:
                r = callback(*args, **kwargs)
            except Exception:
                LOGGER.error("Callback '%s' failed", str(callback))
                raise
            if r is not None:
                return r
        return None

    def get_deps(self, plugin_name):
        plugin = self.plugins[plugin_name]
        for dep in plugin.get_deps():
            if 'interface' not in dep:
                raise KeyError(
                    "Missing interface in dependency list of plugin"
                    " '{}'".format(plugin_name)
                )
            if 'defaults' not in dep:
                raise KeyError(
                    "Missing default plugins in dependency list of plugin"
                    " '{}' (interface={})".format(
                        plugin_name, dep['interface']
                    )
                )

            yield {
                'interface': dep['interface'],
                'actives': {
                    x.__module__
                    for x in self.get_by_interface(dep['interface'])
                },
                'defaults': set(dep['defaults']),
            }

    def get_active_plugins(self):
        return self.plugins.keys()
