#!/usr/pkg/bin/ruby
#
# $Id: vaporadmin 203 2003-06-26 16:45:17Z bolzer $
# Author::  Oliver M. Bolzer (mailto:oliver@fakeroot.net)
# Copyright:: (c) Oliver M. Bolzer, 2002
# License:: Distributes under the same terms as Ruby
#
# Administrative Tool for Repository Management of Vapor

require 'vapor'
require 'vapor/repositorymgr'
require 'rexml/document'

unless REXML::VERSION_MAJOR >= 2 and REXML::VERSION_MINOR >= 4
  raise LoadError, "vaporadmin requires REXML >= 2.4.0"
end

begin # only load when available
  require 'termios'
rescue LoadError
end

# repository specifier is invalid
class InvalidRepositoryError < StandardError
end

# problem with XML-specification
class InvalidXMLError < StandardError
end

# an Vapor::VaporException has properly been handled
class HandledVaporException < StandardError
end


class VaporAdmin

  include Vapor

  Commands = ['init','help','add']
  Help_Text = Hash.new
 
  # initialize with repository-specifier
  def initialize( rep_spec )
    spec = rep_spec.scan(/([\w\.]+)(:[\S]*)?@([\w\.]+)(:[\d]+)?\/(\w+)/).flatten
    
    # bark on invalid repository specifier
    if spec.empty? then
      raise InvalidRepositoryError, "invalid repository specification: #{rep_spec}" 
    end

    @username = spec[0]
    @pass     = spec[1]
    @dbhost   = spec[2]
    @dbport   = spec[3]
    @dbname   = spec[4]
     
    if !@pass.nil? then
      @pass.sub!( /^:/, '' ) 
    end
   
    if !@dbport.nil? then
      @dbport.sub!( /^:/, '' ).to_i
    end

  end # initialize()

  # wheter the password is not set yet
  def need_password?
    @pass.nil?
  end # need_password?()

  # set password
  def password=( password )
    @pass = password
  end # password=()

  # output help message
  def self.help( restargs )

    message = Array.new
    if !restargs.empty? and Help_Text.has_key?( restargs[0] ) then
      message << Help_Text[ restargs[0] ]
    else
      message << "usage: vaporadmin [repository] <command> [args]"
      message << "Type `vaporadmin help <command>' for help on a specific command."
      message << ""
      message << "All commands except 'help' require a repository specification"
      message << "in the form of <user[:pass]@dbhost[:port]/dbname>"
      message << ""
      message << "Available commands:"
      Commands.sort.each{|c| message << "   " + c }
    end

    message( message.join("\n") )
  end # self.help()

  # help message for "help"
  Help_Text['help'] = ["help: Display usage message.",
                       "usage: vaporadmin help [COMMAND]"
                      ]

  def execute( command, restargs )
    raise TypeError unless command.is_a? String
    raise TypeError unless restargs.is_a? Array

    if command == "help"
      then self.class.help( restargs )
    else
      self.send( command, restargs )
    end
  end
  
  # initialize a repository
  def init( restargs )
    db_spec = ["pg", @dbname, @dbhost, @dbport ].compact.join(':')
    mgr = nil
    begin
      mgr = RepositoryManager.new( db_spec, @username, @pass, false )
      mgr.init_repository
    rescue Vapor::RepositoryOfflineError => e
      error( "Cound not connect to repository: #{e.message}" )
      raise HandledVaporException 
    rescue Vapor::BackendInconsistentError => e
      error( "Could not initialize repository: #{e.message}" )
      raise HandledVaporException
    end
  end # init()

  # help message for "init"
  Help_Text['init'] = ["init: Initialize the Repository.",
                       "usage: vaporadmin REPOSITORY init"
                      ]

  # add one or more classes to the repository, each argument is the filename
  # of an XML file containing information about one or more classes
  def add( restargs )
    if restargs.empty?
      raise InvalidXMLError, "no file specified.\nTry `vaporadmin help add' for usage." 
    end

    # check existence of all files
    restargs.each{|filename|
      if !FileTest.readable?(filename)
        raise InvalidXMLError, "ERROR: #{filename} not found."
      end
    }

    # connect to repository
    db_spec = ["pg", @dbname, @dbhost, @dbport ].compact.join(':')
    @mgr = nil
    begin
      @mgr = RepositoryManager.new( db_spec, @username, @pass )
    rescue Vapor::RepositoryOfflineError, Vapor::BackendInconsistentError => e
      error( "Cound not connect to repository, #{e.message}" )
      raise HandledVaporException
    end
    
    # parse and process each file
    add_queue = Array.new
    restargs.each{|filename|
      begin
        File.open( filename ){|file|
          tree = REXML::Document.new( file ).root
          add_queue += add_process_xml( tree, filename)
        }
      rescue REXML::ParseException => e
        error( "parse error in #{filename}, #{e.message}" )
        raise HandledVaporException
      rescue InvalidXMLError => e
        error ( e.message )
        raise HandledVaporException
      end
    }
   
    # start adding the classes
    deferred_classes = Array.new
    class_count = 0
    @mgr.start_transaction
    message( "Attempting to add classes to repository:" )
    add_queue.each{|klass|
      begin
        @mgr.addclass( klass )
        message( "  #{klass.name}" )
        class_count += 1
      rescue DuplicateClassError
        error( "class #{klass.name} already registered with Repository." )
        message( "Aborting without saving changes." )
        raise HandledVaporException
      rescue InvalidMetadataError => e
        error( "while adding #{klass.name}, #{e.message}" )
        message( "Aborting without saving changes." )
        raise HandledVaporException
      rescue UnknownSuperclassError
        deferred_classes << klass
      rescue Exception => e
        error( "unrecoverble error #{e.type} while adding #{klass.name}, #{e.message.chomp}" )
        message( "Aborting without saving changes." )
        raise HandledVaporException
      end
    }

    # sort deferred_classes so that parents come before children that inherit
    # from them. The superclass is not in the repository and if it's also
    # not here, we can't know about it => error
    sorted = Array.new
    while not deferred_classes.empty? do
      klass = deferred_classes.shift
      if sorted.detect{ |k| klass.superclass == k.name } then
        sorted << klass
      elsif @mgr.known_classes.detect{ |k| klass.superclass == k.name } then
        sorted << klass
      elsif
         deferred_classes.detect{ |k| klass.superclass == k.name } then
         deferred_classes << klass
      else
        error( "superclass #{klass.superclass} of #{klass.name} not found, aborting." )
        raise HandledVaporException 
      end
    end

    # add sorted classes to repository
    sorted.each{|klass|
      begin
        @mgr.addclass( klass )
        message( "  #{klass.name}" )
        class_count += 1
      rescue DuplicateClassError
        error( "class #{klass.name} already registered with Repository, aborting." )
        raise HandledVaporException
      rescue UnknownSuperclassError
        error( "superclass #{klass.superclass} not found anywhere, aborting" )
        raise HandledVaporException
      end
    }
 
    @mgr.commit_transaction
    message( "Added #{class_count} new classes to Repository." )

  end # add()

  Help_Text['add'] = ["add: Add one or more classes to the Repository.",
                      "usage: vaporadmin REPOSITORY init XML-FILE [XML-FILE...]"
                     ]
  # extract all classes from a XML-Tree
  def add_process_xml( tree, filename, namespace = '' )

    # check top-level tag
    if tree.local_name != 'vapor' and tree.local_name != 'module' then
      raise InvalidXMLException, "expecting top-level element <vapor> but was <#{tree.local_name}> in #{filename}" 
    end

    generated_classes = Array.new
    # decend into content
    tree.each_element{|element|

      case element.local_name
      when 'class'
        if element.attributes['name'].nil? then
          raise InvalidXMLError, "mission name for <class> in #{filename}"
        end 
        # convert <class/> into ClassMetaData
        generated_classes << add_generate_class( element, namespace )
      when 'module'
        generated_classes += add_process_xml( element, filename, namespace +  element.attributes['name'] + '::' )
      else
        raise InvalidXMLError, "Unknwon XML element <#{element.local_name}> where <clss> or <module> expected in #{filename}"
      end
    }

    return generated_classes

  end # add_process_xml()
  private :add_process_xml

  # convert an <class/> into ClassMetaData while watching for Repository
  # consistency
  def add_generate_class( element, namespace = '' )
    raise TypeError unless element.is_a? REXML::Element
    raise TypeError unless namespace.is_a? String 

    name = namespace + element.attributes['name']
    superclass = element.attributes['superclass']
    
    klass = ClassMetaData.new( name, superclass, name.to_s )

    # collect attributes
    element.each_element('attribute'){|attr|
      aname = attr.attributes['name']
      if aname.nil? then
        raise InvalidXMLError, "missing name of attribute in definition of #{name}."
      end

      is_array = 
        case attr.attributes['is_array']
        when 'true'         then true
        when 'false', nil   then false
        else
          raise InvalidXMLError, "invalid Array specification #{attr.attributes['is_array']} for #{name}.#{aname}"
        end

      type =
        case attr.attributes['type']
        when 'String'    then ClassAttribute::String
        when 'Integer'   then ClassAttribute::Integer
        when 'Date'      then ClassAttribute::Date
        when 'Reference' then ClassAttribute::Reference
        when 'Float'     then ClassAttribute::Float
        when 'Boolean'   then ClassAttribute::Boolean
        else
          raise InvalidXMLError, "invalid type #{attr.attributes['type']} for #{name}.#{aname}"
        end
      
      klass.attributes << ClassAttribute.new( aname, type, is_array )

      indexed =
        case attr.attributes['index']
        when 'true'      then true
        when 'false',nil then false
        else
          raise InvalidXMLError, "invalid value #{attr.attributes['index']} for #{name}.#{aname}"
        end
      if indexed then
        klass.indexes << [ aname ]
      end

      unique =
        case attr.attributes['unique']
        when 'true'      then true
        when 'false',nil then false
        else
          aise InvalidXMLError, "invalid value #{attr.attributes['unique']} for #{name}.#{aname}"
        end
      if unique then
        klass.unique << [ aname ]
      end

    }

    # look for indexes
    element.each_element('index'){|index|
      index_attributes = Array.new
      index.each_element('attribute'){|attr|
        aname = attr.attributes['name']
        if aname.nil? then
          raise InvalidXMLError, "missing name of attribute in index definition of #{name}."
        end
        index_attributes << aname
      }
      klass.indexes << index_attributes
    }

    # look for uniqueness constraints
    element.each_element('unique'){|uniq|
      unique_attributes = Array.new
      uniq.each_element('attribute'){|attr|
        aname = attr.attributes['name']
        if aname.nil? then
          raise InvalidXMLError, "missing name of attribute in uniqueness constraint of #{name}."
        end
        unique_attributes << aname
      }
      klass.unique << unique_attributes
    }
    
    # done
    return klass
  end # add_generate_class()
  private :add_generate_class

