#
# Copyright 2018 Pixar
#
# Licensed under the terms set forth in the LICENSE.txt file available at
# https://openusd.org/license.
#

from __future__ import print_function

import sys
import importlib

from pxr import Tf
from pxr import Plug

from .qt import QtGui, QtWidgets


class DuplicateCommandPlugin(Exception):
    """Exception raised when two command plugins are registered with the same
    name.
    """

    def __init__(self, name):
        super(DuplicateCommandPlugin, self).__init__(
            ("A command plugin with the name '{}' has already been "
            "registered.").format(name))
        self.name = name


class DeferredImport(object):
    """Defers importing a module until one of the target callable objects is
    called for the first time. Note that there is no way to know if a callable
    object exists in the target module or even if the target module exists until
    import time. All objects that are referenced are assumed to exist until
    proven otherwise when they are called (at which point an ImportError is
    raised).

    Example:

    math = DeferredImport("math")

    # You can pull as many callable objects from `math` as desired, even if they
    # don't actually exist in `math`.
    sqrt = math.sqrt
    cos = math.cos
    foo = math.foo # does not exist in the real `math` module

    # The `math` module will only be imported when this next line runs because
    # this is the first invocation of a callable object from `math`.
    cos(0)

    # This will raise an ImportError because `math.foo` doesn't really exist.
    foo(0)
    """

    def __init__(self, moduleName, packageName=None):

        self._moduleName = moduleName
        self._packageName = packageName

        self._module = None

    def __getattr__(self, attr):
        """Returns a function which calls the target function of the module and
        passes along any parameters. The module is lazy-imported when a function
        returned by this method is called for the first time.
        """

        def f(*args, **kwargs):

            if self._module is None:

                # Try to import the target module.
                try:
                    self._module = importlib.import_module(
                        self._moduleName, package=self._packageName)
                except ImportError:
                    raise ImportError(
                        "Failed deferred import: module '{}' not found.".format(
                            self._moduleName))

            # Try to get the target function from the imported module.
            try:
                moduleFunction = getattr(self._module, attr)
            except AttributeError:
                raise ImportError(("Failed deferred import: callable object "
                    " '{}' from module '{}' not found").format(
                        attr, self._moduleName))

            # Module and function loaded successfully. Now we can call the
            # function and pass it the parameters.
            return moduleFunction(*args, **kwargs)

        # Return the deferring function. It will be called at some point after
        # this method returns.
        return f


class PluginContainer(object):
    """A base class for a container which holds some Usdview plugins. Specific
    containers should inherit from this class and define the 'registerPlugins'
    and 'configureView' methods.
    """

    def deferredImport(self, moduleName):
        """Return a DeferredImport object which can be used to lazy load
        functions when they are invoked for the first time.
        """

        return DeferredImport(moduleName, self.__module__)

    def registerPlugins(self, plugRegistry, plugCtx):
        """This method is called after the container is discovered by Usdview,
        and should call 'registerCommandPlugin' one or more times on the
        plugRegistry to add commands to Usdview.
        """

        raise NotImplementedError

    def configureView(self, plugRegistry, plugUIBuilder):
        """This method is called directly after 'registerPlugins' and can be
        used to add menus which invoke a plugin command using the plugUIBuilder.
        """

        raise NotImplementedError

# We load the PluginContainer using libplug so it needs to be a defined Tf.Type.
PluginContainerTfType = Tf.Type.Define(PluginContainer)


class CommandPlugin(object):
    """A Usdview command plugin object. The plugin's `callback` parameter must
    be a callable object which takes a UsdviewApi object as its only parameter.
    """

    def __init__(self, name, displayName, callback, description, usdviewApi):

        self._name = name
        self._displayName = displayName
        self._callback = callback
        self._usdviewApi = usdviewApi
        self._description = description

    @property
    def name(self):
        """Return the command's name."""

        return self._name

    @property
    def displayName(self):
        """Return the command's display name."""

        return self._displayName

    @property
    def description(self):
        """Return the command description."""

        return self._description

    def run(self):
        """Run the command's callback function."""

        self._callback(self._usdviewApi)


class PluginMenu(object):
    """Object which adds Usdview command plugins to a QMenu."""

    def __init__(self, qMenu):

        self._qMenu = qMenu
        self._submenus = dict()

    def addItem(self, commandPlugin, shortcut=None):
        """Add a new command plugin to the menu. Optionally, provide a hotkey/
        shortcut.
        """

        action = self._qMenu.addAction(commandPlugin.displayName,
            lambda: commandPlugin.run())

        action.setToolTip(commandPlugin.description)

        if shortcut is not None:
            action.setShortcut(QtGui.QKeySequence(shortcut))

        return action

    def findOrCreateSubmenu(self, menuName):
        """Get a PluginMenu object for the submenu with the given name. If no
        submenu with the given name exists, it is created.
        """

        if menuName in self._submenus:
            return self._submenus[menuName]
        else:
            subQMenu = self._qMenu.addMenu(menuName)
            subQMenu.setToolTipsVisible(True)
            submenu = PluginMenu(subQMenu)
            self._submenus[menuName] = submenu
            return submenu

    def addSeparator(self):
        """Add a separator to the menu."""

        self._qMenu.addSeparator()


