# Copyright 2017 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


require "google/cloud/firestore/v1"
require "google/cloud/firestore/document_snapshot"
require "google/cloud/firestore/query_listener"
require "google/cloud/firestore/convert"
require "google/cloud/firestore/aggregate_query"
require "google/cloud/firestore/filter"
require "json"

module Google
  module Cloud
    module Firestore
      ##
      # # Query
      #
      # Represents a query to the Firestore API.
      #
      # Instances of this class are immutable. All methods that refine the query
      # return new instances.
      #
      # @example
      #   require "google/cloud/firestore"
      #
      #   firestore = Google::Cloud::Firestore.new
      #
      #   # Create a query
      #   query = firestore.col(:cities).select(:population)
      #
      #   query.get do |city|
      #     puts "#{city.document_id} has #{city[:population]} residents."
      #   end
      #
      # @example Listen to a query for changes:
      #   require "google/cloud/firestore"
      #
      #   firestore = Google::Cloud::Firestore.new
      #
      #   # Create a query
      #   query = firestore.col(:cities).order(:population, :desc)
      #
      #   listener = query.listen do |snapshot|
      #     puts "The query snapshot has #{snapshot.docs.count} documents "
      #     puts "and has #{snapshot.changes.count} changes."
      #   end
      #
      #   # When ready, stop the listen operation and close the stream.
      #   listener.stop
      #
      class Query
        ##
        # @private The parent path for the query.
        attr_accessor :parent_path

        ##
        # @private The type for limit queries.
        attr_reader :limit_type

        ##
        # @private The Google::Cloud::Firestore::V1::StructuredQuery object.
        attr_accessor :query

        ##
        # @private The firestore client object.
        attr_accessor :client

        ##
        # @private Creates a new Query.
        def initialize query, parent_path, client, limit_type: nil
          query ||= StructuredQuery.new
          @query = query
          @parent_path = parent_path
          @limit_type = limit_type
          @client = client
        end

        ##
        # Restricts documents matching the query to return only data for the
        # provided fields.
        #
        # @param [FieldPath, String, Symbol, Array<FieldPath|String|Symbol>]
        #   fields A field path to filter results with and return only the
        #   specified fields. One or more field paths can be specified.
        #
        #   If a {FieldPath} object is not provided then the field will be
        #   treated as a dotted string, meaning the string represents individual
        #   fields joined by ".". Fields containing `~`, `*`, `/`, `[`, `]`, and
        #   `.` cannot be in a dotted string, and should provided using a
        #   {FieldPath} object instead.
        #
        # @return [Query] New query with `select` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.select(:population)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def select *fields
          new_query = @query.dup
          new_query ||= StructuredQuery.new

          fields = Array(fields).flatten.compact
          fields = [FieldPath.document_id] if fields.empty?
          field_refs = fields.flatten.compact.map do |field|
            field = FieldPath.parse field unless field.is_a? FieldPath
            StructuredQuery::FieldReference.new \
              field_path: field.formatted_string
          end

          new_query.select = StructuredQuery::Projection.new
          field_refs.each do |field_ref|
            new_query.select.fields << field_ref
          end

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # @private This is marked private and can't be removed.
        #
        # Selects documents from all collections, immediate children and nested,
        # of where the query was created from.
        #
        # @return [Query] New query with `all_descendants` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.all_descendants
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def all_descendants
          new_query = @query.dup
          new_query ||= StructuredQuery.new

          if new_query.from.empty?
            raise "missing collection_id to specify descendants"
          end

          new_query.from.last.all_descendants = true

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # @private This is marked private and can't be removed.
        #
        # Selects only documents from collections that are immediate children of
        # where the query was created from.
        #
        # @return [Query] New query with `direct_descendants` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.direct_descendants
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def direct_descendants
          new_query = @query.dup
          new_query ||= StructuredQuery.new

          if new_query.from.empty?
            raise "missing collection_id to specify descendants"
          end

          new_query.from.last.all_descendants = false

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Adds filter to the where clause
        #
        # @overload where(filter)
        #   Pass Firestore::Filter to `where` via field_or_filter argument.
        #
        #  @param filter [::Google::Cloud::Firestore::Filter]
        #
        # @overload where(field, operator, value)
        #   Pass arguments to `where` via positional arguments.
        #
        #   @param field [FieldPath, String, Symbol] A field path to filter
        #     results with.
        #     If a {FieldPath} object is not provided then the field will be
        #     treated as a dotted string, meaning the string represents individual
        #     fields joined by ".". Fields containing `~`, `*`, `/`, `[`, `]`, and
        #     `.` cannot be in a dotted string, and should provided using a
        #     {FieldPath} object instead.
        #
        #   @param operator [String, Symbol] The operation to compare the field
        #     to. Acceptable values include:
        #     * less than: `<`, `lt`
        #     * less than or equal: `<=`, `lte`
        #     * greater than: `>`, `gt`
        #     * greater than or equal: `>=`, `gte`
        #     * equal: `=`, `==`, `eq`, `eql`, `is`
        #     * not equal: `!=`
        #     * in: `in`
        #     * not in: `not-in`, `not_in`
        #     * array contains: `array-contains`, `array_contains`
        #
        #   @param value [Object] The value to compare the property to. Defaults to nil.
        #     Possible values are:
        #     * Integer
        #     * Float/BigDecimal
        #     * String
        #     * Boolean
        #     * Array
        #     * Date/Time
        #     * StringIO
        #     * Google::Cloud::Datastore::Key
        #     * Google::Cloud::Datastore::Entity
        #     * nil
        #
        # @return [Query] New query with `where` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.where(:population, :>=, 1000000)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a filter
        #   filter = Filter.create(:population, :>=, 1000000)
        #
        #   # Add filter to where clause
        #   query = query.where filter
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def where filter_or_field = nil, operator = nil, value = nil
          if query_has_cursors?
            raise "cannot call where after calling " \
                  "start_at, start_after, end_before, or end_at"
          end

          new_query = @query.dup
          new_query ||= StructuredQuery.new

          if filter_or_field.is_a? Google::Cloud::Firestore::Filter
            new_query.where = filter_or_field.filter
          else
            new_filter = Google::Cloud::Firestore::Filter.new filter_or_field, operator, value
            add_filters_to_query new_query, new_filter.filter
          end

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Specifies an "order by" clause on a field.
        #
        # @param [FieldPath, String, Symbol] field A field path to order results
        #   with.
        #
        #   If a {FieldPath} object is not provided then the field will be
        #   treated as a dotted string, meaning the string represents individual
        #   fields joined by ".". Fields containing `~`, `*`, `/`, `[`, `]`, and
        #   `.` cannot be in a dotted string, and should provided using a
        #   {FieldPath} object instead.
        # @param [String, Symbol] direction The direction to order the results
        #   by. Values that start with "a" are considered `ascending`. Values
        #   that start with "d" are considered `descending`. Default is
        #   `ascending`. Optional.
        #
        # @return [Query] New query with `order` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:name)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Order by name descending:
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:name, :desc)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def order field, direction = :asc
          if query_has_cursors? || limit_type == :last
            raise "cannot call order after calling limit_to_last, start_at, start_after, end_before, or end_at"
          end

          new_query = @query.dup
          new_query ||= StructuredQuery.new

          field = FieldPath.parse field unless field.is_a? FieldPath

          new_query.order_by << StructuredQuery::Order.new(
            field:     StructuredQuery::FieldReference.new(
              field_path: field.formatted_string
            ),
            direction: order_direction(direction)
          )

          Query.start new_query, parent_path, client, limit_type: limit_type
        end
        alias order_by order

        ##
        # Skips to an offset in a query. If the current query already has
        # specified an offset, this will overwrite it.
        #
        # @param [Integer] num The number of results to skip.
        #
        # @return [Query] New query with `offset` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.limit(5).offset(10)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def offset num
          new_query = @query.dup
          new_query ||= StructuredQuery.new

          new_query.offset = num

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Limits a query to return only the first matching documents.
        #
        # If the current query already has a limit set, this will overwrite it.
        #
        # @param [Integer] num The maximum number of results to return.
        #
        # @return [Query] New query with `limit` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:name, :desc).offset(10).limit(5)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def limit num
          if limit_type == :last
            raise "cannot call limit after calling limit_to_last"
          end

          new_query = @query.dup
          new_query ||= StructuredQuery.new

          new_query.limit = Google::Protobuf::Int32Value.new value: num

          Query.start new_query, parent_path, client, limit_type: :first
        end

        ##
        # Limits a query to return only the last matching documents.
        #
        # You must specify at least one "order by" clause for limitToLast queries.
        # (See {#order}.)
        #
        # Results for `limit_to_last` queries are only available once all documents
        # are received. Hence, `limit_to_last` queries cannot be streamed using
        # {#listen}.
        #
        # @param [Integer] num The maximum number of results to return.
        #
        # @return [Query] New query with `limit_to_last` called on it.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:name, :desc).limit_to_last(5)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def limit_to_last num
          new_query = @query.dup

          if new_query.nil? || new_query.order_by.nil? || new_query.order_by.empty?
            raise "specify at least one order clause before calling limit_to_last"
          end

          if limit_type != :last # Don't reverse order_by more than once.
            # Reverse the order_by directions since we want the last results.
            new_query.order_by.each do |order|
              order.direction = order.direction.to_sym == :DESCENDING ? :ASCENDING : :DESCENDING
            end

            # Swap the cursors to match the reversed query ordering.
            new_end_at = new_query.start_at.dup
            new_start_at = new_query.end_at.dup
            if new_end_at
              new_end_at.before = !new_end_at.before
              new_query.end_at = new_end_at
            end
            if new_start_at
              new_start_at.before = !new_start_at.before
              new_query.start_at = new_start_at
            end
          end

          new_query.limit = Google::Protobuf::Int32Value.new value: num

          Query.start new_query, parent_path, client, limit_type: :last
        end

        ##
        # Starts query results at a set of field values. The field values can be
        # specified explicitly as arguments, or can be specified implicitly by
        # providing a {DocumentSnapshot} object instead. The result set will
        # include the document specified by `values`.
        #
        # If the current query already has specified `start_at` or
        # `start_after`, this will overwrite it.
        #
        # The values are associated with the field paths that have been provided
        # to `order`, and must match the same sort order. An ArgumentError will
        # be raised if more explicit values are given than are present in
        # `order`.
        #
        # @param [DocumentSnapshot, Object, Array<Object>] values The field
        #   values to start the query at.
        #
        # @return [Query] New query with `start_at` called on it.
        #
        # @example Starting a query at a document reference id
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .start_at(nyc_doc_id)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query at a document reference object
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #   nyc_ref = cities_col.doc nyc_doc_id
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .start_at(nyc_ref)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query at multiple explicit values
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .start_at(1000000, "New York City")
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query at a DocumentSnapshot
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Get a document snapshot
        #   nyc_snap = firestore.doc("cities/NYC").get
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .start_at(nyc_snap)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def start_at *values
          raise ArgumentError, "must provide values" if values.empty?

          if limit_type == :last
            raise "cannot call start_at after calling limit_to_last"
          end

          new_query = @query.dup
          new_query ||= StructuredQuery.new

          cursor = values_to_cursor values, new_query
          cursor.before = true
          new_query.start_at = cursor

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Starts query results after a set of field values. The field values can
        # be specified explicitly as arguments, or can be specified implicitly
        # by providing a {DocumentSnapshot} object instead. The result set will
        # not include the document specified by `values`.
        #
        # If the current query already has specified `start_at` or
        # `start_after`, this will overwrite it.
        #
        # The values are associated with the field paths that have been provided
        # to `order`, and must match the same sort order. An ArgumentError will
        # be raised if more explicit values are given than are present in
        # `order`.
        #
        # @param [DocumentSnapshot, Object, Array<Object>] values The field
        #   values to start the query after.
        #
        # @return [Query] New query with `start_after` called on it.
        #
        # @example Starting a query after a document reference id
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .start_after(nyc_doc_id)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query after a document reference object
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #   nyc_ref = cities_col.doc nyc_doc_id
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .start_after(nyc_ref)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query after multiple explicit values
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .start_after(1000000, "New York City")
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Starting a query after a DocumentSnapshot
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Get a document snapshot
        #   nyc_snap = firestore.doc("cities/NYC").get
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .start_after(nyc_snap)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def start_after *values
          raise ArgumentError, "must provide values" if values.empty?

          if limit_type == :last
            raise "cannot call start_after after calling limit_to_last"
          end


          new_query = @query.dup
          new_query ||= StructuredQuery.new

          cursor = values_to_cursor values, new_query
          cursor.before = false
          new_query.start_at = cursor

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Ends query results before a set of field values. The field values can
        # be specified explicitly as arguments, or can be specified implicitly
        # by providing a {DocumentSnapshot} object instead. The result set will
        # not include the document specified by `values`.
        #
        # If the current query already has specified `end_before` or
        # `end_at`, this will overwrite it.
        #
        # The values are associated with the field paths that have been provided
        # to `order`, and must match the same sort order. An ArgumentError will
        # be raised if more explicit values are given than are present in
        # `order`.
        #
        # @param [DocumentSnapshot, Object, Array<Object>] values The field
        #   values to end the query before.
        #
        # @return [Query] New query with `end_before` called on it.
        #
        # @example Ending a query before a document reference id
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .end_before(nyc_doc_id)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query before a document reference object
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #   nyc_ref = cities_col.doc nyc_doc_id
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .end_before(nyc_ref)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query before multiple explicit values
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .end_before(1000000, "New York City")
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query before a DocumentSnapshot
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Get a document snapshot
        #   nyc_snap = firestore.doc("cities/NYC").get
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .end_before(nyc_snap)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def end_before *values
          raise ArgumentError, "must provide values" if values.empty?

          if limit_type == :last
            raise "cannot call end_before after calling limit_to_last"
          end


          new_query = @query.dup
          new_query ||= StructuredQuery.new

          cursor = values_to_cursor values, new_query
          cursor.before = true
          new_query.end_at = cursor

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Ends query results at a set of field values. The field values can
        # be specified explicitly as arguments, or can be specified implicitly
        # by providing a {DocumentSnapshot} object instead. The result set will
        # include the document specified by `values`.
        #
        # If the current query already has specified `end_before` or
        # `end_at`, this will overwrite it.
        #
        # The values are associated with the field paths that have been provided
        # to `order`, and must match the same sort order. An ArgumentError will
        # be raised if more explicit values are given than are present in
        # `order`.
        #
        # @param [DocumentSnapshot, Object, Array<Object>] values The field
        #   values to end the query at.
        #
        # @return [Query] New query with `end_at` called on it.
        #
        # @example Ending a query at a document reference id
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .end_at(nyc_doc_id)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query at a document reference object
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #   nyc_doc_id = "NYC"
        #   nyc_ref = cities_col.doc nyc_doc_id
        #
        #   # Create a query
        #   query = cities_col.order(firestore.document_id)
        #                     .end_at(nyc_ref)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query at multiple explicit values
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .end_at(1000000, "New York City")
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Ending a query at a DocumentSnapshot
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Get a document snapshot
        #   nyc_snap = firestore.doc("cities/NYC").get
        #
        #   # Create a query
        #   query = cities_col.order(:population, :desc)
        #                     .order(:name)
        #                     .end_at(nyc_snap)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def end_at *values
          raise ArgumentError, "must provide values" if values.empty?

          if limit_type == :last
            raise "cannot call end_at after calling limit_to_last"
          end


          new_query = @query.dup
          new_query ||= StructuredQuery.new

          cursor = values_to_cursor values, new_query
          cursor.before = false
          new_query.end_at = cursor

          Query.start new_query, parent_path, client, limit_type: limit_type
        end

        ##
        # Retrieves document snapshots for the query.
        #
        # @param [Time] read_time Reads documents as they were at the given time.
        #   This may not be older than 270 seconds. Optional
        #
        # @yield [documents] The block for accessing the document snapshots.
        # @yieldparam [DocumentSnapshot] document A document snapshot.
        #
        # @return [Enumerator<DocumentSnapshot>] A list of document snapshots.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.select(:population)
        #
        #   query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        # @example Get query with read time
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   cities_col = firestore.col "cities"
        #
        #   # Create a query
        #   query = cities_col.select(:population)
        #
        #   read_time = Time.now
        #
        #   query.get(read_time: read_time) do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def get read_time: nil
          ensure_service!

          return enum_for :get, read_time: read_time unless block_given?

          results = service.run_query parent_path, @query, read_time: read_time

          # Reverse the results for Query#limit_to_last queries since that method reversed the order_by directions.
          results = results.to_a.reverse if limit_type == :last

          results.each do |result|
            next if result.document.nil?
            yield DocumentSnapshot.from_query_result result, client
          end
        end
        alias run get

        ##
        # Creates an AggregateQuery object for the query.
        #
        # @return [AggregateQuery] New empty aggregate query.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Get a collection reference
        #   query = firestore.col "cities"
        #
        #   # Create an aggregate query
        #   aggregate_query = query.aggregate_query
        #
        def aggregate_query
          AggregateQuery.new self, parent_path, client
        end

        ##
        # Listen to this query for changes.
        #
        # @yield [callback] The block for accessing the query snapshot.
        # @yieldparam [QuerySnapshot] snapshot A query snapshot.
        #
        # @return [QueryListener] The ongoing listen operation on the query.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #
        #   # Create a query
        #   query = firestore.col(:cities).order(:population, :desc)
        #
        #   listener = query.listen do |snapshot|
        #     puts "The query snapshot has #{snapshot.docs.count} documents "
        #     puts "and has #{snapshot.changes.count} changes."
        #   end
        #
        #   # When ready, stop the listen operation and close the stream.
        #   listener.stop
        #
        def listen &callback
          raise ArgumentError, "callback required" if callback.nil?

          ensure_service!

          QueryListener.new(self, &callback).start
        end
        alias on_snapshot listen

        ##
        # Serializes the instance to a JSON text string. See also {Query.from_json}.
        #
        # @return [String] A JSON text string.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #   query = firestore.col(:cities).select(:population)
        #
        #   json = query.to_json
        #
        #   new_query = Google::Cloud::Firestore::Query.from_json json, firestore
        #
        #   new_query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def to_json options = nil
          query_json = Google::Cloud::Firestore::V1::StructuredQuery.encode_json query
          {
            "query" => JSON.parse(query_json),
            "parent_path" => parent_path,
            "limit_type" => limit_type
          }.to_json options
        end

        ##
        # Deserializes a JSON text string serialized from this class and returns it as a new instance. See also
        # {#to_json}.
        #
        # @param [String] json A JSON text string serialized using {#to_json}.
        # @param [Google::Cloud::Firestore::Client] client A connected client instance.
        #
        # @return [Query] A new query equal to the original query used to create the JSON text string.
        #
        # @example
        #   require "google/cloud/firestore"
        #
        #   firestore = Google::Cloud::Firestore.new
        #   query = firestore.col(:cities).select(:population)
        #
        #   json = query.to_json
        #
        #   new_query = Google::Cloud::Firestore::Query.from_json json, firestore
        #
        #   new_query.get do |city|
        #     puts "#{city.document_id} has #{city[:population]} residents."
        #   end
        #
        def self.from_json json, client
          raise ArgumentError, "client is required" unless client

          json = JSON.parse json
          query_json = json["query"]
          raise ArgumentError, "Field 'query' is required" unless query_json
          query = Google::Cloud::Firestore::V1::StructuredQuery.decode_json query_json.to_json
          start query, json["parent_path"], client, limit_type: json["limit_type"]&.to_sym
        end

        ##
        # @private Start a new Query.
        def self.start query, parent_path, client, limit_type: nil
          new query, parent_path, client, limit_type: limit_type
        end

        protected

        ##
        # @private
        StructuredQuery = Google::Cloud::Firestore::V1::StructuredQuery

        ##
        # @private
        INEQUALITY_FILTERS = [
          :LESS_THAN,
          :LESS_THAN_OR_EQUAL,
          :GREATER_THAN,
          :GREATER_THAN_OR_EQUAL
        ].freeze

        def value_nil? value
          [nil, :null, :nil].include? value
        end

        def value_nan? value
          # Comparing NaN values raises, so check for #nan? first.
          return true if value.respond_to?(:nan?) && value.nan?
          [:nan].include? value
        end

        def value_unary? value
          value_nil?(value) || value_nan?(value)
        end

        def composite_filter
          StructuredQuery::Filter.new(
            composite_filter: StructuredQuery::CompositeFilter.new(op: :AND)
          )
        end

        def add_filters_to_query query, filter
          if query.where.nil?
            query.where = filter
          elsif query.where.filter_type == :composite_filter
            query.where.composite_filter.filters << filter
          else
            old_filter = query.where
            query.where = composite_filter
            query.where.composite_filter.filters << old_filter
            query.where.composite_filter.filters << filter
          end
        end

        def order_direction direction
          return :DESCENDING if direction.to_s.downcase.start_with? "d"
          :ASCENDING
        end

        def query_has_cursors?
          query.start_at || query.end_at
        end

        def values_to_cursor values, query
          if values.count == 1 && values.first.is_a?(DocumentSnapshot)
            return snapshot_to_cursor values.first, query
          end

          # The *values param in start_at, start_after, etc. will wrap an array argument in an array, so unwrap it here.
          values = values.first if values.count == 1 && values.first.is_a?(Array)

          # pair values with their field_paths to ensure correct formatting
          order_field_paths = order_by_field_paths query
          if values.count > order_field_paths.count
            # raise if too many values provided for the cursor
            raise ArgumentError, "There cannot be more cursor values than order by fields"
          end

          values = values.zip(order_field_paths).map do |value, field_path|
            if field_path == doc_id_path && !value.is_a?(DocumentReference)
              value = document_reference value
            end
            Convert.raw_to_value value
          end

          Google::Cloud::Firestore::V1::Cursor.new values: values
        end

        def snapshot_to_cursor snapshot, query
          if snapshot.parent.path != query_collection_path
            raise ArgumentError, "cursor snapshot must belong to collection"
          end

          # first, add any inequality filters missing from existing order_by
          ensure_inequality_field_paths_in_order_by! query

          # second, make sure __name__ is present in order_by
          ensure_document_id_in_order_by! query

          # lastly, create cursor for all field_paths in order_by
          values = order_by_field_paths(query).map do |field_path|
            if field_path == doc_id_path
              snapshot.ref
            else
              snapshot[field_path]
            end
          end
          values_to_cursor values, query
        end

        def ensure_inequality_field_paths_in_order_by! query
          inequality_paths = inequality_filter_field_paths query
          orig_order = order_by_field_paths query

          inequality_paths.reverse_each do |field_path|
            next if orig_order.include? field_path

            query.order_by.unshift StructuredQuery::Order.new(
              field:     StructuredQuery::FieldReference.new(
                field_path: field_path
              ),
              direction: :ASCENDING
            )
          end
        end

        def ensure_document_id_in_order_by! query
          return if order_by_field_paths(query).include? doc_id_path

          query.order_by.push StructuredQuery::Order.new(
            field:     StructuredQuery::FieldReference.new(
              field_path: doc_id_path
            ),
            direction: last_order_direction(query)
          )
        end

        def inequality_filter_field_paths query
          return [] if query.where.nil?

          # The way we construct a query, where is always a CompositeFilter
          filters = if query.where.filter_type == :composite_filter
                      query.where.composite_filter.filters
                    else
                      [query.where]
                    end
          ineq_filters = filters.select do |filter|
            if filter.filter_type == :field_filter
              INEQUALITY_FILTERS.include? filter.field_filter.op
            end
          end
          ineq_filters.map { |filter| filter.field_filter.field.field_path }
        end

        def order_by_field_paths query
          query.order_by.map { |order_by| order_by.field.field_path }
        end

        def last_order_direction query
          last_order_by = query.order_by.last
          return :ASCENDING if last_order_by.nil?
          last_order_by.direction
        end

        def document_reference document_path
          if document_path.to_s.split("/").count.even?
            raise ArgumentError, "document_path must refer to a document"
          end

          DocumentReference.from_path(
            "#{query_collection_path}/#{document_path}", client
          )
        end

        def query_collection_path
          "#{parent_path}/#{query_collection_id}"
        end

        def query_collection_id
          # We trust that query.from is always set, since Query cannot be
          # created without it.
          return nil if query.from.empty?
          query.from.first.collection_id
        end

        def doc_id_path
          "__name__".freeze
        end

        ##
        # @private Raise an error unless an database available.
        def ensure_client!
          raise "Must have active connection to service" unless client
        end

        ##
        # @private The Service object.
        def service
          ensure_client!

          client.service
        end

        ##
        # @private Raise an error unless an active connection to the service
        # is available.
        def ensure_service!
          raise "Must have active connection to service" unless service
        end
      end
    end
  end
end