end # class VaporAdmin

# output error message to standard error
def error( message )
  STDERR.write( "ERROR: " + message )
  STDERR.write( "\n" )
end # error()

# output a warning message to standard error
def warn( message )
  STDERR.write( message )
  STDERR.write( "\n" )
end

# output a normal message to standard error
def message( message )
  STDOUT.write( message )
  STDOUT.write( "\n" )
end # message()

################ main ###################

first_argument = ARGV.shift
db_spec = ''

# check if first argument is "help" or repository specifier
case first_argument
when nil    # no option given
  warn( "Try `vaporadmin help' for usage." )
  exit 1
when "help" # help requested
  VaporAdmin.help( ARGV )
  exit 0
else
  db_spec = first_argument
end

# check if command is valid
command = ARGV.shift
if !VaporAdmin::Commands.include? command then
  warn( "unknown command: #{command}" )
  warn( "Try `vaporadmin help' for usage." )
  exit 1
end

# escape route for "help"
if command == "help" then
  VaporAdmin.help( ARGV )
  exit 0
end

# initialize VaporAdmin
admin = nil
begin
  admin = VaporAdmin.new( db_spec )
rescue InvalidRepositoryError => e
  warn( e.message )
  warn( "Try `vaporadmin help' for usage." )
  exit 1
end

# ask user for password if needed
if admin.need_password? then
  password = nil

  # set screen property to noecho if Termios present
  if defined? Termios then
    oldattr = Termios.getattr(STDIN)
    newattr = oldattr.dup
    newattr.c_lflag &= ~Termios::ECHO
    Termios.setattr( STDIN, Termios::TCSANOW, newattr)
  else
    warn( "WARNING: Your password will be echoed on screen." )
    warn( "         Install the Termios module for Ruby to surpress screen echo." ) 
  end

  begin
    # read password
    while password.nil? do
      STDOUT << "Password: "
      password = STDIN.gets
      password.chomp!
      STDOUT << "\n"
    end
    admin.password = password
  rescue Interrupt
    exit 130  # exit code for SIGINT 
  ensure
    # restore screen echo
    if defined? Termios then
      Termios.setattr( STDIN, Termios::TCSANOW, oldattr )
    end
  end

end
# execute action
begin
  admin.execute( command, ARGV )
rescue HandledVaporException 
  exit 1
rescue Interrupt
  exut 130
end

exit 0