#!/usr/bin/ruby

require 'optparse'
require 'digest/md5'
require 'net/ftp'
require 'net/smtp'
require 'rpm/spec'
require 'nkf'
require 'tiny-buildfarm'

=begin
== BuildInfo Class
=end
class BuildInfo
  FormatVersion = [0, 2]

=begin
=== Class Methods
=end

=begin
--- BuildInfo::new
=end
  def initialize(specfile, build_binary = true, build_source = true)
    unless FileTest.exist?(specfile)
      raise RuntimeError, "no such file: #{specfile}"
    end

    @spec = RPM::Spec.dump(specfile)
    @top_dir = @spec.top_dir
    @arches = []
    @packages = []
    @source_package = nil

    if build_binary
      @spec.packages.each do |arch, pkgs|
	@arches << arch
       	pkgs.sort{|a, b| a.name <=> b.name}.each do |pkg|
	  @packages << pkg
   	end
      end
    end

    if build_source
      @source_package = @spec.source_package
    end

    @changelog = @spec.changelog
  end

=begin
=== Instance Methods
=end

=begin
--- BuildInfo#each_package
=end
  def each_package
    yield(@source_package) if @source_package
    each_binary_package {|pkg| yield(pkg)}
  end

=begin
--- BuildInfo#each_binary_package
=end
  def each_binary_package
    @packages.each{|pkg| yield(pkg)}
  end

=begin
--- BuildInfo#each_package_path
=end
  def each_package_path
    yield(package_path(@source_package)) if @source_package
    each_binary_package_path {|path| yield(path)}
  end

=begin
--- BuildInfo#each_binary_package_path
=end
  def each_binary_package_path
    each_binary_package {|pkg| package_path(pkg)}
  end

=begin
--- BuildInfo#each_package_path
=end
  def current_changes
    changes = []
    version = version_release(@source_package)

    cversion = changes_version(@changelog.first)
    if !cversion
      changes << @changelog.first

    else
      @changelog.each do |c|
    	cversion = changes_version(c)
     	if cversion == version || !cversion
  	  changes << c
   	else
  	  break
   	end
      end
    end

    if changes.size == 0
      raise RuntimeError, 'no changelog for the version'
    end

    changes
  end

=begin
--- BuildInfo#packages_exist?
=end
  def packages_exist?
    r = true
    each_package_path do |path|
      unless FileTest.file?(path)
	r = false
	break
      end
    end

    r
  end

=begin
--- BuildInfo#packages_fresh?
=end
  def packages_fresh?(time)
    r = true
    each_package_path do |path|
      if FileTest.file?(path)
	if File.mtime(path) < time
	  r =false
	  break
	end
      end
    end

    r
  end

=begin
--- BuildInfo#to_s
=end
  def to_s
    changes = current_changes

    info = []
    info << 'Format: ' + FormatVersion.join('.')
    info << 'Date: ' + Time.now.strftime('%a, %d %b %Y %H:%M:%S %z')
    info << 'Name: ' + @spec.name
    info << 'Version: ' + version_release(@spec.source_package)
    info << 'Architecture: ' + @arches.join(' ')
    info << 'Distribution: ' + (@spec.distribution || '')
    info << 'Vendor: ' + (@spec.vendor || '')
    info << 'Packager: ' + (@spec.packager || '')
    info << 'Changed-By: ' + changes_name(@spec.changelog.first)
    info << 'Summary: '
    each_binary_package do |pkg|
      info << ' ' + pkg.name + ' - ' + pkg.summary
    end
    info << 'Changes:'
    info << changes.collect{|c|
	      sprintf("* %s %s\n%s",
		      c.time.strftime('%Y-%d-%m'),
		      c.name, c.text)
	    }.join("\n\n").gsub(/^$/, '.').gsub(/^/, ' ')
    info << 'Source: '
    info << ' ' + file_checksum(package_path(@source_package)).join(' ') + 
	    ' ' + @source_package.group + ' ' + 
	    remove_topdir(package_path(@source_package))
    info << 'Binary: '
    each_binary_package do |pkg|
      path = package_path(pkg)
      info << ' ' + file_checksum(path).join(' ') + 
		' ' + pkg.group + ' ' + remove_topdir(path)
    end

    info.join("\n") + "\n"
  end

=begin
--- BuildInfo#write_out
=end
  def write_out
    fn = filename
    File.unlink(fn) if FileTest.exist?(fn)
    open(fn, 'w') do |f|
      f.print self
    end
  end

