# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with this
# work for additional information regarding copyright ownership.  The ASF
# licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
# License for the specific language governing permissions and limitations under
# the License.

module Buildr #:nodoc:

  class Options

    # Runs the build in parallel when true (defaults to false). You can force a parallel build by
    # setting this option directly, or by running the parallel task ahead of the build task.
    #
    # This option only affects recursive tasks. For example:
    #   buildr parallel package
    # will run all package tasks (from the sub-projects) in parallel, but each sub-project's package
    # task runs its child tasks (prepare, compile, resources, etc) in sequence.
    attr_accessor :parallel

  end

  task('parallel') { Buildr.options.parallel = true }


  module Build

    include Extension

    first_time do
      desc 'Build the project'
      Project.local_task('build') { |name| "Building #{name}" }
      desc 'Clean files generated during a build'
      Project.local_task('clean') { |name| "Cleaning #{name}" }

      desc 'The default task is build'
      task 'default'=>'build'
    end

    before_define(:build => [:compile, :test]) do |project|
      project.recursive_task 'build'
      project.recursive_task 'clean'
      project.clean do
        rm_rf project.path_to(:target)
        rm_rf project.path_to(:reports)
      end
    end

    after_define(:build)

    # :call-seq:
    #    build(*prereqs) => task
    #    build { |task| .. } => task
    #
    # Returns the project's build task. With arguments or block, also enhances that task.
    def build(*prereqs, &block)
      task('build').enhance prereqs, &block
    end

    # :call-seq:
    #    clean(*prereqs) => task
    #    clean { |task| .. } => task
    #
    # Returns the project's clean task. With arguments or block, also enhances that task.
    def clean(*prereqs, &block)
      task('clean').enhance prereqs, &block
    end

  end

  module Hg #:nodoc:
    module_function

    # :call-seq:
    #   hg(*args)
    #
    # Executes a Mercurial (hg) command passing through the args and returns the output.
    # Throws exception if the exit status is not zero. For example:
    #   hg 'commit'
    #   hg 'update', 'default'
    def hg(*args)
      cmd = "hg #{args.shift} #{args.map { |arg| arg.inspect }.join(' ')}"
      output = `#{cmd}`
      fail "Mercurial command \"#{cmd}\" failed with status #{$?.exitstatus}\n#{output}" unless $?.exitstatus == 0
      return output
    end

    # Return a list of uncommitted / untracked files as reported by hg status
    # The codes used to show the status of files are:
    #   M = modified
    #   A = added
    #   R = removed
    #   C = clean
    #   ! = missing (deleted by non-hg command, but still tracked)
    #   ? = not tracked
    #   I = ignored
    #     = origin of the previous file listed as A (added)
    def uncommitted_files
      `hg status`.scan(/^(A|M|R|!|\?) (\S.*)$/).map{ |match| match.last.split.last }
    end

    # Commit the given file with a message. The file should already be added to the Mercurial index.
    def commit(file, message)
      hg 'commit', '-m', message, file
    end

    # Update the remote branch with the local commits
    # This will push the current remote destination and current branch.
    def push
      hg 'push'
    end

    # Return the name of the current local branch or nil if none.
    def current_branch
      hg('branch').to_s.strip
    end

    # Return the aliases (if any) of any remote repositories which the current local branch tracks
    def remote
      hg('paths').scan(/^(?:default|default-push)\s+=\s+(\S.*)/).map{ |match| match.last }
    end
  end

  module Git  #:nodoc:
    module_function

    # :call-seq:
    #   git(*args)
    #
    # Executes a Git command and returns the output. Throws exception if the exit status
    # is not zero. For example:
    #   git 'commit'
    #   git 'remote', 'show', 'origin'
    def git(*args)
      cmd = "git #{args.shift} #{args.map { |arg| arg.inspect }.join(' ')}"
      output = `#{cmd}`
      fail "GIT command \"#{cmd}\" failed with status #{$?.exitstatus}\n#{output}" unless $?.exitstatus == 0
      return output
    end

    # Returns list of uncommited/untracked files as reported by git status.
    def uncommitted_files
      `git status`.scan(/^#(\t|\s{7})(\S.*)$/).map { |match| match.last.split.last }
    end

    # Commit the given file with a message.
    # The file has to be known to Git meaning that it has either to have been already committed in the past
    # or freshly added to the index. Otherwise it will fail.
    def commit(file, message)
      git 'commit', '-m', message, file
    end

    # Update the remote refs using local refs
    #
    # By default, the "remote" destination of the push is the the remote repo linked to the current branch.
    # The default remote branch is the current local branch.
    def push(remote_repo = remote, remote_branch = current_branch)
      git 'push', remote, current_branch
    end

    # Return the name of the remote repository whose branch the current local branch tracks,
    # or nil if none.
    def remote(branch = current_branch)
      remote = git('config', '--get', "branch.#{branch}.remote").to_s.strip
      remote if !remote.empty? && git('remote').include?(remote)
    end

    # Return the name of the current branch
    def current_branch
      git('branch')[/^\* (.*)$/, 1]
    end
  end


  module Svn #:nodoc:
    module_function

    # :call-seq:
    #   svn(*args)
    #
    # Executes a SVN command and returns the output. Throws exception if the exit status
    # is not zero. For example:
    #   svn 'commit'
    def svn(*args)
      output = `svn #{args.shift} #{args.map { |arg| arg.inspect }.join(' ')}`
      fail "SVN command failed with status #{$?.exitstatus}" unless $?.exitstatus == 0
      return output
    end

    def tag(tag_name)
      url = tag_url repo_url, tag_name
      remove url, 'Removing old copy' rescue nil
      copy Dir.pwd, url, "Release #{tag_name}"
    end

    # Status check reveals modified files, but also SVN externals which we can safely ignore.
    def uncommitted_files
      svn('status', '--ignore-externals').split("\n").reject { |line| line =~ /^X\s/ }
    end

    def commit(file, message)
      svn 'commit', '-m', message, file
    end

    # :call-seq:
    #   tag_url(svn_url, version) => tag_url
    #
    # Returns the SVN url for the tag.
    # Can tag from the trunk or from branches.
    # Can handle the two standard repository layouts.
    #   - http://my.repo/foo/trunk => http://my.repo/foo/tags/1.0.0
    #   - http://my.repo/trunk/foo => http://my.repo/tags/foo/1.0.0
    def tag_url(svn_url, tag)
      trunk_or_branches = Regexp.union(%r{^(.*)/trunk(.*)$}, %r{^(.*)/branches(.*)/([^/]*)$})
      match = trunk_or_branches.match(svn_url)
      prefix = match[1] || match[3]
      suffix = match[2] || match[4]
      prefix + '/tags' + suffix + '/' + tag
    end

    # Return the current SVN URL
    def repo_url
      svn('info', '--xml')[/<url>(.*?)<\/url>/, 1].strip
    end

    def copy(dir, url, message)
      svn 'copy', '--parents', dir, url, '-m', message
    end

    def remove(url, message)
      svn 'remove', url, '-m', message
    end

  end


  class Release #:nodoc:

    THIS_VERSION_PATTERN  = /(THIS_VERSION|VERSION_NUMBER)\s*=\s*(["'])(.*)\2/

    class << self

      # Use this to specify a different tag name for tagging the release in source control.
      # You can set the tag name or a proc that will be called with the version number,
      # for example:
      #   Release.tag_name = lambda { |ver| "foo-#{ver}" }
      attr_accessor :tag_name

      # Use this to specify a different commit message to commit the buildfile with the next version in source control.
      # You can set the commit message or a proc that will be called with the next version number,
      # for example:
      #   Release.commit_message = lambda { |ver| "Changed version number to #{ver}" }
      attr_accessor :commit_message

      # Use this to specify the next version number to replace VERSION_NUMBER with in the buildfile.
      # You can set the next version or a proc that will be called with the current version number.
      # For example, with the following buildfile:
      #   THIS_VERSION = "1.0.0-rc1"
      #   Release.next_version = lambda { |version|
      #       version[-1] = version[-1].to_i + 1
      #       version
      #   }
      #
      # Release.next_version will return "1.0.0-rc2", so at the end of the release, the buildfile will contain VERSION_NUMBER = "1.0.0-rc2"
      #
      attr_accessor :next_version

      # :call-seq:
      #     add(MyReleaseClass)
      #
      # Add a Release implementation to the list of available Release classes.
      def add(release)
        @list ||= []
        @list |= [release]
      end
      alias :<< :add

      # The list of supported Release implementations
      def list
        @list ||= []
      end

      # Finds and returns the Release instance for this project.
      def find
        unless @release
          klass = list.detect { |impl| impl.applies_to? }
          @release = klass.new if klass
        end
        @release
      end

    end

    # :call-seq:
    #   make()
    #
    # Make a release.
    def make
      @this_version = extract_version
      check
      with_release_candidate_version do |release_candidate_buildfile|
        args = []
        args << 'buildr' << '--buildfile' << release_candidate_buildfile
        args << '--environment' << Buildr.environment unless Buildr.environment.to_s.empty?
        args << 'clean' << 'upload' << 'DEBUG=no'
        sh *args
      end
      tag_release resolve_tag
      update_version_to_next if this_version != resolve_next_version(this_version)
    end

    def check
      if this_version == resolve_next_version(this_version) && this_version.match(/-SNAPSHOT$/)
        fail "The next version can't be equal to the current version #{this_version}.\nUpdate THIS_VERSION/VERSION_NUMBER, specify Release.next_version or use NEXT_VERSION env var"
      end
    end

    # :call-seq:
    #   extract_version() => this_version
    #
    # Extract the current version number from the buildfile.
    # Raise an error if not found.
    def extract_version
      buildfile = File.read(version_file)
      buildfile.scan(THIS_VERSION_PATTERN)[0][2]
    rescue
      fail 'Looking for THIS_VERSION = "..." in your Buildfile, none found'
    end

    # Use this to specify a different tag name for tagging the release in source control.
    # You can set the tag name or a proc that will be called with the version number,
    # for example:
    #   Release.find.tag_name = lambda { |ver| "foo-#{ver}" }
    # Deprecated: you should use Release.tag_name instead
    def tag_name=(tag_proc)
      Buildr.application.deprecated "Release.find.tag_name is deprecated. You should use Release.tag_name instead"
      Release.tag_name=(tag_proc)
    end

  protected

    # the initial value of THIS_VERSION
    attr_accessor :this_version

    # :call-seq:
    #   version_file()
    # Provides the file containing the version of the project.
    # If the project contains a version.rb file next to the Buildr build file,
    # it is used. Otherwise, always use the buildfile.
    def version_file
      version_rb_file = File.dirname(Buildr.application.buildfile.to_s)  + '/version.rb'
      return version_rb_file if File.exists?(version_rb_file)
      return Buildr.application.buildfile.to_s
    end

    # :call-seq:
    #   with_release_candidate_version() { |filename| ... }
    #
    # Yields to block with release candidate buildfile, before committing to use it.
    #
    # We need a Buildfile with upgraded version numbers to run the build, but we don't want the
    # Buildfile modified unless the build succeeds. So this method updates the version number in
    # a separate (Buildfile.next) file, yields to the block with that filename, and if successful
    # copies the new file over the existing one.
    #
    # The release version is the current version without '-SNAPSHOT'.  So:
    #   THIS_VERSION = 1.1.0-SNAPSHOT
    # becomes:
    #   THIS_VERSION = 1.1.0
    # for the release buildfile.
    def with_release_candidate_version
      release_candidate_buildfile = version_file + '.next'

      release_candidate_buildfile_contents = change_version { |version|
        version.gsub(/-SNAPSHOT$/, "")
      }
      File.open(release_candidate_buildfile, 'w') { |file| file.write release_candidate_buildfile_contents }
      begin
        yield release_candidate_buildfile
        mv release_candidate_buildfile, version_file
      ensure
        rm release_candidate_buildfile rescue nil
      end
    end

    # :call-seq:
    #   change_version() { |this_version| ... } => buildfile
    #
    # Change version number in the current Buildfile, but without writing a new file (yet).
    # Returns the contents of the Buildfile with the modified version number.
    #
    # This method yields to the block with the current (this) version number and expects
    # the block to return the updated version.
    def change_version
      current_version = extract_version
      new_version = yield(current_version)
      buildfile = File.read(version_file)
      buildfile.gsub(THIS_VERSION_PATTERN) { |ver| ver.sub(/(["']).*\1/, %Q{"#{new_version}"}) }
    end

    # Return the name of the tag to tag the release with.
    def resolve_tag
      version = extract_version
      tag = Release.tag_name || version
      tag = tag.call(version) if Proc === tag
      tag
    end

    # Return the new value of THIS_VERSION based on the version passed.
    #
    # This method receives the existing value of THIS_VERSION
    def resolve_next_version(current_version)
        next_version = Release.next_version
        next_version ||= lambda { |v|
          snapshot = v.match(/-SNAPSHOT$/)
          version = v.gsub(/-SNAPSHOT$/, "").split(/\./)
          if snapshot
            version[-1] = sprintf("%0#{version[-1].size}d", version[-1].to_i + 1) + '-SNAPSHOT'
          end
          version.join('.')
        }
        next_version = ENV['NEXT_VERSION'] if ENV['NEXT_VERSION']
        next_version = ENV['next_version'] if ENV['next_version']
        next_version = next_version.call(current_version) if Proc === next_version
        next_version
    end

    # Move the version to next and save the updated buildfile
    def update_buildfile
      buildfile = change_version { |version| # THIS_VERSION minus SNAPSHOT
        resolve_next_version(this_version) # THIS_VERSION
      }
      File.open(version_file, 'w') { |file| file.write buildfile }
    end

    # Return the message to use to commit the buildfile with the next version
    def message
      version = extract_version
      msg = Release.commit_message || "Changed version number to #{version}"
      msg = msg.call(version) if Proc === msg
      msg
    end

    def update_version_to_next
      update_buildfile
    end
  end


  class HgRelease < Release
    class << self
      def applies_to?
        if File.exist? '.hg/requires'
          true
        else
          curr_pwd = Dir.pwd
          Dir.chdir('..') do
            return false if curr_pwd == Dir.pwd # Means going up one level is not possible.
            applies_to?
          end
        end
      end
    end

    # Fails if one of these 2 conditions are not met:
    #   1. The reository is not 'clean'; no content staged or unstaged
    #   2. The repository is only a local repository and has no remote refs
    def check
      super
      info "Working in branch '#{Hg.current_branch}'"
      uncommitted = Hg.uncommitted_files
      fail "Uncommitted files violate the First Principle Of Release!\n#{uncommitted.join("\n")}" unless uncommitted.empty?
      fail "You are releasing from a local branch that does not track a remote!" if Hg.remote.empty?
    end

    # Tag this release in Mercurial
    def tag_release(tag)
      unless this_version == extract_version
        info "Committing buildfile with version number #{extract_version}"
        Hg.commit File.basename(version_file), message
        Hg.push if Hg.remote
      end
      info "Tagging release #{tag}"
      Hg.hg 'tag', tag, '-m', "[buildr] Cutting release #{tag}"
      Hg.push if Hg.remote
    end

    # Update buildfile with next version number
    def update_version_to_next
      super
      info "Current version is now #{extract_version}"
      Hg.commit File.basename(version_file), message
      Hg.push if Hg.remote
    end
  end


  class GitRelease < Release
    class << self
      def applies_to?
        if File.exist? '.git/config'
          true
        else
          curr_pwd = Dir.pwd
          Dir.chdir('..') do
            return false if curr_pwd == Dir.pwd # Means going up one level is not possible.
            applies_to?
          end
        end
      end
    end

    # Fails if one of these 2 conditions are not met:
    #    1. the repository is clean: no content staged or unstaged
    #    2. some remote repositories are defined but the current branch does not track any
    def check
      super
      uncommitted = Git.uncommitted_files
      fail "Uncommitted files violate the First Principle Of Release!\n#{uncommitted.join("\n")}" unless uncommitted.empty?
      fail "You are releasing from a local branch that does not track a remote!" unless Git.remote
    end

    # Add a tag reference in .git/refs/tags and push it to the remote if any.
    # If a tag with the same name already exists it will get deleted (in both local and remote repositories).
    def tag_release(tag)
      unless this_version == extract_version
        info "Committing buildfile with version number #{extract_version}"
        Git.commit File.basename(version_file), message
        Git.push if Git.remote
      end
      info "Tagging release #{tag}"
      Git.git 'tag', '-d', tag rescue nil
      Git.git 'push', Git.remote, ":refs/tags/#{tag}" rescue nil if Git.remote
      Git.git 'tag', '-a', tag, '-m', "[buildr] Cutting release #{tag}"
      Git.git 'push', Git.remote, 'tag', tag if Git.remote
    end

    def update_version_to_next
      super
      info "Current version is now #{extract_version}"
      Git.commit File.basename(version_file), message
      Git.push if Git.remote
    end
  end


  class SvnRelease < Release
    class << self
      def applies_to?
        File.exist?('.svn')
      end
    end

    def check
      super
      fail "Uncommitted files violate the First Principle Of Release!\n"+Svn.uncommitted_files.join("\n") unless Svn.uncommitted_files.empty?
      fail "SVN URL must contain 'trunk' or 'branches/...'" unless Svn.repo_url =~ /(trunk)|(branches.*)$/
    end

    def tag_release(tag)
      # Unlike Git, committing the buildfile with the released version is not necessary.
      # svn tag does commit & tag.
      info "Tagging release #{tag}"
      Svn.tag tag
    end

    def update_version_to_next
      super
      info "Current version is now #{extract_version}"
      Svn.commit version_file, message
    end
  end

  Release.add HgRelease
  Release.add SvnRelease
  Release.add GitRelease

  desc 'Make a release'
  task 'release' do |task|
    release = Release.find
    fail 'Unable to detect the Version Control System.' unless release
    release.make
  end

end

class Buildr::Project #:nodoc:
  include Buildr::Build
end
