# Copyright (C) 2008-2009 LottaNZB Development Team
# 
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 3.
# 
# This program 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 General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.

"""
Makes it possible to extend the functionality of LottaNZB using plug-ins.

On one hand, plug-ins enable the user to customize the application so that
only those features are present he/she is interested in. On the other hand,
plug-ins can help to keep the application modular.

LottaNZB's plug-in mechanism was mainly inspired by the one of the GNOME
applications Deluge and Exaile. The plug-in selection has been copied from
GNOME Do.

The plug-in mechanism is not meant to be used by external developers right
now as LottaNZB's (internal) API is not so stable so that plug-ins are likely
to require continuous maintenance.
"""

import sys
import gtk

import logging
log = logging.getLogger(__name__)

from imp import find_module, load_module
from os import listdir
from os.path import join, isfile, isdir, dirname

from lottanzb import resources
from lottanzb.config import ConfigSection
from lottanzb.util import GObject, gproperty, gsignal, Timer, _
from lottanzb.gui.prefs import PrefsTabBase

class PluginConfig(ConfigSection):
    """
    Base class for plug-in configuration classes.
    
    It doesn't have more than an option 'enabled', which of course indicates
    whether the plug-in is enabled or not.
    
    Plug-ins that need to store information in the configuration file need to
    create a subclass of this class called 'Config' in the plug-in module.
    """
    
    enabled = gproperty(type=bool, default=False)
    
    # If this property is True, it means that the plug-in hasn't been
    # explicitly been enabled or disabled by the user yet.
    # FIXME: Find a better property name.
    first_usage = True
    
    def set_property(self, key, value):
        """
        Ensures that the `first_usage` property is set to False if the plug-in
        is enabled or disabled.
        """
        
        if key == "enabled":
            self.first_usage = False
        
        ConfigSection.set_property(self, key, value)

