# frozen_string_literal: true

# Copyright (C) 2014-2020 MongoDB Inc.
#
# 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
#
#   http://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.

module Mongo
  module Auth
    # Base class for authenticators.
    #
    # Each authenticator is instantiated for authentication over a particular
    # connection.
    #
    # @api private
    class Base
      # @return [ Mongo::Auth::User ] The user to authenticate.
      attr_reader :user

      # @return [ Mongo::Connection ] The connection to authenticate over.
      attr_reader :connection

      # Initializes the authenticator.
      #
      # @param [ Auth::User ] user The user to authenticate.
      # @param [ Mongo::Connection ] connection The connection to authenticate
      #   over.
      def initialize(user, connection, **_opts)
        @user = user
        @connection = connection
      end

      def conversation
        @conversation ||= self.class.const_get(:Conversation).new(user, connection)
      end

      private

      # Performs a single-step conversation on the given connection.
      def converse_1_step(connection, conversation)
        msg = conversation.start(connection)
        dispatch_msg(connection, conversation, msg)
      end

      # Performs a two-step conversation on the given connection.
      #
      # The implementation is very similar to +converse_multi_step+, but
      # conversations using this method do not involve the server replying
      # with {done: true} to indicate the end of the conversation.
      def converse_2_step(connection, conversation)
        msg = conversation.start(connection)
        reply_document = dispatch_msg(connection, conversation, msg)
        msg = conversation.continue(reply_document, connection)
        dispatch_msg(connection, conversation, msg)
      end

      # Performs the variable-length SASL conversation on the given connection.
      #
      # @param [ Server::Connection ] connection The connection.
      # @param [ Auth::*::Conversation ] conversation The conversation.
      # @param [ BSON::Document | nil ] speculative_auth_result The
      #   value of speculativeAuthenticate field of hello response of
      #   the handshake on the specified connection.
      def converse_multi_step(connection, conversation,
                              speculative_auth_result: nil)
        # Although the SASL conversation in theory can have any number of
        # steps, all defined authentication methods have a predefined number
        # of steps, and therefore all of our authenticators have a fixed set
        # of methods that generate payloads with one method per step.
        # We support a maximum of 3 total exchanges (start, continue and
        # finalize) and in practice the first two exchanges always happen.
        if speculative_auth_result
          reply_document = speculative_auth_result
        else
          msg = conversation.start(connection)
          reply_document = dispatch_msg(connection, conversation, msg)
        end
        msg = conversation.continue(reply_document, connection)
        reply_document = dispatch_msg(connection, conversation, msg)
        conversation.process_continue_response(reply_document)
        unless reply_document[:done]
          msg = conversation.finalize(connection)
          reply_document = dispatch_msg(connection, conversation, msg)
        end
        unless reply_document[:done]
          raise Error::InvalidServerAuthResponse,
                'Server did not respond with {done: true} after finalizing the conversation'
        end
        reply_document
      end

      def dispatch_msg(connection, conversation, msg)
        context = Operation::Context.new(options: {
                                           server_api: connection.options[:server_api],
                                         })
        if (server_api = context.server_api)
          msg = msg.maybe_add_server_api(server_api)
        end
        reply = connection.dispatch([ msg ], context)
        reply_document = reply.documents.first
        validate_reply!(connection, conversation, reply_document)
        connection_global_id = (connection.global_id if connection.respond_to?(:global_id))
        result = Operation::Result.new(reply, connection.description, connection_global_id, context: context)
        connection.update_cluster_time(result)
        reply_document
      end

      # Checks whether reply is successful (i.e. has {ok: 1} set) and
      # raises Unauthorized if not.
      def validate_reply!(connection, _conversation, doc)
        return unless doc[:ok] != 1

        message = Error::Parser.build_message(
          code: doc[:code],
          code_name: doc[:codeName],
          message: doc[:errmsg]
        )

        raise Unauthorized.new(user,
                               used_mechanism: self.class.const_get(:MECHANISM),
                               message: message,
                               server: connection.server,
                               code: doc[:code])
      end
    end
  end
end
