# -*- 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.

"""
Module responsible for starting the Application
"""

__maintainer__ = 'Philippe Normand <philippe@fluendo.com>'

import sys
import os
import datetime
import gobject
import platform


from elisa.core import __version__, version_info
from elisa.core.config import Config, ConfigError
from elisa.core.log import Loggable
from elisa.core.utils import exception_hook, locale_helper
from elisa.core.utils.splash_screen import SplashScreen
from elisa.core.utils.update_checker import UpdateChecker
from elisa.core import bus
from elisa.core import media_uri

from elisa.core.components.message import Message

from elisa.core import config_upgrader
from elisa.core.launcher import plugin_directories
from elisa.core.plugin_registry import PluginRegistry
from elisa.core import input_manager, service_manager
from elisa.core import metadata_manager, resource_manager
from elisa.core import interface_controller
from elisa.core.media_directory_helper import MediaDirectoryHelper

from twisted.internet import reactor, defer

# import pkg_resources after plugin_registry
import pkg_resources

# database
try:
    from elisa.extern.storm_wrapper import store
    from storm.locals import create_database
except ImportError:
    store = None

# import the default configuration and settings
from elisa.core.default_config import CONFIG_DIR, DEFAULT_CONFIG, CONFIG_FILE

# Although PICTURES_CACHE is deprecated, it is kept here for backward
# compatibility as some external plugins import it from here.
from elisa.core.default_config import PICTURES_CACHE

from distutils.version import LooseVersion


class ComponentsLoadedMessage(Message):
    """
    Sent when all components have been instantiated
    """


class NewElisaVersionMessage(Message):
    """
    Sent when Application detects a new Elisa version is available
    online.

    @ivar version: new version in dotted syntax (ex: 10.0.0)
    @type version: unicode
    @ivar installer_url:     URL of the new win32 installer (optional)
    @type installer_url:     unicode
    """

    def __init__(self, version, installer_url=None):
        super(NewElisaVersionMessage, self).__init__()
        self.version = version
        self.installer_url = installer_url


class BaseApplication(Loggable):
    """
    @ivar first_run:            wether or not the Application is being executed for the
                                first time.
    @type first_run:            C{bool}
    """

    config = None
    plugin_registry = None

    def __init__(self, options, plugin_directories):
        # we do this here and not in initialize() as this needs to be done as
        # soon as possible
        super(BaseApplication, self).__init__()

        from elisa.core import log
        log.init()

        self.first_run = False
        config = options['config-file']
        self._config_filename = self._get_config_filename(config)
        if not os.path.exists(self._config_filename):
            self.first_run = True

        self.config, self._install_date, self._user_id = self._load_config(config)
        # this must be called before the creation of the plugin registry
        self._config_breakage_fix()
        self.plugin_registry = self._create_plugin_registry(plugin_directories)

    def _config_breakage_fix(self):
        # This is a part of the fix of
        # https://bugs.launchpad.net/elisa/+bug/381404. Basically, we consider
        # that it doesn't make sense to disable elisa-plugin-database since
        # moovida is quite unusable without it, and therefore, we change the
        # conf if we find it disabled.

        disabled_plugins = self.config.get_option('disabled_plugins',
                                                  section='general',
                                                  default=[])

        if 'elisa-plugin-database' in disabled_plugins:
            disabled_plugins.remove('elisa-plugin-database')

        self.config.set_option('disabled_plugins', disabled_plugins,
                               section='general')

    def _create_plugin_registry(self, plugin_directories):
        disabled_plugins = self.config.get_option('disabled_plugins',
                                                  section='general',
                                                  default=[])
        plugin_registry = PluginRegistry(self.config, plugin_directories)
        plugin_registry.load_plugins(disabled_plugins)
        return plugin_registry

    def _get_config_filename(self, config):
        if config is not None and not isinstance(config, basestring):
            # config is already a config-object
            config_filename = config.get_filename()
        elif config is None:
            config_filename = os.path.join(CONFIG_DIR, CONFIG_FILE)
        else:
            # config is a string
            config_filename = config

        dirname = os.path.dirname(config_filename)
        if dirname and not os.path.exists(dirname):
            try:
                os.makedirs(dirname)
            except OSError, e:
                self.warning("Could not create '%s': %s" % (dirname, e))
                raise
        return config_filename

    def _load_config(self, config):
        today = datetime.date.today().isoformat()
        if config is not None and not isinstance(config, basestring):
            # config is already a config-object
            cfg = config
        else:
            config_filename = self._config_filename
            self.info("Using config file: %r", config_filename)

            # we store byte strings in config so we need to encode the
            # db file path.
            config_dir = CONFIG_DIR.encode(locale_helper.system_encoding())
            config_dir = config_dir.replace('\\','/')
            db_string = 'sqlite:%s/moovida.db' % config_dir

            default_config = DEFAULT_CONFIG % {'version': __version__,
                                               'install_date': today,
                                               'database_uri': db_string}

            try:
                cfg = Config(config_filename, default_config=default_config)
            except ConfigError, error:
                self.warning(error)
                raise

            if not cfg.first_load:
                # ok we might have an old config format here
                upgrader = config_upgrader.ConfigUpgrader(cfg, default_config)
                cfg = upgrader.update_for(version_info)

        install_date = cfg.get_option('install_date', section='general',
                                      default=today)
        user_id = cfg.get_option('user_id', section='general', default='')
        # Note: a redirection incident on the server provoked the user_id to
        # be set to None; the code has been shielded to not fallback on None
        # anymore but on the default value ''.
        if user_id == None:
            user_id = ''
        return (cfg, install_date, user_id)

    def is_power_user(self):
        """
        Return whether the user is a "power user".

        A power user has access to advanced functionality, e.g. advanced
        configuration settings in the UI or installation of unstable plugins.
        Some advanced features may be unstable, the power user will use them at
        his own risk.

        @return: whether the user is a "power user"
        @rtype:  C{bool}
        """
        option = self.config.get_option('power_user', section='general',
                                        default='0')
        # All other values than '1' translate to False.
        return (option == '1')


