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

  # The ZipTask creates a new Zip file. You can include any number of files and and directories,
  # use exclusion patterns, and include files into specific directories.
  #
  # For example:
  #   zip('test.zip').tap do |task|
  #     task.include 'srcs'
  #     task.include 'README', 'LICENSE'
  #   end
  #
  # See Buildr#zip and ArchiveTask.
  class ZipTask < ArchiveTask

    # Compression level for this Zip.
    attr_accessor :compression_level

    def initialize(*args) #:nodoc:
      self.compression_level = Zlib::DEFAULT_COMPRESSION
      super
    end

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

    def entries #:nodoc:
      @entries ||= Zip::File.open(name) { |zip| zip.entries }
    end

    private

    def create_from(file_map, transform_map)
      Zip::OutputStream.open name do |zip|
        seen = {}
        mkpath = lambda do |dir|
          dirname = (dir[-1..-1] =~ /\/$/) ? dir : dir + '/'
          unless dir == '.' || seen[dirname]
            mkpath.call File.dirname(dirname)
            zip.put_next_entry(dirname, compression_level)
            seen[dirname] = true
          end
        end

        paths = file_map.keys.sort
        paths.each do |path|
          contents = file_map[path]
          warn "Warning:  Path in zipfile #{name} contains backslash: #{path}" if path =~ /\\/

          # Must ensure that the directory entry is created for intermediate paths, otherwise
          # zips can be created without entries for directories which can break some tools
          mkpath.call File.dirname(path)

          entry_created = false
          to_transform = []
          transform = transform_map.key?(path)
          [contents].flatten.each do |content|
            if content.respond_to?(:call)
              unless entry_created
                entry = zip.put_next_entry(path, compression_level)
                entry.unix_perms = content.mode & 07777 if content.respond_to?(:mode)
                entry_created = true
              end
              if transform
                output = StringIO.new
                content.call output
                to_transform << output.string
              else
                content.call zip
              end
            elsif content.nil? || File.directory?(content.to_s)
              mkpath.call path
            else
              File.open content.to_s, 'rb' do |is|
                unless entry_created
                  entry = zip.put_next_entry(path, compression_level)
                  entry.unix_perms = is.stat.mode & 07777
                  entry_created = true
                end
                if transform
                  output = StringIO.new
                  while data = is.read(4096)
                    output << data
                  end
                  to_transform << output.string
                else
                  while data = is.read(4096)
                    zip << data
                  end
                end
              end
            end
          end
          if transform_map.key?(path)
            zip << transform_map[path].call(to_transform)
          end
        end
      end
    end

  end


  # :call-seq:
  #    zip(file) => ZipTask
  #
  # The ZipTask creates a new Zip file. You can include any number of files and
  # and directories, use exclusion patterns, and include files into specific
  # directories.
  #
  # For example:
  #   zip('test.zip').tap do |task|
  #     task.include 'srcs'
  #     task.include 'README', 'LICENSE'
  #   end
  def zip(file)
    ZipTask.define_task(file)
  end


  # An object for unzipping/untarring a file into a target directory. You can tell it to include
  # or exclude only specific files and directories, and also to map files from particular
  # paths inside the zip file into the target directory. Once ready, call #extract.
  #
  # Usually it is more convenient to create a file task for extracting the zip file
  # (see #unzip) and pass this object as a prerequisite to other tasks.
  #
  # See Buildr#unzip.
  class Unzip

    # The zip file to extract.
    attr_accessor :zip_file
    # The target directory to extract to.
    attr_accessor :target

    # Initialize with hash argument of the form target=>zip_file.
    def initialize(args)
      @target, arg_names, zip_file = Buildr.application.resolve_args([args])
      @zip_file = zip_file.first
      @paths = {}
    end

    # :call-seq:
    #   extract
    #
    # Extract the zip/tgz file into the target directory.
    #
    # You can call this method directly. However, if you are using the #unzip method,
    # it creates a file task for the target directory: use that task instead as a
    # prerequisite. For example:
    #   build unzip(dir=>zip_file)
    # Or:
    #   unzip(dir=>zip_file).target.invoke
    def extract
      # If no paths specified, then no include/exclude patterns
      # specified. Nothing will happen unless we include all files.
      if @paths.empty?
        @paths[nil] = FromPath.new(self, nil)
      end

      # Otherwise, empty unzip creates target as a file when touching.
      mkpath target.to_s
      if zip_file.to_s.match /\.t?gz$/
        #un-tar.gz
        Zlib::GzipReader.open(zip_file.to_s) { |tar|
          Archive::Tar::Minitar::Input.open(tar) do |inp|
            inp.each do |tar_entry|
              @paths.each do |path, patterns|
                patterns.map([tar_entry]).each do |dest, entry|
                  next if entry.directory?
                  dest = File.expand_path(dest, target.to_s)
                  trace "Extracting #{dest}"
                  mkpath File.dirname(dest) rescue nil
                  File.open(dest, 'wb', entry.mode) {|f| f.write entry.read}
                  File.chmod(entry.mode, dest)
                end
              end
            end
          end
        }
      else
        Zip::File.open(zip_file.to_s) do |zip|
          entries = zip.collect
          @paths.each do |path, patterns|
            patterns.map(entries).each do |dest, entry|
              next if entry.directory?
              dest = File.expand_path(dest, target.to_s)
              trace "Extracting #{dest}"
              mkpath File.dirname(dest) rescue nil
              entry.restore_permissions = true
              entry.extract(dest) { true }
            end
          end
        end
      end
      # Let other tasks know we updated the target directory.
      touch target.to_s
    end

    #reads the includes/excludes and apply them to the entry_name
    def included?(entry_name)
      @paths.each do |path, patterns|
        return true if path.nil?
        if entry_name =~ /^#{path}/
          short = entry_name.sub(path, '')
          if patterns.include.any? { |pattern| File.fnmatch(pattern, entry_name) } &&
            !patterns.exclude.any? { |pattern| File.fnmatch(pattern, entry_name) }
            # trace "tar_entry.full_name " + entry_name + " is included"
            return true
          end
        end
      end
      # trace "tar_entry.full_name " + entry_name + " is excluded"
      return false
    end


    # :call-seq:
    #   include(*files) => self
    #   include(*files, :path=>name) => self
    #
    # Include all files that match the patterns and returns self.
    #
    # Use include if you only want to unzip some of the files, by specifying
    # them instead of using exclusion. You can use #include in combination
    # with #exclude.
    def include(*files)
      if Hash === files.last
        from_path(files.pop[:path]).include *files
      else
        from_path(nil).include *files
      end
      self
    end
    alias :add :include

    # :call-seq:
    #   exclude(*files) => self
    #
    # Exclude all files that match the patterns and return self.
    #
    # Use exclude to unzip all files except those that match the pattern.
    # You can use #exclude in combination with #include.
    def exclude(*files)
      if Hash === files.last
        from_path(files.pop[:path]).exclude *files
      else
        from_path(nil).exclude *files
      end
      self
    end

    # :call-seq:
    #   from_path(name) => Path
    #
    # Allows you to unzip from a path. Returns an object you can use to
    # specify which files to include/exclude relative to that path.
    # Expands the file relative to that path.
    #
    # For example:
    #   unzip(Dir.pwd=>'test.jar').from_path('etc').include('LICENSE')
    # will unzip etc/LICENSE into ./LICENSE.
    #
    # This is different from:
    #  unzip(Dir.pwd=>'test.jar').include('etc/LICENSE')
    # which unzips etc/LICENSE into ./etc/LICENSE.
    def from_path(name)
      @paths[name] ||= FromPath.new(self, name)
    end
    alias :path :from_path

    # :call-seq:
    #   root => Unzip
    #
    # Returns the root path, essentially the Unzip object itself. In case you are wondering
    # down paths and want to go back.
    def root
      self
    end

    # Returns the path to the target directory.
    def to_s
      target.to_s
    end

    class FromPath #:nodoc:

      def initialize(unzip, path)
        @unzip = unzip
        if path
          @path = path[-1] == ?/ ? path : path + '/'
        else
          @path = ''
        end
      end

      # See UnzipTask#include
      def include(*files) #:doc:
        @include ||= []
        @include |= files
        self
      end

      # See UnzipTask#exclude
      def exclude(*files) #:doc:
        @exclude ||= []
        @exclude |= files
        self
      end

      def map(entries)
        includes = @include || ['*']
        excludes = @exclude || []
        entries.inject({}) do |map, entry|
          if entry.name =~ /^#{@path}/
            short = entry.name.sub(@path, '')
            if includes.any? { |pat| File.fnmatch(pat, short) } &&
              !excludes.any? { |pat| File.fnmatch(pat, short) }
              map[short] = entry
            end
          end
          map
        end
      end

      # Documented in Unzip.
      def root
        @unzip
      end

      # The target directory to extract to.
      def target
        @unzip.target
      end

    end

  end

  # :call-seq:
  #    unzip(to_dir=>zip_file) => Zip
  #
  # Creates a task that will unzip a file into the target directory. The task name
  # is the target directory, the prerequisite is the file to unzip.
  #
  # This method creates a file task to expand the zip file. It returns an Unzip object
  # that specifies how the file will be extracted. You can include or exclude specific
  # files from within the zip, and map to different paths.
  #
  # The Unzip object's to_s method return the path to the target directory, so you can
  # use it as a prerequisite. By keeping the Unzip object separate from the file task,
  # you overlay additional work on top of the file task.
  #
  # For example:
  #   unzip('all'=>'test.zip')
  #   unzip('src'=>'test.zip').include('README', 'LICENSE')
  #   unzip('libs'=>'test.zip').from_path('libs')
  def unzip(args)
    target, arg_names, zip_file = Buildr.application.resolve_args([args])
    task = file(File.expand_path(target.to_s)=>zip_file)
    Unzip.new(task=>zip_file).tap do |setup|
      task.enhance { setup.extract }
    end
  end

end
