require 'pdk'

module PDK
  module Module
    class UpdateManager
      # Initialises a blank UpdateManager object, which is used to store and
      # process file additions/removals/modifications.
      def initialize
        require 'set'

        @modified_files = Set.new
        @added_files = Set.new
        @removed_files = Set.new
        @executable_files = Set.new
        @diff_cache = {}
      end

      # Store a pending modification to an existing file.
      #
      # @param path [String] The path to the file to be modified.
      # @param content [String] The new content of the file.
      def modify_file(path, content)
        @modified_files << { path: path, content: content }
      end

      # Store a pending file addition.
      #
      # @param path [String] The path where the file will be created.
      # @param content [String] The content of the new file.
      def add_file(path, content)
        @added_files << { path: path, content: content }
      end

      # Store a pending file removal.
      #
      # @param path [String] The path to the file to be removed.
      def remove_file(path)
        @removed_files << path
      end

      # Store a pending file execute mode change.
      #
      # @param path [String] The path to the file to be made executable.
      def make_file_executable(path)
        @executable_files << path
      end

      # Generate a summary of the changes that will be applied to the module.
      #
      # @raise (see #calculate_diffs)
      # @return [Hash{Symbol => Set,Hash}] the summary of the pending changes.
      def changes
        require 'pdk/util/filesystem'

        calculate_diffs

        {
          added: @added_files,
          removed: @removed_files.select { |f| PDK::Util::Filesystem.exist?(f) },
          modified: @diff_cache.compact,
          'made executable': @executable_files
        }
      end

      # Check if there are any pending changes to apply to the module.
      #
      # @raise (see #changes)
      # @return [Boolean] true if there are changes to apply to the module.
      def changes?
        !changes[:added].empty? ||
          !changes[:removed].empty? ||
          changes[:modified].any? { |_, value| !value.nil? } ||
          !changes[:'made executable'].empty?
      end

      # Check if the update manager will change the specified file upon sync.
      #
      # @param path [String] The path to the file.
      #
      # @raise (see #changes)
      # @return [Boolean] true if the file will be changed.
      def changed?(path)
        changes[:added].any? { |add| add[:path] == path } ||
          changes[:removed].include?(path) ||
          changes[:modified].key?(path) ||
          changes[:'made executable'].include?(path)
      end

      def clear!
        @modified_files.clear
        @added_files.clear
        @removed_files.clear
        @executable_files.clear
        nil
      end

      # Apply any pending changes stored in the UpdateManager to the module.
      #
      # @raise (see #calculate_diffs)
      # @raise (see #write_file)
      # @raise (see #unlink_file)
      def sync_changes!
        calculate_diffs

        files_to_write = @added_files
        files_to_write += @modified_files.reject { |file| @diff_cache[file[:path]].nil? }

        @removed_files.each do |file|
          unlink_file(file)
        end

        files_to_write.each do |file|
          write_file(file[:path], file[:content])
        end

        @executable_files.each do |file|
          update_execute_bits(file)
        end
      end

      # Remove a file from disk.
      #
      # Like FileUtils.rm_f, this method will not fail if the file does not
      # exist. Unlike FileUtils.rm_f, this method will not blindly swallow all
      # exceptions.
      #
      # @param path [String] The path to the file to be removed.
      #
      # @raise [PDK::CLI::ExitWithError] if the file could not be removed.
      def unlink_file(path)
        require 'pdk/util/filesystem'

        if PDK::Util::Filesystem.file?(path)
          PDK.logger.debug(format("unlinking '%{path}'", path: path))
          PDK::Util::Filesystem.rm(path)
        else
          PDK.logger.debug(format("'%{path}': already gone", path: path))
        end
      rescue StandardError => e
        raise PDK::CLI::ExitWithError, format("Unable to remove '%{path}': %{message}", path: path, message: e.message)
      end

      private

      # Loop through all the files to be modified and cache of unified diff of
      # the changes to be made to each file.
      #
      # @raise [PDK::CLI::ExitWithError] if a file being modified isn't
      #   readable.
      def calculate_diffs
        require 'pdk/util/filesystem'

        @modified_files.each do |file|
          next if @diff_cache.key?(file[:path])

          raise PDK::CLI::ExitWithError, format("Unable to open '%{path}' for reading", path: file[:path]) unless PDK::Util::Filesystem.readable?(file[:path])

          old_content = PDK::Util::Filesystem.read_file(file[:path])
          file_diff = unified_diff(file[:path], old_content, file[:content])
          @diff_cache[file[:path]] = file_diff
        end
      end

      # Write or overwrite a file with the specified content.
      #
      # @param path [String] The path to be written to.
      # @param content [String] The data to be written to the file.
      #
      # @raise [PDK::CLI::ExitWithError] if the file is not writeable.
      def write_file(path, content)
        require 'pdk/util/filesystem'

        PDK::Util::Filesystem.mkdir_p(File.dirname(path))
        PDK.logger.debug(format("writing '%{path}'", path: path))
        PDK::Util::Filesystem.write_file(path, content)
      rescue Errno::EACCES
        raise PDK::CLI::ExitWithError, format("You do not have permission to write to '%{path}'", path: path)
      end

      # Generate a unified diff of the changes to be made to a file.
      #
      # @param path [String] The path to the file being diffed (only used to
      #   generate the diff header).
      # @param old_content [String] The current content of the file.
      # @param new_content [String] The new content of the file if the pending
      #   change is applied.
      # @param lines_of_context [Integer] The maximum number of lines of
      #   context to include around the changed lines in the diff output
      #   (default: 3).
      #
      # @return [String] The unified diff of the pending changes to the file.
      def unified_diff(path, old_content, new_content, lines_of_context = 3)
        require 'diff/lcs'
        require 'English'

        output = []

        old_lines = old_content.split($INPUT_RECORD_SEPARATOR).map(&:chomp)
        new_lines = new_content.split($INPUT_RECORD_SEPARATOR).map(&:chomp)

        diffs = Diff::LCS.diff(old_lines, new_lines)

        return if diffs.empty?

        require 'diff/lcs/hunk'

        file_mtime = PDK::Util::Filesystem.stat(path).mtime.localtime.strftime('%Y-%m-%d %H:%M:%S.%N %z')
        now = Time.now.localtime.strftime('%Y-%m-%d %H:%M:%S.%N %z')

        output << "--- #{path}\t#{file_mtime}"
        output << "+++ #{path}.pdknew\t#{now}"

        oldhunk = hunk = nil
        file_length_difference = 0

        diffs.each do |piece|
          hunk = Diff::LCS::Hunk.new(old_lines, new_lines, piece, lines_of_context, file_length_difference)
          file_length_difference = hunk.file_length_difference

          next unless oldhunk

          # If the hunk overlaps with the oldhunk, merge them.
          next if lines_of_context.positive? && hunk.merge(oldhunk)

          output << oldhunk.diff(:unified)
        ensure
          oldhunk = hunk
        end

        output << oldhunk.diff(:unified)

        output.join($INPUT_RECORD_SEPARATOR)
      end

      # Set the execute bits on a file
      def update_execute_bits(path)
        require 'pdk/util/filesystem'

        PDK.logger.debug(format("making '%{path}' executable", path: path))
        PDK::Util::Filesystem.make_executable(path)
      rescue Errno::EACCES
        raise PDK::CLI::ExitWithError, format("You do not have permission to make '%{path}' executable", path: path)
      end
    end
  end
end
