# frozen_string_literal: true

require 'erb'
require 'fileutils'
require 'tempfile'
require 'json'

module GDK
  module Templates
    # ErbRenderer is responsible for rendering templates and providing
    # them access to configuration data
    class ErbRenderer
      attr_reader :source, :context

      # Initialize the renderer providing source, target and local variables
      #
      # @param [Pathname] source
      # @param [Hash] **locals variables available inside the template
      def initialize(source, **locals)
        @source = ensure_pathname(source)
        @context = ::GDK::Templates::Context.new(**locals)
      end

      # The safe render take extra steps to avoid unrecoverable changes:
      # - Render the new content to a temporary file
      # - Display a diff of the changes
      # - Make a timestamped backup of the target file
      # - Provide instructions on how to restore previous changes
      # - Move the temporary file to replace the old one
      #
      # @param [Pathname] target
      # @return [Boolean] whether file was written
      def safe_render!(target)
        target = ensure_pathname(target)

        return false unless should_render?(target)

        write_atomically(target, render_to_string) do |file|
          next true unless target.exist?
          break false if FileUtils.identical?(target, file)

          display_changes!(file.path, target)
          backup = perform_backup!(target)
          warn_overwritten!(backup)

          true
        end
      end

      # Render template into target file
      #
      # @param [Pathname] target
      def render(target)
        target = ensure_pathname(target)

        return unless should_render?(target)

        write_atomically(target, render_to_string)
      end

      # Render template and return its content
      #
      # @return [String] Rendered content
      def render_to_string
        raise ArgumentError, "file not found in: #{source}" unless File.exist?(source)

        template = File.read(source)

        erb = ERB.new(template, trim_mode: '-') # A trim_mode of '-' allows omitting empty lines with <%- -%>
        erb.location = source.to_s # define the file location so errors can point to the right file
        erb.result(@context.context_bindings)
      rescue GDK::ConfigSettings::UnsupportedConfiguration => e
        GDK::Output.abort("#{e.message}.", e)
      end

      private

      # Writes +contents+ to +target+ atomically by using +Tempfile+.
      #
      # To avoid copying across devices, tempfile needs to be created on the same
      # filesystem or `File.rename` fails otherwise.
      def write_atomically(target, contents)
        target.dirname.mkpath

        Tempfile.create(".#{target.basename}", target.dirname) do |temp|
          temp.write(contents)
          temp.rewind # flushes buffers

          yield temp if block_given?
        ensure
          File.rename(temp, target)
        end
      end

      # Compare and display changes between existing and newly rendered content
      #
      # @param [File] new_temporary_file
      # @param [File] existing_file
      def display_changes!(new_temporary_file, existing_file)
        cmd = %W[git --no-pager diff --no-index #{git_color_args} -u #{existing_file} #{new_temporary_file}]
        diff = Shellout.new(cmd).readlines[4..]
        return unless diff

        GDK::Output.puts
        GDK::Output.info("'#{relative_path(existing_file)}' has incoming changes:")

        diff_output = <<~DIFF_OUTPUT
          -------------------------------------------------------------------------------------------------------------
          #{diff.join("\n")}

          -------------------------------------------------------------------------------------------------------------
        DIFF_OUTPUT

        GDK::Output.puts(diff_output, stderr: true)
      end

      def target_protected?(target)
        GDK.config.config_file_protected?(relative_path(target))
      end

      def should_render?(target)
        # if the target is _not_ protected, no need to check any further
        return true unless target_protected?(target)

        if File.exist?(target)
          GDK::Output.warn("Changes to '#{relative_path(target)}' not applied because it's protected in gdk.yml.")

          false
        else
          GDK::Output.warn("Creating missing protected file '#{relative_path(target)}'.")

          true
        end
      end

      def warn_overwritten!(backup)
        GDK::Output.warn "'#{backup.relative_source_file}' has been overwritten. To recover the previous version, run:"
        GDK::Output.puts <<~OVERWRITTEN

          #{backup.recover_cmd_string}
          If you want to protect this file from being overwritten, see:
          https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md#overwriting-configuration-files
          -------------------------------------------------------------------------------------------------------------
        OVERWRITTEN
      end

      # Perform a backup of given file target
      #
      # @param [String] target file that will be back up
      # @return [GDK::Backup]
      def perform_backup!(target)
        Backup.new(target).tap { |backup| backup.backup!(advise: false) }
      end

      def colors?
        @colors ||= Shellout.new('tput colors').try_run.chomp.to_i >= 8
      end

      def git_color_args
        if colors?
          '--color'
        else
          '--no-color'
        end
      end

      def relative_path(target)
        return target unless target.absolute?

        target.relative_path_from(GDK.root)
      end

      def ensure_pathname(path)
        path.is_a?(Pathname) ? path : Pathname.new(path)
      end
    end
  end
end