=begin
--- BuildInfo#filename
=end
  def filename
    File.join(@top_dir,
	      package_name(@source_package).sub(/\.[^.]+\.[^.]+$/, '') + '.bi')
  end

  private

  def version_release(package)
    sprintf('%s-%s', package.version, package.release)
  end

  def package_name(package)
    sprintf('%s-%s.%s.rpm', 
  	    package.name, 
  	    version_release(package),
  	    package.arch)
  end

  def package_path(package)
    if package.arch == 'src' || package.arch == 'nosrc'
      dir = @spec.source_package_dir
    else
      dir = File.join(@spec.package_dir, 
  		      package.arch)
    end

    File.join(dir, package_name(package))
  end

  def remove_topdir(path)
    path = path.dup
    i = path.index(@top_dir)
    if i
      path[i, @top_dir.length] = ''
      path[0, 1] = '' if path[0] == ?/
    end

    path
  end

  def changes_name_split(name_line)
    if /^(.*<[^@>]+@[^>]+>)\s+([^-\s]+-[^-\s]+)$/i =~ name_line
      [$1, $2]
    else
      [name_line, nil]
    end
  end

  def changes_name(changes)
    changes_name_split(changes.name).first
  end

  def changes_version(changes)
    changes_name_split(changes.name).last
  end

  def file_checksum(filename)
    md5 = nil
    size = nil
    if FileTest.exist?(filename)
      md5 = Digest::MD5.new(open(filename, 'r').read).hexdigest
      size = FileTest.size(filename)
    end

    [md5, size]
  end

=begin
== BuildInfo::Explorer Class
=end
  class Loader
    PKGINFO = Struct.new('PackageInfo',
			 :file, :size, :md5, :group)
    def initialize(bifile)
      @top_dir = File.dirname(bifile)
      @text = nil

      @format = nil
      @date = nil
      @name = nil
      @version = nil
      @architecture = nil
      @vendor = nil
      @packager = nil
      @changed_by = nil
      @summary = nil
      @changes = nil
      @source_package = nil
      @packages = nil

      parse(bifile)
    end
    attr_reader :date, :name, :version, :architecture,
      :vendor, :packager, :changed_by, :summary,
      :changes, :source_package, :packages

    def each_package
      yield(@source_package) if @source_package
      each_binary_package {|x| yield(x)}
    end

    def each_package_path
      each_package {|x| yield(File.join(@top_dir, x.file))}
    end

    def each_binary_package
      @packages.each {|x| yield(x)}
    end

    def each_binary_package_path
      each_binary_package {|x| yield(File.join(@top_dir, x.file))}
    end

    def check_files
      r = true

      each_package do |pkg|
	path = File.join(@top_dir, pkg.file)

	if !FileTest.file?(path) ||
	    FileTest.size(path) != pkg.size ||
	    Digest::MD5.new(open(path, 'r').read).hexdigest != pkg.md5
	  r = false
	  break
	end
      end

      r
    end

    def to_s
      @text
    end

    private

    def parse(bifile)
      @text = open(bifile, 'r').read
      @text.scan(/^([^:\s]+):(.*?)(?=^\S|\z)/m) do |x|
	name = $1.downcase
	value = $2
	if !@format && name != 'format'
	  raise RuntimeError, "unknown format: #{$1}"
	end

	case name
	when 'format'
	  @format = value.scan(/[.\d]+/).first.split('.').collect{|x| x.to_i}
	  if @format != FormatVersion
	    raise RuntimeError, "format version mismatch"
	  end

	when 'date'
	  @date = value.strip

	when 'name'
	  @name = value.strip

	when 'version'
	  @version = value.strip

	when 'architecture'
	  @architecture = value.strip.split(/\s+/)

	when 'distribution'
	  @distribution = value.strip

	when 'vendor'
	  @vendor = value.strip

	when 'packager'
	  @packager = value.strip

	when 'changed-by'
	  @changed_by = value.strip

	when 'summary'
	  @summary = []
	  value.each do |x|
	    x.strip!
	    @summary << x
	  end

	when 'changes'
	  @changes = value
	  @changes.gsub!(/^ /, '')
	  @changes.gsub!(/^\.$/, '')

	when 'source'
	  @source_package = 
	    parse_package_line(value)

	when 'binary'
	  @packages = []
	  value.each do |line|
	    next if /^\s*$/ =~ line
	    @packages << parse_package_line(line)
	  end
	else
	  raise RuntimeError, "unknown field: #{$1}"
	end
      end
    end

    def parse_package_line(line)
      if /^ ([\da-f]{32}) (\d+) (\S+) (.*)$/ =~ line
	return PKGINFO.new($4, $2.to_i, $1, $3)
      else
	raise RuntimeError, "bad package line: #{line}"
      end
    end

  end # Loader
end # BuildInfo

#
# misc
#

def rpm_build(specfile, opt)
  c = opt.command_line.dup
  c << specfile

  pid = fork do
    exec(*c)
  end
  pid, es = Process.waitpid2(pid)

  es
end

def do_build(opt, specfile)
  RPM.setup(opt.rcfile, opt.target)

  if opt.build_for == :all
    opt.command_line << '-ba'
    bi = BuildInfo.new(specfile, true, true)
  end

  if opt.build_for == :source
    opt.command_line << '-bs'
    bi = BuildInfo.new(specfile, false, true)
  end

  if opt.build_for == :binary
    opt.command_line << '-bb'
    bi = BuildInfo.new(specfile, true, false)
  end

  if opt.do_build
    now = Time.now
    if rpm_build(specfile, opt) != 0
      raise RuntimeError, "build failed"
    end
    unless bi.packages_fresh?(now)
      raise RuntimeError, "build failed: some rpms are stale"
    end
  end

  unless bi.packages_exist?
    raise RuntimeError, "build failed: missing files"
  end

  #bi.sign! if opt.do_sign
  bi.write_out