class PluginRegistry(object):
    """Manages all plugins loaded by Usdview."""

    def __init__(self, usdviewApi):

        self._usdviewApi = usdviewApi

        self._commandPlugins = dict()

    def registerCommandPlugin(self, name, displayName, callback,
            description=""):
        """Creates, registers, and returns a new command plugin.

        The plugin's `name` parameter is used to find the plugin from the
        registry later. It is good practice to prepend the plugin container's
        name to the plugin's `name` parameter to avoid duplicate names
        (i.e. "MyPluginContainer.myPluginName"). If a duplicate name is found, a
        DuplicateCommandPlugin exception will be raised.

        The `displayName` parameter is the name displayed to users.

        The plugin's `callback` parameter must be a callable object which takes
        a UsdviewApi object as its only parameter.

        The optional `description` parameter is a short description of what the
        command does which can be displayed to users.
        """

        plugin = CommandPlugin(name, displayName, callback, description,
            self._usdviewApi)
        if name in self._commandPlugins:
            raise DuplicateCommandPlugin(name)

        self._commandPlugins[name] = plugin
        return plugin

    def getCommandPlugin(self, name):
        """Finds and returns a registered command plugin. If no plugin with the
        given name is registered, return None instead.
        """

        return self._commandPlugins.get(name, None)


class PluginUIBuilder(object):
    """Used by plugins to construct UI elements in Usdview."""

    def __init__(self, mainWindow):

        self._mainWindow = mainWindow

        self._menus = dict()

        # Insert built-in menus to allow for plug-ins to add to them rather
        # than creating a new menu with duplicate title.
        for c in self._mainWindow.menuBar().children():
            if isinstance(c, QtWidgets.QMenu):
                # Strip keyboard shortcut characters so that insertion is based
                # on title alone.
                # '&'' is the only meaningful keyboard shortcut character that
                # might be present in the title. "&&" is used to insert the
                # literal '&' character but that's rare enough to not worry
                # about here.
                self._menus[str(c.title()).replace('&', '')] = PluginMenu(c)

    def findOrCreateMenu(self, menuName):
        """Get a PluginMenu object for the menu with the given name. If no menu
        with the given name exists, it is created.
        """

        if menuName in self._menus:
            return self._menus[menuName]
        else:
            qMenu = self._mainWindow.menuBar().addMenu(menuName)
            qMenu.setToolTipsVisible(True)
            menu = PluginMenu(qMenu)
            self._menus[menuName] = menu
            return menu


def loadPlugins(usdviewApi, mainWindow):
    """Find and load all Usdview plugins."""

    # Find all the defined container types using libplug.
    containerTypes = Plug.Registry.GetAllDerivedTypes(
        PluginContainerTfType)

    # Find all plugins and plugin container types through libplug.
    plugins = dict()
    for containerType in containerTypes:
        plugin = Plug.Registry().GetPluginForType(containerType)
        pluginContainerTypes = plugins.setdefault(plugin, [])
        pluginContainerTypes.append(containerType)

    # Load each plugin in alphabetical order by name. For each plugin, load all
    # of its containers in alphabetical order by type name.
    allContainers = []
    for plugin in sorted(plugins.keys(), key=lambda plugin: plugin.name):
        plugin.Load()
        pluginContainerTypes = sorted(
            plugins[plugin], key=lambda containerType: containerType.typeName)
        for containerType in pluginContainerTypes:
            if containerType.pythonClass is None:
                print(("WARNING: Missing plugin container '{}' from plugin "
                    "'{}'. Make sure the container is a defined Tf.Type and "
                    "the container's import path matches the path in "
                    "plugInfo.json.").format(
                        containerType.typeName, plugin.name), file=sys.stderr)
                continue
            container = containerType.pythonClass()
            allContainers.append(container)

    # No plugins to load, so don't create a registry.
    if len(allContainers) == 0:
        return None

    # Register all plugins from each container. If there is a naming conflict,
    # abort plugin initialization.
    registry = PluginRegistry(usdviewApi)
    for container in allContainers:
        try:
            container.registerPlugins(registry, usdviewApi)
        except DuplicateCommandPlugin as e:
            print("WARNING: {}".format(e), file=sys.stderr)
            print("Plugins will not be loaded.", file=sys.stderr)
            return None

    # Allow each plugin to construct UI elements.
    uiBuilder = PluginUIBuilder(mainWindow)
    for container in allContainers:
        container.configureView(registry, uiBuilder)

    return registry
