# frozen_string_literal: true

module Mongo
  module SearchIndex
    # A class representing a view of search indexes.
    class View
      extend Forwardable
      include Enumerable
      include Retryable
      include Collection::Helpers

      # @return [ Mongo::Collection ] the collection this view belongs to
      attr_reader :collection

      # @return [ nil | String ] the index id to query
      attr_reader :requested_index_id

      # @return [ nil | String ] the index name to query
      attr_reader :requested_index_name

      # @return [ Hash ] the options hash to use for the aggregate command
      #   when querying the available indexes.
      attr_reader :aggregate_options

      def_delegators :@collection, :tracer

      # Create the new search index view.
      #
      # @param [ Collection ] collection The collection.
      # @param [ Hash ] options The options that configure the behavior of the view.
      #
      # @option options [ String ] :id The specific index id to query (optional)
      # @option options [ String ] :name The name of the specific index to query (optional)
      # @option options [ Hash ] :aggregate The options hash to send to the
      #   aggregate command when querying the available indexes.
      def initialize(collection, options = {})
        @collection = collection
        @requested_index_id = options[:id]
        @requested_index_name = options[:name]
        @aggregate_options = options[:aggregate] || {}

        return if @aggregate_options.is_a?(Hash)

        raise ArgumentError, "The :aggregate option must be a Hash (got a #{@aggregate_options.class})"
      end

      # Create a single search index with the given definition. If the name is
      # provided, the new index will be given that name.
      #
      # @param [ Hash ] definition The definition of the search index.
      # @param [ nil | String ] name The name to give the new search index.
      # @param [ String ] type The type of the search index. Possible values
      #   are 'search' and 'vectorSearch'. The default is 'search'.
      #
      # @return [ String ] the name of the new search index.
      def create_one(definition, name: nil, type: 'search')
        spec = { definition: definition, type: type }.tap do |sp|
          sp[:name] = name unless name.nil?
        end
        create_many([ spec ]).first
      end

      # Create multiple search indexes with a single command.
      #
      # @param [ Array<Hash> ] indexes The description of the indexes to
      #   create. Each element of the list must be a hash with a definition
      #   key, an optional name key, and an optional type key. The type key
      #   must be one of 'search' or 'vectorSearch'. The default is 'search'.
      #
      # @return [ Array<String> ] the names of the new search indexes.
      def create_many(indexes)
        spec = spec_with(indexes: indexes.map { |v| validate_search_index!(v) })
        operation = Operation::CreateSearchIndexes.new(spec)
        context = execution_context
        tracer.trace_operation(operation, context, op_name: 'createSearchIndexes') do
          result = operation.execute(next_primary, context: context)
          result.first['indexesCreated'].map { |idx| idx['name'] }
        end
      end

      # Drop the search index with the given id, or name. One or the other must
      # be specified, but not both.
      #
      # @param [ String ] id the id of the index to drop
      # @param [ String ] name the name of the index to drop
      #
      # @return [ Mongo::Operation::Result | false ] the result of the
      #    operation, or false if the given index does not exist.
      def drop_one(id: nil, name: nil)
        validate_id_or_name!(id, name)

        spec = spec_with(index_id: id, index_name: name)
        op = Operation::DropSearchIndex.new(spec)
        context = execution_context

        tracer.trace_operation(op, context, op_name: 'dropSearchIndex') do
          # per the spec:
          # Drivers MUST suppress NamespaceNotFound errors for the
          # ``dropSearchIndex`` helper.  Drop operations should be idempotent.
          do_drop(op, nil, context)
        end
      end

      # Iterate over the search indexes.
      #
      # @param [ Proc ] block if given, each search index will be yielded to
      #    the block.
      #
      # @return [ self | Enumerator ] if a block is given, self is returned.
      #    Otherwise, an enumerator will be returned.
      def each(&block)
        @result ||= begin
          spec = {}.tap do |s|
            s[:id] = requested_index_id if requested_index_id
            s[:name] = requested_index_name if requested_index_name
          end

          collection.with(read_concern: {}).aggregate(
            [ { '$listSearchIndexes' => spec } ],
            aggregate_options
          )
        end

        return @result.to_enum unless block

        @result.each(&block)
        self
      end

      # Update the search index with the given id or name. One or the other
      # must be provided, but not both.
      #
      # @param [ Hash ] definition the definition to replace the given search
      #    index with.
      # @param [ nil | String ] id the id of the search index to update
      # @param [ nil | String ] name the name of the search index to update
      #
      # @return [ Mongo::Operation::Result ] the result of the operation
      def update_one(definition, id: nil, name: nil)
        validate_id_or_name!(id, name)

        spec = spec_with(index_id: id, index_name: name, index: definition)
        op = Operation::UpdateSearchIndex.new(spec)
        context = execution_context
        tracer.trace_operation(op, context, op_name: 'updateSearchIndex') do
          op.execute(next_primary, context: context)
        end
      end

      # The following methods are to make the view act more like an array,
      # without having to explicitly make it an array...

      # Queries whether the search index enumerable is empty.
      #
      # @return [ true | false ] whether the enumerable is empty or not.
      def empty?
        count.zero?
      end

      private

      # A helper method for building the specification document with certain
      # values pre-populated.
      #
      # @param [ Hash ] extras the values to put into the specification
      #
      # @return [ Hash ] the specification document
      def spec_with(extras)
        {
          coll_name: collection.name,
          db_name: collection.database.name,
        }.merge(extras)
      end

      # A helper method for retrieving the primary server from the cluster.
      #
      # @return [ Mongo::Server ] the server to use
      def next_primary(ping = nil, session = nil)
        collection.cluster.next_primary(ping, session)
      end

      # A helper method for constructing a new operation context for executing
      # an operation.
      #
      # @return [ Mongo::Operation::Context ] the operation context
      def execution_context
        Operation::Context.new(client: collection.client)
      end

      # Validates the given id and name, ensuring that exactly one of them
      # is non-nil.
      #
      # @param [ nil | String ] id the id to validate
      # @param [ nil | String ] name the name to validate
      #
      # @raise [ ArgumentError ] if neither or both arguments are nil
      def validate_id_or_name!(id, name)
        return unless (id.nil? && name.nil?) || (!id.nil? && !name.nil?)

        raise ArgumentError, 'exactly one of id or name must be specified'
      end

      # Validates the given search index document, ensuring that it has no
      # extra keys, and that the name and definition are valid.
      #
      # @param [ Hash ] doc the document to validate
      #
      # @raise [ ArgumentError ] if the document is invalid.
      def validate_search_index!(doc)
        validate_search_index_keys!(doc.keys)
        validate_search_index_name!(doc[:name] || doc['name'])
        validate_search_index_definition!(doc[:definition] || doc['definition'])
        doc
      end

      # Validates the keys of a search index document, ensuring that
      # they are all valid.
      #
      # @param [ Array<String | Hash> ] keys the keys of a search index document
      #
      # @raise [ ArgumentError ] if the list contains any invalid keys
      def validate_search_index_keys!(keys)
        extras = keys - [ 'name', 'definition', 'type', :name, :definition, :type ]

        raise ArgumentError, "invalid keys in search index creation: #{extras.inspect}" if extras.any?
      end

      # Validates the name of a search index, ensuring that it is either a
      # String or nil.
      #
      # @param [ nil | String ] name the name of a search index
      #
      # @raise [ ArgumentError ] if the name is not valid
      def validate_search_index_name!(name)
        return if name.nil? || name.is_a?(String)

        raise ArgumentError, "search index name must be nil or a string (got #{name.inspect})"
      end

      # Validates the definition of a search index.
      #
      # @param [ Hash ] definition the definition of a search index
      #
      # @raise [ ArgumentError ] if the definition is not valid
      def validate_search_index_definition!(definition)
        return if definition.is_a?(Hash)

        raise ArgumentError, "search index definition must be a Hash (got #{definition.inspect})"
      end
    end
  end
end
