# Copyright (C) 2008-2009 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.
ConfigSection lets you enforce certain option value types and specify
validation routines, but this is not obligatory.

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 ConfigSection.

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 or using
brackets. E. g.:

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

import types
import re

import logging

# pylint: disable-msg=C0103
log = logging.getLogger(__name__)

from kiwi.python import namedAny as named_any
from copy import deepcopy

from lottanzb import resources
from lottanzb.util import GObject, gproperty, _

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, ValueError):
    """Raised when trying to assign an invalid value to an option"""
    
    def __init__(self, section, option):
        OptionError.__init__(self, section, option)
        ValueError.__init__(self)
    
    def __str__(self):
        return _("Invalid value for option '%s' in section '%s'.") % \
            (self.option, self.section.get_full_name())

class ConfigSection(GObject):
    """
    Represents a configuration section and makes it possible to easily
    validate its data.
    
    Options can be 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):
        self.name = name
        self.parent = parent
        
        # Contains options and subsections that are not among the GObject
        # properties in this configuration class.
        self._unknown = {}
        
        # All functions in this list are called before the value of an option
        # is changed. The first argument is the option name and the second one
        # the new value. They may be used for validation and can raise an
        # InvalidOptionError. Otherwise a function must return the new value
        # for the option.
        # There are some predefined validation/conversion functions. But the
        # this list can be extended by subclasses or from elsewhere using the
        # `add_validation_function` method.
        self._validation_functions = [
            self._validate_list,
            self._validate_dict,
            self._validate_section_parent
        ]
        
        GObject.__init__(self)
        
        if options:
            self.merge(options)
        
        # FIXME: Deserves to be more elegant. ^^ Unfortunately GObject doesn't
        # allow custom types such as ConfigSection 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 __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.
        """
        
        try:
            return \
                self.get_full_name() == other.get_full_name() and \
                self.get_sections() == other.get_sections() and \
                self.get_options() == other.get_options()
        except AttributeError:
            return False
    
    def __ne__(self, other):
        """
        Checks if there are any differences between two configuration sections.
        """
        
        return not self.__eq__(other)
    
    def __contains__(self, key):
        """
        Checks if there's an option or subsection with a certain name.
        """
        
        return key in self.keys()
    
    def __getinitargs__(self):
        return [self.name]
    
    def __repr__(self):
        return str(dict(self))
    
    def __getattr__(self, key):
        """
        Called when accessing an option that hasn't been declared using
        util.gproperty. Check if the option is among the unknown options and
        return it.
        """
        
        try:
            return self._unknown[key]
        except KeyError:
            raise InexistentOptionError(self, key)
    
    def __delattr__(self, key):
        """
        Makes is possible to remove an option or section that hasn't been
        declared using util.gproperty.
        """
        
        try:
            del self._unknown[key]
        except KeyError:
            raise InexistentOptionError(self, key)
    
    __delitem__ = __delattr__
    
    def keys(self):
        """
        Returns a list of all option and subsection names in this configuration
        section.
        """
        
        return GObject.keys(self) + self._unknown.keys()
    
    def get_option_type(self, key):
        """
        Returns the type of a certain option.
        
        If an option hasn't been declared using util.gproperty, it will
        simply return the type of the actual value.
        """
        
        try:
            return self.get_property_type(key)
        except AttributeError:
            return type(self[key])
    
    def add_validation_function(self, function):
        """
        Registers a function that is called before the value of an option or
        section is changed.
        
        Refer to the `_validation_functions` property for more information.
        """
        
        self._validation_functions.append(function)
    
    def remove_validation_function(self, function):
        """
        Does the opposite of the `add_validation_function` method.
        
        Refer to the `_validation_functions` property for more information.
        """
        
        self._validation_functions.remove(function)
    
    def get_property(self, key):
        """
        Get a certain option or subsection.
        
        Raises a InexistentOptionError instead of a plain TypeError if the
        option or subsection cannot be found.
        """
        
        try:
            return GObject.get_property(self, key)
        except TypeError:
            # The option hasn't been declared using util.gproperty.
            try:
                return self._unknown[key]
            except KeyError:
                raise InexistentOptionError(self, key)
    
    def set_property(self, key, value):
        """Set the value of a certain option or a subsection"""
        
        # Loop through all validation/conversion functions.
        for validation_function in self._validation_functions:
            value = validation_function(key, value)
        
        try:
            GObject.set_property(self, key, value)
        except ValueError:
            raise InvalidOptionError(self, key)
        except (TypeError, AttributeError):
            self._unknown[key] = value
    
    def _validate_list(self, key, value):
        """
        Converts a string separated by newline characters to a list if the
        option name ends with `_list` and the option has been declared using
        `util.gproperty`.
        """
        
        if key.endswith("_list") and not isinstance(value, list) and \
            key in GObject.keys(self):
            if isinstance(value, str):
                # Refer to the get_option_repr method for more information.
                
                if not value:
                    value = []
                else:
                    value = value.split("\n")
                
                if value and not value[0]:
                    del value[0]
            else:
                raise InvalidOptionError(self, key)
        
        return value
    
    def _validate_dict(self, key, value):
        """
        Ensures that plain dictionaries are properly converted to objects of
        ConfigSection or a subclass of it, given that it can be found.
        """
        
        if isinstance(value, dict):
            cls = self.find_section_class(key) or ConfigSection
            value = cls(key, parent=self, options=value)
        
        return value
    
    # pylint: disable-msg=W0613
    def _validate_section_parent(self, key, value):
        """
        Ensures that subsections have the right parent reference.
        """
        
        if isinstance(value, ConfigSection):
            value.parent = self
        
        return value
    
    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 add_section(self, section):
        """
        Adds an already created ConfigSection object to this configuration
        section.
        
        The `parent` property of `section`  will be correctly set.
        """
        
        self[section.name] = section
    
    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 an instance of this class.
                # We've got nothing to do.
                return
            
            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 move_option(self, source, dest):
        """
        Moves an option or subsection to a new place.
        
        This method may be used to rename options or subsection and/or move
        them to other configuration sections. Please not that that they will
        be removed from the old section.
        
        The option or subsection to be moved can be specified using the first
        argument. This may either be a string representing the name of an 
        option or subsection in the current section or a relative path.
        
        The destination may either be a string, a relative path or another
        configuration section.
        """
        
        if isinstance(source, basestring):
            source_section = self
            source_key = source
        elif isinstance(source, list):
            source_section = self.get_by_path(source[:-1])
            source_key = source[-1]
        else:
            raise ValueError("Invalid option source: %s", source)
        
        value = source_section[source_key]
        
        if isinstance(dest, basestring):
            self[source_key] = value
        elif isinstance(dest, list):
            self.set_by_path(dest, value)
        elif isinstance(dest, ConfigSection):
            dest[source_key] = value
        else:
            raise ValueError("Invalid option destination: %s", dest)
        
        del source_section[source_key]
    
    def get_option_repr(self, key):
        """
        Return the string representation of an option, which is going to be
        stored in the configuration file.
        
        Each item of a list option has its own line in the configuration file.
        For esthetic 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 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())
    
    @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
    
    @staticmethod
    def path_to_name(path):
        """Build a section name out of a list of section names"""
        
        return ".".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 ConfigError(Exception):
    """Raised when a configuration file cannot be loaded or saved."""
    
    def __init__(self, config_root, message=""):
        self.config_root = config_root
        
        if message:
            self.message = message
        
        Exception.__init__(self)
    
class LoadingError(ConfigError):
    """Raised when a configuration file cannot be loaded."""
    
    def __str__(self):
        return _("Unable to load the LottaNZB configuration file %s: %s") % \
            (self.config_root.config_file, self.message)

class ConfigNotFoundError(LoadingError):
    """Raised when a configuration file cannot be found."""
    
    message = _("File not found.")

class ParsingError(LoadingError):
    """Raised when a configuration file does not follow legal syntax."""
    
    def __init__(self, config_root, line_no):
        message = _("Syntax error on line: %i") % line_no
        
        LoadingError.__init__(self, config_root, message)

class SavingError(ConfigError):
    """Raised when a configuration file cannot be saved."""
    
    def __str__(self):
        return _("Unable to save the LottaNZB configuration file %s: %s") % \
            (self.config_root.config_file, self.message)

class ConfigRoot(ConfigSection):
    """
    Root ConfigSection that loads and saves a configuration file.
    """
    
    def __init__(self, config_file, base_module=None):
        self.config_file = config_file
        self.base_module = base_module
        
        if not self.base_module:
            self.base_module = self.__module__.split(".")[:-1]
        elif isinstance(self.base_module, basestring):
            self.base_module = self.base_module.split(".")
        
        ConfigSection.__init__(self, self.path_to_name(self.base_module))
    
    def load(self):
        """
        Loads the configuration from the file passed to the constructor.
        """
        
        try:
            file_obj = open(self.config_file, "r")
        except IOError, err:
            if err.errno == 2:
                raise ConfigNotFoundError(self)
            else:
                raise LoadingError(self, err.strerror)
        else:
            self._read(file_obj)
        
        log.debug(_("LottaNZB configuration file loaded."))
    
    def save(self):
        """Saves the configuration to the file passed to the constructor."""
        
        def write_section(section):
            """
            Recursive function that writes all configuration sections to the
            configuration file.
            """
            
            relative_path = section.get_path()[len(self.base_module):]
            section_name = self.path_to_name(relative_path)
            
            # Sort the options and sub sections alphabetically so that they're
            # always at the same place in the configuration file. 
            option_names = section.get_options().keys()
            option_names.sort()
            
            sub_section_names = section.get_sections().keys()
            sub_section_names.sort()
            
            if option_names and (section_name or section is self):
                if section_name:
                    config_file.write("[%s]\n" % section_name)
                
                for option_name in option_names:
                    value = section.get_option_repr(option_name)
                    value = str(value).replace("\n", "\n\t")
                    
                    config_file.write("%s = %s\n" % (option_name, value))
                
                config_file.write("\n")
            
            for sub_section_name in sub_section_names:
                sub_section = section[sub_section_name]
                
                # Makes it possible to have nested configuration files that
                # are saved together with this configuration file.
                if isinstance(sub_section, ConfigRoot):
                    sub_section.save()
                else:
                    write_section(section[sub_section_name])
        
        try:
            config_file = open(self.config_file, "w")
        except IOError, err:
            raise SavingError(self, err.strerror)
        
        write_section(self)
        config_file.close()
        
        log.debug(_("LottaNZB configuration file saved."))
    
    # Regular expressions for parsing section headers and options.
    SECTCRE = re.compile(
        r"\["                        # [
        r"(?P<header>[^]]+)"         # Very permissive!
        r"\]"                        # ]
    )
    
    OPTCRE = re.compile(
        r"(?P<option>[^:=\s][^:=]*)" # Very permissive!
        r"\s*(?P<separator>[:=])\s*" # Any number of space/tab, followed by a
                                     # separator (either : or =), followed by
                                     # any space/tab
        r"(?P<value>.*)$"            # Everything up to the end of the line
    )
    
    def _read(self, file_obj):
        """
        Parse the file passed to this method as a file object.
        
        This method is based on Python's ConfigParser module.
        """
        
        # Allow the root configuration section to have options.
        # Please note that older versions of LottaNZB will not be able to load
        # such configuration files. That's why we should not make use of them
        # for the time being.
        current_section = self
        
        option_name = None
        lineno = 0
        error = None
        
        while True:
            line = file_obj.readline()
            lineno = lineno + 1
            
            if not line:
                break
            
            # Comment or blank line?
            if line.strip() == "" or line[0] in "#;":
                continue
            
            try:
                # Continuation line?
                if line[0].isspace() and current_section and option_name:
                    value = line.strip()
                    
                    if value:
                        # This is not very efficient for list options, but I
                        # don't see a better solution right now.
                        old_val = current_section.get_option_repr(option_name)
                        value = "%s\n%s" % (old_val, value)
                        
                        current_section[option_name] = value
                
                # A section header or option header?
                else:
                    # Is it a section header?
                    match = self.SECTCRE.match(line)
                    
                    if match:
                        section_name = match.group("header")
                        path = section_name.split(".")
                        
                        self.set_by_path(path, {})
                        
                        current_section = self.get_by_path(path)
                        
                        # So sections can't start with a continuation line
                        option_name = None
                    
                    # An option line?
                    else:
                        match = self.OPTCRE.match(line)
                        
                        if match:
                            option_name = match.group("option")
                            option_value = match.group("value")
                            separator = match.group("separator")
                            
                            if separator in ("=", ":") and ";" in option_value:
                                # ";" is a comment delimiter only if it follows
                                # a spacing character
                                pos = option_value.find(";")
                                
                                if pos != -1 and \
                                    option_value[pos - 1].isspace():
                                    option_value = option_value[:pos]
                            
                            option_value = option_value.strip()
                            
                            # Allow empty values
                            if option_value == '""':
                                option_value = ""
                            
                            option_name = option_name.rstrip().lower()
                            current_section[option_name] = option_value
                        else:
                            # A non-fatal parsing error occurred. Set up the
                            # exception but keep going. The exception will be
                            # raised at the end of the file and will contain a
                            # list of all bogus lines.
                            if not error:
                                error = ParsingError(self, lineno)
            except OptionError, error:
                log.warning(str(error))
        
        # If any parsing errors occurred, raise an exception
        if error:
            # pylint: disable-msg=W0706
            raise error

class LottaConfigRoot(ConfigRoot):
    """
    Root ConfigSection that loads and saves the LottaNZB configuration file.
    
    This class automatically upgrades an old-fashioned XML configuration file
    to the new format.
    """
    
    modes = gproperty(type=object)
    gui = gproperty(type=object)
    backend = gproperty(type=object)
    plugins = gproperty(type=object)
    
    revision = gproperty(type=int, default=2)
    
    def load(self):
        """
        If it doesn't exist, look for an existing XML configuration file and
        try to upgrade it.
        """
        
        try:
            ConfigRoot.load(self)
        except ConfigNotFoundError, error:
            try:
                self.convert_xml_config()
            except:
                raise error
            else:
                log.info(_("Upgraded old-fashioned XML configuration file."))
        else:
            self.upgrade()
    
    def upgrade(self):
        """
        Upgrades out-of-date configuration data based on the revision stored in
        the `revision` option.
        """
        
        # The configuration revision of a configuration file was previously
        # stored in the section `core` as `config_revision`. Now that it's
        # possible for the root section to have options, it has been moved.
        try:
            self.revision = self.core.config_revision
        except InexistentOptionError:
            pass
        else:
            del self.core
        
        current_revision = self.revision
        newest_revision = self.get_property_default_value("revision")
        
        for revision in range(current_revision, newest_revision):
            method_name = "upgrade_%i_to_%i" % (revision, revision + 1)
            upgrade_method = getattr(self, method_name, None)
            
            if callable(upgrade_method):
                upgrade_method()
        
        self.revision = newest_revision
        
        if current_revision != newest_revision:
            self.save()
            
            if newest_revision > current_revision:
                log.debug(_("Configuration upgraded to revision %i."),
                    newest_revision)
    
    def try_to_move_option(self, source, dest):
        """
        Invokes the `move_option` method and ignores any exceptions that might
        be raised.
        """
        
        try:
            self.move_option(source, dest)
        except OptionError, error:
            log.debug(str(error))
    
    def upgrade_1_to_2(self):
        """
        Separating the GUI code from LottaNZB's core and and the creation of
        the start_minimized plug-in made it necessary to move options.
        """
        
        for key in ["height", "width", "x_pos", "y_pos", "maximized"]:
            self.try_to_move_option(
                ["gui", "window_%s" % key],
                ["gui", "main", key]
            )
        
        self.try_to_move_option(
            ["gui", "start_minimized"],
            ["plugins", "start_minimized", "enabled"]
        )
    
    def convert_xml_config(self):
        """
        Convert an existing XML configuration file to the new format.
        
        We might drop this function after a few releases.
        """
        
        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=resources.config_dir("lottanzb.xml"))
        
        conversion_table = {
            "prefs_revision": ["revision"],
            "sleep_time": ["backend", "update_interval"],
            
            "start_minimized": ["plugins", "start_minimized", "enabled"],
            "window_width": ["gui", "main", "width"],
            "window_height": ["gui", "main", "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:
                if key in conversion_table:
                    self.set_by_path(conversion_table[key], value)
            except OptionError, error:
                log.warning(str(error))
         
        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"
