# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
#
# gPodder 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; either version 3 of the License, or
# (at your option) any later version.
#
# gPodder 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, see <http://www.gnu.org/licenses/>.

"""
Loads and executes user extensions

Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
define a class named "gPodderExtension", otherwise it will be ignored.

The extensions class defines several callbacks that will be called by gPodder
at certain points. See the methods defined below for a list of callbacks and
their parameters.

For an example extension see share/gpodder/examples/extensions.py
"""

import glob
import imp
import inspect
import json
import os
import functools
import shlex
import subprocess
import sys
import re
from datetime import datetime

import gpodder

_ = gpodder.gettext

from gpodder import util

import logging
logger = logging.getLogger(__name__)


def call_extensions(func):
    """Decorator to create handler functions in ExtensionManager

    Calls the specified function in all user extensions that define it.
    """
    method_name = func.__name__

    @functools.wraps(func)
    def handler(self, *args, **kwargs):
        result = None
        for container in self.containers:
            if not container.enabled or container.module is None:
                continue

            try:
                callback = getattr(container.module, method_name, None)
                if callback is None:
                    continue

                # If the results are lists, concatenate them to show all
                # possible items that are generated by all extension together
                cb_res = callback(*args, **kwargs)
                if isinstance(result, list) and isinstance(cb_res, list):
                    result.extend(cb_res)
                elif cb_res is not None:
                    result = cb_res
            except Exception, exception:
                logger.error('Error in %s in %s: %s', container.filename,
                        method_name, exception, exc_info=True)
        func(self, *args, **kwargs)
        return result

    return handler


class ExtensionMetadata(object):
    # Default fallback metadata in case metadata fields are missing
    DEFAULTS = {
        'description': _('No description for this extension.'),
    }

    def __init__(self, container, metadata):
        if 'title' not in metadata:
            metadata['title'] = container.name

        self.__dict__.update(metadata)

    def __getattr__(self, name):
        try:
            return self.DEFAULTS[name]
        except KeyError:
            raise AttributeError(name)

    @property
    def for_current_ui(self):
        """Check if this extension makes sense for the current UI

        The __only_for__ metadata field in an extension can be a string with
        comma-separated values for UIs. This will be checked against boolean
        variables in the "gpodder.ui" object.

        Example metadata field in an extension:

            __only_for__ = 'gtk,qml'

        In this case, this function will return True if any of the following
        expressions will evaluate to True:

            gpodder.ui.gtk
            gpodder.ui.qml

        New, unknown UIs are silently ignored and will evaluate to False.
        """
        if not hasattr(self, 'only_for'):
            return True

        uis = filter(None, [x.strip() for x in self.only_for.split(',')])
        return any(getattr(gpodder.ui, ui.lower(), False) for ui in uis)

class MissingDependency(Exception):
    def __init__(self, message, dependency, cause=None):
        Exception.__init__(self, message)
        self.dependency = dependency
        self.cause = cause

class MissingModule(MissingDependency): pass
class MissingCommand(MissingDependency): pass

