#
# Author:: John Keiser (<jkeiser@opscode.com>)
# Copyright:: Copyright (c) 2012 Opscode, Inc.
# License:: Apache License, Version 2.0
#
# Licensed 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.
#

require 'chef_fs/file_system'
require 'chef_fs/file_system/operation_failed_error'
require 'chef_fs/file_system/operation_not_allowed_error'

module ChefFS
  module CommandLine

    def self.diff_print(pattern, a_root, b_root, recurse_depth, output_mode, format_path = nil, diff_filter = nil, ui = nil)
      if format_path.nil?
        format_path = proc { |entry| entry.path_for_printing }
      end

      get_content = (output_mode != :name_only && output_mode != :name_status)
      found_match = false
      diff(pattern, a_root, b_root, recurse_depth, get_content).each do |type, old_entry, new_entry, old_value, new_value, error|
        found_match = true unless type == :both_nonexistent
        old_path = format_path.call(old_entry)
        new_path = format_path.call(new_entry)

        case type
        when :common_subdirectories
          if output_mode != :name_only && output_mode != :name_status
            yield "Common subdirectories: #{new_path}\n"
          end

        when :directory_to_file
          next if diff_filter && diff_filter !~ /T/
          if output_mode == :name_only
            yield "#{new_path}\n"
          elsif output_mode == :name_status
            yield "T\t#{new_path}\n"
          else
            yield "File #{old_path} is a directory while file #{new_path} is a regular file\n"
          end

        when :file_to_directory
          next if diff_filter && diff_filter !~ /T/
          if output_mode == :name_only
            yield "#{new_path}\n"
          elsif output_mode == :name_status
            yield "T\t#{new_path}\n"
          else
            yield "File #{old_path} is a regular file while file #{new_path} is a directory\n"
          end

        when :deleted
          next if diff_filter && diff_filter !~ /D/
          if output_mode == :name_only
            yield "#{new_path}\n"
          elsif output_mode == :name_status
            yield "D\t#{new_path}\n"
          elsif old_value
            result = "diff --knife #{old_path} #{new_path}\n"
            result << "deleted file\n"
            result << diff_text(old_path, '/dev/null', old_value, '')
            yield result
          else
            yield "Only in #{format_path.call(old_entry.parent)}: #{old_entry.name}\n"
          end

        when :added
          next if diff_filter && diff_filter !~ /A/
          if output_mode == :name_only
            yield "#{new_path}\n"
          elsif output_mode == :name_status
            yield "A\t#{new_path}\n"
          elsif new_value
            result = "diff --knife #{old_path} #{new_path}\n"
            result << "new file\n"
            result << diff_text('/dev/null', new_path, '', new_value)
            yield result
          else
            yield "Only in #{format_path.call(new_entry.parent)}: #{new_entry.name}\n"
          end

        when :modified
          next if diff_filter && diff_filter !~ /M/
          if output_mode == :name_only
            yield "#{new_path}\n"
          elsif output_mode == :name_status
            yield "M\t#{new_path}\n"
          else
            result = "diff --knife #{old_path} #{new_path}\n"
            result << diff_text(old_path, new_path, old_value, new_value)
            yield result
          end

        when :both_nonexistent
        when :added_cannot_upload
        when :deleted_cannot_download
        when :same
          # Skip these silently
        when :error
          if error.is_a?(ChefFS::FileSystem::OperationFailedError)
            ui.error "#{format_path.call(error.entry)} failed to #{error.operation}: #{error.message}" if ui
            error = true
          elsif error.is_a?(ChefFS::FileSystem::OperationNotAllowedError)
            ui.error "#{format_path.call(error.entry)} #{error.reason}." if ui
          else
            raise error
          end
        end
      end
      if !found_match
        ui.error "#{pattern}: No such file or directory on remote or local" if ui
        error = true
      end
      error
    end

    def self.diff(pattern, old_root, new_root, recurse_depth, get_content)
      ChefFS::Parallelizer.parallelize(ChefFS::FileSystem.list_pairs(pattern, old_root, new_root), :flatten => true) do |old_entry, new_entry|
        diff_entries(old_entry, new_entry, recurse_depth, get_content)
      end
    end

    # Diff two known entries (could be files or dirs)
    def self.diff_entries(old_entry, new_entry, recurse_depth, get_content)
      # If both are directories
      if old_entry.dir?
        if new_entry.dir?
          if recurse_depth == 0
            return [ [ :common_subdirectories, old_entry, new_entry ] ]
          else
            return ChefFS::Parallelizer.parallelize(ChefFS::FileSystem.child_pairs(old_entry, new_entry), :flatten => true) do |old_child, new_child|
              ChefFS::CommandLine.diff_entries(old_child, new_child, recurse_depth ? recurse_depth - 1 : nil, get_content)
            end
          end

        # If old is a directory and new is a file
        elsif new_entry.exists?
          return [ [ :directory_to_file, old_entry, new_entry ] ]

        # If old is a directory and new does not exist
        elsif new_entry.parent.can_have_child?(old_entry.name, old_entry.dir?)
          return [ [ :deleted, old_entry, new_entry ] ]

        # If the new entry does not and *cannot* exist, report that.
        else
          return [ [ :new_cannot_upload, old_entry, new_entry ] ]
        end

      # If new is a directory and old is a file
      elsif new_entry.dir?
        if old_entry.exists?
          return [ [ :file_to_directory, old_entry, new_entry ] ]

        # If new is a directory and old does not exist
        elsif old_entry.parent.can_have_child?(new_entry.name, new_entry.dir?)
          return [ [ :added, old_entry, new_entry ] ]

        # If the new entry does not and *cannot* exist, report that.
        else
          return [ [ :old_cannot_upload, old_entry, new_entry ] ]
        end

      # Neither is a directory, so they are diffable with file diff
      else
        are_same, old_value, new_value = ChefFS::FileSystem.compare(old_entry, new_entry)
        if are_same
          if old_value == :none
            return [ [ :both_nonexistent, old_entry, new_entry ] ]
          else
            return [ [ :same, old_entry, new_entry ] ]
          end
        else
          if old_value == :none
            old_exists = false
          elsif old_value.nil?
            old_exists = old_entry.exists?
          else
            old_exists = true
          end

          if new_value == :none
            new_exists = false
          elsif new_value.nil?
            new_exists = new_entry.exists?
          else
            new_exists = true
          end

          # If one of the files doesn't exist, we only want to print the diff if the
          # other file *could be uploaded/downloaded*.
          if !old_exists && !old_entry.parent.can_have_child?(new_entry.name, new_entry.dir?)
            return [ [ :old_cannot_upload, old_entry, new_entry ] ]
          end
          if !new_exists && !new_entry.parent.can_have_child?(old_entry.name, old_entry.dir?)
            return [ [ :new_cannot_upload, old_entry, new_entry ] ]
          end

          if get_content
            # If we haven't read the values yet, get them now so that they can be diffed
            begin
              old_value = old_entry.read if old_value.nil?
            rescue ChefFS::FileSystem::NotFoundError
              old_value = :none
            end
            begin
              new_value = new_entry.read if new_value.nil?
            rescue ChefFS::FileSystem::NotFoundError
              new_value = :none
            end
          end

          if old_value == :none || (old_value == nil && !old_entry.exists?)
            return [ [ :added, old_entry, new_entry, old_value, new_value ] ]
          elsif new_value == :none
            return [ [ :deleted, old_entry, new_entry, old_value, new_value ] ]
          else
            return [ [ :modified, old_entry, new_entry, old_value, new_value ] ]
          end
        end
      end
    rescue ChefFS::FileSystem::FileSystemError => e
      return [ [ :error, old_entry, new_entry, nil, nil, e ] ]
    end

    private

    def self.sort_keys(json_object)
      if json_object.is_a?(Array)
        json_object.map { |o| sort_keys(o) }
      elsif json_object.is_a?(Hash)
        new_hash = {}
        json_object.keys.sort.each { |key| new_hash[key] = sort_keys(json_object[key]) }
        new_hash
      else
        json_object
      end
    end

    def self.canonicalize_json(json_text)
      parsed_json = JSON.parse(json_text, :create_additions => false)
      sorted_json = sort_keys(parsed_json)
      JSON.pretty_generate(sorted_json)
    end

    def self.diff_text(old_path, new_path, old_value, new_value)
      # Copy to tempfiles before diffing
      # TODO don't copy things that are already in files!  Or find an in-memory diff algorithm
      begin
        new_tempfile = Tempfile.new("new")
        new_tempfile.write(new_value)
        new_tempfile.close

        begin
          old_tempfile = Tempfile.new("old")
          old_tempfile.write(old_value)
          old_tempfile.close

          result = `diff -u #{old_tempfile.path} #{new_tempfile.path}`
          result = result.gsub(/^--- #{old_tempfile.path}/, "--- #{old_path}")
          result = result.gsub(/^\+\+\+ #{new_tempfile.path}/, "+++ #{new_path}")
          result
        ensure
          old_tempfile.close!
        end
      ensure
        new_tempfile.close!
      end
    end
  end
end
