# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2006-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.

__maintainer__ = "Benjamin Kampmann <benjamin@fluendo.com>"

from elisa.core import common
from elisa.core.utils.misc import read_mappings
from elisa.core.components.frontend import Frontend
from elisa.core.pattern_matcher import UriPatternMatcher, MatchNotFound
from elisa.core.log import Loggable
from elisa.core.utils import typefinding, defer
from elisa.core.media_uri import MediaUri, unquote

from elisa.extern.log.log import getFailureMessage

from elisa.plugins.pigment.widgets.theme import Theme
from elisa.plugins.pigment.graph.image import Image
from elisa.plugins.pigment.widgets import const
from elisa.plugins.pigment.pigment_input import PigmentInput

from twisted.python.failure import Failure
from twisted.python import reflect
from twisted.internet import reactor, task

from elisa.core.application import ComponentsLoadedMessage
from elisa.plugins.pigment.message import PigmentFrontendLoadedMessage
from elisa.plugins.pigment.windows_messages_handler import WindowsMessagesHandler

import pgm
from elisa.plugins.pigment.animation.ticker import Ticker
from elisa.plugins.pigment.animation.animation import Animation

import pkg_resources
import os, platform
import weakref
import sys

try:
    import dbus
    from elisa.plugins.pigment.dbus_frontend import DBusFrontend
except ImportError:
    pass


class ControllerNotFound(Exception):
    pass


