# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

cs__scoped_require 'ripper'
cs__scoped_require 'contrast/extension/module'
cs__scoped_require 'contrast/components/interface'
cs__scoped_require 'contrast/logger/log'

# This method is left purposefully at the top level namespace. Moving it
# elsewhere will break functionality as it executes evaluations against the
# namespace from which it is called -- ie putting it in Contrast would make all
# changes it intends for Foo happen to Contrast::Foo instead
#
# @param _class_name [String] the name of the class in which the eval will
#   redefine functionality
# @param content [String] the String content that will function as the code in
#   the given class
def unbound_eval _class_name, content
  # Yuck, this is a top-level method that has to break encapsulation
  # in order to access scoping!
  Contrast::Components::Scope::COMPONENT_INTERFACE.scope_for_current_ec.enter_contrast_scope!
  eval(content) # rubocop:disable Security/Eval
rescue Exception # rubocop:disable Lint/RescueException
  # We can't use components here, so we have to access the log directly. I hate
  # it, but we'll have to deal with it until we remove 2.5 support.
  Contrast::Logger::Log.instance.logger.error('Unable to perform unbound eval of new content', module: class_name)
  # And we need to return nil here, not the value from the logger.
  nil
ensure
  Contrast::Components::Scope::COMPONENT_INTERFACE.scope_for_current_ec.exit_contrast_scope!
end

module Contrast
  module Agent
    # TODO: RUBY-714 remove w/ EOL of 2.5
    # @deprecated Changes to this class are discouraged as this approach is
    #   being phased out with support for those language versions.
    class ClassReopener
      include Contrast::Components::Interface
      access_component :logging

      END_NEW_LINE = "end\n"
      PROTECTED_WITH_NEW_LINE = "protected\n"
      PRIVATE_WITH_NEW_LINE = "private\n"
      CLASS_SELF_LINE = "class << self\n"

      attr_reader :public_singleton_methods, :class_module_path, :class_name, :files, :is_class, :name_space,
                  :public_instance_methods, :protected_instance_methods, :private_instance_methods, :locations

      def initialize module_data
        @class_module_path = module_data.name
        clazz = module_data.mod
        @is_class = clazz.is_a?(Class)
        @public_instance_methods = []
        @protected_instance_methods = []
        @private_instance_methods = []
        @public_singleton_methods = []
        @files = {}
        @name_space = []
        @class_name = nil
        @locations = Hash.new { |h, k| h[k] = [] }
        gather_modules
      end

      # Indicates if any methods were rewritten for this class.
      #
      # @return [Boolean]
      def staged_changes?
        public_singleton_methods.any? ||
            public_instance_methods.any? ||
            protected_instance_methods.any? ||
            private_instance_methods.any?
      end

      # Indicates if this class was written from the given location.
      #
      # @param source_location [Array<String, Integer>] the result of a
      #   Method#source_location call
      # @return [Boolean]
      def written_from_location? source_location
        return false unless source_location

        file = source_location[0]
        location = source_location[1]
        locations[file].include?(location)
      end

      # Marks that this class was written from the given location.
      #
      # @param source_location [Array<String, Integer>] the result of a
      #   Method#source_location call
      # @return [Boolean]
      def written_from_location! source_location
        return false unless source_location

        file = source_location[0]
        location = source_location[1]
        locations[file] << location
      end

      # Evaluate the patches that have been staged for this class, replacing
      # the method definitions with those our rewrite.
      def commit_patches
        return unless staged_changes?

        content = build_content
        valid = Ripper.sexp(content)
        unbound_eval(class_name, content) if !!valid && !class_name.empty?
      end

      # Find the sourcecode of the method at the given location and return it
      # if it is complete and compilable.
      #
      # @param location [Array<String, Integer>] the result of a
      #   Method#source_location call
      # @param method_name [Symbol] the name of the method defined at the given
      #   location
      # @return [String, nil] the code defining the method or nil if no valid
      #   code could be found.
      def source_code location, method_name
        file_name = location[0]
        line_number = location[1]
        return unless file_contents_available?(file_name, line_number)

        files[file_name] = File.readlines(file_name) unless files.key?(file_name)
        lines = files[file_name]
        code = +''
        # location#line_number is 1 based, arrays are 0 based
        line_number -= 1
        lines[line_number..-1].each do |line|
          code << line
          next unless compiles?(code)
          break if complete?(code)
        end
        unless complete?(code) && compiles?(code)
          logger.warn(
              'Failed to determine sourcecode for rewriting.',
              file: file_name,
              method: method_name,
              line_number: line_number)
          return
        end
        code
      end

      private

      def file_contents_available? file_name, line_number
        return false unless file_name && line_number
        return false unless File.exist?(file_name) && File.readable?(file_name)
        return false if File.empty?(file_name)

        true
      end

      def gather_modules
        return if class_module_path.nil?

        segments = class_module_path.split(Contrast::Utils::ObjectShare::DOUBLE_COLON)
        @class_name = segments.last
        current = nil
        segments[0..-2].each do |chunk|
          defined = current ? current.cs__const_defined?(chunk) : Module.cs__const_defined?(chunk)
          next unless defined

          current = current ? current.cs__const_get(chunk) : Module.cs__const_get(chunk)
          if current.is_a?(Class)
            name_space << [chunk, Class]
          elsif current.is_a?(Module)
            name_space << [chunk, Module]
          end
        end
      end

      # code which ends with a , or \ is incomplete.
      #
      # @param code [String] the text to determine if complete
      # @return [Boolean]
      def complete? code
        code !~ /[,\\]\s*\z/
      end

      # code which does not resolve to RubyVM::InstructionSequence does not
      # compile
      #
      # @param code [String] the text to determine if compiles
      # @return [Boolean]
      def compiles? code
        old_verbose = $VERBOSE
        $VERBOSE = nil
        RubyVM::InstructionSequence.compile(code)
        true
      rescue SyntaxError => _e
        false
      ensure
        $VERBOSE = old_verbose
      end

      def build_content
        content = +''
        name_space.each do |arr|
          name = arr[0]
          type = arr[1]
          if type == Class
            content << "class #{ name }\n"
          elsif type == Module
            content << "module #{ name }\n"
          end
        end

        # Set the name to be a class or module
        content << if is_class
                     "class #{ class_name }\n"
                   else
                     "module #{ class_name }\n"
                   end

        # Recreate the updated class methods
        if public_singleton_methods.any?
          content << CLASS_SELF_LINE
          content << public_singleton_methods.join(Contrast::Utils::ObjectShare::NEW_LINE)
          content << Contrast::Utils::ObjectShare::NEW_LINE
          content << END_NEW_LINE
        end

        # Recreate the updated instance methods
        if public_instance_methods.any?
          content << public_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE)
          content << Contrast::Utils::ObjectShare::NEW_LINE
        end

        if protected_instance_methods.any?
          content << PROTECTED_WITH_NEW_LINE
          content << protected_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE)
          content << Contrast::Utils::ObjectShare::NEW_LINE
        end

        if private_instance_methods.any?
          content << PRIVATE_WITH_NEW_LINE
          content << private_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE)
          content << Contrast::Utils::ObjectShare::NEW_LINE
        end

        content << END_NEW_LINE
        name_space.length.times do
          content << END_NEW_LINE
        end
        content
      end
    end
  end
end