# copyright: 2015, Dominik Richter

require "method_source"
require "date"
require "inspec/describe_base"
require "inspec/expect"
require "inspec/impact"
require "inspec/resource"
require "inspec/resources/os"
require "inspec/input_registry"
require "inspec/waiver_file_reader"
require "inspec/utils/convert"

module Inspec
  class Rule
    include ::RSpec::Matchers

    attr_reader :__waiver_data
    attr_accessor :resource_dsl, :na_impact_freeze
    attr_reader :__profile_id

    def initialize(id, profile_id, resource_dsl, opts, &block)
      @impact = nil
      @title = nil
      @descriptions = {}
      @refs = []
      @tags = {}

      @resource_dsl = resource_dsl
      extend resource_dsl # TODO: remove! do it via method_missing

      # not changeable by the user:
      @__code = nil
      @__block = block
      @__source_location = __get_block_source_location(&block)
      @__rule_id = id
      @__profile_id = profile_id
      @__checks = []
      @__skip_rule = {} # { result: true, message: "Why", type: [:only_if, :waiver] }
      @__merge_count = 0
      @__merge_changes = []
      @__skip_only_if_eval = opts[:skip_only_if_eval]
      @__na_rule = {}

      # evaluate the given definition
      return unless block_given?

      begin
        # Pre-check: apply waivers before evaluating the control block.
        # If the control is waived with run: false, skip the block entirely
        # to avoid eager resource evaluation (e.g., `command('find /').stdout`
        # executing expensive commands for waived controls).
        __apply_waivers

        unless @__skip_rule[:result] && @__skip_rule[:type] == :waiver
          instance_eval(&block)

          # Re-apply waivers after instance eval. This is a no-op in practice:
          # run:false waivers are already handled by the pre-check above (the
          # unless guard prevents instance_eval from running at all), and
          # run:true / no-run-key waivers do not set a skip flag. Kept for
          # defensive correctness in case waiver state changes during eval.
          __apply_waivers
        end

      rescue SystemStackError, StandardError => e
        # We've encountered an exception while trying to eval the code inside the
        # control block. We need to prevent the exception from bubbling up, and
        # fail the control. Controls are failed by having a failed resource within
        # them; but since our control block is unsafe (and opaque) to us, let's
        # make a dummy and fail that.
        location = block.source_location.compact.join(":")
        describe "Control Source Code Error" do
          # Rubocop thinks we are raising an exception - we're actually calling RSpec's fail()
          its(location) { fail e.message } # rubocop: disable Style/SignalException
        end

        # instance_eval evaluates the describe block and raise errors if at the resource level any execution is failed
        # Waived controls expect not to raise any controls and get skipped if run is false so __apply_waivers needs to be called here too
        # so that waived control are actually gets waived.
        __apply_waivers
      end
    end

    def to_s
      Inspec::Rule.rule_id(self)
    end

    def id(*_)
      # never overwrite the ID
      @id
    end

    def impact(v = nil)
      # N/A impact freeze is required when only_applicable_if block has reset impact value to zero"
      unless na_impact_freeze
        if v.is_a?(String)
          @impact = Inspec::Impact.impact_from_string(v)
        elsif !v.nil?
          @impact = v
        end
      end

      @impact
    end

    def title(v = nil)
      @title = v unless v.nil?
      @title
    end

    def desc(v = nil, data = nil)
      return @descriptions[:default] if v.nil?

      if data.nil?
        @descriptions[:default] = unindent(v)
      else
        @descriptions[v.to_sym] = unindent(data)
      end
    end

    def descriptions(description_hash = nil)
      return @descriptions if description_hash.nil?

      @descriptions.merge!(description_hash)
    end

    def ref(ref = nil, opts = {})
      return @refs if ref.nil? && opts.empty?

      if opts.empty? && ref.is_a?(Hash)
        opts = ref
      else
        opts[:ref] = ref
      end
      @refs.push(opts)
    end

    def tag(*args)
      args.each do |arg|
        if arg.is_a?(Hash)
          @tags.merge!(arg)
        else
          @tags[arg] ||= nil
        end
      end
      @tags
    end

    def source_file
      @__file
    end

    # Skip all checks if only_if is false
    #
    # @param [Type] &block returns true if tests are added, false otherwise
    # @return [nil]
    def only_if(message = nil, impact: nil)
      return unless block_given?
      return if @__skip_only_if_eval == true

      self.impact(impact) if impact && !yield
      @__skip_rule[:result] ||= !yield
      @__skip_rule[:type] = :only_if
      @__skip_rule[:message] = message
    end

    def only_applicable_if(message = nil)
      return unless block_given?
      return if yield

      impact(0.0)
      self.na_impact_freeze = true # this flag prevents impact value to reset to any other value

      @__na_rule[:result] ||= !yield
      @__na_rule[:type] = :only_applicable_if
      @__na_rule[:message] = message
    end

    # Describe will add one or more tests to this control. There is 2 ways
    # of calling it:
    #
    #   describe resource do ... end
    #
    # or
    #
    #   describe.one do ... end
    #
    # @param [any] Resource to be describe, string, or nil
    # @param [Proc] An optional block containing tests for the described resource
    # @return [nil|DescribeBase] if called without arguments, returns DescribeBase
    def describe(*values, &block)
      if values.empty? && !block_given?
        dsl = resource_dsl
        Class.new(DescribeBase) do
          include dsl
        end.new(method(:__add_check))
      else
        __add_check("describe", values, with_dsl(block))
      end
    end

    def expect(value, &block)
      target = Inspec::Expect.new(value, &with_dsl(block))
      __add_check("expect", [value], target)
      target
    end

    # allow attributes to be accessed within control blocks
    def input(input_name, options = {})
      if options.empty?
        # Simply an access, no event here
        Inspec::InputRegistry.find_or_register_input(input_name, __profile_id).value
      else
        options[:priority] ||= 20
        options[:provider] = :inline_control_code
        evt = Inspec::Input.infer_event(options)
        Inspec::InputRegistry.find_or_register_input(
          input_name,
          __profile_id,
          type: options[:type],
          required: options[:required],
          description: options[:description],
          pattern: options[:pattern],
          event: evt
        ).value
      end
    end

    # Find the Input object, but don't collapse to a value.
    # Will return nil on a miss.
    def input_object(input_name)
      Inspec::InputRegistry.find_or_register_input(input_name, __profile_id)
    end

    def attribute(name, options = {})
      Inspec.deprecate(:attrs_dsl, "Input name: #{name}, Profile: #{__profile_id}")
      input(name, options)
    end

    # Support for Control DSL plugins.
    # This is called when an unknown method is encountered
    # within a control block.
    def method_missing(method_name, *arguments, &block)
      # Check to see if there is a control_dsl plugin activator hook with the method name
      registry = Inspec::Plugin::V2::Registry.instance
      hook = registry.find_activators(plugin_type: :control_dsl, activator_name: method_name).first
      if hook
        # OK, load the hook if it hasn't been already.  We'll then know a module,
        # which we can then inject into the context
        hook.activate

        # Inject the module's methods into the context.
        # implementation_class is the field name, but this is actually a module.
        self.class.include(hook.implementation_class)
        # Now that the module is loaded, it defined one or more methods
        # (presumably the one we were looking for.)
        # We still haven't called it, so do so now.
        send(method_name, *arguments, &block)
      else
        begin
          Inspec::DSL.method_missing_resource(inspec, method_name, *arguments)
        rescue LoadError
          super
        end
      end
    end

    # TODO: figure out why these violations exist and nuke them.
    def self.rule_id(rule)
      rule.instance_variable_get(:@__rule_id)
    end

    def self.set_rule_id(rule, value)
      rule.instance_variable_set(:@__rule_id, value)
    end

    def self.profile_id(rule)
      rule.instance_variable_get(:@__profile_id)
    end

    def self.checks(rule)
      rule.instance_variable_get(:@__checks)
    end

    def self.skip_status(rule)
      rule.instance_variable_get(:@__skip_rule)
    end

    def self.na_status(rule)
      rule.instance_variable_get(:@__na_rule)
    end

    def self.set_skip_rule(rule, value, message = nil, type = :only_if)
      rule.instance_variable_set(:@__skip_rule,
                                 {
                                   result: value,
                                   message: message,
                                   type: type,
                                 })
    end

    def self.merge_count(rule)
      rule.instance_variable_get(:@__merge_count)
    end

    def self.merge_changes(rule)
      rule.instance_variable_get(:@__merge_changes)
    end

    # If a rule is marked to be skipped, this
    # creates a dummay array of "checks" with a skip outcome
    def self.prepare_checks(rule)
      skip_check = skip_status(rule)
      na_check = na_status(rule)
      return checks(rule) unless skip_check[:result].eql?(true) || na_check[:result].eql?(true)

      resource = rule.noop
      if skip_check[:result].eql?(true)
        if skip_check[:message]
          msg = "Skipped control due to #{skip_check[:type]} condition: #{skip_check[:message]}"
        else
          msg = "Skipped control due to #{skip_check[:type]} condition."
        end
        resource.skip_resource(msg)
      else
        if na_check[:message]
          msg = "N/A control due to #{na_check[:type]} condition: #{na_check[:message]}"
        else
          msg = "N/A control due to #{na_check[:type]} condition."
        end
        resource.fail_resource(msg)
      end

      [["describe", [resource], nil]]
    end

    def self.merge(dst, src) # rubocop:disable Metrics/AbcSize
      if src.id != dst.id
        # TODO: register an error, this case should not happen
        return
      end

      sp = rule_id(src)
      dp = rule_id(dst)
      if sp != dp
        # TODO: register an error, this case should not happen
        return
      end

      # merge all fields
      dst.impact(src.impact)                 unless src.impact.nil?
      dst.title(src.title)                   unless src.title.nil?
      dst.descriptions(src.descriptions)     unless src.descriptions.nil?
      dst.tag(src.tag)                       unless src.tag.nil?
      dst.ref(src.ref)                       unless src.ref.nil?

      # merge indirect fields
      # checks defined in the source will completely eliminate
      # all checks that were defined in the destination
      sc = checks(src)
      dst.instance_variable_set(:@__checks, sc) unless sc.empty?
      skip_check = skip_status(src)
      sr = skip_check[:result]
      msg = skip_check[:message]
      skip_type = skip_check[:type]
      set_skip_rule(dst, sr, msg, skip_type) unless sr.nil?

      # Save merge history
      dst.instance_variable_set(:@__merge_count, merge_count(dst) + 1)
      dst.instance_variable_set(
        :@__merge_changes,
        merge_changes(dst) << src.instance_variable_get(:@__source_location)
      )
    end

    private

    def __add_check(describe_or_expect, values, block)
      @__checks.push([describe_or_expect, values, block])
    end

    # Look for an input with a matching ID, and if found, apply waiver
    # skipping logic. Basically, if we have a current waiver, and it says
    # to skip, we'll replace all the checks with a dummy check (same as
    # only_if mechanism)
    # Double underscore: not intended to be called as part of the DSL
    def __apply_waivers
      @__waiver_data = nil
      control_id = @__rule_id # TODO: control ID slugging

      waiver_files = Inspec::Config.cached.final_options["waiver_file"] if Inspec::Config.cached.respond_to?(:final_options)
      unless waiver_files.nil? || waiver_files.empty?
        waiver_data_by_profile = Inspec::WaiverFileReader.fetch_waivers_by_profile(__profile_id, waiver_files)
        return unless waiver_data_by_profile && waiver_data_by_profile[control_id] && waiver_data_by_profile[control_id].is_a?(Hash)

        @__waiver_data = waiver_data_by_profile[control_id]
      else
        # Support for input registry is provided for backward compatibilty with compliance phase of chef-client
        # Chef-client sends waiver information in inputs hash
        input_registry = Inspec::InputRegistry.instance
        waiver_data_via_input = input_registry.inputs_by_profile.dig(__profile_id, control_id)
        return unless waiver_data_via_input && waiver_data_via_input.has_value? && waiver_data_via_input.value.is_a?(Hash)

        @__waiver_data = waiver_data_via_input.value
      end

      __waiver_data["skipped_due_to_waiver"] = false
      __waiver_data["message"] = ""

      # Does it have an expiration date, and if so, is it in the future?
      # This sets a waiver message before checking `run: true`
      expiry = __waiver_data["expiration_date"]
      if expiry
        # YAML will automagically give us a Date or a Time.
        # If transcoding YAML between languages (e.g. Go) the date might have also ended up as a String.
        # A string that does not represent a valid time results in the date 0000-01-01.
        if [Date, Time].include?(expiry.class) || (expiry.is_a?(String) && Time.parse(expiry).year != 0)
          expiry = expiry.to_time if expiry.is_a? Date
          expiry = Time.parse(expiry) if expiry.is_a? String
          if expiry < Time.now # If the waiver expired, return - no skip applied
            __waiver_data["message"] = "Waiver expired on #{expiry}, evaluating control normally"
            return
          end
        else
          ui = Inspec::UI.new
          ui.error("Unable to parse waiver expiration date '#{expiry}' for control #{@__rule_id}")
          ui.exit(:usage_error)
        end
      end

      # Waivers should have a hash value with keys possibly including "run" and
      # expiration_date. We only care here if it has a "run" key and it
      # is false-like, since all non-skipped waiver operations are handled
      # during reporting phase.
      __waiver_data["run"] = Converter.to_boolean(__waiver_data["run"]) if __waiver_data.key?("run")
      return unless __waiver_data.key?("run") && !__waiver_data["run"]

      # OK, apply a skip.
      @__skip_rule[:result] = true
      @__skip_rule[:type] = :waiver
      @__skip_rule[:message] = __waiver_data["justification"]
      __waiver_data["skipped_due_to_waiver"] = true
    end

    #
    # Takes a block and returns a block that will run the given block
    # with access to the resource_dsl of the current class. This is to
    # ensure that inside the constructed Rspec::ExampleGroup users
    # have access to DSL methods. Previous this was done in
    # Inspec::Runner before sending the example groups to rspec. It
    # was moved here to ensure that code inside `its` blocks hae the
    # same visibility into resources as code outside its blocks.
    #
    # @param [Proc] block
    # @return [Proc]
    #
    def with_dsl(block)
      return nil if block.nil?

      dsl = resource_dsl

      return block unless dsl

      proc do |*args|
        include dsl
        instance_exec(*args, &block)
      end
    end

    # Idio(ma)tic unindent, behaves similar to Ruby2.3 curly heredocs.
    # Find the shortest indentation of non-empty lines and strip that from every line
    # See: https://bugs.ruby-lang.org/issues/9098
    #
    # It is implemented here to support pre-Ruby2.3 with this feature and
    # to not force non-programmers to understand heredocs.
    #
    # Please note: tabs are not supported! (they will be removed but they are not
    # treated the same as in Ruby2.3 heredocs)
    #
    # @param [String] text string which needs to be unindented
    # @return [String] input with indentation removed; '' if input is nil
    def unindent(text)
      return "" if text.nil?

      len = text.split("\n").reject { |l| l.strip.empty? }.map { |x| x.index(/[^\s]/) }.compact.min
      text.gsub(/^[[:blank:]]{#{len}}/, "").strip
    end

    # get the source location of the block
    def __get_block_source_location(&block)
      return {} unless block_given?

      r, l = block.source_location
      { ref: r, line: l }
    rescue MethodSource::SourceNotFoundError
      {}
    end
  end
end
