# 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:

  # Base class for ZipTask, TarTask and other archives.
  class ArchiveTask < Rake::FileTask

    # Which files go where. All the rules for including, excluding and merging files
    # are handled by this object.
    class Path #:nodoc:

      # Returns the archive from this path.
      attr_reader :root

      def initialize(root, path)
        @root = root
        @path = path.empty? ? path : "#{path}/"
        @includes = FileList[]
        @excludes = []
        # Expand source files added to this path.
        expand_src = proc { @includes.map{ |file| file.to_s }.uniq }
        @sources = [ expand_src ]
        # Add files and directories added to this path.
        @actions = [] << proc do |file_map|
          expand_src.call.each do |path|
            unless excluded?(path)
              if File.directory?(path)
                in_directory path do |file, rel_path|
                  dest = "#{@path}#{rel_path}"
                  unless excluded?(dest)
                    trace "Adding #{dest}"
                    file_map[dest] = file
                  end
                end
              end
              unless File.basename(path) == "."
                trace "Adding #{@path}#{File.basename(path)}"
                file_map["#{@path}#{File.basename(path)}"] = path
              end
            end
          end
        end
      end

      # :call-seq:
      #   include(*files) => self
      #   include(*files, :path=>path) => self
      #   include(file, :as=>name) => self
      #   include(:from=>path) => self
      #   include(*files, :merge=>true) => self
      def include(*args)
        options = Hash === args.last ? args.pop : nil
        files = to_artifacts(args)
        raise 'AchiveTask.include() values should not include nil' if files.include? nil

        if options.nil? || options.empty?
          @includes.include *files.flatten
        elsif options[:path]
          sans_path = options.reject { |k,v| k == :path }
          path(options[:path]).include *files + [sans_path]
        elsif options[:as]
          raise 'You can only use the :as option in combination with the :path option' unless options.size == 1
          raise 'You can only use one file with the :as option' unless files.size == 1
          include_as files.first.to_s, options[:as]
        elsif options[:from]
          raise 'You can only use the :from option in combination with the :path option' unless options.size == 1
          raise 'You cannot use the :from option with file names' unless files.empty?
          fail 'AchiveTask.include() :from value should not be nil' if [options[:from]].flatten.include? nil
          [options[:from]].flatten.each { |path| include_as path.to_s, '.' }
        elsif options[:merge]
          raise 'You can only use the :merge option in combination with the :path option' unless options.size == 1
          files.each { |file| merge file }
        else
          raise "Unrecognized option #{options.keys.join(', ')}"
        end
        self
      end
      alias :add :include
      alias :<< :include

      # :call-seq:
      #   exclude(*files) => self
      def exclude(*files)
        files = to_artifacts(files)
        @excludes |= files
        @excludes |= files.reject { |f| f =~ /\*$/ }.map { |f| "#{f}/*" }
        self
      end

      # :call-seq:
      #   merge(*files) => Merge
      #   merge(*files, :path=>name) => Merge
      def merge(*args)
        options = Hash === args.last ? args.pop : {}
        files = to_artifacts(args)
        rake_check_options options, :path
        raise ArgumentError, "Expected at least one file to merge" if files.empty?
        path = options[:path] || @path
        expanders = files.collect do |file|
          @sources << proc { file.to_s }
          expander = ZipExpander.new(file)
          @actions << proc do |file_map, transform_map|
            file.invoke() if file.is_a?(Rake::Task)
            expander.expand(file_map, transform_map, path)
          end
          expander
        end
        Merge.new(expanders)
      end

      # Returns a Path relative to this one.
      def path(path)
        return self if path.nil?
        return root.path(path[1..-1]) if path[0] == ?/
        root.path("#{@path}#{path}")
      end

      # Returns all the source files.
      def sources #:nodoc:
        @sources.map{ |source| source.call }.flatten
      end

      def add_files(file_map, transform_map) #:nodoc:
        @actions.each { |action| action.call(file_map, transform_map) }
      end

      # :call-seq:
      #   exist => boolean
      #
      # Returns true if this path exists. This only works if the path has any entries in it,
      # so exist on path happens to be the opposite of empty.
      def exist?
        !entries.empty?
      end

      # :call-seq:
      #   empty? => boolean
      #
      # Returns true if this path is empty (has no other entries inside).
      def empty?
        entries.all? { |entry| entry.empty? }
      end

      # :call-seq:
      #   contain(file*) => boolean
      #
      # Returns true if this ZIP file path contains all the specified files. You can use relative
      # file names and glob patterns (using *, **, etc).
      def contain?(*files)
        files.all? { |file| entries.detect { |entry| File.fnmatch(file, entry.to_s) } }
      end

      # :call-seq:
      #   entry(name) => ZipEntry
      #
      # Returns a ZIP file entry. You can use this to check if the entry exists and its contents,
      # for example:
      #   package(:jar).path("META-INF").entry("LICENSE").should contain(/Apache Software License/)
      def entry(name)
        root.entry("#{@path}#{name}")
      end

      def to_s
        @path
      end

    protected

    # Convert objects to artifacts, where applicable
    def to_artifacts(files)
      files.flatten.inject([]) do |set, file|
        case file
        when ArtifactNamespace
          set |= file.artifacts
        when Symbol, Hash
          set |= [Buildr.artifact(file)]
        when /([^:]+:){2,4}/ # A spec as opposed to a file name.
          set |= [Buildr.artifact(file)]
        when Project
          set |= Buildr.artifacts(file.packages)
        when Rake::Task
          set |= [file]
        when Struct
          set |= Buildr.artifacts(file.values)
        else
          # non-artifacts passed as-is; in particular, String paths are
          # unmodified since Rake FileTasks don't use absolute paths
          set |= [file]
        end
      end
    end

    def include_as(source, as)
        @sources << proc { source }
        @actions << proc do |file_map|
          file = source.to_s
          file(file).invoke
          unless excluded?(file)
            if File.directory?(file)
              in_directory file do |file, rel_path|
                path = rel_path.split('/')[1..-1]
                path.unshift as unless as == '.'
                dest = "#{@path}#{path.join('/')}"
                unless excluded?(dest)
                  trace "Adding #{dest}"
                  file_map[dest] = file
                end
              end
              unless as == "."
                trace "Adding #{@path}#{as}/"
                file_map["#{@path}#{as}/"] = nil # :as is a folder, so the trailing / is required.
              end
            else
              file_map["#{@path}#{as}"] = file
            end

          end
        end
      end

      def in_directory(dir)
        prefix = Regexp.new('^' + Regexp.escape(File.dirname(dir) + File::SEPARATOR))
        Util.recursive_with_dot_files(dir).reject { |file| excluded?(file) }.
          each { |file| yield file, file.sub(prefix, '') }
      end

      def excluded?(file)
        @excludes.any? { |exclude| File.fnmatch(exclude, file) }
      end

      def entries #:nodoc:
        return root.entries unless @path
        @entries ||= root.entries.inject([]) { |selected, entry|
          selected << entry.name.sub(@path, "") if entry.name.index(@path) == 0
          selected
        }
      end

    end


    class Merge
      def initialize(expanders)
        @expanders = expanders
      end

      def include(*files)
        @expanders.each { |expander| expander.include(*files) }
        self
      end
      alias :<< :include

      def exclude(*files)
        @expanders.each { |expander| expander.exclude(*files) }
        self
      end

      def concatenate(*files)
        @expanders.each { |expander| expander.concatenate(*files) }
        self
      end

      def transform(*files, &block)
        @expanders.each { |expander| expander.transform(*files, &block) }
        self
      end
    end


    # Extend one Zip file into another.
    class ZipExpander #:nodoc:

      def initialize(zip_file)
        @zip_file = zip_file.to_s
        @includes = []
        @excludes = []
        @concatenates = []
        @transforms = {}
      end

      def include(*files)
        @includes |= files
        self
      end
      alias :<< :include

      def exclude(*files)
        @excludes |= files
        self
      end

      def concatenate(*files)
        @concatenates |= files
        self
      end

      def transform(*files, &block)
        @transforms[[files].flatten] = block
        self
      end

      def expand(file_map, transform_map, path)
        @includes = ['*'] if @includes.empty?
        Zip::File.open(@zip_file) do |source|
          source.entries.reject { |entry| entry.directory? }.each do |entry|
            if @includes.any? { |pattern| File.fnmatch(pattern, entry.name) } &&
               !@excludes.any? { |pattern| File.fnmatch(pattern, entry.name) }
              dest = path =~ /^\/?$/ ? entry.name : Util.relative_path(path + "/" + entry.name)
              trace "Adding #{dest}"
              if @concatenates.any? { |pattern| File.fnmatch(pattern, entry.name) }
                file_map[dest] << ZipEntryData.new(source, entry)
              elsif @transforms.each_pair.detect do |transform, transform_block|\
                  if transform.any? { |pattern| File.fnmatch(pattern, entry.name) }
                    file_map[dest] << ZipEntryData.new(source, entry)

                    transform_map[dest] = transform_block
                    true
                  end
                end
              else
                file_map[dest] = ZipEntryData.new(source, entry)
              end
            end
          end
        end
      end

    end

    class ZipEntryData
      def initialize(zipfile, entry)
        @zipfile = zipfile
        @entry = entry
      end

      def call(output)
        output.write @zipfile.read(@entry)
      end

      def mode
        @entry.unix_perms
      end
    end

    def initialize(*args) #:nodoc:
      super
      clean

      # Make sure we're the last enhancements, so other enhancements can add content.
      enhance do
        @file_map = Hash.new {|h,k| h[k]=[]}
        @transform_map = {}
        enhance do
          send 'create' if respond_to?(:create)
          # We're here because the archive file does not exist, or one of the files is newer than the archive contents;
          # we need to make sure the archive doesn't exist (e.g. opening an existing Zip will add instead of create).
          # We also want to protect against partial updates.
          rm name rescue nil
          mkpath File.dirname(name)
          begin
            @paths.each do |name, object|
              @file_map[name] = nil unless name.empty?
              object.add_files(@file_map, @transform_map)
            end
            create_from @file_map, @transform_map
          rescue
            rm name rescue nil
            raise
          end
        end
      end
    end

    # :call-seq:
    #   clean => self
    #
    # Removes all previously added content from this archive.
    # Use this method if you want to remove default content from a package.
    # For example, package(:jar) by default includes compiled classes and resources,
    # using this method, you can create an empty jar and afterwards add the
    # desired content to it.
    #
    #    package(:jar).clean.include path_to('desired/content')
    def clean
      @paths = {}
      @paths[''] = Path.new(self, '')
      @prepares = []
      self
    end

    # :call-seq:
    #   include(*files) => self
    #   include(*files, :path=>path) => self
    #   include(file, :as=>name) => self
    #   include(:from=>path) => self
    #   include(*files, :merge=>true) => self
    #
    # Include files in this archive, or when called on a path, within that path. Returns self.
    #
    # The first form accepts a list of files, directories and glob patterns and adds them to the archive.
    # For example, to include the file foo, directory bar (including all files in there) and all files under baz:
    #   zip(..).include('foo', 'bar', 'baz/*')
    #
    # The second form is similar but adds files/directories under the specified path. For example,
    # to add foo as bar/foo:
    #   zip(..).include('foo', :path=>'bar')
    # The :path option is the same as using the path method:
    #   zip(..).path('bar').include('foo')
    # All other options can be used in combination with the :path option.
    #
    # The third form adds a file or directory under a different name. For example, to add the file foo under the
    # name bar:
    #   zip(..).include('foo', :as=>'bar')
    #
    # The fourth form adds the contents of a directory using the directory as a prerequisite:
    #   zip(..).include(:from=>'foo')
    # Unlike <code>include('foo')</code> it includes the contents of the directory, not the directory itself.
    # Unlike <code>include('foo/*')</code>, it uses the directory timestamp for dependency management.
    #
    # The fifth form includes the contents of another archive by expanding it into this archive. For example:
    #   zip(..).include('foo.zip', :merge=>true).include('bar.zip')
    # You can also use the method #merge.
    def include(*files)
      fail "AchiveTask.include() called with nil values" if files.include? nil
      @paths[''].include *files if files.compact.size > 0
      self
    end
    alias :add :include
    alias :<< :include

    # :call-seq:
    #   exclude(*files) => self
    #
    # Excludes files and returns self. Can be used in combination with include to prevent some files from being included.
    def exclude(*files)
      @paths[''].exclude *files
      self
    end

    # :call-seq:
    #   merge(*files) => Merge
    #   merge(*files, :path=>name) => Merge
    #
    # Merges another archive into this one by including the individual files from the merged archive.
    #
    # Returns an object that supports two methods: include and exclude. You can use these methods to merge
    # only specific files. For example:
    #   zip(..).merge('src.zip').include('module1/*')
    def merge(*files)
      @paths[''].merge *files
    end

    # :call-seq:
    #   path(name) => Path
    #
    # Returns a path object. Use the path object to include files under a path, for example, to include
    # the file 'foo' as 'bar/foo':
    #   zip(..).path('bar').include('foo')
    #
    # Returns a Path object. The Path object implements all the same methods, like include, exclude, merge
    # and so forth. It also implements path and root, so that:
    #   path('foo').path('bar') == path('foo/bar')
    #   path('foo').root == root
    def path(name)
      return @paths[''] if name.nil?
      normalized = name.split('/').inject([]) do |path, part|
        case part
        when '.', nil, ''
          path
        when '..'
          path[0...-1]
        else
          path << part
        end
      end.join('/')
      @paths[normalized] ||= Path.new(self, normalized)
    end

    # :call-seq:
    #   root => ArchiveTask
    #
    # Call this on an archive to return itself, and on a path to return the archive.
    def root
      self
    end

    # :call-seq:
    #   with(options) => self
    #
    # Passes options to the task and returns self. Some tasks support additional options, for example,
    # the WarTask supports options like :manifest, :libs and :classes.
    #
    # For example:
    #   package(:jar).with(:manifest=>'MANIFEST_MF')
    def with(options)
      options.each do |key, value|
        begin
          send "#{key}=", value
        rescue NoMethodError
          raise ArgumentError, "#{self.class.name} does not support the option #{key}"
        end
      end
      self
    end

    def invoke_prerequisites(args, chain) #:nodoc:
      @prepares.each { |prepare| prepare.call(self) }
      @prepares.clear

      file_map = Hash.new {|h,k| h[k]=[]}
      transform_map = {}
      @paths.each do |name, path|
        path.add_files(file_map, transform_map)
      end

      # filter out Procs (dynamic content), nils and others
      @prerequisites |= file_map.values.select { |src| src.is_a?(String) || src.is_a?(Rake::Task) }

      super
    end

    def needed? #:nodoc:
      return true unless File.exist?(name)
      # You can do something like:
      #   include('foo', :path=>'foo').exclude('foo/bar', path=>'foo').
      #     include('foo/bar', :path=>'foo/bar')
      # This will play havoc if we handled all the prerequisites together
      # under the task, so instead we handle them individually for each path.
      #
      # We need to check that any file we include is not newer than the
      # contents of the Zip. The file itself but also the directory it's
      # coming from, since some tasks touch the directory, e.g. when the
      # content of target/classes is included into a WAR.
      most_recent = @paths.collect { |name, path| path.sources }.flatten.
        select { |file| File.exist?(file) }.collect { |file| File.stat(file).mtime }.max
      File.stat(name).mtime < (most_recent || Rake::EARLY) || super
    end

    # :call-seq:
    #   empty? => boolean
    #
    # Returns true if this ZIP file is empty (has no other entries inside).
    def empty?
      path("").empty
    end

    # :call-seq:
    #   contain(file*) => boolean
    #
    # Returns true if this ZIP file contains all the specified files. You can use absolute
    # file names and glob patterns (using *, **, etc).
    def contain?(*files)
      path("").contain?(*files)
    end

  protected

    # Adds a prepare block. These blocks are called early on for adding more content to
    # the archive, before invoking prerequsities. Anything you add here will be invoked
    # as a prerequisite and used to determine whether or not to generate this archive.
    # In contrast, enhance blocks are evaluated after it was decided to create this archive.
    def prepare(&block)
      @prepares << block
    end

    def []=(key, value) #:nodoc:
      raise ArgumentError, "This task does not support the option #{key}."
    end

  end


end
