require 'rant/rantlib'

class Rant::Generators::RubyPackage

    class << self
	def rant_generate(app, ch, args, &block)
	    if !args || args.empty?
		self.new(:app => app, :__caller__ => ch, &block)
	    elsif args.size == 1
		pkg_name = case args.first
		when String: args.first
		when Symbol: args.first.to_s
		else
		    app.abort("RubyPackage takes only one additional " +
			"argument, which should be a string or symbol.")
		end
		self.new(:app => app, :__caller__ => ch,
		    :name => pkg_name, &block)
	    else
		app.abort(app.pos_text(file, ln),
		    "RubyPackage takes only one additional argument, " +
		    "which should be a string or symbol.")
	    end
	end
    end

    # Attributes with a single value.
    PACKAGE_SINGLE_ATTRS = [
	"name",
	"date",
	"description",
	"email",
	"has_rdoc",
	"homepage",
	"platform",
	"required_ruby_version",
	"rubyforge_project",
	"summary",
	"version",
    ]

    # These attributes may be set to a single value, which will be
    # converted to an array with a single element.
    PACKAGE_TO_LIST_ATTRS = [
	"author",
	"bindir",
	"executable",
	"extension",
	"files",
	"rdoc_options",
	"requires",
	"test_files",
	"test_suite",
    ]

    PACKAGE_ATTRS = PACKAGE_SINGLE_ATTRS + PACKAGE_TO_LIST_ATTRS

    EXPLICIT_GEM_MAPPING = {
	"executable" => "executables"
	# add requires => requirements ?
    }

    PACKAGE_NO_VAL = Object.new

    PACKAGE_SINGLE_ATTRS.each { |a|
	eval <<-EOM, binding
	    def #{a}(val = PACKAGE_NO_VAL)
		if val.equal? PACKAGE_NO_VAL
		    @data["#{a}"]
		else
		    self.#{a} = val
		end
	    end
	    def #{a}=(val)
		@data["#{a}"] = val
	    end
	EOM
    }
    PACKAGE_TO_LIST_ATTRS.each { |a|
	eval <<-EOM, binding
	    def #{a}(val0 = PACKAGE_NO_VAL, *args)
		if val0.equal? PACKAGE_NO_VAL
		    @data["#{a}"]
		else
		    self.#{a} = [val0, *args].flatten
		end
	    end
	    def #{a}=(val)
		unless val.nil? || Array === val
		    if val.respond_to? :to_ary
			val = val.to_ary
		    else
			val = [val]
		    end
		end
		@data["#{a}"] = val
	    end
	EOM
    }

    # A hash containing all package information.
    attr_reader :data
    # Directory where packages go to. Defaults to "pkg".
    attr_accessor :pkg_dir

    def initialize(opts = {})
	@app = opts[:app] || Rant.rantapp
	@pkg_dir = "pkg"
	@pkg_dir_task = nil
	@dist_dir_task = nil
	@gem_task = nil
	@tar_task = nil
	@zip_task = nil
	@package_task = nil
	name = opts[:name]
	@ch = opts[:__caller__] || Rant::Lib.parse_caller_elem(caller[0])
	unless name
	    # TODO: pos_text
	    @app.warn_msg(@app.pos_text(@ch[:file], @ch[:ln]),
		"No package name given, using directory name.")
	    # use directory name as project name
	    name = File.split(Dir.pwd)[1]
	    # reset name if it contains a slash or a backslash
	    name = nil if name =~ /\/|\\/
	end
	@data = { "name" => name }

	yield self if block_given?
    end

    def method_missing(sym, *args)
	super unless args.size == 1
	a = sym.to_s
	if a =~ /^gem_([^=]+)=$/
	    @data["gem-#$1"] = args.first
	else
	    super
	end
    end

    def validate_attrs(pkg_type = :general)
	%w(name files).each { |a|
	    pkg_requires_attr a
	}
	case pkg_type
	when :gem
	    %w(version summary).each { |a|
		gem_requires_attr a
	    }
	end
    end
    private :validate_attrs

    def gem_requires_attr(attr_name)
	unless @data[attr_name] || @data["gem-#{attr_name}"]
	    @app.abort("RubyPackaged defined: " +
		@app.pos_text(@ch[:file], @ch[:ln]),
		"gem specification requires `#{attr_name}' attribute")
	end
    end

    def pkg_requires_attr(attr_name)
	unless @data[attr_name]
	    @app.abort("RubyPackaged defined: " +
		@app.pos_text(@ch[:file], @ch[:ln]),
		"`#{attr_name}' attribute required")
	end
    end

    def map_to_gemspec spec
	mapped_attrs = []
	# Map attributes from data to the gem spec as explicitely
	# specified.
	EXPLICIT_GEM_MAPPING.each_pair { |attr, gem_attr|
	    setter = "#{gem_attr}="
	    if @data.key? attr
		mapped_attrs << attr
		spec.send setter, @data[attr]
	    end
	}
	# Try to map other attributes.
	@data.each_pair { |attr, val|
	    next if attr =~ /^gem\-./
	    next if mapped_attrs.include? attr
	    setter = "#{attr}="
	    spec.send(setter, val) if spec.respond_to? setter
	}
	# `gem-' attributes override others for gem spec
	@data.each_pair { |attr, val|
	    if attr =~ /^gem\-(.+)$/
		spec.send("#$1=", val)
	    end
	}
    end
    private :map_to_gemspec

    def pkg_dir_task
	return if @pkg_dir_task
	if @dist_dir_task
	    # not ideal but should work: If only the gem task will
	    # be run, dist dir creation wouldn't be necessary
	    return @pkg_dir_task = @dist_dir_task
	end
	@pkg_dir_task = @app.gen(Rant::Generators::Directory, @pkg_dir)
    end

    def dist_dir_task
	return if @dist_dir_task
	pkg_name = pkg_dist_dir
	dist_dir = pkg_dist_dir
	@dist_dir_task = @app.gen(Rant::Generators::Directory,
		dist_dir => files) { |t|
	    # ensure to create new and empty destination directory
	    if Dir.entries(dist_dir).size > 2	# "." and ".."
		@app.sys.rm_rf(dist_dir)
		@app.sys.mkdir(dist_dir)
	    end
	    # evaluate directory structure first
	    dirs = []
	    fl = []
	    files.each { |e|
		if test(?d, e)
		    dirs << e unless dirs.include? e
		else	# assuming e is a file
		    fl << e
		    dir = File.dirname(e)
		    dirs << dir unless dir == "." || dirs.include?(dir)
		end
	    }
	    # create directory structure
	    dirs.each { |dir|
		dest = File.join(dist_dir, dir)
		@app.sys.mkpath(dest) unless test(?d, dest)
	    }
	    # link or copy files
	    fl.each { |f|
		dest = File.join(dist_dir, f)
		@app.sys.safe_ln(f, dest)
	    }
	}
    end

    # Create task for gem building. If tname is a true value, a
    # shortcut-task will be created.
    def gem_task(tname = :gem)
	validate_attrs(:gem)
	# We first define the task to create the gem, and afterwards
	# the task to create the pkg directory to ensure that a
	# pending description is used to describe the gem task.
	pkg_name = gem_pkg_path
	if tname
	    # shortcut task
	    @app.task({:__caller__ => @ch, tname => pkg_name})
	end
	# actual gem-creating task
	@gem_task = @app.file({:__caller__ => @ch,
		pkg_name => [@pkg_dir] + files}) { |t|
	    # We require rubygems as late as possible to save some
	    # execution cycles if possible ;)
	    begin
		require 'rubygems'
	    rescue LoadError => e
		t.fail "Couldn't load `rubygems'. " +
		    "Probably RubyGems isn't installed on your system."
	    end
	    Gem.manage_gems
	    # map rdoc options from application vars
	    @data["rdoc_options"] ||= @app.var[:rubydoc_opts]
	    if @data["rdoc_options"]
		# remove the --op option, otherwise rubygems will
		# install the rdoc in the wrong directory (at least as
		# of version 0.8.6 of rubygems)
		@data["rdoc_options"] = without_rdoc_op_opt(@data["rdoc_options"])
		# automatically set "has_rdoc" to true unless it was
		# explicitely set to false (but if someone sets
		# options for rdoc, he probably wants to run rdoc...)
		@data["has_rdoc"] = true if @data["has_rdoc"].nil?
	    end
	    spec = Gem::Specification.new do |s|
		map_to_gemspec(s)
	    end
	    fn = nil
	    begin
		fn = Gem::Builder.new(spec).build
	    rescue Gem::InvalidSpecificationException => e
		t.fail "Invalid Gem specification: " + e.message
	    rescue Gem::Exception => e
		t.fail "Gem error: " + e.message
	    end
	    @app.sys.mv(fn, @pkg_dir) if @pkg_dir
	}
	pkg_dir_task
    end

    def tar_task(tname = :tar)
	validate_attrs
	# Create tar task first to ensure that a pending description
	# is used for the tar task and not for the dist dir task.
	pkg_name = tar_pkg_path
	pkg_files = files
	if tname
	    # shortcut task
	    @app.task({:__caller__ => @ch, tname => pkg_name})
	end
	# actual tar-creating task
	@tar_task = @app.file(:__caller__ => @ch,
		pkg_name => [pkg_dist_dir] + pkg_files) { |t|
	    @app.sys.cd(@pkg_dir) {
		@app.sys %W(tar zcf #{tar_pkg_name} #{pkg_base_name})
	    }
	}
	dist_dir_task
    end

    def zip_task(tname = :zip)
	validate_attrs
	# Create zip task first to ensure that a pending description
	# is used for the zip task and not for the dist dir task.
	pkg_name = zip_pkg_path
	pkg_files = files
	if tname
	    # shortcut task
	    @app.task({:__caller__ => @ch, tname => pkg_name})
	end
	# actual zip-creating task
	@zip_task = @app.file(:__caller__ => @ch,
		pkg_name => [pkg_dist_dir] + pkg_files) { |t|
	    @app.sys.cd(@pkg_dir) {
		# zip options:
		#   y: store symlinks instead of referenced files
		#   r: recurse into directories
		#   q: quiet operation
		@app.sys %W(zip -yqr #{zip_pkg_name} #{pkg_base_name})
	    }
	}
	dist_dir_task
    end

    # Create a task which runs gem/zip/tar tasks.
    def package_task(tname = :package)
	def_tasks = [@gem_task, @tar_task, @zip_task].compact
	if def_tasks.empty?
	    # take description for overall package task
	    pdesc = @app.pop_desc
	    unless def_available_tasks
		@app.desc pdesc
		@app.warn_msg("No tools for packaging available (tar, zip, gem):",
		    "Can't generate task `#{tname}'.")
		return
	    end
	    @app.desc pdesc
	end
	pre = []
	pre << tar_pkg_path if @tar_task
	pre << zip_pkg_path if @zip_task
	pre << gem_pkg_path if @gem_task
	@app.task(:__caller__ => @ch, tname => pre)
    end

    # Returns true if at least one task was defined.
    def def_available_tasks
	defined = false
	if Rant::Env.have_tar?
	    # we don't create shortcut tasks, hence nil as argument
	    self.tar_task(nil)
	    defined = true
	end
	if Rant::Env.have_zip?
	    self.zip_task(nil)
	    defined = true
	end
	begin
	    require 'rubygems'
	    self.gem_task(nil)
	    defined = true
	rescue LoadError
	end
	defined
    end

    def pkg_base_name
	unless name
	    @app.abort(@app.pos_text(@ch[:file], @ch[:ln]),
		"`name' required for packaging")
	end
	version ? "#{name}-#{version}" : name
    end

    def gem_pkg_path
	pkg_dist_dir + ".gem"
    end

    #--
    # Arghhh... tar makes me feel angry
    #++

    def tar_pkg_name
	pkg_base_name + ".tar.gz"
    end

    def tar_pkg_path
	pkg_dist_dir + ".tar.gz"
    end

    def zip_pkg_name
	pkg_base_name + ".zip"
    end

    def zip_pkg_path
	pkg_dist_dir + ".zip"
    end

    def pkg_dist_dir
	@pkg_dir ? File.join(@pkg_dir, pkg_base_name) : pkg_base_name
    end

    # Remove -o and --op options from rdoc arguments.
    # Note that this only works if -o isn't part of an argument with
    # multiple one-letter options!
    def without_rdoc_op_opt(rdoc_args)
	last_was_op = false
	rdoc_args.reject { |arg|
	    if last_was_op
		last_was_op = false
		next true
	    end
	    case arg
	    when /^(-o|--op)$/
		last_was_op = true
		true
	    when /^-o./
		true
	    else
		false
	    end
	}
    end

end	# class Rant::RubyPackage