require "inspec/utils/deprecation"

# For backwards compatibility during the rename (see #3802),
# maintain the Inspec::Attribute namespace for people checking for
# Inspec::Attribute::DEFAULT_ATTRIBUTE
module Inspec
  class Attribute
    # This only exists to create the Inspec::Attribute::DEFAULT_ATTRIBUTE symbol with a class
    class DEFAULT_ATTRIBUTE; end # rubocop: disable Naming/ClassAndModuleCamelCase
  end
end

module Inspec
  class Input

    class Error < Inspec::Error; end

    class ValidationError < Error
      attr_accessor :input_name
      attr_accessor :input_value
      attr_accessor :input_type
      attr_accessor :input_pattern
    end

    class TypeError < Error
      attr_accessor :input_type
    end

    class PatternError < Error
      attr_accessor :input_pattern
    end

    class RequiredError < Error
      attr_accessor :input_name
    end

    #===========================================================================#
    #                        Class Input::Event
    #===========================================================================#

    # TODO: break this out to its own file under inspec/input?
    # Information about how the input obtained its value.
    # Each time it changes, an Input::Event is added to the #events array.
    class Event
      EVENT_PROPERTIES = [
        :action,   # :create, :set, :fetch
        :provider, # Name of the plugin
        :priority, # Priority of this plugin for resolving conflicts.  1-100, higher numbers win.
        :value,    # New value, if provided.
        :file,     # File containing the input-changing action, if known
        :line,     # Line in file containing the input-changing action, if known
        :hit,      # if action is :fetch, true if the remote source had the input
      ].freeze

      # Value has a special handler
      EVENT_PROPERTIES.reject { |p| p == :value }.each do |prop|
        attr_accessor prop
      end

      attr_reader :value

      def initialize(properties = {})
        @value_has_been_set = false
        properties.each do |prop_name, prop_value|
          if EVENT_PROPERTIES.include? prop_name
            # OK, save the property
            send((prop_name.to_s + "=").to_sym, prop_value)
          else
            raise "Unrecognized property to Input::Event: #{prop_name}"
          end
        end
      end

      def value=(the_val)
        # Even if set to nil or false, it has indeed been set; note that fact.
        @value_has_been_set = true
        @value = the_val
      end

      def value_has_been_set?
        @value_has_been_set
      end

      def diagnostic_string
        to_h.reject { |_, val| val.nil? }.to_a.map { |pair| "#{pair[0]}: '#{pair[1]}'" }.join(", ")
      end

      def to_h
        EVENT_PROPERTIES.each_with_object({}) do |prop, hash|
          hash[prop] = send(prop)
        end
      end

      def self.probe_stack
        frames = caller_locations(2, 40)
        frames.reject! { |f| f.path && f.path.include?("/lib/inspec/") }
        frames.first
      end
    end # class Event

    #===========================================================================#
    #                    Class NO_VALUE_SET
    #===========================================================================#
    # This special class is used to represent the value when an input has
    # not been assigned a value. This allows a user to explicitly assign nil
    # to an input.
    class NO_VALUE_SET # rubocop: disable Naming/ClassAndModuleCamelCase
      def initialize(name, warn_on_create = true)
        @name = name

        # output warn message if we are in a exec call
        if warn_on_create && Inspec::BaseCLI.inspec_cli_command == :exec
          Inspec::Log.warn(
            "Input '#{@name}' does not have a value. "\
            "Use --input-file or --input to provide a value for '#{@name}' or specify a  "\
            "value with `input('#{@name}', value: 'somevalue', ...)`."
          )
        end
      end

      def method_missing(*_)
        self
      end

      def respond_to_missing?(_, _)
        true
      end

      def to_s
        "Input '#{@name}' does not have a value. Skipping test."
      end

      def is_a?(klass)
        if klass == Inspec::Attribute::DEFAULT_ATTRIBUTE
          Inspec.deprecate(:rename_attributes_to_inputs, "Don't check for `is_a?(Inspec::Attribute::DEFAULT_ATTRIBUTE)`, check for `Inspec::Input::NO_VALUE_SET")
          true # lie for backward compatibility
        else
          super(klass)
        end
      end

      def kind_of?(klass)
        if klass == Inspec::Attribute::DEFAULT_ATTRIBUTE
          Inspec.deprecate(:rename_attributes_to_inputs, "Don't check for `kind_of?(Inspec::Attribute::DEFAULT_ATTRIBUTE)`, check for `Inspec::Input::NO_VALUE_SET")
          true # lie for backward compatibility
        else
          super(klass)
        end
      end
    end # class NO_VALUE_SET

    #===========================================================================#
    #                       Class Inspec::Input
    #===========================================================================#

    # Validation types for input values
    VALID_TYPES = %w{
      String
      Numeric
      Regexp
      Array
      Hash
      Boolean
      Any
    }.freeze

    # TODO: this is not used anywhere?
    # If you call `input` in a control file, the input will receive this priority.
    # You can override that with a :priority option.
    DEFAULT_PRIORITY_FOR_DSL_ATTRIBUTES = 20

    # If you somehow manage to initialize an Input outside of the DSL,
    # AND you don't provide an Input::Event, this is the priority you get.
    DEFAULT_PRIORITY_FOR_UNKNOWN_CALLER = 10

    # If you directly call value=, this is the priority assigned.
    # This is the highest priority within InSpec core; though plugins
    # are free to go higher.
    DEFAULT_PRIORITY_FOR_VALUE_SET = 60

    attr_reader :description, :events, :identifier, :name, :required, :sensitive, :title, :type, :pattern

    def initialize(name, options = {})
      @name = name
      @opts = options
      if @opts.key?(:default)
        Inspec.deprecate(:attrs_value_replaces_default, "input name: '#{name}'")
        if @opts.key?(:value)
          Inspec::Log.warn "Input #{@name} created using both :default and :value options - ignoring :default"
          @opts.delete(:default)
        end
      end

      # Array of Input::Event objects.  These compete with one another to determine
      # the value of the input when value() is called, as well as providing a
      # debugging record of when and how the value changed.
      @events = []
      events.push make_creation_event(options)
      update(options)
    end

    # TODO: is this here just for testing?
    def set_events
      events.select { |e| e.action == :set }
    end

    def diagnostic_string
      "Input #{name}, with history:\n" +
        events.map(&:diagnostic_string).map { |line| "  #{line}" }.join("\n")
    end

    #--------------------------------------------------------------------------#
    #                           Managing Value
    #--------------------------------------------------------------------------#

    def update(options)
      _update_set_metadata(options)
      normalize_type_restriction!
      normalize_pattern_restriction!

      # Values are set by passing events in; but we can also infer an event.
      if options.key?(:value) || options.key?(:default)
        if options.key?(:event)
          if options.key?(:value) || options.key?(:default)
            Inspec::Log.warn "Do not provide both an Event and a value as an option to attribute('#{name}') - using value from event"
          end
        else
          self.class.infer_event(options) # Sets options[:event]
        end
      end
      events << options[:event] if options.key? :event

      enforce_type_restriction!
      enforce_pattern_restriction!
    end

    # We can determine a value:
    # 1. By event.value (preferred)
    # 2. By options[:value]
    # 3. By options[:default] (deprecated)
    def self.infer_event(options)
      # Don't rely on this working; you really should be passing a proper Input::Event
      # with the context information you have.
      location = Input::Event.probe_stack
      event = Input::Event.new(
        action: :set,
        provider: options[:provider] || :unknown,
        priority: options[:priority] || Inspec::Input::DEFAULT_PRIORITY_FOR_UNKNOWN_CALLER,
        file: location.path,
        line: location.lineno
      )

      if options.key?(:default)
        Inspec.deprecate(:attrs_value_replaces_default, "attribute name: '#{name}'")
        if options.key?(:value)
          Inspec::Log.warn "Input #{@name} created using both :default and :value options - ignoring :default"
          options.delete(:default)
        else
          options[:value] = options.delete(:default)
        end
      end
      event.value = options[:value] if options.key?(:value)
      options[:event] = event
    end

    private

    def _update_set_metadata(options)
      # Basic metadata
      @title = options[:title] if options.key?(:title)
      @description = options[:description] if options.key?(:description)
      @required = options[:required] if options.key?(:required)
      @identifier = options[:identifier] if options.key?(:identifier) # TODO: determine if this is ever used
      @type = options[:type] if options.key?(:type)
      @sensitive = options[:sensitive] if options.key?(:sensitive)
      @pattern = options[:pattern] if options.key?(:pattern)
    end

    def make_creation_event(options)
      loc = options[:location] || Event.probe_stack
      Input::Event.new(
        action: :create,
        provider: options[:provider],
        file: loc.path,
        line: loc.lineno
      )
    end

    # Determine the current winning value, but don't validate it
    def current_value(warn_on_missing = true)
      # Examine the events to determine highest-priority value. Tie-break
      # by using the last one set.
      events_that_set_a_value = events.select(&:value_has_been_set?)
      winning_priority = events_that_set_a_value.map(&:priority).max
      winning_events = events_that_set_a_value.select { |e| e.priority == winning_priority }
      winning_event = winning_events.last # Last for tie-break

      if winning_event.nil?
        # No value has been set - return special no value object
        NO_VALUE_SET.new(name, warn_on_missing)
      else
        winning_event.value # May still be nil
      end
    end

    public

    def value=(new_value, priority = DEFAULT_PRIORITY_FOR_VALUE_SET)
      # Inject a new Event with the new value.
      location = Event.probe_stack
      events << Event.new(
        action: :set,
        provider: :value_setter,
        priority: priority,
        value: new_value,
        file: location.path,
        line: location.lineno
      )

      enforce_type_restriction!
      enforce_pattern_restriction!
    end

    def value
      enforce_required_validation!
      current_value
    end

    def has_value?
      !current_value(false).is_a? NO_VALUE_SET
    end

    def to_hash
      as_hash = { name: name, options: {} }
      %i{description title identifier type required value sensitive pattern}.each do |field|
        val = send(field)
        next if val.nil?

        as_hash[:options][field] = val
      end
      as_hash
    end

    #--------------------------------------------------------------------------#
    #                           Value Type Coercion
    #--------------------------------------------------------------------------#

    def to_s
      "Input #{name} with value " + (sensitive ? "*** (senstive)" : "#{current_value}")
    end

    #--------------------------------------------------------------------------#
    #                               Validation
    #--------------------------------------------------------------------------#

    private

    def enforce_required_validation!
      return unless required
      # skip if we are not doing an exec call (archive/vendor/check)
      return unless Inspec::BaseCLI.inspec_cli_command == :exec

      proposed_value = current_value(false)
      if proposed_value.nil? || proposed_value.is_a?(NO_VALUE_SET)
        error = Inspec::Input::RequiredError.new
        error.input_name = name
        raise error, "Input '#{error.input_name}' is required and does not have a value."
      end
    end

    def enforce_type_restriction! # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
      return unless type
      return unless has_value?

      type_req = type
      return if type_req == "Any"

      proposed_value = current_value(false)

      invalid_type = false
      if type_req == "Regexp"
        invalid_type = true unless valid_regexp?(proposed_value)
      elsif type_req == "Numeric"
        invalid_type = true unless valid_numeric?(proposed_value)
      elsif type_req == "Boolean"
        invalid_type = true unless [true, false].include?(proposed_value)
      elsif proposed_value.is_a?(Module.const_get(type_req)) == false
        # TODO: why is this case here?
        invalid_type = true
      end

      if invalid_type == true
        error = Inspec::Input::ValidationError.new
        error.input_name = @name
        error.input_value = proposed_value
        error.input_type = type_req
        raise error, "Input '#{error.input_name}' with value '#{error.input_value}' does not validate to type '#{error.input_type}'."
      end
    end

    def normalize_type_restriction!
      return unless type

      type_req = type.capitalize
      abbreviations = {
        "Num" => "Numeric",
        "Regex" => "Regexp",
      }
      type_req = abbreviations[type_req] if abbreviations.key?(type_req)
      unless VALID_TYPES.include?(type_req)
        error = Inspec::Input::TypeError.new
        error.input_type = type_req
        raise error, "Type '#{error.input_type}' is not a valid input type."
      end
      @type = type_req
    end

    def enforce_pattern_restriction!
      return unless pattern
      return unless has_value?

      string_value = current_value(false).to_s

      valid_pattern = string_value.match?(pattern)
      unless valid_pattern
        error = Inspec::Input::ValidationError.new
        error.input_name = @name
        error.input_value = string_value
        error.input_pattern = pattern
        raise error, "Input '#{error.input_name}' with value '#{error.input_value}' does not validate to pattern '#{error.input_pattern}'."
      end
    end

    def normalize_pattern_restriction!
      return unless pattern

      unless valid_regexp?(pattern)
        error = Inspec::Input::PatternError.new
        error.input_pattern = pattern
        raise error, "Pattern '#{error.input_pattern}' is not a valid regex pattern."
      end
      @pattern = pattern
    end

    def valid_numeric?(value)
      Float(value)
      true
    rescue
      false
    end

    def valid_regexp?(value)
      # check for invalid regex syntax
      Regexp.new(value)
      true
    rescue
      false
    end
  end
end
