#
# setup.rb
#
#   Copyright (c) 2000,2001 Minero Aoki <aamine@loveruby.net>
#
#   This program is free software.
#   You can distribute/modify this program under the terms of
#   the GNU General Public License version 2 or later.
#

require 'tempfile'
if i = ARGV.index(/\A--rbconfig=/) then
  file = $'
  ARGV.delete_at(i)
  require file
else
  require 'rbconfig'
end


class InstallError < StandardError; end


class ToplevelInstaller

  Version   = '2.2.1'
  Copyright = 'Copyright (c) 2000,2001 Minero Aoki'


  TASKS = [
    [ 'config',   'save your config configurations' ],
    [ 'setup',    'compiles extention or else' ],
    [ 'install',  'installs packages' ],
    [ 'clean',    "does `make clean' for each extention" ],
    [ 'dryrun',   'does test run' ],
    [ 'show',     'shows current configuration' ]
  ]

  FILETYPES = %w( bin lib ext share )


  def initialize( root, argv )
    @root_dir  = File.expand_path(root)
    @curr_dir  = ''
    @argv      = argv

    @config    = ConfigTable.create
    @task      = nil
    @task_args = nil
    @verbose   = true
    @hooks     = []

    @no_harm = false  ###tmp

    load_installer
  end

  attr :root_dir
  alias package_root root_dir
  attr :config
  attr :task
  attr :task_args

  attr :verbose
  
  def no_harm?
    @no_harm
  end


  def execute
    @task = parsearg( @argv )
    @task_args = @argv
    parsearg_TASK @task, @argv

    unless @task == 'config' or @task == 'clean' then
      @config = ConfigTable.load
    end

    case @task
    when 'config', 'setup', 'install'
      init_selection
      try @task
    when 'show'
      do_show
    when 'dryrun'
      do_dryrun
    else
      raise 'must not happen'
    end
  end

  def try( task )
    $stderr.printf "setup.rb: entering %s phase...\n", task
    begin
      __send__ 'do_' + task
    rescue
      $stderr.printf "%s failed\n", task
      raise
    end
    $stderr.printf "setup.rb: %s done.\n", task
  end


  #
  # processing arguments
  #

  def parsearg( argv )
    task_re = /\A(?:#{TASKS.collect {|i| i[0] }.join '|'})\z/
    arg = argv.shift

    case arg
    when /\A\w+\z/
      task_re === arg or raise InstallError, "wrong task: #{arg}"
      return arg

    when '-h', '--help'
      print_usage $stdout
      exit 0

    when '-v', '--version'
      puts "setup.rb version #{Version}"
      exit 0
    
    when '--copyright'
      puts Copyright
      exit 0

    else
      raise InstallError, "unknown global option '#{arg}'"
    end
  end


  def parsearg_TASK( task, argv )
    mid = "parsearg_#{task}"
    if respond_to? mid, true then
      __send__ mid, argv
    else
      argv.empty? or
          raise InstallError, "#{task}:  unknown options: #{argv.join ' '}"
    end
  end

  def parsearg_config( args )
    @config_args = {}
    re = /\A--(#{ConfigTable.keys.join '|'})=/
    args.each do |i|
      m = re.match(i) or raise InstallError, "config: unknown option #{i}"
      @config_args[ m[1] ] = m.post_match
    end
  end

  def parsearg_install( args )
    @no_harm = false
    args.each do |i|
      if i == '--no-harm' then
        @no_harm = true
      else
        raise InstallError, "install: unknown option #{i}"
      end
    end
  end

  def parsearg_dryrun( args )
    @dryrun_args = args
  end


  def print_usage( out )
    out.puts
    out.puts 'Usage:'
    out.puts '  ruby setup.rb <global option>'
    out.puts '  ruby setup.rb <task> [<task options>]'

    out.puts
    out.puts 'Tasks:'
    TASKS.each do |name, desc|
      out.printf "  %-10s  %s\n", name, desc
    end

    fmt = "  %-20s %s\n"
    out.puts
    out.puts 'Global options:'
    out.printf fmt, '-h,--help',    'print this message'
    out.printf fmt, '-v,--version', 'print version'
    out.printf fmt, '--copyright',  'print copyright'

    out.puts
    out.puts 'Options for config:'
    ConfigTable::DESCRIPTER.each do |name, (default, arg, desc, default2)|
      default = default2 || default
      out.printf "  %-20s %s [%s]\n", "--#{name}=#{arg}", desc, default
    end
    out.printf "  %-20s %s [%s]\n",
        '--rbconfig=path', 'your rbconfig.rb to load', "running ruby's"

    out.puts
    out.puts 'Options for install:'
    out.printf "  %-20s %s [%s]\n",
        '--no-harm', 'only display what to do if given', 'off'

    out.puts
    out.puts 'This archive includes:'
    out.print '  ', packages().sort.join(' '), "\n"

    out.puts
  end


  #
  # tasks
  #

  def do_config
    @config_args.each do |k,v|
      @config[k] = v
    end
    @config.save
  end

  def do_setup
    selected_installers.each {|inst| inst.setup }
  end

  def do_install
    selected_installers.each {|inst| inst.install }
  end

  def do_clean
    selected_installers.each {|inst| inst.clean }
=begin ?
    Dir.glob( 'ext/*' ).each do |name|
      if dir? name then
        chdir( name ) {
          command @config['make-prog'] + ' clean' if File.file? 'Makefile'
        }
      end
    end
=end
    rmf ConfigTable::SAVE_FILE
  end

  def do_show
    ConfigTable.each_name do |k|
      v = @config.noproc_get(k)
      if not v or v.empty? then
        v = '(not specified)'
      end
      printf "%-10s %s\n", k, v
    end
  end
  
  def do_dryrun
    unless dir? 'tmp' then
      $stderr.puts 'setup.rb: setting up temporaly environment...'
      @verbose = $DEBUG
      begin
        @config['bin-dir']  = isdir(File.expand_path('.'), 'tmp', 'bin')
        @config['rb-dir']   = isdir(File.expand_path('.'), 'tmp', 'lib')
        @config['so-dir']   = isdir(File.expand_path('.'), 'tmp', 'ext')
        @config['data-dir'] = isdir(File.expand_path('.'), 'tmp', 'share')
        do_install
      rescue
        rmrf 'tmp'
        $stderr.puts '[BUG] setup.rb: cannot prepare tmp/ for dryrun'
        raise
      end
    end

    exec @config['ruby-prog'], '-I./tmp/lib', '-I./tmp/ext', * @dryrun_args
  end
  

  #
  # packages
  #

  def load_installer
    @installers = {}
    load_installers_from_pathconv
    load_installers
  end

  def load_installers
    if File.directory? subpath('setup') then
      Dir.glob( subpath('setup/*.rb') ).each do |file|
        load file
        pack = File.basename(file)[0..-4]
        klass = Object.const_get("Installer_#{pack}")
        @installers[pack] = klass.new( self, pack, klass::DIRS )
      end
    end
  end

  def load_installers_from_pathconv
    records = {}
    FILETYPES.each do |type|
      next unless File.exist? subpath(type, 'PATHCONV')

      foreach_pathconv_record( subpath(type, 'PATHCONV') ) do
      |dir, pack, targ, top|
        (records[pack] ||= {}).update( "#{type}/#{dir}" => [targ, top] )
      end
    end
    records.each do |pack, desc|
      @installers[pack] = Installer.new( self, pack, desc )
    end
  end

  def packages
    @installers.keys
  end

  def selected_installers
    @installers.values
  end

  def init_selection
    with    = extract_dirs( @config['with'] )
    without = extract_dirs( @config['without'] )

    packs = packages()
    [ [with,    with_pack    = [], with_dir    = []],
      [without, without_pack = [], without_dir = []] ].each do |orig, pack, dir|
      orig.each do |i|
        if    packs.include? i then pack.push i
        elsif dir? i           then dir.push i
        else
          raise InstallError, "no such package or directory '#{i}'"
        end
      end
    end

    @installers.delete_if {|name, inst|
        not is_included? name, with_pack, without_pack   \
                            and
        inst.descripter.keys.delete_if {|d|
                not is_included? d, with_dir, without_dir }.empty?
    }
    @installers.each do |name, inst|
      if is_included? name, with_pack, without_pack then
        inst.descripter.delete_if {|k,v| without_dir.include? k }
      else
        inst.descripter.delete_if {|k,v| not with_dir.include? k }
      end
    end
  end

  def extract_dirs( s )
    ret = []
    s.split(',').each do |i|
      if /[\*\?]/ === i then
        tmp = Dir.glob(i)
        tmp.delete_if {|d| not dir? d }
        if tmp.empty? then
          tmp.push i   # causes error
        else
          ret.concat tmp
        end
      else
        ret.push i
      end
    end

    ret
  end

  def is_included?( name, with, without )
    if with.empty? then
      not without.include? name
    else
      with.include? name
    end
  end

  def install_dir?( dir )
    true
  end

  def foreach_pathconv_record( fname )
    File.foreach( fname ) do |line|
      line.strip!
      next if line.empty?
      a = line.split(/\s+/)
      a[2] ||= '.'
      yield a
    end
  end

end


###
### config
###

class ConfigTable

  c = ::Config::CONFIG

  rubypath = c['bindir'] + '/' + c['ruby_install_name']

  major = c['MAJOR'].to_i
  minor = c['MINOR'].to_i
  teeny = c['TEENY'].to_i
  version = "#{major}.#{minor}"

  # >=1.4.4 is new path
  newpath_p = ((major >= 2) or
               ((major == 1) and
                ((minor >= 5) or
                 ((minor == 4) and (teeny >= 4)))))
  
  if newpath_p then
    sitelibdir = "site_ruby/#{version}"
  else
    sitelibdir = "#{version}/site_ruby"
  end

  DESCRIPTER = [
    [ 'prefix',    [ c['prefix'],
                     'path',
                     'path prefix' ] ],
    [ 'std-ruby',  [ "$prefix/lib/ruby/#{version}",
                     'path',
                     'directory for standard ruby libraries' ] ],
    [ 'site-ruby', [ "$prefix/lib/ruby/#{sitelibdir}",
                     'path',
                     'directory for non-standard ruby libraries' ] ],
    [ 'bin-dir',   [ '$prefix/bin',
                     'path',
                     'directory to install commands' ] ],
    [ 'rb-dir',    [ '$site-ruby',
                     'path',
                     'directory to install ruby scripts' ] ],
    [ 'so-dir',    [ "$site-ruby/#{c['arch']}",
                     'path',
                     'directory to install ruby extentions' ] ],
    [ 'data-dir',  [ '$prefix/share',
                     'path',
                     'directory to install data' ] ],
    [ 'ruby-path', [ rubypath,
                     'path',
                     'path to ruby for #!' ] ],
    [ 'ruby-prog', [ rubypath,
                     'path',
                     'path to ruby for installation' ] ],
    [ 'make-prog', [ 'make',
                     'name',
                     'make program to compile ruby extentions' ] ],
    [ 'with',      [ '',
                     'name,name...',
                     'package name(s) you want to install' ],
                     'ALL' ],
    [ 'without',   [ '',
                     'name,name...',
                     'package name(s) you do not want to install' ] ]
  ]

  def self.each_name( &block )
    keys().each( &block )
  end

  def self.keys
    DESCRIPTER.collect {|k,*discard| k }
  end


  SAVE_FILE = 'config.save'

  def self.create
    c = new()
    c.init
    c
  end

  def self.load
    c = new()
    File.file? SAVE_FILE or raise InstallError, 'setup.rb config first'
    File.foreach( SAVE_FILE ) do |line|
      k, v = line.split( '=', 2 )
      c.noproc_set k, v.strip
    end
    c
  end

  def initialize
    @table = {}
  end

  def init
    DESCRIPTER.each do |k, (default, vname, desc, default2)|
      @table[k] = default
    end
  end

  def save
    File.open( SAVE_FILE, 'w' ) do |f|
      @table.each do |k, v|
        f.printf "%s=%s\n", k, v if v
      end
    end
  end

  def []=( k, v )
    d = DESCRIPTER.assoc(k) or
            raise InstallError, "unknown config option #{k}"
    if d[1][1] == 'path' and v[0,1] != '$' then
      @table[k] = File.expand_path(v)
    else
      @table[k] = v
    end
  end
    
  def []( key )
    @table[key] and @table[key].sub( %r_\$([^/]+)_ ) { self[$1] }
  end

  def noproc_set( key, val )
    @table[key] = val
  end

  def noproc_get( key )
    @table[key]
  end

end


###
### Node Installer
###

class Installer

  def initialize( root, name, desc )
    @root_installer = root
    @name           = name
    @descripter     = desc

    @curr_dir = '.'
    @verbose  = true
  end

  attr :root_installer
  attr :name
  alias package_name name
  attr :descripter

  def inspect
    "#<#{type} #{@name}>"
  end

  def no_harm?
    @root_installer.no_harm?
  end

  def package_root
    @root_installer.root_dir
  end

  def config( key )
    @root_installer.config[key]
  end

  #
  # setup
  #

  def setup
    foreach_dir_in( 'bin' ) do
      Dir.foreach( current_directory ) do |fname|
        next unless File.file? "#{current_directory}/#{fname}"
        add_rubypath "#{current_directory}/#{fname}"
      end
    end

    foreach_dir_in( 'ext' ) do
      extconf
      make
    end
  end

  SHEBANG_RE = /\A\#!\s*\S*ruby/

  def add_rubypath( path )
    $stderr.puts %Q<setting #! line to "\#!#{config('ruby-path')}"> if @verbose
    return if no_harm?

    tmpf = nil
    File.open( path ) do |f|
      first = f.gets
      return unless SHEBANG_RE === first   # reject '/usr/bin/env ruby'

      tmpf = Tempfile.open( 'amsetup' )
      tmpf.print first.sub( SHEBANG_RE, '#!' + config('ruby-path') )
      tmpf.write f.read
      tmpf.close
    end
    
    mod = File.stat( path ).mode
    tmpf.open
    File.open( File.basename(path), 'w' ) {|w|
      w.write tmpf.read
    }
    File.chmod mod, File.basename(path)

    tmpf.close true
  end

  def extconf
    command "#{config('ruby-prog')} #{current_directory}/extconf.rb"
  end

  def make
    command config('make-prog')
  end

  #
  # install
  #

  def install
    foreach_dir_in( 'bin' ) do |target, topfile|
      install_bin
    end

    foreach_dir_in( 'lib' ) do |target, topfile|
      install_rb target
      create_topfile target, topfile if topfile
    end

    foreach_dir_in( 'ext' ) do |target, topfile|
      install_so target
    end

    foreach_dir_in( 'share' ) do |target, topfile|
      install_data target
    end
  end

  def install_bin
    install_all isdir(config('bin-dir')), 0555
  end

  def install_rb( dir )
    install_all isdir(config('rb-dir') + '/' + dir), 0644
  end

  def install_data( dir )
    install_all isdir(config('data-dir') + '/' + dir), 0644
  end

  def install_all( dest, mode )
    Dir.foreach( current_directory ) do |fname|
      next if fname[0,1] == '.'
      next unless File.file? "#{current_directory}/#{fname}"
      unless File.file? fname then
        fname = "#{current_directory}/#{fname}"
      end

      _install fname, dest, mode
    end
  end

  def create_topfile( name, req )
    d = isdir(config('rb-dir'))
    $stderr.puts "creating wrapper file #{d}/#{name}.rb ..." if @verbose
    return if no_harm?

    File.open( "#{d}/#{name}.rb", 'w' ) do |f|
      f.puts "require '#{name}/#{req}'"
    end
    File.chmod 0644, "#{d}/#{name}.rb"
  end


  def install_so( dest )
    to = isdir(File.expand_path config('so-dir') + '/' + dest)
    find_so('.').each do |name|
      _install name, to, 0555
    end
  end

  DLEXT = ::Config::CONFIG['DLEXT']

  def find_so( dir )
    fnames = nil
    Dir.open( dir ) {|d| fnames = d.to_a }
    exp = /\.#{DLEXT}\z/
    fnames.find_all {|fn| exp === fn } or
          raise InstallError,
          'no ruby extention exists: have you done "ruby setup.rb setup" ?'
  end

  #
  # clean
  #

  def clean
    foreach_dir_in( 'ext' ) do
      _clean
    end
  end
  
  def _clean
    command config('make-prog') + ' clean' if File.file? 'Makefile'
  end

  #
  # lib
  #

  def foreach_dir_in( filetype )
    return unless dir? subpath(filetype)

    descripter.each do |dir, args|
      next unless File.dirname(dir) == filetype

      unless install_dir? dir then
        $stderr.puts "setup.rb: skip #{dir}(#{name()}) by user option"
        next
      end

      chdir( dir ) {
        yield args
      }
    end
  end

  def install_dir?( dir )
    @root_installer.install_dir? dir
  end

end


###
### File operations
###

module FileOp

  def chdir( path )
    mkpath path
    save = Dir.pwd
    Dir.chdir path
    @curr_dir = path
    yield
    Dir.chdir save
    @curr_dir = ''
  end

  def current_directory
    subpath( @curr_dir )
  end

  def subpath( *pathes )
    File.join( package_root, *pathes )
  end

  def isdir( dn )
    mkpath dn
    dn
  end

  def mkpath( dname )
    $stderr.puts "mkdir -p #{dname}" if @verbose
    return if no_harm?

    # does not check '/'... it's too abnormal case
    dirs = dname.split(%r_(?=/)_)
    if /\A[a-z]:\z/i === dirs[0] then
      disk = dirs.shift
      dirs[0] = disk + dirs[0]
    end
    dirs.each_index do |idx|
      path = dirs[0..idx].join('')
      Dir.mkdir path unless dir? path
    end
  end

  def rmf( fname )
    $stderr.puts "rm -f #{fname}" if @verbose
    return if no_harm?

    if File.exist? fname or File.symlink? fname then
      File.chmod 777, fname
      File.unlink fname
    end
  end

  def rmrf( dn )
    $stderr.puts "rm -rf #{dn}" if @verbose
    return if no_harm?

    Dir.chdir dn
    Dir.foreach('.') do |fn|
      next if fn == '.'
      next if fn == '..'
      if dir? fn then
        verbose_off {
          rmrf fn
        }
      else
        verbose_off {
          rmf fn
        }
      end
    end
    Dir.chdir '..'
    Dir.rmdir dn
  end

  def _install( from, to, mode )
    $stderr.puts "install #{from} #{to}" if @verbose
    return if no_harm?

    if dir? to then
      to = to + '/' + File.basename(from)
    end
    str = nil
    File.open( from, 'rb' ) {|f| str = f.read }
    if diff? str, to then
      verbose_off {
        rmf to if File.exist? to
      }
      File.open( to, 'wb' ) {|f| f.write str }
      File.chmod mode, to
    end
  end

  def diff?( orig, comp )
    return true unless File.exist? comp
    s2 = nil
    File.open( comp, 'rb' ) {|f| s2 = f.read }
    orig != s2
  end

  def command( str )
    $stderr.puts str if @verbose
    system str or raise RuntimeError, "'system #{str}' failed"
  end

  def dir?( dname )
    # for corrupted windows stat()
    File.directory?( (dname[-1,1] == '/') ? dname : dname + '/' )
  end

  def verbose_off
    save, @verbose = @verbose, false
    yield
    @verbose = save
  end

end


class ToplevelInstaller
  include FileOp
end
class Installer
  include FileOp
end


###
### main
###

if $0 == __FILE__ then
  begin
    installer = ToplevelInstaller.new( File.dirname($0), ARGV.dup )
    installer.execute
  rescue => err
    raise if $DEBUG
    $stderr.puts err.message
    $stderr.puts 'try "ruby setup.rb --help" for usage'
    exit 1
  end
end
