# Copyright (C) 2008 LottaNZB Development Team
# 
# This program 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; version 3.
# 
# This program 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, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.

"""Distributed configuration system with validation support, nested sections...

This module aims to simplify the handling of configuration data in an
application. It doesn't contain any information about the actual configuration
used by the application such as its structure, default values, validation
routines etc.

Configuration files are parsed and generated using Python's ConfigParser class,
so their fully standard-compliant. Subsections, which aren't covered by the
standard are implemented using the dot character, e. g. [section.subsection].

Each section and all of its content is stored as an instance of ConfigSection.
This class has two subclasses: GConfigSection and VanillaConfigSection.
GConfigSection, being a subclass of GObject, lets you enforce certain
option value types and specify validation routines.

If this module needs to create a ConfigSection instance, it will look for a
class named 'Config' in the application module that matches the section's name.
(e. g. the class Config in the modes module for the section called [modes]).
If it doesn't find such a customized class, it falls back to
VanillaConfigSection.

The class Config in this module loads and saves the configuration file.
In the case of LottaNZB, this is done during the initialization phase of
lottanzb.core.App.

Both options and subsections can be accessed either as attributes using
brackets. E. g.:

    print App().config.modes.active
    App().config["plugins"].categories["enabled"] = False
    App().save()

This module automatically upgrades an old-fashioned XML configuration file to
the new format.
"""

import types

import logging
log = logging.getLogger(__name__)

from kiwi.python import namedAny as named_any
from ConfigParser import ConfigParser, ParsingError
from copy import deepcopy

from lottanzb.util import GObject, _

class OptionError(Exception):
    """Raised if there is a problem accessing or changing an option"""
    
    def __init__(self, section, option):
        self.section = section
        self.option = option
        
        Exception.__init__(self)

class InexistentOptionError(OptionError, AttributeError):
    """Raised when trying to access an option that doesn't exist"""
    
    def __init__(self, section, option):
        OptionError.__init__(self, section, option)
        AttributeError.__init__(self)
    
    def __str__(self):
        return "Invalid option '%s' in section '%s'." % \
            (self.option, self.section.get_full_name())

class InvalidOptionError(OptionError):
    """Raised when trying to assign an invalid value to an option"""
    
    def __str__(self):
        return "Invalid value for option '%s' in section '%s'." % \
            (self.option, self.section.get_full_name())

class ConfigSection(object):
    """The abstract base class for a single configuration section"""
    
    DELIMITER = "."
    
    def __init__(self, name, parent=None, options=None):
        self.name = name
        self.parent = parent
        
        self.merge(options or {})
    
    def __eq__(self, other):
        """
        Overrides Python's built-in mechanism used to test for equality of two
        configuration sections. The section's parents may be different from
        each other, but the section names and all options and subsections must
        match.
        """
        
        if not isinstance(other, ConfigSection):
            return False
        
        return \
            self.get_full_name() == other.get_full_name() and \
            self.get_sections() == other.get_sections() and \
            self.get_options() == other.get_options()
    
    def __ne__(self, other):
        """
        Checks if there are any differences between two configuration sections.
        """
        
        return not self.__eq__(other)
    
    def merge(self, options):
        """Merge the content of a dictionary-like object into the object.
        
        Please note that this method tries to preserve existing ConfigSection
        objects, so that references to them don't break.
        """
        
        for key in options:
            try:
                assert isinstance(self[key], ConfigSection)
            except (AssertionError, OptionError):
                self[key] = options[key]
            else:
                self[key].merge(options[key])
    
    def get_sections(self):
        """Returns a dictionary containing all subsections of this section"""
        
        sections = {}
        
        for key in self:
            value = self[key]
            
            if isinstance(value, ConfigSection):
                sections[key] = value
        
        return sections
    
    def get_options(self):
        """Returns a dictionary containing all options in this section"""
        
        options = {}
        
        for key in self:
            value = self[key]
            
            if not isinstance(value, ConfigSection):
                options[key] = value
        
        return options
    
    def apply_section_class(self, name, cls):
        """
        Manually apply a custom ConfigSection class to this section.
        
        This method is only meant to be used if this module's mechanism of
        looking up custom ConfigSection classes won't be able to find it
        automatically.
        """
        
        if name in self.get_sections():
            if self[name].__class__ is cls:
                # The subsection is already based on this class. We've got
                # nothing to do.
                return self[name]
            
            section = cls(name, parent=self, options=self[name])
        else:
            section = cls(name, parent=self)
        
        self[name] = section
    
    def get_by_path(self, path):
        """
        Returns the value of a nested option based on its path.
        
        The following two lines of code return the same:
        config.get_by_path(["modes", "active"])
        config.modes.active
        """
        
        key = path.pop(0)
        
        if path:
            return self[key].get_by_path(path)
        else:
            return self[key]
    
    def set_by_path(self, path, value):
        """
        Sets the value of a nested option based on its path.
        
        The following two lines of code do the same:
        config.set_by_path(["modes", "active"], "standalone")
        config.modes.active = "standalone"
        """
        
        self.merge(self.path_to_dict(path, value))
    
    def get_option_repr(self, key):
        """
        Return the string representation of an option, which is going to be
        stored in the configuration file.
        
        Subclasses may override this method.
        """
        
        return str(self[key])
    
    def deep_copy(self):
        """
        Creates a deep copy of this section. Please note that the parent
        section remains the same.
        """
        
        return deepcopy(self, { id(self.parent): self.parent })
    
    def get_path(self):
        """Returns a list of the names of all parent sections."""
        
        sections = [self.name]
        section = self
        
        while section.parent is not None:
            section = section.parent
            sections.append(section.name)
        
        sections.reverse()
        
        return sections
    
    def get_full_name(self):
        """Returns the complete section name"""
        
        return self.path_to_name(self.get_path())
    
    def _process_raw_option(self, key, value):
        """
        This method is called whenever a new value is assigned to both an
        option or a subsection of this section. It ensures that plain
        dictionaries are properly converted to VanillaConfigSection objects or
        custom ConfigSection classes, given that they can be found.
        
        It also ensures that subsection have the right parent reference.
        """
        
        if type(value) is dict and not isinstance(value, VanillaConfigSection):
            cls = self.find_section_class(key) or VanillaConfigSection
            value = cls(key, parent=self, options=value)
        elif isinstance(value, ConfigSection):
            value.parent = self
        
        return value
    
    @staticmethod
    def path_to_dict(path, value):
        """
        Turns a path into a nested dictionary.
        
        Example:
        
        Config.path_to_dict(["modes", "active"], "standalone")
         => { 'modes' : { 'active' : 'standalone' } }
        """
        
        path = path[:]
        option = { path.pop(): value }
        
        path.reverse()
        
        for section in path:
            option = { section: option }
        
        return option
    
    def path_to_name(self, path):
        """Build a section name out of a list of section names"""
        
        return self.DELIMITER.join(path)
    
    def find_section_class(self, section):
        """
        Checks for a custom ConfigSection class based on the section's name.
        
        If the section name is "modes.local_frontend" for example, it tries to
        import the module with the same name and checks if it contains a
        ConfigSection subclass called "Config".
        
        Returns None if no matching class could be found.
        """
        
        name = self.path_to_name(self.get_path() + [section, "Config"])
        
        try:
            config_cls = named_any(name)
            
            assert issubclass(config_cls, ConfigSection)
        except (AttributeError, AssertionError, ValueError):
            log.debug("Could not find configuration class '%s'.", name)
        else:
            return config_cls

