# frozen_string_literal: true

require 'bolt/error'

# This class validates config against a schema, raising an error that includes
# details about any invalid configuration.
#
module Bolt
  class Validator
    attr_reader :deprecations, :warnings

    def initialize
      @errors       = []
      @deprecations = []
      @warnings     = []
      @path         = []
    end

    # This is the entry method for validating data against the schema.
    #
    def validate(data, schema, location = nil)
      @schema   = schema
      @location = location

      validate_value(data, schema)

      raise_error
    end

    # Raises a ValidationError if there are any errors. All error messages
    # created during validation are concatenated into a single error
    # message.
    #
    private def raise_error
      return unless @errors.any?

      message = "Invalid configuration"
      message += " at #{@location}" if @location
      message += ":\n"
      message += @errors.map { |error| "\s\s#{error}" }.join("\n")

      raise Bolt::ValidationError, message
    end

    # Validate an individual value. This performs validation that is
    # common to all values, including type validation. After validating
    # the value's type, the value is passed off to an individual
    # validation method for the value's type.
    #
    private def validate_value(value, definition, plugin_supported = false)
      definition       = @schema.dig(:definitions, definition[:_ref]) if definition[:_ref]
      plugin_supported = definition[:_plugin] if definition.key?(:_plugin)

      return if plugin_reference?(value, plugin_supported)
      return unless valid_type?(value, definition)

      case value
      when Hash
        validate_hash(value, definition, plugin_supported)
      when Array
        validate_array(value, definition, plugin_supported)
      when String
        validate_string(value, definition)
      when Numeric
        validate_number(value, definition)
      end
    end

    # Validates a hash value, logging errors for any validations that fail.
    # This will enumerate each key-value pair in the hash and validate each
    # value individually.
    #
    private def validate_hash(value, definition, plugin_supported)
      properties = definition[:properties] ? definition[:properties].keys : []

      if definition[:properties] && definition[:additionalProperties].nil?
        validate_keys(value.keys, properties)
      end

      if definition[:required] && (definition[:required] - value.keys).any?
        missing = definition[:required] - value.keys
        @errors << "Value at '#{path}' is missing required keys #{missing.join(', ')}"
      end

      value.each_pair do |key, val|
        @path.push(key)

        if properties.include?(key)
          check_deprecated(key, definition[:properties][key])
          validate_value(val, definition[:properties][key], plugin_supported)
        elsif definition[:additionalProperties].is_a?(Hash)
          validate_value(val, definition[:additionalProperties], plugin_supported)
        end
      ensure
        @path.pop
      end
    end

    # Validates an array value, logging errors for any validations that fail.
    # This will enumerate the items in the array and validate each item
    # individually.
    #
    private def validate_array(value, definition, plugin_supported)
      if definition[:uniqueItems] && value.size != value.uniq.size
        @errors << "Value at '#{path}' must not include duplicate elements"
        return
      end

      return unless definition.key?(:items)

      value.each_with_index do |item, index|
        @path.push(index)
        validate_value(item, definition[:items], plugin_supported)
      ensure
        @path.pop
      end
    end

    # Validates a string value, logging errors for any validations that fail.
    #
    private def validate_string(value, definition)
      if definition.key?(:enum) && !definition[:enum].include?(value)
        message  = "Value at '#{path}' must be "
        message += "one of " if definition[:enum].count > 1
        message += definition[:enum].join(', ')
        multitype_error(message, value, definition)
      end
    end

    # Validates a numeric value, logging errors for any validations that fail.
    #
    private def validate_number(value, definition)
      if definition.key?(:minimum) && value < definition[:minimum]
        @errors << "Value at '#{path}' must be a minimum of #{definition[:minimum]}"
      end

      if definition.key?(:maximum) && value > definition[:maximum]
        @errors << "Value at '#{path}' must be a maximum of #{definition[:maximum]}"
      end
    end

    # Adds warnings for unknown config options.
    #
    private def validate_keys(keys, known_keys)
      (keys - known_keys).each do |key|
        message  = "Unknown option '#{key}'"
        message += " at '#{path}'" if @path.any?
        message += " at #{@location}" if @location
        message += "."
        @warnings << message
      end
    end

    # Adds a warning if the given option is deprecated.
    #
    private def check_deprecated(key, definition)
      definition = @schema.dig(:definitions, definition[:_ref]) if definition[:_ref]

      if definition.key?(:_deprecation)
        message  = "Option '#{path}' "
        message += "at #{@location} " if @location
        message += "is deprecated. #{definition[:_deprecation]}"
        @deprecations << { option: key, message: message }
      end
    end

    # Returns true if a value is a plugin reference. This also validates whether
    # a value can be a plugin reference in the first place. If the value is a
    # plugin reference but cannot be one according to the schema, then this will
    # log an error.
    #
    private def plugin_reference?(value, plugin_supported)
      if value.is_a?(Hash) && value.key?('_plugin')
        unless plugin_supported
          @errors << "Value at '#{path}' is a plugin reference, which is unsupported at "\
                      "this location"
        end

        true
      else
        false
      end
    end

    # Asserts the type for each option against the type specified in the schema
    # definition. The schema definition can specify multiple valid types, so the
    # value needs to only match one of the types to be valid. Returns early if
    # there is no type in the definition (in practice this shouldn't happen, but
    # this will safeguard against any dev mistakes).
    #
    private def valid_type?(value, definition)
      return unless definition.key?(:type)

      types = Array(definition[:type])

      if types.include?(value.class)
        true
      else
        if types.include?(TrueClass) || types.include?(FalseClass)
          types = types - [TrueClass, FalseClass] + ['Boolean']
        end

        @errors << "Value at '#{path}' must be of type #{types.join(' or ')}"

        false
      end
    end

    # Adds an error that includes additional helpful information for values
    # that accept multiple types.
    #
    private def multitype_error(message, value, definition)
      if Array(definition[:type]).count > 1
        types    = Array(definition[:type]) - [value.class]
        message += " or must be of type #{types.join(' or ')}"
      end

      @errors << message
    end

    # Returns the formatted path for the key.
    #
    private def path
      @path.join('.')
    end
  end
end
