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

import gobject
from cPickle import dump, load, UnpicklingError
from elisa.core.utils import locale_helper
from elisa.core import media_uri
from elisa.plugins.pigment.widgets import const
from elisa.plugins.pigment.widgets.style import Style

from elisa.core import log

try:
    import gio
except ImportError:
    gio = None

import pkg_resources

import logging
import copy
import pprint
import os
import re

from cssutils import CSSParser

_DEFAULT_THEME = None

STYLES_FILENAME = 'styles.conf'
RESOURCES_FILENAME = 'resources.conf'


class ResourceNotExisting(Exception):
    pass


def makeTheme(module_name, styles_conf, resources_conf):
    theme = Theme(module_name=module_name, styles_conf=styles_conf,
                  resources_conf=resources_conf)
    return theme

class ValueWithUnit(object):
    """
    A shell for a value associated with a unit.

    @ivar value: the value to represent
    @type value: object
    @ivar unit:  the unit associated with the value
    @type unit:  str
    """

    def __init__(self, value, unit=None):
        """
        Constructor.

        @param value: the value to represent
        @type value:  object
        @param unit:  the unit associated with the value
        @type unit:   str
        """
        self.value = value
        self.unit = unit

    def __cmp__(self, other):
        if isinstance(other, ValueWithUnit):
            return self.value == other.value and self.unit == other.unit
        else:
            return self.value == other

