module Buildr

  # This module gives you a way to access the individual properties of an
  # artifact: id, group, type, classifier and version. It also provides other
  # methods commonly used on an artifact, specifically #to_hash and #to_spec.
  module ActsAsArtifact

    ARTIFACT_ATTRIBUTES = [:group, :id, :type, :classifier, :version]

    class << self
      def included(mod)
        mod.extend self
      end
    end

    # The artifact identifier.
    attr_reader :id
    # The group identifier.
    attr_reader :group
    # The file type.
    attr_reader :type
    # The version number.
    attr_reader :version
    # Optional artifact classifier.
    attr_reader :classifier

    # Returns the artifact specification as a hash.
    def to_spec_hash()
      base = { :group=>group, :id=>id, :type=>type, :version=>version }
      classifier.blank? ? base : base.merge(:classifier=>classifier)
    end
    alias_method :to_hash, :to_spec_hash

    # Returns the artifact specification, in the structure:
    #   <group>:<artifact>:<type>:<version>
    # or
    #   <group>:<artifact>:<type>:<classifier><:<version>
    def to_spec()
      classifier.blank? ? "#{group}:#{id}:#{type}:#{version}" : "#{group}:#{id}:#{type}:#{classifier}:#{version}"
    end

    # Apply specification to this artifact.
    def apply_spec(spec)
      spec = Artifact.to_hash(spec)
      ARTIFACT_ATTRIBUTES.each { |key| instance_variable_set("@#{key}", spec[key]) }
      self
    end

    # Convenience method that returns a POM artifact.
    def pom()
      return self if type.to_s == "pom"
      artifact(:group=>group, :id=>id, :version=>version, :type=>"pom", :classifier=>classifier)
    end

  end


  # The Artifact task maps to an artifact file in the local repository
  # and knows how to download the file from a remote repository.
  #
  # The task will only download the file if it does not exist. You can
  # enhance the task to create the artifact yourself.
  class Artifact < Rake::FileCreationTask

    # The default file type for artifacts, if not specified.
    DEFAULT_FILE_TYPE = "jar"

    include ActsAsArtifact

    class << self

      # Lookup a previously registered artifact task based on the
      # artifact specification (string or hash).
      def lookup(spec)
        @artifacts ||= {}
        @artifacts[to_spec(spec)]
      end

      # Register an artifact task(s) for later lookup (see #lookup).
      def register(*tasks)
        @artifacts ||= {}
        fail "You can only register an artifact task, strings and hashes are just not good enough" unless
          tasks.all? { |task| task.respond_to?(:to_spec) && task.respond_to?(:invoke) }
        tasks.each { |task| @artifacts[task.to_spec] = task }
        tasks
      end

      # Turn a spec into a hash. This method accepts a string, hash or any object
      # that responds to the method to_spec. There are several reasons to use this
      # method:
      # * You can pass anything that could possibly be a spec, and get a hash.
      # * It will check that the spec includes the group identifier, artifact
      #   identifier and version number and set the file type, if missing.
      # * It will always return a new specs hash.
      #
      # :nodoc:
      def to_hash(spec)
        if spec.respond_to?(:to_spec)
          to_hash spec.to_spec
        elsif Hash === spec
          # Sanitize the hash and check it's valid.
          spec = ARTIFACT_ATTRIBUTES.inject({}) { |h, k| h[k] = spec[k] ; h }
          fail "Missing group identifier for #{spec.inspect}" if spec[:group].blank?
          fail "Missing artifact identifier for #{spec.inspect}" if spec[:id].blank?
          fail "Missing version for #{spec.inspect}" if spec[:version].blank?
          spec[:type] = DEFAULT_FILE_TYPE if spec[:type].blank?
          spec
        elsif String === spec
          group, id, type, version, *rest = spec.split(":")
          unless rest.empty?
            # Optional classifier comes before version.
            classifier, version = version, rest.shift
            fail "Expecting <project:id:type:version> or <project:id:type:classifier:version>, found <#{spec}>" unless rest.empty?
          end
          to_hash :group=>group, :id=>id, :type=>type, :version=>version, :classifier=>classifier
        else
          fail "Expecting a String, Hash or object that responds to to_spec"
        end
      end

      # Convert a hash back to a spec string. This method accepts
      # a string, hash or any object that responds to to_spec.
      # :nodoc:
      def to_spec(hash)
        hash = to_hash(hash) unless Hash === hash
        version = ":#{hash[:version]}" unless hash[:version].blank?
        classifier = ":#{hash[:classifier]}" unless hash[:classifier].blank?
        "#{hash[:group]}:#{hash[:id]}:#{hash[:type] || DEFAULT_FILE_TYPE}#{classifier}#{version}"
      end

      # Convert a hash to a file name.
      # :nodoc:
      def hash_to_file_name(hash)
        version = "-#{hash[:version]}" unless hash[:version].blank?
        classifier = "-#{hash[:classifier]}" unless hash[:classifier].blank?
        "#{hash[:id]}#{version}#{classifier}.#{hash[:type] || DEFAULT_FILE_TYPE}"
      end

    end

    def execute()
      # Default behavior: download the artifact from one of the remote
      # repositories if the file does not exist. But this default behavior
      # is counter useful if the artifact knows how to build itself
      # (e.g. download from a different location), so don't perform it
      # if the task found a different way to create the artifact.
      super
      unless Rake.application.options.dryrun || File.exist?(name)
        repositories.download(to_spec)
      end
    end

  end


  # Holds the path to the local repository, URLs for remote repositories, and
  # settings for the deployment repository.
  class Repositories
    include Singleton

    # Returns the path to the local repository.
    #
    # The default path is .m2/repository relative to the home directory.
    # You can change the location of the local repository by using a symbol
    # link or by setting a different path. If you set a different path, do it
    # in the buildr.rb file instead of the Rakefile. 
    def local()
      @local ||= ENV["local_repo"] || File.join(ENV["HOME"], ".m2", "repository")
    end

    # Sets the path to the local repository.
    def local=(dir)
      @local = dir ? File.expand_path(dir) : nil
    end

    # Locates an artifact in the local repository based on its specification.
    #
    # For example:
    #   locate :group=>"log4j", :id=>"log4j", :version=>"1.1"
    #     => ~/.m2/repository/log4j/log4j/1.1/log4j-1.1.jar
    def locate(spec)
      spec = Artifact.to_hash(spec)
      File.join(local, spec[:group].split("."), spec[:id], spec[:version], Artifact.hash_to_file_name(spec))
    end

    # Returns a hash of all the remote repositories. The key is the repository
    # identifier, and the value is the repository base URL.
    def remote()
      @remote ||= {}
    end

    # Sets the remote repositories from a hash. See #remote.
    def remote=(hash)
      case hash
      when nil
        @remote = {}
      when Hash
        @remote = hash.clone
      else
        raise ArgumentError, "Expecting a hash" unless Hash === hash
      end
    end

    # Adds more remote repositories from a hash. See #remote.
    #
    # For example:
    #   repositories.remote.add :ibiblio=>"http://www.ibiblio.org/maven2"
    def add(hash)
      remote.merge!(hash)
    end

    # Attempts to download the artifact from one of the remote repositories
    # and store it in the local repository. Returns the path if downloaded,
    # otherwise raises an exception.
    def download(spec)
      spec = Artifact.to_hash(spec) unless Hash === spec
      path = locate(spec)
      
      puts "Downloading #{Artifact.to_spec(spec)}" if Rake.application.options.trace
      return path if remote.any? do |repo_id, repo_url|
        begin
          rel_path = spec[:group].gsub(".", "/") +
            "/#{spec[:id]}/#{spec[:version]}/#{Artifact.hash_to_file_name(spec)}"
          Transports.perform URI.parse(repo_url.to_s) do |http|
            mkpath File.dirname(path), :verbose=>false
            http.download(rel_path, path)
            begin
              http.download(rel_path.ext("pom"), path.ext("pom"))
            rescue Transports::NotFound
            end
          end
          true
        rescue Exception=>error
          warn error if Rake.application.options.trace
          false
        end
      end
      fail "Failed to download #{Artifact.to_spec(spec)}, tried the following repositories:\n#{repositories.remote.values.join("\n")}"
    end

    # Specifies the deployment repository. Accepts a hash with the different
    # repository settings (e.g. url, username, password). Anything else is
    # interepted as the URL.
    #
    # For example:
    #   repositories.deploy_to = { :url=>"sftp://example.com/var/www/maven/",
    #                              :username="john", :password=>"secret" }
    # or:
    #   repositories.deploy_to = "sftp://john:secret@example.com/var/www/maven/"
    def deploy_to=(options)
      options = { :url=>options } unless Hash === options
      @deploy_to = options
    end

    # Returns the current deployment repository configuration. This is a more
    # convenient way to specify deployment in the Rakefile, and override it
    # locally. For example:
    #   # Rakefile
    #   repositories.deploy_to[:url] ||= "sftp://example.com"
    #   # buildr.rb
    #   repositories.deploy_to[:url] = "sftp://acme.org"
    def deploy_to()
      @deploy_to ||= {}
    end

  end


  # Returns a global object for setting local, remote and deploy repositories.
  # See Repositories.
  def repositories()
    Repositories.instance
  end

  # Creates a file task to download and install the specified artifact.
  #
  # The artifact specification can be a string or a hash.
  # The file task points to the artifact in the local repository.
  #
  # You can provide alternative behavior to create the artifact instead
  # of downloading it from a remote repository.
  #
  # For example, to specify an artifact:
  #   artifact("log4j:log4j:jar:1.1")
  #
  # To use the artifact in a task:
  #   unzip artifact("org.apache.pxe:db-derby:zip:1.2")
  #
  # To specify an artifact and the means for creating it:
  #   download(artifact("dojo:dojo-widget:zip:2.0")=>
  #     "http://download.dojotoolkit.org/release-2.0/dojo-2.0-widget.zip")
  def artifact(spec, &block)
    spec = Artifact.to_hash(spec)
    unless task = Artifact.lookup(spec)
      task = Artifact.define_task(repositories.locate(spec))
      Artifact.register(task)
    end
    task.apply_spec spec
    task.enhance &block
  end

  # Creates multiple artifacts from a set of specifications and returns
  # an array of tasks.
  #
  # You can pass any number of arguments, each of which can be:
  # * An artifact specification, string or hash. Returns a new task for
  #   each specification, by calling #artifact.
  # * An artifact task or any other task. Returns the task as is.
  # * A project. Returns all packaging tasks in that project.
  # * An array of artifacts. Returns all the artifacts found there.
  #
  # This method handles arrays of artifacts as if they are flattend,
  # to help in managing large combinations of artifacts. For example:
  #   xml = [ xerces, xalan, jaxp ]
  #   ws = [ axis, jax-ws, jaxb ]
  #   db = [ jpa, mysql, sqltools ]
  #   base = [ xml, ws, db ]
  #   artifacts(base, models, services)
  #
  # You can also pass tasks and project. This is particularly useful for
  # dealing with dependencies between projects that are part of the same
  # build.
  #
  # For example:
  #   artifacts(base, models, services, module1, module2)
  #
  # When passing a project as argument, it expands that project to all
  # its packaging tasks. You can then use the resulting artifacts as
  # dependencies that will force these packages to be build inside the
  # project, without installing them in the local repository.
  def artifacts(*specs)
    specs.inject([]) do |set, spec|
      case spec
      when Hash
        set |= [artifact(spec)]
      when /:/
        set |= [artifact(spec)]
      when String
        set |= [file(spec)]
      when Project
        set |= artifacts(spec.packages)
      when Rake::Task
        set |= [spec]
      when Array
        set |= artifacts(*spec)
      else
        fail "Invalid artifact specification: #{spec.to_s || 'nil'}"
      end
      set
    end
  end

  # Convenience method for defining multiple artifacts that belong
  # to the same version and group. Accepts multiple artifact identifiers
  # (or arrays of) followed by two has keys:
  # * :under -- The group identifier
  # * :version -- The version number
  #
  # For example:
  #   group "xbean", "xbean_xpath", "xmlpublic", :under=>"xmlbeans", :version=>"2.1.0"
  # Or:
  #   group %w{xbean xbean_xpath xmlpublic}, :under=>"xmlbeans", :version=>"2.1.0"
  def group(*args)
    hash = args.pop
    args.flatten.map { |id| artifact :group=>hash[:under], :version=>hash[:version], :id=>id }
  end 

  # Deploys all the specified artifacts/files. Specify the deployment
  # server by passing a hash as the last argument, or have it use
  # repositories.deploy_to.
  #
  # For example:
  #   deploy(*process.packages, :url=>"sftp://example.com/var/www/maven")
  def deploy(*args)
    # Where do we release to?
    if Hash === args.last
      options = args.pop
    else
      options = repositories.deploy_to
      options = { :url=>options.to_s } unless Hash === options
    end
    url = options[:url]
    options = options.reject { |k,v| k === :url }
    fail "Don't know where to deploy, perhaps you forgot to set repositories.deploy_to" if url.blank?

    args.each { |arg| arg.invoke if arg.respond_to?(:invoke) }
    Transports.perform url, options do |session|
      args.each do |artifact|
        if artifact.respond_to?(:to_spec)
          # Upload artifact relative to base URL, need to create path before uploading.
          puts "Deploying #{artifact.to_spec}" if verbose
          spec = artifact.to_spec_hash
          path = spec[:group].gsub(".", "/") + "/#{spec[:id]}/#{spec[:version]}/"
          session.mkpath path
          session.upload artifact.to_s, path + Artifact.hash_to_file_name(spec)
        else
          # Upload artifact to URL.
          puts "Deploying #{artifact}" if verbose
          session.upload artifact, File.basename(artifact)
        end
      end
    end
  end

end