# Author:: Lamont Granquist (<lamont@chef.io>)
# Copyright:: Copyright (c) Chef Software 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.
#
# Some portions of this file are derived from material in the diff-lcs
# project licensed under the terms of the MIT license, provided below.
#
# Copyright:: Copyright 2004-2016, Austin Ziegler
# License:: MIT
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of this Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
# SOFTWARE.

class Chef
  class Util
    class Diff
      # @todo: to_a, to_s, to_json, inspect defs, accessors for @diff and @error
      # @todo: move coercion to UTF-8 into to_json

      def for_output
        # formatted output to a terminal uses arrays of strings and returns error strings
        @diff.nil? ? [ @error ] : @diff
      end

      def for_reporting
        # caller needs to ensure that new files aren't posted to resource reporting
        return nil if @diff.nil?

        @diff.join("\\n")
      end

      def use_tempfile_if_missing(file)
        tempfile = nil
        unless TargetIO::File.exist?(file)
          Chef::Log.trace("File #{file} does not exist to diff against, using empty tempfile")
          tempfile = Tempfile.new("chef-diff")
          file = tempfile.path
        end
        yield file
        unless tempfile.nil?
          tempfile.close
          tempfile.unlink
        end
      end

      def diff(old_file, new_file)
        use_tempfile_if_missing(old_file) do |old_file|
          use_tempfile_if_missing(new_file) do |new_file|
            @error = do_diff(old_file, new_file)
          end
        end
      end

      # produces a unified-output-format diff with 3 lines of context
      # ChefFS uses udiff() directly
      def udiff(old_file, new_file)
        require "diff/lcs"
        require "diff/lcs/hunk"

        diff_str = ""
        file_length_difference = 0

        old_data = IO.readlines(old_file).map(&:chomp)
        new_data = IO.readlines(new_file).map(&:chomp)
        diff_data = ::Diff::LCS.diff(old_data, new_data)

        return diff_str if old_data.empty? && new_data.empty?
        return "No differences encountered\n" if diff_data.empty?

        # write diff header (standard unified format)
        ft = File.stat(old_file).mtime.localtime.strftime("%Y-%m-%d %H:%M:%S.%N %z")
        diff_str << "--- #{old_file}\t#{ft}\n"
        ft = File.stat(new_file).mtime.localtime.strftime("%Y-%m-%d %H:%M:%S.%N %z")
        diff_str << "+++ #{new_file}\t#{ft}\n"

        # loop over diff hunks. if a hunk overlaps with the last hunk,
        # join them. otherwise, print out the old one.
        old_hunk = hunk = nil
        diff_data.each do |piece|

          hunk = ::Diff::LCS::Hunk.new(old_data, new_data, piece, 3, file_length_difference)
          file_length_difference = hunk.file_length_difference
          next unless old_hunk
          next if hunk.merge(old_hunk)

          diff_str << old_hunk.diff(:unified) << "\n"
        ensure
          old_hunk = hunk

        end
        diff_str << old_hunk.diff(:unified) << "\n"
        diff_str
      end

      private

      def do_diff(old_file, new_file)
        if Chef::Config[:diff_disabled]
          return "(diff output suppressed by config)"
        end

        diff_filesize_threshold = Chef::Config[:diff_filesize_threshold]
        diff_output_threshold = Chef::Config[:diff_output_threshold]

        # Download files for diffs in Target Mode, then work locally
        if ChefConfig::Config.target_mode?
          connection = Chef.run_context&.transport_connection

          old_copy = Tempfile.new(old_file)
          connection.download(old_file, old_copy.path) if connection.file(old_file).exist?
          old_file = old_copy.path

          new_copy = Tempfile.new(new_file)
          connection.download(new_file, new_copy.path) if connection.file(new_file).exist?
          new_file = new_copy.path
        end

        if ::File.size(old_file) > diff_filesize_threshold || ::File.size(new_file) > diff_filesize_threshold
          return "(file sizes exceed #{diff_filesize_threshold} bytes, diff output suppressed)"
        end

        # macOS(BSD?) diff will *sometimes* happily spit out nasty binary diffs
        return "(current file is binary, diff output suppressed)" if is_binary?(old_file)
        return "(new content is binary, diff output suppressed)" if is_binary?(new_file)

        begin
          Chef::Log.trace("Running: diff -u #{old_file} #{new_file}")
          diff_str = udiff(old_file, new_file)

        rescue Exception => e
          # Should *not* receive this, but in some circumstances it seems that
          # an exception can be thrown even using shell_out instead of shell_out!
          return "Could not determine diff. Error: #{e.message}"
        end

        if !diff_str.empty? && diff_str != "No differences encountered\n"
          if diff_str.length > diff_output_threshold
            "(long diff of over #{diff_output_threshold} characters, diff output suppressed)"
          else
            diff_str = encode_diff_for_json(diff_str)
            @diff = diff_str.split("\n")
            "(diff available)"
          end
        else
          "(no diff)"
        end
      end

      def is_binary?(path)
        File.open(path) do |file|
          # XXX: this slurps into RAM, but we should have already checked our diff has a reasonable size
          buff = file.read
          buff = "" if buff.nil?
          begin
            return buff !~ /\A[\s[:print:]]*\z/m
          rescue ArgumentError => e
            return true if /invalid byte sequence/.match?(e.message)

            raise
          end
        end
      end

      def encode_diff_for_json(diff_str)
        diff_str.encode!("UTF-8", invalid: :replace, undef: :replace, replace: "?")
      end

    end
  end
end