class Application(BaseApplication):
    """ Application is the entry point of Elisa. It groups all the necessary
    elements needed for Elisa to run. It is in charge of instantiating a
    Config and a PluginRegistry. Application also provides access to
    input events and data, and holds the user interfaces.

    @ivar plugin_registry:      loads and manages the plugins
    @type plugin_registry:      L{elisa.core.plugin_registry.PluginRegistry}
    @ivar config:               Application's configuration file, storing options
    @type config:               L{elisa.core.config.Config}
    @ivar options:              Options passed on the command line when
                                launching elisa
    @type options:              L{elisa.core.options.Options}
    @ivar bus:                  DOCME
    @type bus:                  L{elisa.core.bus.Bus}
    @ivar metadata_manager:     DOCME
    @type metadata_manager:     L{elisa.core.metadata_manager.MetadataManager}
    @ivar resource_manager:     DOCME
    @type resource_manager:     L{elisa.core.resource_manager.ResourceManager}
    @ivar service_manager:      DOCME
    @type service_manager:      L{elisa.core.service_manager.ServiceManager}
    @ivar interface_controller: DOCME
    @type interface_controller: L{elisa.core.interface_controller.InterfaceController}
    @ivar input_manager:        DOCME
    @type input_manager:        L{elisa.core.input_manager.InputManager}
    @ivar store:                the access point to the database using storm
    @type store:                L{elisa.extern.twisted_storm.store.DeferredStore}
    """

    log_category = "application"

    def __init__(self, options):
        super(Application, self).__init__(options, plugin_directories)
        
        if SplashScreen is None or options['nosplash']:
            self._splash = None
        else:
            self._splash = SplashScreen()
            self._splash.show_all()

        # load the exception hook asap
        self.show_tracebacks = True
        self._load_exception_hook()

        self.debug("Creating")
        self.running = False

        self.store = None

        self.bus = bus.Bus()

        self.service_manager = service_manager.ServiceManager()
        self.metadata_manager = metadata_manager.MetadataManager()
        self.resource_manager = resource_manager.ResourceManager()
        self.input_manager = input_manager.InputManager()

        for manager in (self.service_manager, self.metadata_manager,
                        self.resource_manager, self.input_manager):
            self.plugin_registry.register_plugin_status_changed_callback(manager.plugin_status_changed_cb)

        self.interface_controller = interface_controller.InterfaceController()
        self.options = options

    def _update_check_callback(self, results):
        try:
            version = results['version']
        except KeyError:
            version = None

        try:
            user_id = results['user_id']
        except KeyError:
            # Note: server side issues will trigger that code path; it is vital
            # to handle it well, that is to set user_id to the right default
            # value: ''
            user_id = ''

        try:
            installer_url = results['installer_url']
        except KeyError:
            installer_url = None

        if version and LooseVersion(version) > LooseVersion(__version__):
            msg = NewElisaVersionMessage(version, installer_url)
            self.bus.send_message(msg)

        # store user_id in config
        self.config.set_option('user_id', user_id, section='general')
        self._user_id = user_id

        # store the country code
        try:
            self.config.set_option('country_code', results['country_code'],
                    section='general')
        except KeyError:
            # country code not given in the response
            pass

        # write the config file as soon as the user_id is generated as to
        # avoid to the maximum possible extent multiple id per user
        self.config.write()

    def _load_exception_hook(self):
        """ Override the default system exception hook with our own
        """
        # FIXME: make this configurable
        self.logdir = None
        self.debug_level = int(self.show_tracebacks)

        sys.excepthook = self._excepthook
        # log twisted errors: Deactivated
        # from twisted.python import log
        # log.err = self.log_failure

    def _excepthook(self, *args):
        data = exception_hook.format_traceback(*args)
        path = exception_hook.write_to_logfile(data, self.logdir)
        self.warning("An Traceback occurred and got saved to %s" % path)
        self._after_hook(data)

    def _after_hook(self, data):
        if self.debug_level > 0:
            print data
            if self.debug_level == 2:
                try:
                    import pdb
                except ImportError:
                    print "pdb missing. debug shell not started"
                    return

                print "You are now in a debug shell. Application hold until" \
                      " you press 'c'!"
                pdb.set_trace()

    # Exception handling methods
    def log_traceback(self):
        """
        Log the traceback without stopping the process. This could ususally be
        used in parts, where you want to go on and log the exception.
        Example::

            try:
                component.initialize()
            except:
                # and log all the other exceptions
                path = application.log_traceback()
                self.warning("Initilize Component '%s' failed. Traceback saved at %s" % path)
            self.going_on()

        @return: path to the file, where the traceback got logged
        """
        data = exception_hook.format_traceback()
        path = exception_hook.write_to_logfile(data, self.logdir)
        self._after_hook(data)
        return path

    def log_failure(self, failure):
        """
        Log the twisted failure without re-raising the exception. Example in
        an errback::

            def errback(failure):
                path = application.log_failure(failure)
                self.warning("Connection refused. Full output at %s" % path)
                return

        @param failure: the failure to log
        @type failure:  L{twisted.python.failure.Failure}

        @return: path to the file, where the traceback got logged
        """
        data = exception_hook.format_failure(failure)
        path = exception_hook.write_to_logfile(data, self.logdir)
        self._after_hook(data)
        return path

    def initialize(self):
        """
        Load the providers for the different managers, then initialize the
        interface_controller.
        """
        # If the configuration directory does not exist, create it
        if not os.path.exists(CONFIG_DIR):
            os.makedirs(CONFIG_DIR, 0755)

        # we want to load the providers and then initialize the
        # interface controller
        self.media_directories = MediaDirectoryHelper()

        def initialize_interface(result):
            return self.interface_controller.initialize()

        def enable_plugins(result):
            # Enable all the loaded plugins
            return self.plugin_registry.enable_plugins()

        def ready(result):
            self.bus.send_message(ComponentsLoadedMessage())

        def ipython_shell(result):
            if self.options['shell']:
                def start_shell():
                    try:
                        from IPython.Shell import IPShellEmbed
                    except ImportError:
                        self.warning("IPython not available, --shell option " \
                                     "ignored")
                    else:
                        IPShellEmbed([])()
                reactor.callInThread(start_shell)
            return result

        dfr = self.initialize_db()
        dfr.addCallback(initialize_interface)
        dfr.addCallback(enable_plugins)
        dfr.addCallback(ready)
        dfr.addCallback(ipython_shell)
        return dfr

    def initialize_db(self):
        """
        initialize the database depending on the configuration
        """
        if store == None:
            self.warning("Could not import storm. Database disabled")
            return defer.succeed(None)

        db_string = self.config.get_option('database', section='general',
                                                    default='')

        # ensure the db path is a valid unicode string for the OS
        db_string = db_string.decode(locale_helper.system_encoding())

        try:
            db = create_database(db_string)
        except Exception, e:
            return defer.fail(e)

        self.store = store.DeferredStore(db, auto_reload=False)
        return self.store.start()

    def start(self):
        """
        Execute the application.

        Start the bus and the update checker.
        """
        if hasattr(gobject, 'set_prgname'):
            gobject.set_prgname('elisa')

        self.running = True
        self.info("Starting")

        self.bus.start()

        extra_affiliation_params = {'aen': '', 'entity_id': '', 'referrer': '',
                                    'traffic_unit': ''}
        if platform.system() == 'Windows':
            from elisa.core.utils.mswin32.tools import \
                should_install_recommended_plugins
            from elisa.core.utils.mswin32 import tools
            import win32con

            self.config.set_option('auto_install_new_recommended_plugins',
                                   should_install_recommended_plugins(),
                                   section='plugin_registry')

            elisa_reg_key = tools.ElisaRegKey(win32con.HKEY_LOCAL_MACHINE)
            mapping = {'aen': 'aen', 'entity_id': 'aid',
                       'referrer': 'referer', 'traffic_unit': 'sid'}
            for key, value in mapping.iteritems():
                try:
                    reg_value = elisa_reg_key.get_value(value)
                    reg_value = media_uri.quote(reg_value)
                    self.debug("Value for %r in windows registry: %r",
                               value, reg_value)
                except:
                    self.debug("%r not found in windows registry", value)
                    reg_value = ''
                extra_affiliation_params[key] = reg_value

            elisa_reg_key.close()

        self.update_checker = UpdateChecker(self._install_date, self._user_id,
                                            __version__,
                                            **extra_affiliation_params)
        self.update_checker.start(self._update_check_callback)

        self._close_splash_screen()

        return defer.succeed(True)

    def stop(self, stop_reactor=True):
        """
        Stop the application.

        @param stop_reactor: stop the reactor after stopping the application
        @type stop_reactor:  C{bool}
        @rtype:              L{twisted.internet.defer.Deferred}
        """
        self._close_splash_screen()

        self.update_checker.stop()

        def interface_controller_stopped(result):
            self.info("Stopping managers")

            manager_deferreds = []
            for manager in (self.metadata_manager, self.resource_manager,
                            self.service_manager, self.input_manager):
                manager_deferreds.append(manager.clean())
            if self.store:
                # FIXME: store is not closed properly: self.store.stop should be
                # called after the commit
                manager_deferreds.append(self.store.commit())

            dfr = defer.DeferredList(manager_deferreds)
            dfr.addCallback(managers_stopped)
            return dfr

        def managers_stopped(managers):
            self.info("Stopping reactor")
            self.bus.stop()


            if self.config:
                self.config.write()

            if stop_reactor and reactor.running:
                if platform.system() == 'Windows':
                    # FIXME: extremely ugly hack to terminate Elisa under Windows
                    # fix needed:
                    # - fix the locks in gst_metadata when it is out of process
                    reactor.fireSystemEvent('shutdown')
                    from elisa.core.utils.mswin32 import tools
                    try:
                        tools.exit()
                    except AssertionError:
                        # Running elisa uninstalled
                        import win32process
                        win32process.ExitProcess(0)
                else:
                    reactor.stop()
                    # FIXME: workaround the fact that Pigment has issues quitting
                    # when used throught Python (rendering thread is trying to
                    # access the GIL while the main thread has it locked)
                    os._exit(0)

        if self.running:
            self.running = False
            # stop the interface controller and the
            # managers
            self.info("Stopping interface controller")
            dfr = self.interface_controller.stop()
            dfr.addCallback(interface_controller_stopped)
            return dfr

        return defer.succeed(None)

    def _close_splash_screen(self):
        if self._splash != None:
            self._splash.destroy()