class PigmentFrontendExtensionMixin(Loggable):

    def __init__(self):
        super(PigmentFrontendExtensionMixin, self).__init__()
        self.controller_matcher = UriPatternMatcher()
        self.decorator_matcher = UriPatternMatcher()
        self._controller_store = {}

    def _load_enabled_controller_and_decorator_mappings(self):
        """
        Load the controller and decorator mappings from all the enabled
        plugins' metadata.
        """
        registry = common.application.plugin_registry
        enabled_plugins = [plugin for plugin in registry.get_enabled_plugins()]
        for dist in pkg_resources.working_set:
            if dist.key not in enabled_plugins:
                continue
            self._update_controller_mappings(dist, True)
            self._update_decorator_mappings(dist, True)
            self._loaded_plugins.append(dist.key)

    def _update_controller_mappings(self, plugin, enabled):
        # Load/unload the controller mappings for a given plugin.
        metadata = 'controller_mappings.txt'
        if not plugin.has_metadata(metadata):
            return
        lines = plugin.get_metadata_lines(metadata)
        mappings = read_mappings(lines)
        for path_pattern, controller_path in mappings:
            if enabled:
                self.add_controller(path_pattern, controller_path)
            else:
                self.remove_controller(path_pattern)

    def _update_decorator_mappings(self, plugin, enabled):
        # Load/unload the decorator mappings for a given plugin.
        metadata = 'decorator_mappings.txt'
        if not plugin.has_metadata(metadata):
            return
        lines = plugin.get_metadata_lines(metadata)
        mappings = read_mappings(lines)
        for path_pattern, decorator_path in mappings:
            if enabled:
                self.add_decorator(path_pattern, decorator_path)
            else:
                self.remove_decorator(path_pattern)

    def plugin_status_changed_cb(self, plugin, status):
        """
        Callback meant to be invoked (by the plugin registry) when the status
        of a plugin has changed.
        Update the controller and decorator mappings accordingly.
        """
        self._update_controller_mappings(plugin, status)
        self._update_decorator_mappings(plugin, status)
        return defer.succeed(None)

    def add_controller(self, path_pattern, controller):
        """
        Add a new controller to the frontend. The controller is the one that
        will be loaded for paths matching C{path_pattern}.

        @param path_pattern: regular expression pattern
        @type path_pattern: C{str}
        @param controller: controller class or component path
        @type controller: L{elisa.core.components.controller.Controller} or
                          component path string of a controller class
        """
        self.controller_matcher.add_pattern(path_pattern, controller)

    def remove_controller(self, path_pattern):
        """
        Remove a controller from the frontend.
        The controller is the one that would be loaded for paths matching
        C{path_pattern}.

        @param path_pattern: a regular expression pattern
        @type path_pattern:  C{str}
        """
        self.controller_matcher.remove_pattern(path_pattern)

    def create_controller(self, path, config=None, **kwargs):
        """
        Create a controller for the given path.

        @param path: path
        @type path: C{str}
        @param config: configuration to set for the controller
        @type config: configobj section
        @return: controller instance
        @rtype: L{elisa.core.components.controller.Controller}
        """
        try:
            controller = self.controller_matcher.match(path)
        except MatchNotFound:
            raise ControllerNotFound(path)

        if isinstance(controller, basestring):
            plugin_registry = common.application.plugin_registry
            dfr = plugin_registry.create_component(controller, config, **kwargs)
        else:
            dfr = controller.create(config, **kwargs)

        dfr.addCallback(self._store_controller, path)
        dfr.addCallback(self._set_frontend)
        dfr.addCallback(self._set_path, path)
        dfr.addCallback(self._decorate)
        return dfr

    def _store_controller(self, controller, path):
        if not self._controller_store.get(path):
            self._controller_store[path] = []
        self._controller_store[path].append(weakref.ref(controller))

        return controller

    def retrieve_controllers(self, path):
        """
        Retrieve the list of controllers for a given path.

        The list will contain all the controllers created for the path which
        haven't been garbage collected.

        @param path: the controllers' path
        @type path: string
        @returns: list of controllers for the given path
        @rtype: list of L{elisa.plugins.pigment.PigmentController}
        """
        controllers = self._controller_store.get(path, [])
        return [controller() for controller in controllers
                if controller() is not None]

    def add_decorator(self, path_pattern, decorator):
        """
        Add a decorator function for controllers matching path_pattern.

        A controller decorator is a callable object that is called when a new
        controller is created. It can be used to alter the behaviour of a
        controller (say, the UI created by the controller). Controller
        decorators are called with a controller instance as their only argument
        and should return a deferred.

        @param path_pattern: path pattern
        @type path_pattern: C{str}
        @param decorator: decorator callable or decorator path string
        @type decorator: callable or C{str}
        """
        self.decorator_matcher.add_pattern(path_pattern, decorator)

    def remove_decorator(self, path_pattern):
        """
        Remove a decorator function from the frontend.
        The decorator is the one that would be called for controllers with a
        path matching C{path_pattern}.

        @param path_pattern: a regular expression pattern
        @type path_pattern:  C{str}
        """
        self.decorator_matcher.remove_pattern(path_pattern)

    def _set_frontend(self, controller):
        def return_controller(result):
            return controller

        dfr = defer.maybeDeferred(controller.set_frontend, self)
        dfr.addCallback(return_controller)
        return dfr

    def _set_path(self, controller, path):
        controller.set_path(path)

        return controller

    def _decorator_callback(self, result, resultlist):
        resultlist.append((True, result))

        return result

    def _decorator_errback(self, failure, resultlist, decorator):
        self.warning('exception calling decorator %s:\n%s',
                     decorator, getFailureMessage(failure))
        resultlist.append((False, failure))

        # swallow the failure
        return None

    def _iterate_decorators_callback(self, iterator, results, controller, dfr):
        controller.pending_decorator_deferreds.remove(dfr) 
        return controller

    def _iterate_decorators(self, controller, matches, resultlist):
        for decorator in matches:
            if isinstance(decorator, basestring):
                try:
                    decorator = reflect.namedAny(decorator.replace(':', '.'))
                except:
                    failure = Failure()
                    self.warning('exception importing decorator %s:\n%s' %
                            (decorator, getFailureMessage(failure)))
                    resultlist.append((False, failure))
                    continue

            # some decorators might not return a Deferred so we ensure
            # we get one, using maybeDeferred. This maybeDeferred call
            # also wraps any exception raised by the decorator in a
            # defer.fail()
            dfr = defer.maybeDeferred(decorator, controller)

            self.debug('called decorator %s' % decorator)
            dfr.addCallbacks(self._decorator_callback,
                             errback=self._decorator_errback,
                             callbackArgs=(resultlist,),
                             errbackArgs=(resultlist, decorator))
            yield dfr

    def _decorate(self, controller):
        try:
            matches = self.decorator_matcher.match(controller.path, all=True)
        except MatchNotFound:
            return self._ready(controller)

        results = []
        dfr = task.coiterate(self._iterate_decorators(controller, matches, results))
        controller.pending_decorator_deferreds.append(dfr)
        dfr.addCallback(self._iterate_decorators_callback, results, controller, dfr)
        dfr.addCallback(self._ready)

        return dfr

    def _ready(self, controller):
        controller.ready()
        return defer.succeed(controller)


