require "chef/chef_fs/knife"

class Chef
  class Knife
    class Xargs < Chef::ChefFS::Knife
      banner "knife xargs [COMMAND]"

      category "path-based"

      deps do
        require "chef/chef_fs/file_system"
        require "chef/chef_fs/file_system/not_found_error"
      end

      # TODO modify to remote-only / local-only pattern (more like delete)
      option :local,
        :long => "--local",
        :boolean => true,
        :description => "Xargs local files instead of remote"

      option :patterns,
        :long => "--pattern [PATTERN]",
        :short => "-p [PATTERN]",
        :description => "Pattern on command line (if these are not specified, a list of patterns is expected on standard input). Multiple patterns may be passed in this way.",
        :arg_arity => [1, -1]

      option :diff,
        :long => "--[no-]diff",
        :default => true,
        :boolean => true,
        :description => "Whether to show a diff when files change (default: true)"

      option :dry_run,
        :long => "--dry-run",
        :boolean => true,
        :description => "Prevents changes from actually being uploaded to the server."

      option :force,
        :long => "--[no-]force",
        :boolean => true,
        :default => false,
        :description => "Force upload of files even if they are not changed (quicker and harmless, but doesn't print out what it changed)"

      option :replace_first,
        :long => "--replace-first REPLACESTR",
        :short => "-J REPLACESTR",
        :description => "String to replace with filenames. -J will only replace the FIRST occurrence of the replacement string."

      option :replace_all,
        :long => "--replace REPLACESTR",
        :short => "-I REPLACESTR",
        :description => "String to replace with filenames. -I will replace ALL occurrence of the replacement string."

      option :max_arguments_per_command,
        :long => "--max-args MAXARGS",
        :short => "-n MAXARGS",
        :description => "Maximum number of arguments per command line."

      option :max_command_line,
        :long => "--max-chars LENGTH",
        :short => "-s LENGTH",
        :description => "Maximum size of command line, in characters"

      option :verbose_commands,
        :short => "-t",
        :description => "Print command to be run on the command line"

      option :null_separator,
        :short => "-0",
        :boolean => true,
        :description => "Use the NULL character (\0) as a separator, instead of whitespace"

      def run
        error = false
        # Get the matches (recursively)
        files = []
        pattern_args_from(get_patterns).each do |pattern|
          Chef::ChefFS::FileSystem.list(config[:local] ? local_fs : chef_fs, pattern).each do |result|
            if result.dir?
              # TODO option to include directories
              ui.warn "#{format_path(result)}: is a directory. Will not run #{command} on it."
            else
              files << result
              ran = false

              # If the command would be bigger than max command line, back it off a bit
              # and run a slightly smaller command (with one less arg)
              if config[:max_command_line]
                command, tempfiles = create_command(files)
                begin
                  if command.length > config[:max_command_line].to_i
                    if files.length > 1
                      command, tempfiles_minus_one = create_command(files[0..-2])
                      begin
                        error = true if xargs_files(command, tempfiles_minus_one)
                        files = [ files[-1] ]
                        ran = true
                      ensure
                        destroy_tempfiles(tempfiles)
                      end
                    else
                      error = true if xargs_files(command, tempfiles)
                      files = [ ]
                      ran = true
                    end
                  end
                ensure
                  destroy_tempfiles(tempfiles)
                end
              end

              # If the command has hit the limit for the # of arguments, run it
              if !ran && config[:max_arguments_per_command] && files.size >= config[:max_arguments_per_command].to_i
                command, tempfiles = create_command(files)
                begin
                  error = true if xargs_files(command, tempfiles)
                  files = []
                  ran = true
                ensure
                  destroy_tempfiles(tempfiles)
                end
              end
            end
          end
        end

        # Any leftovers commands shall be run
        if files.size > 0
          command, tempfiles = create_command(files)
          begin
            error = true if xargs_files(command, tempfiles)
          ensure
            destroy_tempfiles(tempfiles)
          end
        end

        if error
          exit 1
        end
      end

      def get_patterns
        if config[:patterns]
          [ config[:patterns] ].flatten
        elsif config[:null_separator]
          stdin.binmode
          stdin.read.split("\000")
        else
          stdin.read.split(/\s+/)
        end
      end

      def create_command(files)
        command = name_args.join(" ")

        # Create the (empty) tempfiles
        tempfiles = {}
        begin
          # Create the temporary files
          files.each do |file|
            tempfile = Tempfile.new(file.name)
            tempfiles[tempfile] = { :file => file }
          end
        rescue
          destroy_tempfiles(files)
          raise
        end

        # Create the command
        paths = tempfiles.keys.map { |tempfile| tempfile.path }.join(" ")
        if config[:replace_all]
          final_command = command.gsub(config[:replace_all], paths)
        elsif config[:replace_first]
          final_command = command.sub(config[:replace_first], paths)
        else
          final_command = "#{command} #{paths}"
        end

        [final_command, tempfiles]
      end

      def destroy_tempfiles(tempfiles)
        # Unlink the files now that we're done with them
        tempfiles.keys.each { |tempfile| tempfile.close! }
      end

      def xargs_files(command, tempfiles)
        error = false
        # Create the temporary files
        tempfiles.each_pair do |tempfile, file|
          begin
            value = file[:file].read
            file[:value] = value
            tempfile.open
            tempfile.write(value)
            tempfile.close
          rescue Chef::ChefFS::FileSystem::OperationNotAllowedError => e
            ui.error "#{format_path(e.entry)}: #{e.reason}."
            error = true
            tempfile.close!
            tempfiles.delete(tempfile)
            next
          rescue Chef::ChefFS::FileSystem::NotFoundError => e
            ui.error "#{format_path(e.entry)}: No such file or directory"
            error = true
            tempfile.close!
            tempfiles.delete(tempfile)
            next
          end
        end

        return error if error && tempfiles.size == 0

        # Run the command
        if config[:verbose_commands] || Chef::Config[:verbosity] && Chef::Config[:verbosity] >= 1
          output sub_filenames(command, tempfiles)
        end
        command_output = `#{command}`
        command_output = sub_filenames(command_output, tempfiles)
        stdout.write command_output

        # Check if the output is different
        tempfiles.each_pair do |tempfile, file|
          # Read the new output
          new_value = IO.binread(tempfile.path)

          # Upload the output if different
          if config[:force] || new_value != file[:value]
            if config[:dry_run]
              output "Would update #{format_path(file[:file])}"
            else
              file[:file].write(new_value)
              output "Updated #{format_path(file[:file])}"
            end
          end

          # Print a diff of what was uploaded
          if config[:diff] && new_value != file[:value]
            old_file = Tempfile.open(file[:file].name)
            begin
              old_file.write(file[:value])
              old_file.close

              diff = `diff -u #{old_file.path} #{tempfile.path}`
              diff.gsub!(old_file.path, "#{format_path(file[:file])} (old)")
              diff.gsub!(tempfile.path, "#{format_path(file[:file])} (new)")
              stdout.write diff
            ensure
              old_file.close!
            end
          end
        end

        error
      end

      def sub_filenames(str, tempfiles)
        tempfiles.each_pair do |tempfile, file|
          str = str.gsub(tempfile.path, format_path(file[:file]))
        end
        str
      end

    end
  end
end