class ExtensionContainer(object):
    """An extension container wraps one extension module"""

    def __init__(self, manager, name, config, filename=None, module=None):
        self.manager = manager

        self.name = name
        self.config = config
        self.filename = filename
        self.module = module
        self.enabled = False
        self.error = None

        self.default_config = None
        self.parameters = None
        self.metadata = ExtensionMetadata(self, self._load_metadata(filename))

    def require_command(self, command):
        result = util.find_command(command)
        if result is None:
            msg = _('Command not found: %(command)s') % {'command': command}
            raise MissingCommand(msg, command)
        return result

    def _load_metadata(self, filename):
        if not filename or not os.path.exists(filename):
            return {}

        extension_py = open(filename).read()
        metadata = dict(re.findall("__([a-z_]+)__ = '([^']+)'", extension_py))

        # Support for using gpodder.gettext() as _ to localize text
        localized_metadata = dict(re.findall("__([a-z_]+)__ = _\('([^']+)'\)",
            extension_py))

        for key in localized_metadata:
            metadata[key] = gpodder.gettext(localized_metadata[key])

        return metadata

    def set_enabled(self, enabled):
        if enabled and not self.enabled:
            try:
                self.load_extension()
                self.error = None
                self.enabled = True
                if hasattr(self.module, 'on_load'):
                    self.module.on_load()
            except Exception, exception:
                logger.error('Cannot load %s from %s: %s', self.name,
                        self.filename, exception, exc_info=True)
                if isinstance(exception, ImportError):
                    # Wrap ImportError in MissingCommand for user-friendly
                    # message (might be displayed in the GUI)
                    match = re.match('No module named (.*)', exception.message)
                    if match:
                        module = match.group(1)
                        msg = _('Python module not found: %(module)s') % {
                            'module': module
                        }
                        exception = MissingCommand(msg, module, exception)
                self.error = exception
                self.enabled = False
        elif not enabled and self.enabled:
            try:
                if hasattr(self.module, 'on_unload'):
                    self.module.on_unload()
            except Exception, exception:
                logger.error('Failed to on_unload %s: %s', self.name,
                        exception, exc_info=True)
            self.enabled = False

    def load_extension(self):
        """Load and initialize the gPodder extension module"""
        if self.module is not None:
            logger.info('Module already loaded.')
            return

        if not self.metadata.for_current_ui:
            logger.info('Not loading "%s" (only_for = "%s")',
                    self.name, self.metadata.only_for)
            return

        basename, extension = os.path.splitext(os.path.basename(self.filename))
        fp = open(self.filename, 'r')
        try:
            module_file = imp.load_module(basename, fp, self.filename,
                    (extension, 'r', imp.PY_SOURCE))
        finally:
            # Remove the .pyc file if it was created during import
            util.delete_file(self.filename + 'c')
        fp.close()

        self.default_config = getattr(module_file, 'DefaultConfig', {})
        if self.default_config:
            self.manager.core.config.register_defaults({
                'extensions': {
                    self.name: self.default_config,
                }
            })
        self.config = getattr(self.manager.core.config.extensions, self.name)

        self.module = module_file.gPodderExtension(self)
        logger.info('Module loaded: %s', self.filename)