class PigmentFrontend(Frontend, PigmentFrontendExtensionMixin):
    """
    Specialised L{elisa.core.components.frontend.Frontend} for the Pigment
    toolkit.

    It creates the canvas, the viewport, and the root controller (which keeps
    the root of the widgets hierarchy), using values specified in the
    configuration.

    @ivar viewport: the Pigment viewport
    @type viewport: L{pgm.Viewport}
    @ivar canvas: the Pigment canvas
    @type canvas: L{pgm.Canvas}
    @ivar config: data from the configuration file loaded at Elisa startup, or
                  the default
    @type config: L{elisa.core.config.Config}
    @ivar controller: the root controller, containing the root of widget
                      hierarchy
    @type controller: L{elisa.plugins.pigment.pigment_controller.PigmentController}
    @ivar gtk_window: Gtk window, optionnally embedding the Pigment viewport
    @type gtk_window: L{gtk.Window}
    @ivar windows_messages_handler: (win32-only). Signal handler for wndprocs
    @type windows_messages_handler: C{elisa.plugins.pigment.windows_message_handler.WindowsMessageHandler}
    """

    default_config = {'window_width': '1024',
                      'touchscreen': '0',
                      'use_gtk': '0',
                      'headless': '0',
                      'start_fullscreen': '1',
                      'controller_path': '',
                      'theme': None,
                      'disable_togglefullscreen': '0',
                     }

    config_doc = {'window_width' : 'Here you can set the width in pixels the'
                                   ' window should have at startup. The height'
                                   ' is computed using screen_ratio.'
                                   ' If this value is 0, we decide'
                                   ' automatically.',
                  'use_gtk'     : 'Start the frontend inside a gtk window',
                  'touchscreen' : 'If set to 1, the mouse behaviour will be'
                                  ' adapted for touchscreen equipped hardware.',
                  'headless': 'Start in headless mode',
                  'start_fullscreen': 'If set to 1, Moovida will start fullscreen'
                                      ' otherwise windowed',
                  'controller_path': 'Path used to decide which controller'
                                     'should be instantiated an startup',
                  'theme': 'Module name of the theme',
                  'disable_togglefullscreen': 'Disable switching fullscreen mode',
                 }

    def initialize(self):
        self._master_drawables = {}
        self._theme = None

        dfr = super(PigmentFrontend, self).initialize()

        # nVidia driver specific option that inhibits its yielding behaviour
        # this allows Pigment rendering to be smooth even when the system as a
        # whole has a high load.
        # It is unnecessary for ATI proprietary drivers and all open source
        # drivers.
        # more details at:
        # http://us.download.nvidia.com/XFree86/Linux-x86/169.07/README/chapter-11.html
        os.environ["__GL_YIELD"] = "NOTHING"

        # Deactivate forcing indirect rendering since it causes more troubles
        # than it's worth with DRI drivers: Elisa always works better with
        # LIBGL_ALWAYS_INDIRECT unset.
        # https://bugs.launchpad.net/ubuntu/+source/desktop-effects/+bug/137388
        os.unsetenv("LIBGL_ALWAYS_INDIRECT")

        # force constant framerate to a upper limit in order to avoid using
        # all the CPU by rendering as fast as possible; it happens when
        # synchronisation with Vblank fails completely
        # See Elisa bug: https://bugs.launchpad.net/elisa/+bug/231899
        os.environ["PGM_GL_FPS"] = "120"

        # signal handlers id
        self._signal_handler_ids = []

        # FIXME: this is a workaround a Python import bug; at that point for
        # some reason pgm is not defined. This should be fixed with Python
        # 3000
        import pgm

        factory = pgm.ViewportFactory('opengl')
        viewport = factory.create()

        self.viewport = viewport

        viewport.title = 'Moovida Media Center'
        self.canvas = pgm.Canvas()

        # We pass a reference of the canvas to the Image class in order to
        # allow transparent in-texture-memory caching.
        # It is necessary since Pigment's cloning mechanism requires the
        # insertion of the master drawables in the canvas.
        # This prevents having several Pigment frontends loaded simultaneously.
        Image.canvas = self.canvas

        viewport.set_canvas(self.canvas)
        id = viewport.connect('delete-event', self._viewport_delete_event)
        self._signal_handler_ids.append(id)
        id = viewport.connect('drag-motion-event',
                              self._viewport_drag_motion_event)
        self._signal_handler_ids.append(id)
        id = viewport.connect('drag-drop-event', self._viewport_drag_drop_event)
        self._signal_handler_ids.append(id)
        id = viewport.connect('drag-leave-event',
                              self._viewport_drag_leave_event)
        self._signal_handler_ids.append(id)

        self._in_drag = False
        self._drag_motion_result = False

        window_width = self.config.as_int('window_width')
        if window_width is not 0:
            viewport.width = window_width

        # compute the aspect ratio
        w, h = viewport.screen_size_mm
        screen_aspect_ratio = h / float(w)

        # set the viewport aspect ratio to the screen physical aspect ratio
        viewport.height = viewport.width * screen_aspect_ratio

        w, h = viewport.screen_resolution
        resolution_aspect_ratio = h / float(w)

        # width is set to 400.0 in order to make variations in depth
        # (z coordinate) visible
        self.canvas.width = 400.0
        # set the canvas aspect ratio to the screen physical aspect ratio
        self.canvas.height = self.canvas.width * screen_aspect_ratio

        # resize the viewport to compensate for the canvas projection
        # deformation applied to correct monitors with non square pixels.
        viewport.width *= (screen_aspect_ratio / resolution_aspect_ratio)

        fullscreen = self.config.as_bool('start_fullscreen')
        if common.application.options['fullscreen']:
            fullscreen = True
        elif common.application.options['unfullscreen']:
            fullscreen = False

        self.headless = '--headless' in sys.argv or \
            self.config.as_bool('headless')

        if self.config.as_bool('use_gtk') is True:
            self.debug("using gtk")

            import gtk
            import pgm.gtk

            self.gtk_window = gtk.Window(gtk.WINDOW_TOPLEVEL)
            embed = pgm.gtk.PgmGtk()
            self.gtk_window.add(embed)
            embed.set_viewport(viewport)
            self.gtk_window.resize(*viewport.size)
            self.gtk_window.set_focus_child(embed)
            self.gtk_window.set_title('Moovida Media Center')

            if fullscreen:
                self.gtk_window.fullscreen()
            else:
                self.gtk_window.unfullscreen()
        else:
            viewport.fullscreen = fullscreen

            # Pigment configure events are sent on window move/resize. This
            # callback allows to regenerate our texts considering the new
            # window size.
            self._resize_delay = 0.2
            self._resize_delayed = None
            id = viewport.connect('configure-event', self._configure_callback)
            self._signal_handler_ids.append(id)
            self.gtk_window = None

        # set the theme
        self._initialize_theme()

        # Update animations in the viewport 'update-pass' signal. This is the
        # best solution to update Pigment animations because it's synchronized
        # with the rendering. Note that this is the only Pigment signal which
        # is emitted in another thread than the mainloop one.
        ticker = Ticker()
        Animation.set_ticker(ticker)
        id = viewport.connect('update-pass', self._update_pass_callback, ticker)
        self._signal_handler_ids.append(id)

        # hide the cursor
        viewport.cursor = pgm.VIEWPORT_NONE

        if self.config.as_bool('touchscreen') is False:

            # delay the hiding of the mouse cursor
            self._cursor_delay = 1.000
            self._cursor_delayed = None

            # signal triggered when the canvas gets resized
            self._viewport_size = viewport.size
            id = viewport.connect('motion-notify-event',
                                  self._motion_notify_callback)
            self._signal_handler_ids.append(id)

        self.windows_msg_handler = None
        if platform.system() == 'Windows':
            self.windows_msg_handler = WindowsMessagesHandler(viewport)

        self.pigment_input = PigmentInput()
        self.pigment_input.viewport = self.viewport

        application = common.application
        application.input_manager.register_component(self.pigment_input)
        application.input_manager.connect('input-event', self.handle_input)

        application.bus.register(self._components_loaded_msg,
                                                    ComponentsLoadedMessage)

        self._initialize_dbus()

        return dfr

    def _get_toplevel_window(self):
        return self.gtk_window or self.viewport

    def show(self):
        """
        Show the toplevel window and notify the other components.
        """
        toplevel = self._get_toplevel_window()
        toplevel.show()
        toplevel.focus()
        self.controller.toplevel_window_shown()

    def hide(self):
        """
        Hide the toplevel window.
        """
        toplevel = self._get_toplevel_window()
        toplevel.hide()
        self.controller.toplevel_window_hidden()

    def _components_loaded_msg(self, message, sender):

        # load the mappings and register a callback for updates

        self._loaded_plugins = []
        self._load_enabled_controller_and_decorator_mappings()

        plugin_registry = common.application.plugin_registry
        plugin_registry.register_plugin_status_changed_callback(self.plugin_status_changed_cb)

        # load the first controller in the next iteration
        reactor.callLater(0, self._load_first_controller)

    def _load_first_controller(self):
        # Create the first PigmentController
        controller_path = self.config['controller_path']
        dfr = self.create_controller(controller_path)

        def controller_created(controller):
            # remember the root controller
            self.controller = controller
            controller.widget.size = self.canvas.size
            controller.widget.canvas = self.canvas
            controller.prepare()
            controller.widget.set_focus()

            # Show the window
            if not self.headless:
                if self.config.as_bool('use_gtk'):
                    self.gtk_window.show_all()
                else:
                    self.viewport.show()

            # sent out the loaded message
            message = PigmentFrontendLoadedMessage(controller)
            common.application.bus.send_message(message, sender=self)
            return self

        dfr.addCallback(controller_created)
        return dfr

    def _viewport_delete_event(self, viewport, event):
        common.application.stop()

    def reduce_window(self):
        if self.gtk_window:
            self.gtk_window.iconify()

    def handle_input(self, input_manager, input_event):
        # forward it to the controller
        self.controller.handle_input(input_manager, input_event)

    def _initialize_theme(self):
        """Initialize a theme for the frontend."""

        theme_modulename = self.config.get('theme')

        if not theme_modulename:
            self.debug('No theme configured, using the default')
        else:
            self.debug("Setting theme '%s'" % self.config.get('theme'))
            theme = Theme.load_from_module(theme_modulename)
            self._style_handler_id = theme.connect('styles-updated',
                                                   self._reload_theme)
            self._resource_handler_id = theme.connect('resources-updated',
                                                      self._reload_theme)
            # setting the theme for the widgets, too
            Theme.set_default(theme)

        self.set_theme(Theme.get_default())

    def _reload_theme(self, theme):
        self.info('reloading theme')
        self.set_theme(self._theme)

    def set_theme(self, theme):
        """Set a new theme for the frontend.

        @param theme: the new theme
        @type theme: L{elisa.plugins.widgets.Theme}
        """
        self._theme = theme

        # setting the theme for the widgets, too
        Theme.set_default(theme)

        for icon, master in self._master_drawables.iteritems():
            image_path = theme.get_resource(icon)
            master.set_from_file(image_path)

        if not getattr(self, 'controller', None):
            return

        self.controller.widget.load_theme(theme)
        for widget in self.controller.widget.get_descendants():
            widget.load_theme(theme)

    def get_theme(self):
        """Get the current theme.

        @return: the current theme
        @rtype:  L{elisa.plugins.widgets.Theme}
        """
        return self._theme

    def load_from_theme(self, resource, image):
        """
        Loads an icon from the theme into a Pigment image.

        @param resource: icon resource to load
        @type resource: str
        @param image: drawable into which the icon will be loaded
        @type image: L{pgm.Image}

        @rtype: L{twisted.internet.defer.Deferred}
        @return: triggered whenever the image is loaded and shown
        """
        path = self.get_theme().get_resource(resource)
        return image.set_from_file_with_caching(path)

    def clean(self):
        self._clean_dbus()

        def controller_cleaned(result):
            self.controller.removed()

            viewport = self.viewport

            if self.windows_msg_handler:
                self.windows_msg_handler.clean()
                self.windows_msg_handler = None

            # disconnecting from all the signals it connected to
            for id in self._signal_handler_ids:
                viewport.disconnect(id)

            # disconnecting from the theme signals
            self._theme.disconnect(self._style_handler_id)
            self._theme.disconnect(self._resource_handler_id)

            # unbinding the canvas from the OpenGL viewport
            viewport.set_canvas(None)

            return super(PigmentFrontend, self).clean()

        dfr = self.controller.clean()
        dfr.addCallback(controller_cleaned)
        return dfr

    # canvas resize
    def _configure_callback(self, viewport, event):
        if not self._resize_delayed or not self._resize_delayed.active():
            self._resize_delayed = reactor.callLater(self._resize_delay,
                                                     self._resize_canvas)

    def _update_pass_callback(self, viewport, ticker):
        ticker.tick()

    def _resize_canvas(self):
        if self._viewport_size != self.viewport.size:
            self.debug("regeneration the canvas")
            self._viewport_size = self.viewport.size
            self.controller.widget.regenerate()

    # cursor hiding
    def _motion_notify_callback(self, viewport, event):
        viewport.cursor = pgm.VIEWPORT_INHERIT

        if self._cursor_delayed is not None and self._cursor_delayed.active():
            self._cursor_delayed.reset(self._cursor_delay)
        else:
            self._cursor_delayed = reactor.callLater(self._cursor_delay, self._hide_cursor)

    def _hide_cursor(self):
        self.viewport.cursor = pgm.VIEWPORT_NONE

    def _initialize_dbus(self):
        if 'dbus' not in sys.modules:
            # no dbus support
            return

        bus = dbus.SessionBus()
        self.bus_name = \
                dbus.service.BusName('com.fluendo.Elisa', bus)
        self.dbus_frontend = DBusFrontend(self, bus, 
                '/com/fluendo/Elisa/Plugins/Pigment/Frontend', self.bus_name)

    def _clean_dbus(self):
        if 'dbus' not in sys.modules:
            # no dbus support
            return

        bus = dbus.SessionBus()
        self.dbus_frontend.remove_from_connection(bus,
                '/com/fluendo/Elisa/Plugins/Pigment/Frontend')
        # BusName implements __del__, eew
        del self.bus_name

        # remove the reference cycle
        del self.dbus_frontend
        
    def _viewport_drag_motion_event(self, viewport, event):
        if not self._in_drag:
            self._in_drag = True
            
            # We only check the first uri since PoblesecController.playfiles()
            # will only check that anyway.
            file_uri = MediaUri(event.uri[0])
            # We only handle file for now because
            # PoblesecController.playfiles() only knows about files.
            if file_uri.scheme != 'file':
                self._drag_motion_result = False
            else:
                try:
                    media_type = typefinding.typefind(file_uri)
                    self._drag_motion_result = True
                except typefinding.UnknownFileExtension:
                    self.warning("Unknown file type: %s" % event.uri)
                    self._drag_motion_result = False

        return self._drag_motion_result
    
    def _viewport_drag_drop_event(self, viewport, event):
        self._in_drag = False
        files = []
        for uri_string in event.uri:
            uri = MediaUri(uri_string)
            if uri.scheme == 'file':
                #FIXME: shouldn't the unquote be handled in MediaUri?
                files.append(unquote(uri.path))

        # Delegate the playback to the main controller.
        self.controller.play_files(files)

    def _viewport_drag_leave_event(self, viepwort, event):
        self._in_drag = False

    def toggle_fullscreen(self):
        toggle_disabled=self.config.as_bool('disable_togglefullscreen')
        if not toggle_disabled:
            self.viewport.fullscreen = not self.viewport.fullscreen