class GConfigSection(ConfigSection, GObject):
    """
    Easier validation of configuration section data 
    
    Using this class, options are stored as GObject properties, which means
    that they have a certain type and a default value. You can also override
    the set_property and get_property methods and add other custom methods.
    
    Make a subclass called "Config" in the module where the configuration data
    will be used, if the section name and the module name match, the config
    module will be able to automatically apply it.
    
    Subsections should be defined using "property(type=object)".
    Lists should be defined identically, but using the name suffix "list".
    """
    
    def __init__(self, name, parent=None, options=None):
        GObject.__init__(self)
        ConfigSection.__init__(self, name, parent, options)
        
        # FIXME: Deserves to be more elegant. ^^ Unfortunately GObject doesn't
        # allow custom types such as GConfigSection or list, only object.
        for key, value in self.get_options().iteritems():
            if not value and self.get_option_type(key) is types.ObjectType:
                if key.endswith("list"):
                    # Make sure that list options are never None.
                    self[key] = []
                else:
                    # Seems to be a subsection.
                    self[key] = {}
    
    def __contains__(self, key):
        return key in self.keys()
    
    def __getinitargs__(self):
        return [self.name]
    
    def __repr__(self):
        return str(dict(self))
    
    def get_property(self, key):
        """
        Get a certain option or subsection.
        
        Raises a InexistentOptionError instead of a plain TypeError.
        """
        
        try:
            return GObject.get_property(self, key)
        except TypeError:
            raise InexistentOptionError(self, key)
    
    def set_property(self, key, value):
        """Set the value of a certain option or a subsection"""
        
        if key.endswith("list") and not isinstance(value, list):
            if isinstance(value, str):
                # Refer to the get_option_repr method for more information.
                if not value:
                    value = []
                else:
                    value = value.split("\n")
                
                if not value[0]:
                    del value[0]
            else:
                raise InvalidOptionError(self, key)
        
        value = self._process_raw_option(key, value)
        
        try:
            GObject.set_property(self, key, value)
        except ValueError:
            raise InvalidOptionError(self, key)
        except (AttributeError, KeyError):
            raise InexistentOptionError(self, key)
    
    def get_option_repr(self, key):
        """
        Each item of a list option has its own line in the configuration file.
        For aesthetic reasons the first item is stored on a new line and not
        after the equal sign.
        
        Example:
        
        my_option_list =
            Item 1
            Item 2
        """
        
        value = self[key]
        
        if isinstance(value, list):
            return "\n".join([""] + value)
        else:
            return ConfigSection.get_option_repr(self, key)