class ExtensionManager(object):
    """Loads extensions and manages self-registering plugins"""

    def __init__(self, core):
        self.core = core
        self.filenames = os.environ.get('GPODDER_EXTENSIONS', '').split()
        self.containers = []

        core.config.add_observer(self._config_value_changed)
        enabled_extensions = core.config.extensions.enabled

        if os.environ.get('GPODDER_DISABLE_EXTENSIONS', '') != '':
            logger.info('Disabling all extensions (from environment)')
            return

        for name, filename in self._find_extensions():
            logger.debug('Found extension "%s" in %s', name, filename)
            config = getattr(core.config.extensions, name)
            container = ExtensionContainer(self, name, config, filename)
            if name in enabled_extensions:
                container.set_enabled(True)
            self.containers.append(container)

    def shutdown(self):
        for container in self.containers:
            container.set_enabled(False)

    def _config_value_changed(self, name, old_value, new_value):
        if name == 'extensions.enabled':
            for container in self.containers:
                new_enabled = (container.name in new_value)
                if new_enabled != container.enabled:
                    logger.info('Extension "%s" is now %s', container.name,
                            'enabled' if new_enabled else 'disabled')
                    container.set_enabled(new_enabled)
                    if new_enabled and not container.enabled:
                        logger.warn('Could not enable extension: %s',
                                container.error)
                        self.core.config.extensions.enabled = [x
                                for x in self.core.config.extensions.enabled
                                if x != container.name]

    def _find_extensions(self):
        extensions = {}

        if not self.filenames:
            builtins = os.path.join(gpodder.prefix, 'share', 'gpodder',
                'extensions', '*.py')
            user_extensions = os.path.join(gpodder.home, 'Extensions', '*.py')
            self.filenames = glob.glob(builtins) + glob.glob(user_extensions)

        # Let user extensions override built-in extensions of the same name
        for filename in self.filenames:
            if not filename or not os.path.exists(filename):
                logger.info('Skipping non-existing file: %s', filename)
                continue

            name, _ = os.path.splitext(os.path.basename(filename))
            extensions[name] = filename

        return sorted(extensions.items())

    def get_extensions(self):
        """Get a list of all loaded extensions and their enabled flag"""
        return self.containers

    # Define all known handler functions here, decorate them with the
    # "call_extension" decorator to forward all calls to extension scripts that have
    # the same function defined in them. If the handler functions here contain
    # any code, it will be called after all the extensions have been called.

    @call_extensions
    def on_ui_initialized(self, model, update_podcast_callback,
            download_episode_callback):
        """Called when the user interface is initialized.

        @param model: A gpodder.model.Model instance
        @param update_podcast_callback: Function to update a podcast feed
        @param download_episode_callback: Function to download an episode
        """
        pass

    @call_extensions
    def on_podcast_subscribe(self, podcast):
        """Called when the user subscribes to a new podcast feed.

        @param podcast: A gpodder.model.PodcastChannel instance
        """
        pass

    @call_extensions
    def on_podcast_updated(self, podcast):
        """Called when a podcast feed was updated

        This extension will be called even if there were no new episodes.

        @param podcast: A gpodder.model.PodcastChannel instance
        """
        pass

    @call_extensions
    def on_podcast_update_failed(self, podcast, exception):
        """Called when a podcast update failed.

        @param podcast: A gpodder.model.PodcastChannel instance

        @param exception: The reason.
        """
        pass

    @call_extensions
    def on_podcast_save(self, podcast):
        """Called when a podcast is saved to the database

        This extensions will be called when the user edits the metadata of
        the podcast or when the feed was updated.

        @param podcast: A gpodder.model.PodcastChannel instance
        """
        pass

    @call_extensions
    def on_podcast_delete(self, podcast):
        """Called when a podcast is deleted from the database

        @param podcast: A gpodder.model.PodcastChannel instance
        """
        pass

    @call_extensions
    def on_episode_save(self, episode):
        """Called when an episode is saved to the database

        This extension will be called when a new episode is added to the
        database or when the state of an existing episode is changed.

        @param episode: A gpodder.model.PodcastEpisode instance
        """
        pass

    @call_extensions
    def on_episode_downloaded(self, episode):
        """Called when an episode has been downloaded

        You can retrieve the filename via episode.local_filename(False)

        @param episode: A gpodder.model.PodcastEpisode instance
        """
        pass

    @call_extensions
    def on_all_episodes_downloaded(self):
        """Called when all episodes has been downloaded
        """
        pass

    @call_extensions
    def on_episodes_context_menu(self, episodes):
        """Called when the episode list context menu is opened

        You can add additional context menu entries here. You have to
        return a list of tuples, where the first item is a label and
        the second item is a callable that will get the episode as its
        first and only parameter.

        Example return value:

        [('Mark as new', lambda episodes: ...)]

        @param episodes: A list of gpodder.model.PodcastEpisode instances
        """
        pass

    @call_extensions
    def on_episode_delete(self, episode, filename):
        """Called just before the episode's disk file is about to be
        deleted."""
        pass

    @call_extensions
    def on_episode_removed_from_podcast(self, episode):
        """Called just before the episode is about to be removed from
        the podcast channel, e.g., when the episode has not been
        downloaded and it disappears from the feed.

        @param podcast: A gpodder.model.PodcastChannel instance
        """
        pass

    @call_extensions
    def on_notification_show(self, title, message):
        """Called when a notification should be shown

        @param title: title of the notification
        @param message: message of the notification
        """
        pass

    @call_extensions
    def on_download_progress(self, progress):
        """Called when the overall download progress changes

        @param progress: The current progress value (0..1)
        """
        pass

    @call_extensions
    def on_ui_object_available(self, name, ui_object):
        """Called when an UI-specific object becomes available

        XXX: Experimental. This hook might go away without notice (and be
        replaced with something better). Only use for in-tree extensions.

        @param name: The name/ID of the object
        @param ui_object: The object itself
        """
        pass

