# frozen_string_literal: true
# rubocop:todo all

module Mongoid
  module Attributes

    # This module defines projection helpers.
    #
    # Projection rules are rather non-trivial. See
    # https://www.mongodb.com/docs/manual/reference/method/db.collection.find/#find-projection
    # for server documentation.
    # 4.4 server (and presumably all older ones) requires that a projection
    # for content fields is either exclusionary or inclusionary, i.e. one
    # cannot mix exclusions and inclusions in the same query.
    # However, _id can be excluded in a projection that includes content
    # fields.
    # Integer projection values other than 0 and 1 aren't officially
    # documented as of this writing; see DOCSP-15266.
    # 4.4 server also allows nested hash projection specification
    # in addition to dot notation, which I assume Mongoid doesn't handle yet.
    #
    # @api private
    class Projector
      def initialize(projection)
        if projection
          @content_projection = projection.dup
          @content_projection.delete('_id')
          @id_projection_value = projection['_id']
        else
          @content_projection = nil
          @id_projection_value = nil
        end
      end

      attr_reader :id_projection_value
      attr_reader :content_projection

      # Determine if the specified attribute, or a dot notation path, is allowed
      # by the configured projection, if any.
      #
      # If there is no configured projection, returns true.
      #
      # @param [ String ] name The name of the attribute or a dot notation path.
      #
      # @return [ true | false ] Whether the attribute is allowed by projection.
      #
      # @api private
      def attribute_or_path_allowed?(name)
        # Special handling for _id.
        if name == '_id'
          result = unless id_projection_value.nil?
            value_inclusionary?(id_projection_value)
          else
            true
          end
          return result
        end

        if content_projection.nil?
          # No projection (as opposed to an empty projection).
          # All attributes are allowed.
          return true
        end

        # Find an item which matches or is a parent of the requested name/path.
        # This handles the case when, for example, the projection was
        # {foo: true} and we want to know if foo.bar is allowed.
        item, value = content_projection.detect do |path, value|
          (name + '.').start_with?(path + '.')
        end
        if item
          return value_inclusionary?(value)
        end

        if content_inclusionary?
          # Find an item which would be a strict child of the requested name/path.
          # This handles the case when, for example, the projection was
          # {"foo.bar" => true} and we want to know if foo is allowed.
          # (It is as a container of bars.)
          item, value = content_projection.detect do |path, value|
            (path + '.').start_with?(name + '.')
          end
          if item
            return true
          end
        end

        !content_inclusionary?
      end

      private

      # Determines whether the projection for content fields is inclusionary.
      #
      # An empty projection is inclusionary.
      def content_inclusionary?
        if content_projection.empty?
          return value_inclusionary?(id_projection_value)
        end

        value_inclusionary?(content_projection.values.first)
      end

      def value_inclusionary?(value)
        case value
        when Integer
          value >= 1
        when true
          true
        when false
          false
        else
          # The various expressions that are permitted as projection arguments
          # imply an inclusionary projection.
          true
        end
      end
    end
  end
end