class VanillaConfigSection(ConfigSection, dict):
    """
    An implementation of ConfigSection that doesn't support default values
    and validation. Used if there's no appropriate GConfigSection class.
    """
    
    def __init__(self, name, parent=None, options=None):
        dict.__init__(self)
        ConfigSection.__init__(self, name, parent, options)
    
    def __getitem__(self, key):
        """
        Get a certain option or subsection.
        
        Raises a InexistentOptionError instead of a plain TypeError.
        """
        
        try:
            return dict.__getitem__(self, key)
        except KeyError:
            raise InexistentOptionError(self, key)
    
    def __setitem__(self, key, value):
        """Set the value of a certain option or a subsection"""
        
        value = self._process_raw_option(key, value)
        
        dict.__setitem__(self, key, value)
    
    def __getattr__(self, key):
        """Support attribute-like access of options and subsections"""
        
        return self[key]
    
    def __setattr__(self, key, value):
        """
        Support attribute-like access of options and subsections.
        
        It won't let you create options or subsection that don't exist yet!
        """
        
        if key in self:
            self[key] = value
        else:
            self.__dict__[key] = value

class Config(VanillaConfigSection):
    """
    Root ConfigSection that loads and saves the LottaNZB configuration file.
    """
    
    def __init__(self, config_file, base_module=None, main_sections=None):
        self.config_file = config_file
        self.base_module = base_module
        
        if not self.base_module:
            self.base_module = self.__module__.split(self.DELIMITER)[:-1]
        
        name = self.DELIMITER.join(self.base_module)
        
        VanillaConfigSection.__init__(self, name)
        
        if main_sections:
            for section_name in main_sections:
                self[section_name] = {}
    
    def load(self):
        """
        Loads the configuration from the file passed to the constructor.
        
        If it doesn't exist, look for an existing XML configuration file and
        try to upgrade it.
        """
        
        def error(message):
            raise Exception(_("Unable to load the LottaNZB configuration file "
                "%s: %s") % (self.config_file, message))
        
        try:
            parser = ConfigParser()
            parser.readfp(open(self.config_file, "r"))
        except ParsingError, e:
            error(_("Syntax error on line %i.") % e.errors[0][0])
        except IOError, e:
            if e.errno == 2:
                try:
                    self.convert_xml_config()
                except:
                    error(_("File not found."))
                else:
                    log.info(_("Upgraded old-fashioned XML config file."))
            else:
                error(str(e))
        else:
            for section in parser.sections():
                path = section.split(self.DELIMITER)
                self.set_by_path(path, {})
                
                for option in parser.options(section):
                    try:
                        value = parser.get(section, option)
                        self.set_by_path(path + [option], value)
                    except OptionError, error:
                        log.warning(str(error))
            
            log.debug(_("LottaNZB configuration file loaded."))
    
    def save(self):
        """Saves the configuration to the file passed to the constructor."""
        
        def error(message):
            raise Exception(_("Unable to save the LottaNZB configuration to "
                "the file %s: %s") % (self.config_file, message))
        
        parser = ConfigParser()
        
        def fetch_section(section):
            relative_path = section.get_path()[len(self.base_module):]
            name = self.path_to_name(relative_path)
            
            if name:
                parser.add_section(name)
                
                for key, value in section.get_options().iteritems():
                    parser.set(name, key, section.get_option_repr(key))
            
            map(fetch_section, section.get_sections().itervalues())
        
        fetch_section(self)
        
        try:
            parser.write(open(self.config_file, "w"))
        except Exception, e:
            error(str(e))
        else:
            log.debug(_("LottaNZB configuration file saved."))
    
    def convert_xml_config(self):
        """
        Convert an existing XML configuration file to the new format.
        
        We might drop this function after a few releases.
        """
        
        # We don't want to have a circular import
        from lottanzb.core import App
        
        try:
            # For Python 2.5
            from xml.etree.ElementTree import ElementTree
        except ImportError:
            # For Python 2.4
            from elementtree.ElementTree import ElementTree
        
        tree = ElementTree(file=App().user_dir("lottanzb.xml"))
        
        conversion_table = {
            "prefs_revision": ["core", "config_revision"],
            "sleep_time": ["backend", "update_interval"],
            
            "start_minimized": ["gui", "start_minimized"],
            "window_width": ["gui", "window_width"],
            "window_height": ["gui", "window_height"],
            
            "hellanzb_launcher": ["modes", "standalone", "hellanzb_command"],
            "frontend_config_file": ["modes", "local_frontend", "config_file"],
            "xmlrpc_address": ["modes", "remote_frontend", "address"],
            "xmlrpc_port": ["modes", "remote_frontend", "port"],
            "xmlrpc_password": ["modes", "remote_frontend", "password"]
        }
        
        for key, value in [(key.tag, key.text) for key in list(tree.getroot())]:
            try:
                self.set_by_path(conversion_table[key], value)
            except:
                pass
        
        self.plugins = {}
        self.modes.active = "standalone"
        
        if tree.find("frontend_mode").text == "True":
            if tree.find("remote").text == "True":
                self.modes.active = "remote_frontend"
            else:
                self.modes.active = "local_frontend"
