module GraphQL
  class Schema
    # This module provides a function for validating GraphQL types.
    #
    # Its {RULES} contain objects that respond to `#call(type)`. Rules are
    # looked up for given types (by class ancestry), then applied to
    # the object until an error is returned.
    class Validation
      # Lookup the rules for `object` based on its class,
      # Then returns an error message or `nil`
      # @param object [Object] something to be validated
      # @return [String, Nil] error message, if there was one
      def self.validate(object)
        rules = RULES.reduce([]) do |memo, (parent_class, validations)|
          memo + (object.is_a?(parent_class) ? validations : [])
        end
        # Stops after the first error
        rules.reduce(nil) { |memo, rule| memo || rule.call(object) }
      end

      module Rules
        # @param property_name [Symbol] The method to validate
        # @param allowed_classes [Class] Classes which the return value may be an instance of
        # @return [Proc] A proc which will validate the input by calling `property_name` and asserting it is an instance of one of `allowed_classes`
        def self.assert_property(property_name, *allowed_classes)
          allowed_classes_message = allowed_classes.map(&:name).join(" or ")
          -> (obj) {
            property_value = obj.public_send(property_name)
            is_valid_value = allowed_classes.any? { |allowed_class| property_value.is_a?(allowed_class) }
            is_valid_value ? nil : "#{property_name} must return #{allowed_classes_message}, not #{property_value.class.name} (#{property_value.inspect})"
          }
        end

        # @param property_name [Symbol] The method whose return value will be validated
        # @param from_class [Class] The class for keys in the return value
        # @param to_class [Class] The class for values in the return value
        # @return [Proc] A proc to validate that validates the input by calling `property_name` and asserting that the return value is a Hash of `{from_class => to_class}` pairs
        def self.assert_property_mapping(property_name, from_class, to_class)
          -> (obj) {
            property_value = obj.public_send(property_name)
            error_message = nil
            if !property_value.is_a?(Hash)
              "#{property_name} must be a hash of {#{from_class.name} => #{to_class.name}}, not a #{property_value.class.name} (#{property_value.inspect})"
            else
              invalid_key, invalid_value = property_value.find { |key, value| !key.is_a?(from_class) || !value.is_a?(to_class) }
              if invalid_key
                "#{property_name} must map #{from_class} => #{to_class}, not #{invalid_key.class.name} => #{invalid_value.class.name} (#{invalid_key.inspect} => #{invalid_value.inspect})"
              else
                nil # OK
              end
            end
          }
        end

        # @param property_name [Symbol] The method whose return value will be validated
        # @param list_member_class [Class] The class which each member of the returned array should be an instance of
        # @return [Proc] A proc to validate the input by calling `property_name` and asserting that the return is an Array of `list_member_class` instances
        def self.assert_property_list_of(property_name, list_member_class)
          -> (obj) {
            property_value = obj.public_send(property_name)
            if !property_value.is_a?(Array)
              "#{property_name} must be an Array of #{list_member_class.name}, not a #{property_value.class.name} (#{property_value.inspect})"
            else
              invalid_member = property_value.find { |value| !value.is_a?(list_member_class) }
              if invalid_member
                "#{property_name} must contain #{list_member_class.name}, not #{invalid_member.class.name} (#{invalid_member.inspect})"
              else
                nil # OK
              end
            end
          }
        end

        def self.assert_named_items_are_valid(item_name, get_items_proc)
          -> (type) {
            items = get_items_proc.call(type)
            error_message = nil
            items.each do |item|
              item_message = GraphQL::Schema::Validation.validate(item)
              if item_message
                error_message = "#{item_name} #{item.name.inspect} #{item_message}"
                break
              end
            end
            error_message
          }
        end


        FIELDS_ARE_VALID = Rules.assert_named_items_are_valid("field", -> (type) { type.all_fields })

        HAS_ONE_OR_MORE_POSSIBLE_TYPES = -> (type) {
          type.possible_types.length > 1 ? nil : "must have at least one possible type"
        }

        NAME_IS_STRING = Rules.assert_property(:name, String)
        DESCRIPTION_IS_STRING_OR_NIL = Rules.assert_property(:description, String, NilClass)
        ARGUMENTS_ARE_STRING_TO_ARGUMENT = Rules.assert_property_mapping(:arguments, String, GraphQL::Argument)
        ARGUMENTS_ARE_VALID =  Rules.assert_named_items_are_valid("argument", -> (type) { type.arguments.values })

        DEFAULT_VALUE_IS_VALID_FOR_TYPE = -> (type) {
          if !type.default_value.nil?
            coerced_value = type.type.coerce_input(type.default_value)
            if coerced_value.nil?
              "default value #{type.default_value.inspect} is not valid for type #{type.type}"
            end
          end
        }

        TYPE_IS_VALID_INPUT_TYPE = -> (type) {
          outer_type = type.type
          inner_type = outer_type.is_a?(GraphQL::BaseType) ? outer_type.unwrap : nil

          case inner_type
          when GraphQL::ScalarType, GraphQL::InputObjectType, GraphQL::EnumType
            # OK
          else
            "type must be a valid input type (Scalar or InputObject), not #{outer_type.class} (#{outer_type})"
          end
        }
      end

      # A mapping of `{Class => [Proc, Proc...]}` pairs.
      # To validate an instance, find entries where `object.is_a?(key)` is true.
      # Then apply each rule from the matching values.
      RULES = {
        GraphQL::Field => [
          Rules::NAME_IS_STRING,
          Rules::DESCRIPTION_IS_STRING_OR_NIL,
          Rules.assert_property(:deprecation_reason, String, NilClass),
          Rules.assert_property(:type, GraphQL::BaseType),
          Rules.assert_property(:property, Symbol, NilClass),
          Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT,
          Rules::ARGUMENTS_ARE_VALID,
        ],
        GraphQL::Argument => [
          Rules::NAME_IS_STRING,
          Rules::DESCRIPTION_IS_STRING_OR_NIL,
          Rules::TYPE_IS_VALID_INPUT_TYPE,
          Rules::DEFAULT_VALUE_IS_VALID_FOR_TYPE,
        ],
        GraphQL::BaseType => [
          Rules::NAME_IS_STRING,
          Rules::DESCRIPTION_IS_STRING_OR_NIL,
        ],
        GraphQL::ObjectType => [
          Rules.assert_property_list_of(:interfaces, GraphQL::InterfaceType),
          Rules::FIELDS_ARE_VALID,
        ],
        GraphQL::InputObjectType => [
          Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT,
          Rules::ARGUMENTS_ARE_VALID,
        ],
        GraphQL::UnionType => [
          Rules.assert_property_list_of(:possible_types, GraphQL::ObjectType),
          Rules::HAS_ONE_OR_MORE_POSSIBLE_TYPES,
        ],
        GraphQL::InterfaceType => [
          Rules::FIELDS_ARE_VALID,
        ],
      }
    end
  end
end