# = XmlConfigFile
#
# Author::    Maik Schmidt (mailto:contact@maik-schmidt.de)
# Copyright:: Copyright (c) 2003,2004 Maik Schmidt
# License::   Distributes under the same terms as Ruby

require 'observer'
require 'thread'
require 'rexml/document'
require 'rexml/xpath'

# This class represents an XML configuration file as it is
# used by many modern applications. Using this class you
# can access an XML file's content easily using XPath.
class XmlConfigFile
  include Observable
  include REXML

  DEFAULT_SEPARATOR = "."

  DEFAULT_TRUE_VALUES  = ['1', 'yes', 'true', 'on']
  DEFAULT_FALSE_VALUES = ['0', 'no', 'false', 'off']

  attr_reader :filename, :true_values, :false_values, :doc
  attr_accessor :expand_attributes, :path_prefix 
  attr_accessor :no_hash_key_element_path

  # Conversion of DEFAULT_TRUE_VALUES to Ruby Boolean
  # objects, everything else to strings.
  attr_accessor :auto_convert_boolean

  # Conversion of any node text to Ruby Object attempted
  # by evaluation, otherwise returns string value of node.
  attr_accessor :auto_convert_any

  # Creates and initializes a new XmlConfigFile object.
  #
  # filename::
  #   Name of the configuration file to be loaded.
  # reloadPeriod::
  #  If the configuration file should be reloaded periodically,
  #  set this parameter to a numeric value greater than zero,
  #  which specifies the reload period measured in seconds.
  #
  # The following exceptions may be raised:
  # 
  # Errno::ENOENT::
  #   If the specified file does not exist.
  # REXML::ParseException::
  #   If the specified file is not wellformed.
  # ArgumentError::
  #   If the reload period is not a number greater than zero.
  def initialize(filename, reloadPeriod = nil)
    @filename = filename
    @fileaccess = Mutex.new
    @true_values = DEFAULT_TRUE_VALUES
    @false_values = DEFAULT_FALSE_VALUES
    @expand_attributes = false
    @path_prefix = ''
    @doc = load_configuration_file(@filename)
    @reloaderThread = initialize_reloader_thread(reloadPeriod)
  end

  # Returns a configuration parameter as an Object. The
  # parameter itself has to be identified by an XPath
  # expression.
  #
  # In the event that an Object isn't created it will
  # return a string
  #
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as String value.
  # default::
  #   Default value to be returned, if the XPath expression
  #   returned an empty node list. Of course, the default
  #   value should be a String value in such a case. The
  #   method does not check this explicitly.
  def get_object(xpath, default = nil)
    parameter = XPath.first(@doc, @path_prefix + xpath)
    return node_to_object(parameter, default)
  end
  alias_method :getObject, :get_object
  alias_method :getParameterObject, :get_object
  
  # Returns a configuration parameter as a String. The
  # parameter itself has to be identified by an XPath
  # expression.
  # 
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as String value.
  # default::
  #   Default value to be returned, if the XPath expression
  #   returned an empty node list. Of course, the default
  #   value should be a String value in such a case. The
  #   method does not check this explicitly.
  def get_string(xpath, default = nil)
    parameter = XPath.first(@doc, @path_prefix + xpath)
    return node_to_text(parameter, default)
  end
  alias_method :[], :get_string
  alias_method :getParameter, :get_string
  alias_method :get_parameter, :get_string

  # Sets a configuration parameter. The parameter has to
  # be specified by an XPath expression.
  # If the parameter was set successfully, all observers
  # will be notified.
  #
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as String value.
  # value::
  #   The parameter value to be set.
  # create::
  #   If the node specified by xpath does not exist, it
  #   will be created, if this parameter is set to true.
  #   Otherwise, an exception will be thrown.
  def set_parameter(xpath, value, create = false)
    parameter = XPath.first(@doc, xpath)
    if parameter.nil?
      if create
        raise ArgumentError, "Not yet implemented!"
      else
        raise ArgumentError
      end
      raise ArgumentError
    elsif parameter.instance_of?(Element)
      parameter.text = value.to_s
    elsif parameter.instance_of?(Attribute)
      parameter.element.add_attribute(parameter.name, value.to_s)
    end
    changed
    notify_observers(@filename)
  end
  alias_method :[]=, :set_parameter

  # Returns a configuration parameter as an Integer. The
  # parameter itself has to be identified by an XPath
  # expression.
  # 
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as Integer value.
  # default::
  #   Default value to be returned, if the XPath expression
  #   returned an empty node list. Of course, the default
  #   value should be an Integer value in such a case. The
  #   method does not check this explicitly.
  #
  # The following exception may be raised:
  #
  # ArgumentError::
  #   If a parameter was found and could not be converted
  #   into an Integer.
  def get_int(xpath, default = nil)
    parameter = get_string(xpath)
    parameter ? Integer(parameter) : default
  end
  alias_method :getIntParameter, :get_int
  alias_method :get_int_parameter, :get_int

  # Returns a configuration parameter as a Float. The
  # parameter itself has to be identified by an XPath
  # expression.
  # 
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as Float value.
  # default::
  #   Default value to be returned, if the XPath expression
  #   returned an empty node list. Of course, the default
  #   value should be a Float value in such a case. The
  #   method does not check this explicitly.
  #
  # The following exception may be raised:
  #
  # ArgumentError::
  #   If a parameter was found and could not be converted
  #   into a Float.
  def get_float(xpath, default = nil)
    parameter = get_string(xpath)
    parameter ? Float(parameter) : default
  end
  alias_method :getFloatParameter, :get_float
  alias_method :get_float_parameter, :get_float

  # Returns a configuration parameter as a boolean. The
  # parameter itself has to be identified by an XPath
  # expression.
  #
  # By default, the values in DEFAULT_TRUE_VALUES are
  # assumed to be true and the values in DEFAULT_FALSE_VALUES
  # are assumed to be false. You can define your own
  # values by setting true_values respectively false_values
  # to an array containing the values of your choice.
  #
  # xpath::
  #   An XPath expression identifying the configuration
  #   parameter to be read and returned as boolean value.
  # default::
  #   Default value to be returned, if the XPath expression
  #   returned an empty node list. Of course, the default
  #   value should be a boolean value in such a case. The
  #   method does not check this explicitly.
  #
  # The following exception may be raised:
  #
  # ArgumentError::
  #   If a parameter was found and could not be converted
  #   into a boolean.
  def get_boolean(xpath, default = nil)
    parameter = get_string(xpath)
    return default unless parameter
    value = convert_boolean(parameter)
    return value unless value.nil?
    raise ArgumentError
  end
  alias_method :getBooleanParameter, :get_boolean
  alias_method :get_boolean_parameter, :get_boolean

  # Returns a set of String parameters as a one-dimensional
  # Hash.
  # If no parameters specified by xpath were found, an
  # empty Hash will be returned.
  #
  # E.g. the document snippet
  #
  # ...
  #   <db>
  #     <name>shop</shop>
  #     <user>scott</user>
  #   </db>
  # ...
  # 
  # will become the following Hash
  #
  # { db.name => shop, db.user => scott }
  #
  # xpath::
  #   An XPath expression specifying the nodes to be selected.
  # pathSeparator::
  #   String separating the single path elements in the Hash keys.
  def get_parameters(xpath, pathSeparator = DEFAULT_SEPARATOR)
    parameters = Hash.new
    XPath.each(@doc, xpath) { |node|
      parameters.update(get_properties(node, pathSeparator))
    }
    return parameters
  end
  alias_method :getParameters, :get_parameters

  # Returns an array of Hashes. Each element contains a set of String
  # parameters as Hash.
  # If no parameters specified by xpath were found, an empty Array
  # will be returned.
  #
  # E.g. the document snippet
  #
  # ...
  #   <db>
  #     <name>shop</shop>
  #     <user>scott</user>
  #   </db>
  #   <db>
  #     <name>factory</shop>
  #     <user>anna</user>
  #   </db>
  # ...
  # 
  # will become the following Array
  #
  # [{ db.name => shop, db.user => scott }, 
  #  { db.name => factory, db.user => anna }]
  #
  # This method was originally contributed by Nigel Ball.
  #
  # xpath::
  #   An XPath expression specifying the nodes to be selected.
  # pathSeparator::
  #   String separating the single path elements in the Hash keys.
  def get_string_array(xpath, pathSeparator = DEFAULT_SEPARATOR, expand = false)
    old_expand_attributes = @expand_attributes
    @expand_attributes = expand
    parameter_array = Array.new
    XPath.each(@doc, xpath) { |node|
      parameter_array << get_properties(node, pathSeparator)
    }
    @expand_attributes = old_expand_attributes
    return parameter_array
  end
  alias_method :get_parameter_array, :get_string_array

  # Sets the list of values, that will be interpreted as true
  # boolean parameters. The case of all the elements will be
  # set to downcase and all leading and trailing whitespaces
  # will be removed.
  #
  # values::
  #   List of values to be interpreted as true boolean values.
  def true_values=(values)
    @true_values = values.collect { |x| x.strip.downcase }
  end

  # Sets the list of values, that will be interpreted as false
  # boolean parameters. The case of all the elements will be
  # set to downcase and all leading and trailing whitespaces
  # will be removed.
  #
  # values::
  #   List of values to be interpreted as false boolean values.
  def false_values=(values)
    @false_values = values.collect { |x| x.strip.downcase }
  end

  # Stores the current configuration into a file.
  #
  # filename::
  #   Name of the file to store configuration in. Defaults
  #   to the current configuration file's name.
  def store(filename = nil)
    filename = @filename if filename.nil?
    @fileaccess.synchronize {
      File.open(filename, "w") { |f| f.write(to_s) }
    }
  end
  
  # Returns the current configuration as an XML string.
  def to_s
    content = ''
    @doc.write(content, 0)
    return content
    #return @doc.to_s
  end
  
  # Closes the configuration file, i.e. stops the reloader
  # thread.
  def close
    @reloaderThread.exit unless @reloaderThread.nil?
    @reloaderThread = nil
  end
  
  # Converts the current configuration into a Hash in the same
  # way as the Perl module XML::Simple.
  def to_hash
  end

  private

  # Initializes and returns a reloader thread, that will reload
  # the configuration file periodically (only if it has changed,
  # of course). If the configuration was reloaded, all observers
  # will be notified.
  # If the configuration file to be reloaded is not
  # wellformed or could not be found, an error message
  # will be printed to standard error and the last
  # working configuration will be used.
  # 
  # reloadPeriod::
  #   Reload period measured in seconds.
  #
  # The following exception may be raised:
  #
  # ArgumentError::
  #   If the reload period is not a number greater than zero.
  def initialize_reloader_thread(reloadPeriod)
    return nil if reloadPeriod.nil?

    if !reloadPeriod.kind_of?(Integer) && !reloadPeriod.kind_of?(Float)
      raise ArgumentError
    elsif reloadPeriod <= 0
      raise ArgumentError
    end

    @lastModificationTime = File::mtime(@filename).to_i
    return Thread.new {
      loop do
        sleep(reloadPeriod)
        begin
          currentModificationTime = File::mtime(@filename).to_i
          if currentModificationTime != @lastModificationTime
            @lastModificationTime = currentModificationTime
            @fileaccess.synchronize {
              currentConfiguration = load_configuration_file(@filename)
              @doc = currentConfiguration
            }
            changed
            notify_observers(@filename)
          end
        rescue Exception => ex
          $stderr.puts("Invalid configuration in '#{@filename}':", ex)
        end
      end
    }
  end

  # Loads and parses an XML configuration file. Additionally,
  # references to environment variables will be replaced by
  # their actual values.
  #
  # filename::
  #   Name of the configuration file to be loaded.
  # substituteVariables::
  #   Indicates, if references to environment variables should
  #   be replaced by their values.
  #
  # The following exceptions may be raised:
  # 
  # Errno::ENOENT::
  #   If the specified file does not exist.
  # REXML::ParseException::
  #   If the specified file is not wellformed.
  def load_configuration_file(filename, substituteVariables = true)
    xmlstring = File.readlines(filename).to_s
    doc = Document.new(xmlstring)
    substitute_environment_variables(doc) if substituteVariables
    return doc
  end

  # Replaces all references to environment variables in a
  # String by their actual values.
  #
  # text::
  #   A String containing references to environment
  #   variables to be replaced.
  def substitute_environment_variable(text)
    text.gsub!(/\$\{(\w+)\}/) { |s| ENV[$1] ? ENV[$1] : "" }
    text
  end

  # Replaces all references to environment variables in a
  # document with their actual values.
  #
  # doc::
  #   Document containing references to be replaced.
  def substitute_environment_variables(doc)
    XPath.each(doc, '//*') { |element|
      if element.instance_of?(Element)
        if element.text
          element.text = substitute_environment_variable(element.text)
        end
        element.attributes.each { |name, value|
          substitute_environment_variable(value)
        }
      end
    }
  end
  
  # Converts a document node into a String.
  # If the node could not be converted into a String
  # for any reason, default will be returned.
  #
  # node::
  #   Document node to be converted.
  # default::
  #   Value to be returned, if node could not be converted.
  def node_to_text(node, default = nil)
    if node.instance_of?(Element) 
      # Bug fix provided by Sandra Silcot.
      return node.text.nil? ? default : node.text.strip
    elsif node.instance_of?(Attribute)
      return node.value.nil? ? default : node.value.strip
    elsif node.instance_of?(Text)
      return node.to_s.strip
    else
      return default
    end
  end

  # Converts a document node into a Ruby object.
  #
  # If :auto_convert_boolean is set then String
  # objects matching DEFAULT_FALSE_VALUES or DEFAULT_TRUE_VALUES
  # will be converted to a Ruby Boolean Object.
  #
  # If :auto_convert_any is set then String objects
  # will be attempted to be converted to evaluated values.
  # DEFAULT_[TRUE|FALSE]_VALUES will be ignored. 
  #
  # "1" becomes FixNum instance
  # "true" becomes TrueClass instance
  # "blah" becomes String instance
  def conditional_node_to_object(node, default = nil)
    if self.auto_convert_any
      return node_to_object(node,default)
    elsif self.auto_convert_boolean
      return node_to_boolean(node,default)
    else
      return node_to_text(node,default)
    end  
  end  

  # Converts a document node into a Boolean.
  #
  # If matches against DEFAULT_TRUE_VALUES 'true' is returned.
  # If matches against DEFAULT_FALSE_VALUE 'false is returned.
  #
  # If the Node cannot be converted to Boolean, then it
  # will return the node as it would have been if it was
  # converted to a String
  def node_to_boolean(node, default = nil)
    nodeText = node_to_text(node,default)
    value = convert_boolean(nodeText)
    return value unless value.nil?
    return nodeText
  end

  # Converts a document node into a a native ruby object.
  #  
  # If auto_convert_boolean is on, this
  # method will defer to the DEFAULT_TRUE_VALLUES
  # and DEFAULT_FALSE_VALUES for conversion. 
  #
  # This will cause behaviour like '1' and '0' to become
  # 'true', 'false' instead of FixNum instance 1 and 0.
  #
  # Attempts to evaluate the nodeText, if it cannot it will
  # just return the node's text.
  def node_to_object(node, default = nil) 
    nodeText = node_to_text(node,default)
    retried = false
    if self.auto_convert_boolean	
      value = convert_boolean(nodeText)
      return value unless value.nil?
    end
    if not nodeText.nil?
      evalString = nodeText
    else
      evalString = ''
    end
    begin 	
      val = eval(evalString)
    rescue SyntaxError, NameError, TypeError
      # Would like to find out what all the possible
      # errors of eval would be.
      #
      # Curtis Says:
      #
      # This is probably an ugly hack.
      #
      # Uncertain if trying lowercase will
      # have an unwanted effect. This is
      # for 'TrUe' to evaluate to true::TrueClass
      if not retried 
        retried = true
        evalString = nodeText.downcase
        retry
      end
      val = nodeText
    end
    val 	
  end

  # Converts a string into Boolean object,
  #
  # Returns nil on un-success.
  def convert_boolean(parameter)
    unless parameter.nil?
      lowParam = parameter.downcase
      return true  if @true_values.include?(lowParam)
      return false if @false_values.include?(lowParam)
    end
    return nil
  end

  # Returns the path to a node as String. All elements
  # will be delimited by the string pathSeparator.
  #
  # node::
  #   Node to return path for.
  # pathSeparator::
  #   String separating the single path elements in
  #   the Hash keys.
  def get_path(node, pathSeparator)
    unless self.no_hash_key_element_path
      if node.parent == @doc.root
        return node.name
      else
        return get_path(node.parent, pathSeparator) + pathSeparator + node.name
      end
    else
      return node.name
    end
  end

  # Converts a document node into a Hash object.
  #
  # node::
  #   Node to be converted into a Hash.
  # pathSeparator::
  #   String separating the single path elements in the Hash keys.
  def get_properties(node, pathSeparator)
    parameters = Hash.new
    if node.instance_of?(Element)
      if !node.has_elements?
        parameters[get_path(node, pathSeparator)] = conditional_node_to_object(node)
      else
        node.each_element { |child|
          parameters.update(get_properties(child, pathSeparator))
        }
      end
      if @expand_attributes
        node.attributes.each { |name, value|
          parameters[get_path(node, pathSeparator) + pathSeparator + name] = value
        }
      end
    end
    return parameters
  end
end

