#!/usr/bin/python
# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2007-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.
#
# Author: Alessandro Decina <alessandro@fluendo.com>

import os
import pkg_resources
import sys
from distutils.version import LooseVersion


elisa_local_directory = os.path.join(os.path.expanduser('~'), '.moovida')
if not os.path.exists(elisa_local_directory):
    os.makedirs(elisa_local_directory)


plugin_directories = [os.path.join(elisa_local_directory, 'plugins')]
plugin_path_environment = 'ELISA_PLUGIN_PATH'
if plugin_path_environment in os.environ:
    plugin_directories += \
        os.environ[plugin_path_environment].split(os.path.pathsep)


class Launcher(object):
    # Relative paths we set when running under windows. If you are the lucky
    # maintainer of d-dependencies these days, you probably want to keep this
    # part updated.
    win_pgm_path = r'deps\lib\pigment-0.3'
    win_pixbuf_module_file = r'etc\gtk-2.0\gdk-pixbuf.loaders'
    win_library_paths = ['.', 
            r'deps\bin',
            r'deps\lib\site-packages\pywin32_system32']
    win_python_paths = ['elisa-core',
            r'deps\lib',
            r'deps\lib\site-packages',
            r'deps\lib\site-packages\gtk-2.0',
            r'deps\lib\site-packages\gtk-2.0\\gobject',
            r'deps\lib\site-packages\win32',
            r'deps\lib\site-packages\win32com',
            r'deps\lib\site-packages\PIL',
            r'deps\lib\site-packages\gst-0.10',
            r'deps\lib\site-packages\win32\\lib',
            r'deps\libsite-packages\setuptools-0.6c8-py2.5.egg',
            r'deps\bin\DLLs',
            r'deps\lib\site-packages\pywin32_system32']
    
    def main(self, argv):
        """
        --> This is where you should start reading the source code <--

        The main entry point is split in two parts: main_before_voodoo() and
        main_after_voodoo().
        
        The first function does all kinds of black magic to try and load the
        most recent version of elisa.core. It should be AS MINIMAL AS IT CAN BE
        and SHOULD NEVER BE MODIFIED. When we ship a new version of the core in
        ~/.moovida/plugins, an OLD VERSION OF THE FUNCTION (the one installed
        in a systemwide path) WILL BE EXECUTED anyway.

        The second function does the actual initialization. If
        main_before_voodoo() is done right (as we're sure it is since I wrote
        it), the latest available version of main_after_voodoo() will be
        executed.

        Yes, that's right. I'm awesome.
        """
        self.main_before_voodoo()
        # from now on it's safe to import stuff
        return self.main_after_voodoo(argv)

    def setup_win_output_redirection(self):
        # See http://www.py2exe.org/index.cgi/StderrLog
        log_file = os.path.join(elisa_local_directory, 'moovida.log')
        sys.stdout = sys.stderr = open(log_file, 'w')

    def set_win_environment_pre_voodoo(self):
        if hasattr(sys, 'frozen'):
            if not sys.executable.endswith('moovida_console.exe'):
                self.setup_win_output_redirection()
            
            # delete GST_PLUGIN_PATH as it can conflict with our own plugins
            environment = self.get_environment()
            if environment.get('GST_PLUGIN_PATH', ''):
                environment['GST_PLUGIN_PATH'] = ''

        prefix = self.get_win_prefix()
        
        # set PATH as soon as possible as we import
        # twisted.internet.glib2reactor that glib and other dlls
        library_paths = [os.path.join(prefix, entry)
                for entry in self.win_library_paths]
        self.add_environment_path('PATH', library_paths, prepend = True)
        
        # FIXME: python in the build system is BROKEN so we have to do this. We
        # REALLY need to fix it, but it's not going to happen anytime soon so...
        python_paths = [os.path.join(prefix, entry)
                for entry in self.win_python_paths]
        sys.path.extend(python_paths)
    
    def main_before_voodoo(self):
        import elisa
        import elisa.core
        import elisa.core.launcher
        import elisa.plugins

        if self.get_platform() == 'Windows':
            self.set_win_environment_pre_voodoo()

        launcher_version_info = elisa.core.version_info
        self.info('Launcher core version: %s' % elisa.core.__version__)

        # get the set of elisa.* modules imported at this time
        our_modules = set([module for module in sys.modules.itervalues()
                if module is not None and module.__name__.startswith('elisa')])

        expected = set([elisa, elisa.core, elisa.core.launcher, elisa.plugins])

        unexpected = our_modules - expected
        if unexpected:
            self.error('WARNING: some unexpected modules are loaded: %s'
                    % (' '.join([module.__name__ for module in unexpected])))
        
        requirement = pkg_resources.Requirement.parse('elisa')
        core_dist = pkg_resources.working_set.find(requirement)

        working_set = pkg_resources.working_set
        
        # remove the core distribution from the working_set
        
        # what we want to do here is this:
        # working_set.entry_keys[core_dist.location].remove(core_dist.key)
        # unfortunately some versions of pkg_resources don't normalize
        # path entries so the same distribution could be under different paths
        entries = [(path_entry, keys) 
                for path_entry, keys in working_set.entry_keys.iteritems()
                if core_dist.key in keys]
        for entry, keys in entries:
            keys.remove(core_dist.key)

        del working_set.by_key[core_dist.key]

        # empty elisa.__path__, it will be populated again when we add again the
        # core to the working_set.
        # NOTE: when you do:
        # python setup.py install --root=/ --single-version-externally-managed
        # which is what packagers are doing (god forbid them), namespaces are
        # not used so we have to put back elisa.__path__ ourselves 
        old_path, elisa.__path__ = list(elisa.__path__), []

        env = pkg_resources.Environment(plugin_directories)
        distributions, errors = pkg_resources.working_set.find_plugins(env)
        new_core_dist = None
        for dist in distributions:
            if dist.project_name == 'elisa':
                # Is this core more recent than the current one?
                if LooseVersion(dist.version) > LooseVersion(core_dist.version):
                    new_core_dist = dist
                    break

        if new_core_dist is None:
            # oh well, put back the old one
            working_set.add(core_dist)
        else:
            # found a new core
            working_set.add(new_core_dist)

        if not elisa.__path__:
            # no namespace packages? put back the old path
            elisa.__path__ = old_path

        # we need to install the glib2reactor before importing
        # twisted.python.rebuild else it will load another reactor
        import gobject
        gobject.threads_init()
        from twisted.internet import glib2reactor
        glib2reactor.install()

        from twisted.python.rebuild import rebuild
        # reload this very same code...
        rebuild(elisa.core)
        rebuild(elisa.core.launcher)
        # BAM.
        
        self.info('Current core version: %s' % elisa.core.__version__)

        current_version_info = elisa.core.version_info
        if current_version_info < launcher_version_info:
            # THIS SHOULD NEVER HAPPEN
            self.error('PANIC! Current core older than the previous?\n'
                    'Expect massive breakage.')

            # oh well.. let's go on and give it a shot anyway...

    def main_after_voodoo(self, argv):
        if self.get_platform() == 'Windows':
            self.set_win_environment_after_voodoo()
        
        from elisa.core.options import Options
        from twisted.python.usage import UsageError

        # This part should probably be moved to Application. Since I don't want
        # to touch Application now, leave it as something for another day (and
        # another dev).
        self.options = Options()
        try:
            self.options.parseOptions(argv[1:])
        except UsageError, errortext:
            self.error('%s: %s' % (argv[0], errortext))
            self.error('%s: Try --help for usage details.' % (argv[0]))
            sys.exit(1)
        
        if self.poke_running_instance():
            self.info('An instance of Moovida is already running, exiting.')
            self.exit(2)

        self.run_application()
    
    def set_win_environment_after_voodoo(self):
        prefix = self.get_win_prefix()
        environment = self.get_environment()

        # note we use add_environment_path and not set_environment to allow
        # developers to override our settings, which is what we usually do when
        # running elisa uninstalled in windows
        pgm_plugin_path = os.path.join(prefix, self.win_pgm_path)
        self.add_environment_path('PGM_PLUGIN_PATH', [pgm_plugin_path])
   
        # set GDK_PIXBUF_MODULE_FILE if it isn't set yet
        pixbuf_loaders = os.path.join(prefix, 'deps',
                'etc', 'gtk-2.0', 'gdk-pixbuf.loaders')
        if not environment.get('GDK_PIXBUF_MODULE_FILE', ''):
            environment['GDK_PIXBUF_MODULE_FILE'] = pixbuf_loaders

    def poke_running_instance(self):
        if self.get_platform() == 'Windows':
            res = self.poke_win_running_instance()
        else:
            res = self.poke_unix_running_instance()

        return res

    def poke_unix_running_instance(self):
        try:
            import dbus
            import dbus.mainloop.glib
        except ImportError:
            # can't detect if an instance is running
            return False

        dbus.set_default_main_loop(dbus.mainloop.glib.DBusGMainLoop())
        bus = dbus.SessionBus()
        daemon = bus.get_object(dbus.BUS_DAEMON_NAME, dbus.BUS_DAEMON_PATH)
        daemon_iface = dbus.Interface(daemon, dbus.BUS_DAEMON_IFACE)

        if self.options.get('force-startup', False):
            return

        if 'com.fluendo.Elisa' not in daemon_iface.ListNames():
            return False
 
        if len(self.options['files']) > 0:
            bus = dbus.SessionBus()
            file_player = bus.get_object('com.fluendo.Elisa',
                    '/com/fluendo/Elisa/Plugins/Poblesec/FilePlayer')
            file_player_iface = dbus.Interface(file_player,
                    'com.fluendo.Elisa.Plugins.Poblesec.FilePlayer')
            file_player_iface.play_file(self.options['files'])
            
            # Set focus to the window
            frontend = bus.get_object('com.fluendo.Elisa',
                    '/com/fluendo/Elisa/Plugins/Pigment/Frontend')
            frontend_iface = dbus.Interface(frontend,
                    'com.fluendo.Elisa.Plugins.Pigment.Frontend')
            frontend_iface.show()
            
        return True

    def poke_win_running_instance(self):
        from win32gui import FindWindow
        from win32api import SendMessage
        import pywintypes
        import ctypes
        from ctypes import Structure, POINTER, byref, c_void_p, c_char, sizeof, cast
        from ctypes.wintypes import ULONG
        from elisa.core.utils.mswin32.structures import COPYDATASTRUCT

        from win32con import WM_COPYDATA

        try:
            handle = FindWindow("PIGMENT_CLASS", "Moovida Media Center")
        except pywintypes.error:
            # As stated in the MSDN docs on:
            # http://msdn.microsoft.com/en-us/library/ms633499.aspx
            # FindWindow call should return a NULL handle if the
            # window wasn't found but it's not the case on windows < 7
            return False

        # on windows7 (experimental, build 7100), FindWindow doesn't
        # raise an exception but returns an NULL handle if the window
        # wasn't found, as correctly stated in the above MSDN link.
        if handle == 0:
            return False

        if len(self.options['files']) > 0: 
            # We use '|' as a file separator
            command_line = '|'.join(self.options['files'])
            
            class COMMANDLINEDATA(Structure):
                _fields_ = [("data", c_char * len(command_line))]
            
            commandlinedata = COMMANDLINEDATA()
            commandlinedata.data = command_line
            copydata = COPYDATASTRUCT()
            COMMAND_LINE = POINTER(ULONG)()
            COMMAND_LINE.value = 1
            copydata.dwData = COMMAND_LINE
            copydata.cbData = sizeof(commandlinedata)
            copydata.lpData = cast(byref(commandlinedata), c_void_p)
            SendMessage(handle, WM_COPYDATA, 0, copydata)

        return True

    # some trivial utility functions used to make mocking in tests easier
    def info(self, message):
        print message

    def error(self, message):
        print >> sys.stderr, message
    
    def get_platform(self):
        import platform
        return platform.system()

    def get_environment(self):
        return os.environ

    def get_win_prefix(self):
        # assume we're running with a py2exe launcher in windows
        return os.path.dirname(sys.executable)

    def add_environment_path(self, variable, entries, prepend=False):
        environment = self.get_environment()
        value = environment.get(variable, '').split(os.pathsep)
        if prepend:
            value = entries + value
        else:
            value.extend(entries)
        environment[variable] = os.pathsep.join(value)

    def run_application(self):
        # FIXME: this should all be put in Application
        from elisa.core.application import Application
        from elisa.core import common
        from twisted.internet import reactor

        application = Application(self.options)
        common.set_application(application)


        def start_app():
            def initialize_done(result):
                return application.start()

            def initialize_failure(failure):
                print 'Moovida failed to initialize:', failure
                reactor.stop()

            dfr = application.initialize()
            dfr.addCallbacks(initialize_done, initialize_failure)

        reactor.callWhenRunning(start_app)

        # this could lead to an ugly "'NoneType' object is
        # unsubscriptable" in twisted, when the reactor gets killed from
        # the outside. Bug is reported and fixed in twisted-svn/trunk:
        # http://twistedmatrix.com/trac/ticket/2265
        reactor.addSystemEventTrigger('before', 'shutdown', application.stop)
        reactor.run()

    def exit(self, code):
        sys.exit(code)


def main():
    Launcher().main(sys.argv)


if __name__ == '__main__':
    main()
