module Schemacop
  module V3
    class ArrayNode < Node
      ATTRIBUTES = %i[
        min_items
        max_items
        unique_items
      ].freeze

      supports_children

      def self.allowed_options
        super + ATTRIBUTES + %i[additional_items reject filter]
      end

      def self.dsl_methods
        super + NodeRegistry.dsl_methods(false) + %i[dsl_add dsl_list dsl_cont]
      end

      attr_reader :items
      attr_accessor :list_item
      attr_accessor :cont_item

      def dsl_add(type, **options, &block)
        if @options[:additional_items].is_a?(Node)
          fail Exceptions::InvalidSchemaError, 'You can only use "add" once to specify additional items.'
        end

        @options[:additional_items] = create(type, **options, &block)
      end

      def dsl_list(type, **options, &block)
        if list_item.is_a?(Node)
          fail Exceptions::InvalidSchemaError, 'You can only use "list" once.'
        end

        @list_item = create(type, **options, &block)
      end

      def dsl_cont(type, **options, &block)
        if cont_item.is_a?(Node)
          fail Exceptions::InvalidSchemaError, 'You can only use "cont" once.'
        end

        @cont_item = create(type, **options, &block)
      end

      def add_child(node)
        @items << node
      end

      def as_json
        json = { type: :array }

        if cont_item
          json[:contains] = cont_item.as_json
        end

        if list?
          json[:items] = @list_item.as_json
        elsif @items.any?
          json[:items] = @items.map(&:as_json)
          if options[:additional_items] == true
            json[:additionalItems] = true
          elsif options[:additional_items].is_a?(Node)
            json[:additionalItems] = options[:additional_items].as_json
          else
            json[:additionalItems] = false
          end
        end

        return process_json(ATTRIBUTES, json)
      end

      def allowed_types
        { Array => :array }
      end

      def _validate(data, result:)
        super_data = super
        return if super_data.nil?

        # Preprocess
        super_data = preprocess_array(super_data)

        # Validate length
        length = super_data.size

        if options[:min_items] && length < options[:min_items]
          result.error "Array has #{length} items but needs at least #{options[:min_items]}."
        end

        if options[:max_items] && length > options[:max_items]
          result.error "Array has #{length} items but needs at most #{options[:max_items]}."
        end

        if list?
          # Validate list
          super_data.each_with_index do |value, index|
            result.in_path :"[#{index}]" do
              list_item._validate(value, result: result)
            end
          end
        elsif items.any?
          # Validate tuple
          if length == items.size || (options[:additional_items] != false && length >= items.size)
            items.each_with_index do |child_node, index|
              value = super_data[index]

              result.in_path :"[#{index}]" do
                child_node._validate(value, result: result)
              end
            end

            # Validate additional items #
            if options[:additional_items].is_a?(Node)
              (items.size..(length - 1)).each do |index|
                additional_item = super_data[index]
                result.in_path :"[#{index}]" do
                  options[:additional_items]._validate(additional_item, result: result)
                end
              end
            end
          else
            result.error "Array has #{length} items but must have exactly #{items.size}."
          end
        end

        if cont_item.present? && super_data.none? { |obj| item_matches?(cont_item, obj) }
          result.error "At least one entry must match schema #{cont_item.as_json.inspect}."
        end

        # Validate uniqueness #
        if options[:unique_items] && super_data.size != super_data.uniq.size
          result.error 'Array has duplicate items.'
        end
      end

      def children
        (@items + [@cont_item]).compact
      end

      def cast(value)
        return default unless value

        result = []

        value.each_with_index do |value_item, index|
          if cont_item.present? && item_matches?(cont_item, value_item)
            result << cont_item.cast(value_item)
          elsif list?
            result << list_item.cast(value_item)
          elsif items.any?
            if options[:additional_items] != false && index >= items.size
              if options[:additional_items].is_a?(Node)
                result << options[:additional_items].cast(value_item)
              else
                result << value_item
              end
            else
              item = item_for_data(value_item)
              result << item.cast(value_item)
            end
          else
            result << value_item
          end
        end

        return preprocess_array(result)
      end

      protected

      def preprocess_array(value)
        # Handle filter
        if options[:filter]
          block = Proc.new(&options[:filter])

          value = value.filter do |item|
            block.call(item)
          rescue NoMethodError
            true
          end
        end

        # Handle reject
        if options[:reject]
          block = Proc.new(&options[:reject])

          value = value.reject do |item|
            block.call(item)
          rescue NoMethodError
            false
          end
        end

        return value
      end

      def list?
        list_item.present?
      end

      def item_for_data(data)
        item = children.find { |c| item_matches?(c, data) }
        return item if item

        fail "Could not find specification for item #{data.inspect}."
      end

      def init
        @items = []
        @cont_item = nil

        if options[:additional_items].nil?
          options[:additional_items] = false
        end
      end

      def validate_self
        if list? && items.any?
          fail 'Can\'t use "list" and normal items.'
        end

        if list? && @options[:additional_items].is_a?(Node)
          fail 'Can\'t use "list" and additional items.'
        end

        unless options[:min_items].nil? || options[:min_items].is_a?(Integer)
          fail 'Option "min_items" must be an "integer"'
        end

        unless options[:max_items].nil? || options[:max_items].is_a?(Integer)
          fail 'Option "max_items" must be an "integer"'
        end

        unless options[:unique_items].nil? || options[:unique_items].is_a?(TrueClass) || options[:unique_items].is_a?(FalseClass)
          fail 'Option "unique_items" must be a "boolean".'
        end

        if options[:min_items] && options[:max_items] && options[:min_items] > options[:max_items]
          fail 'Option "min_items" can\'t be greater than "max_items".'
        end
      end
    end
  end
end