class PluginBase(GObject):
    """
    Base class for plug-ins with various helper methods.
    
    Each plug-in module needs to contain a subclass of this class called
    'Plugin', so that the `plugins` module can find it on its own.
    
    There are a few important class properties containing general
    information about the plug-in. Besides that, every plug-in instance
    has a property 'app', which holds a reference to the core.App singleton
    and a property 'config', which points to the plug-in configuration.
    
    Whenever the plug-in is enabled or disabled, the load/unload methods are
    called. These methods need to be implemented by each module.
    Alternatively one can also implement the refresh method, which is called
    in both cases.
    """
    
    # Don't forget to make the following properties translatable:
    title = ""
    description = ""
    author = ""
    
    # A list of all plug-ins that need to be enabled before this plug-in may
    # be enabled. Additionally, this plug-in will automatically be disabled if
    # one of the plug-ins in this list is disabled.
    # Example: ["newzbin"]
    requires = []
    
    # A list of lottanzb.modes.Mode subclasses.
    # Makes it possible to restrict the use of the plug-in to certain usage
    # modes. By default, this list is empty and the plug-in can be used no
    # matter what mode is active.
    # If the currenly active usage mode is not among the ones specified in
    # the non-empty list, the plug-in's `lock` property will be set to `True`.
    requires_modes = []
    
    locked = gproperty(
        type    = bool,
        default = False,
        nick    = "If set, the plug-in cannot be enabled.",
        blurb   = "A plug-in may be locked because the user shouldn't be able "
                  "to use it at that particular point of time."
    )
    
    # Holds a short explanation why the plug-in has been locked. If the plug-in
    # is not locked, this property should be reset to an empty string.
    lock_message = ""
    
    gsignal("load")
    gsignal("unload")
    
    def __init__(self, app, config):
        GObject.__init__(self)
        
        self.app = app
        self.config = config
        self.config.connect("notify::enabled", self.on_config_enabled_changed)
        self.connect("notify::locked", self.on_lock_changed)
        
        # Helper property for the 'connect_when_enabled' method.
        self._handlers = {}
        
        # Helper property for the 'run_regularly_when_enabled' method.
        self._timers = []
        
        def validate_dependencies(key, value):
            """
            Called when the user attempts to enable this plug-in.
            
            Raises a `PluginEnablingError` if there's a problem calling the
            `import_dependencies` method or if the plug-ins required by this
            plug-in cannot be enabled.
            """
            
            if key == "enabled" and value and not self.enabled:
                try:
                    if self.locked:
                        # The plug-in is locked and can therefore not be enabled
                        raise PluginEnablingError(self, self.lock_message)
                    
                    # Enable all plug-ins that this plug-in depends on.
                    for plugin in self.get_required_plugins():
                        plugin.enabled = True
                    
                    self.import_dependencies()
                except ImportError, error:
                    # Turns an ImportError into a PluginDependencyError, which
                    # contains a reference to the plug-in itself and the name
                    # of the module that could not be imported.
                    raise PluginDependencyError(self, str(error).split(" ")[-1])
            
            return value
        
        self.config.add_validation_function(validate_dependencies)
        
        def start_timers(*args):
            """Starts all registered timers when the plug-in is loaded."""
            
            for timer in self._timers:
                timer.start()
        
        def stop_timers(*args):
            """Stops all registered timers when the plug-in is unloaded."""
            
            for timer in self._timers:
                timer.stop()
        
        def connect_handlers(*args):
            """
            Connects all previously registered event handlers when the
            plug-in is loaded.
            """
            
            for handler_info in self._handlers:
                self._connect_handler(handler_info)
        
        def disconnect_handlers(*args):
            """
            Disconnects all previously registered event handlers when the
            plug-in is unloaded.
            """
            
            for handler_info in self._handlers:
                self._disconnect_handler(handler_info)
        
        self.connect("load", start_timers)
        self.connect("load", connect_handlers)
        
        self.connect("unload", stop_timers)
        self.connect("unload", disconnect_handlers)
        
        # This plug-in is to be disabled as soon as the user disables one of
        # the plug-ins listed in the `requires` property.
        def handle_required_plugin_unload(*args):
            self.enabled = False
        
        for plugin in self.get_required_plugins():
            plugin.connect("unload", handle_required_plugin_unload)
        
        if self.requires_modes:
            def on_mode_changed(*args):
                mode = self.app.mode_manager.active_mode
                
                # Check if the active mode is supported by the plug-in.
                # Don't lock the plug-in if there's no active mode.
                if mode and not isinstance(mode, *self.requires_modes):
                    self.lock(_("Can only be used in stand-alone mode."))
                else:
                    self.unlock()
            
            self.app.mode_manager.connect_async("notify::active-mode",
                on_mode_changed)
        
        # If the plug-in should be enabled according to the configuration, try
        # to do so.
        if self.enabled:
            try:
                validate_dependencies("enabled", True)
            except PluginEnablingError, error:
                # The required imports could not be made, disable the plug-in.
                self.enabled = False
                log.error(str(error))
            else:
                self.load()
    
    def load(self):
        """
        This method is called whenever the plug-in is enabled.
        """
        
        self.refresh()
        self.emit("load")
    
    def unload(self):
        """
        This method is called whenever the plug-in is disabled.
        """
        
        self.refresh()
        self.emit("unload")
    
    def refresh(self):
        """
        This method is called whenever the plug-in is enabled or disabled.
        """
        
        pass
    
    def lock(self, message):
        """
        Locks the plug-in so that it cannot be enabled.
        
        Use the first parameter to provide a textual reason for the lock.
        """
        
        # The `locked_message` property needs to be set first so that functions
        # reacting to changes of the `locked` property have something to work
        # with.
        self.locked_message = message
        self.locked = True
    
    def unlock(self):
        """Unlocks the plug-in that has previously been locked."""
        
        self.locked_message = ""
        self.locked = False
    
    def on_lock_changed(self, *args):
        """
        Internal method that disables the plug-in when it's locked while still
        being enabled.
        """
        
        if self.locked and self.enabled:
            log.info(_("Disabling plug-in '%s'... %s"),
                self.name, self.locked_message)
            
            self.enabled = False
    
    def import_dependencies(self):
        """
        Override this method to import modules that are not necessarily
        installed on the users system (like feedparser). If a module cannot
        be imported a ImportError will be raised and the user will be unable
        to enable the plug-in while LottaNZB asks him to install the required
        software.
        
        Example:
        # Head of the plug-in file (not absolutely necessary)
        try: import feedparser
        except ImportError: feedparser = None
        
        # The overridden import_dependencies method:
            def import_dependencies(self):
                global feedparser
                
                if not feedparser:
                    import feedparser
        """
        
        pass
    
    def insert_menu_item(self, menu, menu_item, position=-1):
        """
        Add a new item to the main window menu.
        
        It's a good idea to call this method from within the
        on_main_window_ready method. The first argument is the menu the new
        item has to be added to when the plug-in is enabled. The item itself
        is the second argument while the third argument can be used to
        specify the position of the menu item. Defaults to the bottom of the
        menu. You can either specify a numerical index or another
        gtk.MenuItem.
        """
        
        menu_item.show()
        
        def on_load(*args):
            """
            Add the menu item to the menu at the given position if it isn't
            already there.
            """
            
            menu_items = menu.get_children()
            
            if not menu_item in menu_items:
                index = position
                
                if isinstance(position, gtk.MenuItem):
                    index = menu_items.index(position) + 1
                # The insert method doesn't allow values smaller than -1.
                # That's why the index might need to be shifted appropriately.
                elif index < -1:
                    index = len(menu_items) + index + 1
                
                menu.insert(menu_item, index)
                
                # Make sure that the menu is visible.
                if menu.get_children():
                    menu.get_attach_widget().set_property("visible", True)
        
        def on_unload(*args):
            """
            Remove the menu item from the menu.
            """
            
            menu_items = menu.get_children()
            
            if menu_item in menu_items:
                menu.remove(menu_item)
                
                # Hide the menu if the (just removed) menu entry was the only
                # remaining one.
                if not menu.get_children():
                    menu.get_attach_widget().set_property("visible", False)
        
        self.connect("load", on_load)
        self.connect("unload", on_unload)
        
        if self.enabled:
            on_load()
    
    def insert_download_list_column(self, column, position=-1):
        """
        Add a column to the download queue when the plug-in is enabled.
        
        It's a good idea to call this method from within the
        on_main_window_ready method.
        
        Please note that this method is likely to be deprecated once the
        blueprint `download-treeview-overhaul` has been implemented.
        """
        
        def on_refresh(*args):
            columns = self.app.main_window.download_list.get_columns()[:]
            
            if self.enabled and not column in columns:
                columns.insert(position, column)
            elif not self.enabled and column in columns:
                columns.remove(column)
            else:
                return
            
            self.app.main_window.download_list.set_columns(columns)
        
        self.connect("load", on_refresh)
        self.connect("unload", on_refresh)
        
        if self.enabled:
            on_refresh()
    
    def run_regularly_when_enabled(self, timeout, handler, *args):
        """
        Regularly call a function unless the plug-in is disabled.
        """
        
        timer = Timer(timeout, handler, *args)
        
        self._timers.append(timer)
        
        if self.enabled:
            timer.start()
    
    def connect_when_enabled(self, an_object, event, handler, *args):
        """
        Observe an event of certain object unless the plug-in is disabled.
        """
        
        handler_info = (an_object, event, handler, args)
        
        self._handlers[handler_info] = None
        
        if self.enabled:
            self._connect_handler(handler_info)
    
    def _connect_handler(self, handler_info):
        an_object, event, handler, args = handler_info
        handler_id = an_object.connect(event, handler, *args)
        
        self._handlers[handler_info] = handler_id
    
    def _disconnect_handler(self, handler_info):
        # pylint: disable-msg=W0612
        an_object, event, handler, args = handler_info
        handler_id = self._handlers[handler_info]
        
        an_object.disconnect(handler_id)
        
        self._handlers[handler_info] = None
    
    def on_main_window_ready(self, main_window):
        """
        This method is called as soon as LottaNZB's main window is ready.
        """
        
        pass
    
    def on_config_enabled_changed(self, *args):
        if self.enabled:
            self.load()
        else:
            self.unload()
    
    def is_enabled(self):
        """
        If the plug-in is enabled or not.
        
        This getter method isn't meant to be used directly. Use the `enabled`
        property instead.
        """
        
        return self.config.enabled
    
    def set_enabled(self, enabled):
        """
        Enables or disables the plug-in.
        
        This setter method isn't meant to be used directly. Use the `enabled`
        property instead.
        """
        
        self.config.enabled = enabled
    
    enabled = property(is_enabled, set_enabled)
    
    def get_prefs_tab_class(self):
        """
        The PluginPrefsTabBase subclass used to create the configuration tab.
        
        Returns None if the plug-in cannot be configured.
        """
        
        try:
            return sys.modules[self.__class__.__module__].PrefsTab
        except AttributeError:
            pass
    
    def get_required_plugins(self):
        """
        Rather than the `requires` property, this method returns a list of the
        actual `Plugin` instances that this plug-in requires to be enabled.
        """
        
        plugins = []
        
        for plugin_name in self.requires:
            plugins.append(self.app.plugin_manager.plugins[plugin_name])
        
        return plugins
    
    @property
    def name(self):
        """
        The technical name of the plug-in (its directory name in plugins/).
        """
        
        return self.__class__.__module__.split(".")[-1]