class Theme(gobject.GObject):
    """
    A theme object, that adds styles to widgets properties and to stock
    resources.

    A basic theme will be built from the default configuration files, providing
    necessary style information for the widgets: without that, widgets won't
    work.

    @ivar widget_styles:   the styles for the widgets, for each state
    @type widget_styles:   dict of states to
                           L{elisa.plugins.pigment.widgets.Style}s
    @ivar stock_resources: the map of resource names to file paths
    @type stock_resources: a dict strings to strings
    @ivar fallback_themes: a dictionary of plugin names to Themes, caching
                           information necessary to do the right fallback
                           for missing resources
    @type fallback_themes: dictionary of strings to
                           L{elisa.core.components.theme.Theme}
    """

    __gsignals__ = {
        'styles-updated': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                          ()),
        'resources-updated': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                              ()),
    }

    default_theme = None

    _cache = {}

    # regular expression used to match style values of the form url(PATH)
    _url_regexp = re.compile('url\((\S+)\)')

    def __init__(self, module_name=None, styles_conf=None, resources_conf=None):
        """
        DOCME
        """
        gobject.GObject.__init__(self)
        self._init(module_name, styles_conf, resources_conf)

    def _init(self, module_name=None, styles_conf=None, resources_conf=None):
        # use a common _init() method that we can call from __init__ and from
        # __setstate__
        self.widget_styles = {}
        self.stock_resources = {}
        self.fallback_themes = {}
        self.fallback_theme = None
        self._module_name = module_name

        if styles_conf:
            self._init_styles(styles_conf)
        if module_name and resources_conf:
            self._init_resources(module_name, resources_conf)

        self._styles_conf = styles_conf
        self._resources_conf = resources_conf
        self._styles_monitor = None
        self._resources_monitor = None

        self.start_monitoring()

    def clean(self):
        self.stop_monitoring()

        for theme in self.fallback_themes.itervalues():
            if theme != None:
                theme.disconnect_by_func(self._fallback_theme_styles_updated)
                theme.disconnect_by_func(self._fallback_theme_resources_updated)

        self.widget_styles = {}
        self.stock_resources = {}
        self.fallback_themes = {}
        self.fallback_theme = None

    def _fallback_theme_styles_updated(self, fallback_theme):
        self.fallback_theme.update(fallback_theme)
        self.emit('styles-updated')

    def _fallback_theme_resources_updated(self, fallback_theme):
        self.fallback_theme.update(fallback_theme)
        self.emit('resources-updated')

    def _on_styles_changed(self, monitor, file, other_file, event):
        if event in (gio.FILE_MONITOR_EVENT_CHANGED, \
                     gio.FILE_MONITOR_EVENT_CREATED):
            self._init_styles(self._styles_conf)
            self.emit('styles-updated')

    def _on_resources_changed(self, monitor, file, other_file, event):
        if event in (gio.FILE_MONITOR_EVENT_CHANGED, \
                     gio.FILE_MONITOR_EVENT_CREATED):
            self._init_resources(self._module_name, self._resources_conf)
            self.emit('resources-updated')

    def start_monitoring(self):
        if not gio:
            return

        if self._styles_conf:
            file = gio.File(self._styles_conf)
            self._styles_monitor = file.monitor_file()
            self._styles_monitor.connect("changed", self._on_styles_changed)

        if self._resources_conf:
            file = gio.File(self._resources_conf)
            self._resources_monitor = file.monitor_file()
            self._resources_monitor.connect("changed", self._on_resources_changed)
        
        

    def stop_monitoring(self):
        if self._styles_monitor:
            self._styles_monitor.cancel()
            self._styles_monitor = None

        if self._resources_monitor:
            self._resources_monitor.cancel()
            self._resources_monitor = None

    def __reduce__(self):
        return makeTheme, (self._module_name, self._styles_conf,
                           self._resources_conf), self.__getstate__()

    def __getstate__(self):
        widget_styles = {}
        for widget, states in self.widget_styles.iteritems():
            for state, style in states.iteritems():
                widget_styles.setdefault(widget, dict())[state] = style.copy()

        return {'widget_styles': widget_styles,
                'stock_resources': self.stock_resources}

    def __setstate__(self, pickled):
        self._init()

        widget_styles = {}
        for widget, states in pickled['widget_styles'].iteritems():
            for state, style in states.iteritems():
                widget_styles.setdefault(widget, dict())[state] = Style(**style)

        self.widget_styles = widget_styles
        self.stock_resources = pickled['stock_resources']

    def _load_css(self, filename):
        parser = CSSParser(loglevel=logging.ERROR)
        try:
            # new api in cssutils 0.9.5b3
            href = media_uri.MediaUri({'scheme': 'file', 'path': filename})
            css = parser.parseFile(filename, href=href)
        except AttributeError, exc:
            css = parser.parse(filename)
        return css

    def _get_cache_directory(self):
        # put this in a method so it can be overridden in tests
        from elisa.core.default_config import CONFIG_DIR
        cache_dir = os.path.join(CONFIG_DIR, u'css')
        if not os.path.exists(cache_dir):
            os.makedirs(cache_dir)

        return cache_dir

    def _get_cached_file(self, filename):
        cache_dir = self._get_cache_directory()
        basename = filename.decode(locale_helper.system_encoding())
        basename = basename.replace(os.sep, u'_').replace(u':', u'_')
        cached_file = os.path.join(cache_dir, basename)
        # append the modification time of the original file in order to keep
        # the cache always synchronised: if the content of the file changes,
        # its modification time too and we will be looking for a different
        # cache file
        mtime = os.stat(filename).st_mtime
        cached_file += unicode(int(mtime))
        return cached_file

    def _get_from_cache(self, filename):
        # check that the cache on disk is valid
        cached_filename = self._get_cached_file(filename)
        if not os.path.exists(cached_filename):
            # no cache on disk has ever been created
            raise KeyError()

        try:
            theme = self._cache[filename]
        except KeyError:
            # cache on disk valid but never loaded
            # load the cache from disk
            cached_file = file(cached_filename)
            try:
                # Nesting try... except in try... finally to work with
                # python < 2.5
                try:
                    theme = load(cached_file)
                    if not isinstance(theme, Theme):
                        raise KeyError()
                except UnpicklingError:
                    raise KeyError()
                except TypeError:
                    # the object can't be reconstructed by pickle.
                    # Let's trigger a regenerate of the cache
                    raise KeyError()
            finally:
                cached_file.close()

            self._cache[filename] = theme

        return theme

    def _add_to_cache(self, filename, parsed):
        self._cache[filename] = parsed
        cached_filename = self._get_cached_file(filename)
        cached_file = file(cached_filename, 'w')
        try:
            dump(parsed, cached_file)
        finally:
            cached_file.close()

    def _guess_style_value_type(self, value, key=None):
        # Try to guess the type of a style property based on heuristics on the
        # key and the form of the value. Return the value typed if successful,
        # or the value as a string (leading and trailing double quotes
        # stripped) if no match can be found.

        # Try to match an integer
        try:
            typed = int(value)
            return typed
        except ValueError:
            pass

        # Try to match a floating point number
        try:
            typed = float(value)
            return typed
        except ValueError:
            pass

        # Try to match a tuple
        lst = value.split(',')
        if len(lst) in (2, 3, 4):
            # Try to match a tuple of integers
            try:
                lst = tuple(map(int, lst))
                return lst
            except ValueError:
                pass

            # Try to match a tuple of floating point numbers
            try:
                lst = tuple(map(float, lst))
                return lst
            except ValueError:
                pass

        # transform value to an absolute filepath if it is of the following form:
        #  url(RELATIVE_PATH)
        match = self._url_regexp.match(value)
        if match != None:
            path = match.group(1)
            # pkg_resources.resource_filename() handles strings in the
            # filesystem encoding, hence all these conversions.
            path = path.encode(locale_helper.filesystem_encoding())
            typed = pkg_resources.resource_filename(self._module_name, path)
            typed = typed.decode(locale_helper.filesystem_encoding())
            return typed

        # Try to match a boolean
        if value.lower() == "true":
            return True
        elif value.lower() == "false":
            return False

        # Try to match a measuring unit
        known_units = ("px", "%")
        for unit in known_units:
            head, sep, tail = value.rpartition(unit)
            if sep and not tail and head:
                # Try to match head with a floating point number
                try:
                    value = float(head)
                    return ValueWithUnit(value, unit)
                except ValueError:
                    pass

        return value.strip().strip('"')

    def _get_styles(self, filename):
        try:
            return self._get_from_cache(filename)
        except KeyError:
            theme = Theme()
            css = self._load_css(filename)
            for rule in css.cssRules:
                if rule.selectorText == 'defaults':
                    continue

                if ':' in rule.selectorText:
                    widget, state = map(str, rule.selectorText.split(':'))
                    state = getattr(const, state.upper())
                else:
                    widget, state = str(rule.selectorText), None

                if widget not in theme.widget_styles:
                    theme.widget_styles[widget] = {}

                props = {}
                for prop in rule.style:
                    value = self._guess_style_value_type(prop.value, prop.name)
                    props[str(prop.name)] = value

                style = Style(**props)
                widget_states = theme.widget_styles[widget]
                if state not in widget_states:
                    widget_states[state] = style
                else:
                    widget_states[state].update(style)

            self._add_to_cache(filename, theme)
            return theme

    def _get_resources(self, module_name, filename):
        try:
            return self._get_from_cache(filename)
        except KeyError:
            theme = Theme()
            css = self._load_css(filename)

            # allow redefinitions of "defaults" sections
            basedir = None
            for rule in css.cssRules:
                if rule.selectorText == 'defaults':
                    bdir = rule.style.getProperty('basedir')
                    if bdir is not None:
                        basedir = bdir.value.strip('"\'')
                else:
                    resource_prefix = str(rule.selectorText)

                    for prop in rule.style:
                        basename = prop.value.strip('"\'')
                        if basedir:
                            filepath = os.path.join(basedir, basename)
                        else:
                            filepath = basename
                        filepath = str(filepath.replace(os.path.sep, '/'))
                        resource_name = ".".join([resource_prefix, str(prop.name)])
                        abs_path = pkg_resources.resource_filename(module_name,
                                                                   filepath)
                        abs_path = abs_path.decode(locale_helper.system_encoding())
                        theme.stock_resources[resource_name] = abs_path

            self._add_to_cache(filename, theme)

            return theme

    def _init_styles(self, styles_conf):
        """
        Read the configuration file and fill up the widgets styles.
        """
        theme = self._get_styles(styles_conf)
        self.update(theme)

    def _init_resources(self, module_name, resources_conf):
        """
        Read the configuration file and fill up the stock resources.
        """
        theme = self._get_resources(module_name, resources_conf)
        self.update(theme)

    @classmethod
    def load_from_module(cls, module_name):
        """
        Build a L{elisa.plugins.pigment.widgets.Theme} object using the
        'styles.conf' and 'resources.conf' files found in the specified module.

        @param module_name: the module to search, in the absolute dotted
                            notation
        @type module_name:  C{str}

        @return:            the new theme, or C{None}
        @rtype:             L{elisa.plugins.pigment.widgets.Theme}
        """
        log_category = 'theme'

        try:
            styles_conf = pkg_resources.resource_filename(module_name,
                                                          STYLES_FILENAME)
            styles_conf = styles_conf.decode(locale_helper.system_encoding())
        except (ImportError, KeyError):
            # ImportError raised when resource not found in uninstalled plugin
            # KeyError raised when resource not found in an egg plugin
            styles_conf = ''
        if os.path.isfile(styles_conf):
            log.debug(log_category,
                      "Loading plugin theme from: %s" % styles_conf)
        else:
            log.debug(log_category, "Cannot find theme file: %s" % styles_conf)
            styles_conf = None

        try:
            resources_conf = pkg_resources.resource_filename(module_name,
                                                             RESOURCES_FILENAME)
            resources_conf = resources_conf.decode(locale_helper.system_encoding())
        except (ImportError, KeyError):
            # ImportError raised when resource not found in uninstalled plugin
            # KeyError raised when resource not found in an egg plugin:
            resources_conf = ''
        if os.path.isfile(resources_conf):
            log.debug(log_category,
                      "Loading resource theme from: %s" % resources_conf)
        else:
            log.debug(log_category,
                      "Cannot find resource file: %s" % resources_conf)
            resources_conf = None

        if resources_conf is None and styles_conf is None:
            return None

        module_theme = cls(module_name=module_name,
                           styles_conf=styles_conf,
                           resources_conf=resources_conf)
        return module_theme

    def update(self, other):
        """
        Merge in-place another theme.

        @param other: the theme from which to update
        @type other: L{elisa.plugins.pigment.widgets.Theme}
        """
        self.merge(other, inplace=True)

    def merge(self, other, inplace=False):
        """
        Merge with another theme, returning a new one.

        The new theme will have all the "properties" of the current style, with
        replaced values from the second, plus further "properties" coming from
        the other theme.

        @param other:   the theme to merge
        @type other:    L{elisa.plugins.pigment.widgets.Theme}
        @param inplace: whether to build another theme, or update the current
                        one
        @type inplace:  C{bool}

        @return:        the new theme
        @rtype:         L{elisa.plugins.pigment.widgets.Theme}
        """

        if inplace:
            new = self
        else:
            new = Theme()

        if not inplace:
            for key, value in self.stock_resources.items():
                new.stock_resources[key] = value
            for widget, styles in self.widget_styles.items():
                new.widget_styles[widget] = copy.deepcopy(styles)

        for key, value in other.stock_resources.items():
            new.stock_resources[key] = value
        for widget, styles in other.widget_styles.items():
            if widget not in new.widget_styles:
                new.widget_styles[widget] = other.widget_styles[widget]
            else:
                for state, style in styles.items():
                    style.update(other.widget_styles[widget][state])
                    new.widget_styles[widget][state] = style

        return new

    def get_style_for_widget(self, widget, state=None, search=True):
        """
        Get the style for a widget class in the specified state.

        @param widget: the classname of the widget
        @type widget:  C{str}
        @param state:  the state for which we want to retrieve the style
                       (one of L{elisa.plugins.pigment.widgets.const}.STATE_*)
        @type state:   C{int}

        @return:       the associated style, or C{None}
        @rtype:        L{elisa.plugins.pigment.widgets.Style}
        """
        if not widget:
            return None

        widget_styles = self.widget_styles.get(widget)
        if widget_styles is None and self.fallback_theme is not None:
            widget_styles = self.fallback_theme.widget_styles.get(widget)

        if widget_styles is None:
            if not search:
                return None

            return self.lookup(widget, 'style', state)

        return widget_styles.get(state)

    def get_resource(self, name, search=True):
        """
        Get the named resources, doing a lookup into the plugins' defaults if
        not found, or None.

        @param name:   the name of the resource
        @type name:    C{str}
        @param search: whether to do the lookup into the plugins defaults
        @type search:  C{bool}
        """
        media = self.stock_resources.get(name)
        if not media and self.fallback_theme:
            media = self.fallback_theme.stock_resources.get(name)

        if media:
            return media

        if not search:
            raise ResourceNotExisting(name)

        return self.lookup(name, 'resource')

    def lookup(self, name, type, state=None):
        """
        Dynamically search for the named resource ('style' or 'resource').

        If a suitable module is found during the search, a L{Theme} object will
        be built and cached for later use: it will be stored in a dictionary
        indexed by absolute module names (in the Python dotted notation).

        @param name:  the full qualified name to look for
                      (e.g.: 'elisa.plugins.pigment.widgets.button.Button'
                      or 'elisa.plugins.shelf.icon')
        @type name:   C{str}
        @param type:  'style' or 'resource'
        @type type:   C{str}
        @param state: the state of the widget. Only used if type == 'style'.
                      One of L{elisa.plugins.pigment.widgets.const}.STATE_*
        @type state:  C{int}
        @return:      the resource found (filepath or style), if any
        @rtype:       C{str} or L{elisa.plugins.pigment.widgets.Style} or
                      C{None}
        """
        if type == 'style' and ':' in name:
            name, state = name.split(':')
            state = getattr(const, state)

        module_name, resource_name = name.rsplit('.', 1)
        if module_name:
            try:
                module_theme = self.fallback_themes[module_name]
            except KeyError:
                module_theme = None
            else:
                # we already searched for the theme, without finding it
                if not self.fallback_themes[module_name]:
                    return
            if module_theme is None:
                module_theme = Theme.load_from_module(module_name)
                # remember that we couldn't find the theme
                self.fallback_themes[module_name] = module_theme

                if not module_theme:
                    return
                else:
                    module_theme.connect('styles-updated',
                                         self._fallback_theme_styles_updated)
                    module_theme.connect('resources-updated',
                                         self._fallback_theme_resources_updated)

                    if not self.fallback_theme:
                        self.fallback_theme = module_theme
                    else:
                        self.fallback_theme.merge(module_theme, inplace=True)

            if type == 'resource':
                #item = module_theme.get_resource(name.split('.')[-1], False)
                item = module_theme.get_resource(name, False)
            elif type == 'style':
                item = module_theme.get_style_for_widget(name, state, False)

            return item

    def __repr__(self):
        r = {}
        r['Theme'] = [{'widget_styles': self.widget_styles},
                      {'stock_resources': self.stock_resources}]

        return pprint.pformat(r)

    @classmethod
    def get_default(cls):
        """Get the default theme."""
        global _DEFAULT_THEME

        if not _DEFAULT_THEME:
            _DEFAULT_THEME = cls()

        return _DEFAULT_THEME

    @staticmethod
    def set_default(theme):
        """Set the default theme."""
        global _DEFAULT_THEME

        if _DEFAULT_THEME != None:
            _DEFAULT_THEME.clean()
        _DEFAULT_THEME = theme


gobject.type_register(Theme)