end

#
# upload
#

def sendmail(dest, bi)
  if $upload.include?(dest)
    mailfrom = $upload[dest]['mailfrom']
    mailto   = $upload[dest]['mailto']
    server   = $upload[dest]['smtp_server']
    port     = $upload[dest]['smtp_port'] || 25

    body = <<E_O_M
From: #{mailfrom}
To: #{mailto}
Subject: uploaded: #{bi.name}
Date: #{Time.now.strftime('%a, %d %b %Y %H:%M:%S %z')}

#{bi}
E_O_M

    print "sending announcing mail to #{mailto}... "
    Net::SMTP.start(server, port) do |smtp|
      smtp.send_mail(NKF.nkf('-j', body), mailfrom, mailto)
    end
    print "done\n"
  else
    raise RuntimeError, "unknown destination: #{dest}"
  end
end

def ftp_upload(dest, files)
  if $upload.include?(dest)
    user     = $upload[dest]['user'] || 'anonymous'
    password = $upload[dest]['password']
    server   = $upload[dest]['server']
    dir      = $upload[dest]['dir']
    passiv   = $upload[dest]['passiv'] || false

    ftp = Net::FTP.new(server, user, password)
    ftp.debug_mode = true if $DEBUG
    ftp.passive = true
    ftp.chdir(dir)
    files.each do |f|
      b = File.basename(f)
      $stderr.print "putting #{b}... "
      ftp.putbinaryfile(f, File.basename(f))
      $stderr.print "done\n"
    end
    ftp.quit
  else
    raise RuntimeError, "unknown destination: #{dest}"
  end
end

def do_upload(opt, bifile)
  bi = BuildInfo::Loader.new(bifile)
  if bi.check_files
    x = []
    # x << bifile
    bi.each_package_path {|f| x << f}
    ftp_upload(opt.do_upload, x)
    sendmail(opt.do_upload, bi)
  else
    raise RuntimeError, "some files are broken"
  end
end

#
# main
#

if __FILE__ == $0
  class InvalidArgument < OptionParser::ParseError
    def message
      args.join(' ')
    end
    alias to_s message
    alias to_str message
  end
  s = Struct.new('ArgStruct',
		 :rcfile, :target, :defines,
		 :do_upload, 
		 :do_sign, :do_build, :build_for, 
		 :command_line).new
  # defaults
  s.rcfile = nil
  s.target = nil
  s.defines = []
  s.do_sign = true
  s.do_build = true
  s.build_for = :all
  s.command_line = [TinyBuildFarm::RPM_CMD]
  s.do_upload = false

  allow_root = false
  ARGV.options do |q|
    q.banner = "usage: #{$0} [options] [rpm-options] {spec-file|bi-file}"
    q.on
    q.on('options:')

    q.on_tail('-h', '--help', 'show this message') {
      puts q
      exit(0)
    }

    q.on('--rcfile=RCFILE', ':-separated list of rpmrc') {|rcs|
      s.rcfile = rcs
      s.command_line << '--rcfile'
      s.command_line << rcs
    }

    q.on('--target=TARGET', 'target architecture') {|arch|
      s.target = arch
      s.command_line << '--target'
      s.command_line << arch
    }

    q.on('--define=DEFINE', 'defining macro statement') {|defs|
      s.defines << defs
      s.command_line << '--define'
      s.command_line << defs
    }

    q.on('--binary-only', 'build only binary pacages') {|bonly|
      s.build_for = :binary
      s.do_upload = false
    }

    q.on('--source-only', 'build only source package') {|sonly|
      s.build_for = :source
      s.do_upload = false
    }

    q.on('--[no-]sign', 'do sign') {|sign|
      s.do_sign = sign
    }

    q.on('--[no-]build', 'do build') {|build|
      s.do_build = build
      s.do_upload = !build
    }

    q.on('--with-rpm=PATH', 'rpm command') {|rpm|
      s.command_line.first.replace(rpm)
    }

    q.on('--[no-]allow-root', 'permit to run by root') {|root|
      allow_root = root
    }

    q.on('--upload=DEST', 'do upload') {|dest|
      s.do_build = false
      s.do_upload = dest
    }

    r = q.parse!

    if ARGV.size == 0
      if s.do_build
	raise InvalidArgument,
	  "spec filename are not given"
      else
	raise InvalidArgument,
	  "build info filename are not given"
      end
    end

    unless allow_root
      raise InvalidArgument, 
	"cannot run as root, unless --allow-root are given" if Process.uid == 0 || Process.euid == 0
    end


    ['/etc/rpmaker.conf', '~/.rpmaker.conf', './.rpmaker.conf'].each do |path|
      load(File.expand_path(path)) if FileTest.exist?(path)
    end

    begin
      if s.do_upload
	do_upload(s, ARGV.last)
      else
	ENV['LANG'] = 'C'
	do_build(s, ARGV.last)
      end

    rescue RuntimeError => e
      raise InvalidArgument, "#{e}"
    end

    r
  end or exit(1)
end