class PluginPrefsTabBase(PrefsTabBase):
    """
    Can be subclassed to make it possible to configure a plug-in in the
    preferences window.
    
    Please name the subclass 'PrefsTab' so that this module can find it on
    its own.
    """
    
    def __init__(self, prefs_window, plugin):
        self.plugin = plugin
        self.label = self.label or plugin.title
        
        PrefsTabBase.__init__(self, prefs_window)

class PluginManager(GObject):
    """
    Used to load and store plug-ins.
    """
    
    def __init__(self, app, config):
        GObject.__init__(self)
        
        self.app = app
        self.config = config
        
        self.plugins = {}
        
        self._plugin_modules = {}
        self._find_plugins()
        
        self.app.connect("notify::main-window", self._on_main_window_ready)
    
    def _find_plugins(self):
        paths = [dirname(__file__), resources.get_user_data("plugins")]
        
        # Ignore the user-specific plug-in directory if it doesn't exist yet.
        for path in [path for path in paths if isdir(path)]:
            for name in listdir(path):
                if isfile(join(path, name, "__init__.py")):
                    try:
                        try:
                            module_info = find_module(name, [path])
                            module = load_module(name, *module_info)
                        except ImportError:
                            raise PluginLoadingError(name)
                        
                        if not hasattr(module, "Plugin"):
                            raise PluginLoadingError(name,
                                _("Unsupported plug-in type.")
                            )
                        
                        self._plugin_modules[name] = module
                        
                        ui_dir = join(path, name, "ui")
                        
                        if isdir(ui_dir):
                            environ.add_resource("ui", ui_dir)
                        
                        setattr(sys.modules[self.__module__], name, module)
                        sys.modules["%s.%s" % (self.__module__, name)] = module
                    except PluginLoadingError, error:
                        log.warning(str(error))
    
    def load_plugins(self):
        for plugin_name in self._plugin_modules:
            try:
                self.load_plugin(plugin_name)
            except PluginLoadingError, error:
                log.warning(str(error))
    
    def load_plugin(self, name):
        if name in self.plugins:
            return
        
        plugin_module = self._plugin_modules[name]
        plugin_cls = plugin_module.Plugin
        
        if type(plugin_cls.requires) is list:
            try:
                for required_plugin_name in plugin_cls.requires:
                    self.load_plugin(required_plugin_name)
            except PluginLoadingError, error:
                raise PluginLoadingError(name,
                    _("This plug-in depends on %s.") % error.plugin
                )
        
        if hasattr(plugin_module, "Config"):
            config_cls = plugin_module.Config
        else:
            config_cls = PluginConfig
        
        self.config.apply_section_class(name, config_cls)
        plugin = plugin_cls(self.app, self.config[name])
        
        self.plugins[name] = plugin
    
    def _on_main_window_ready(self, *args):
        if self.app.main_window:
            for plugin in self.plugins.values():
                plugin.on_main_window_ready(self.app.main_window)

class PluginLoadingError(Exception):
    def __init__(self, name, msg=""):
        self.name = name
        self.msg = msg
        
        Exception.__init__(self)
    
    def __str__(self):
        return _("Could not load plug-in '%s'. %s") % (self.name, self.msg)

class PluginEnablingError(Exception):
    def __init__(self, plugin, message):
        self.plugin = plugin
        self.message = message
        
        Exception.__init__(self)
    
    def __str__(self):
        return _("Could not enable plug-in '%s'. %s") % \
            (self.plugin.name, self.message)

class PluginDependencyError(PluginEnablingError):
    """
    Raised when a required module cannot be imported by a plug-in and that
    can thus not be enabled.
    """
    
    def __init__(self, plugin, dependency):
        self.dependency = dependency
        
        PluginEnablingError.__init__(self, plugin, 
            _("%s is not installed.") % self.dependency)
