# frozen_string_literal: true
# rubocop:todo all

# Copyright (C) 2019-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
  class Server

    # This class encapsulates connections during handshake and authentication.
    #
    # @api private
    class PendingConnection < ConnectionBase
      extend Forwardable

      def initialize(socket, server, monitoring, options = {})
        @socket = socket
        @options = options
        @server = server
        @monitoring = monitoring
        @id = options[:id]
      end

      # @return [ Integer ] The ID for the connection. This is the same ID
      #   as that of the regular Connection object for which this
      #   PendingConnection instance was created.
      attr_reader :id

      def handshake_and_authenticate!
        speculative_auth_doc = nil
        if options[:user] || options[:auth_mech]
          # To create an Auth instance, we need to specify the mechanism,
          # but at this point we don't know the mechanism that ultimately
          # will be used (since this depends on the data returned by
          # the handshake, specifically server version).
          # However, we know that only 4.4+ servers support speculative
          # authentication, and those servers also generally support
          # SCRAM-SHA-256. We expect that user accounts created for 4.4+
          # servers would generally allow SCRAM-SHA-256 authentication;
          # user accounts migrated from pre-4.4 servers may only allow
          # SCRAM-SHA-1. The use of SCRAM-SHA-256 by default is thus
          # sensible, and it is also mandated by the speculative auth spec.
          # If no mechanism was specified and we are talking to a 3.0+
          # server, we'll send speculative auth document, the server will
          # ignore it and we'll perform authentication using explicit
          # command after having defaulted the mechanism later to CR.
          # If no mechanism was specified and we are talking to a 4.4+
          # server and the user account doesn't allow SCRAM-SHA-256, we will
          # authenticate in a separate command with SCRAM-SHA-1 after
          # going through SCRAM mechanism negotiation.
          default_options = Options::Redacted.new(:auth_mech => :scram256)
          speculative_auth_user = Auth::User.new(default_options.merge(options))
          speculative_auth = Auth.get(speculative_auth_user, self)
          speculative_auth_doc = speculative_auth.conversation.speculative_auth_document
        end

        result = handshake!(speculative_auth_doc: speculative_auth_doc)

        if description.unknown?
          raise Error::InternalDriverError, "Connection description cannot be unknown after successful handshake: #{description.inspect}"
        end

        begin
          if speculative_auth_doc && (speculative_auth_result = result['speculativeAuthenticate'])
            unless description.features.scram_sha_1_enabled?
              raise Error::InvalidServerAuthResponse, "Speculative auth succeeded on a pre-3.0 server"
            end
            case speculative_auth_user.mechanism
            when :mongodb_x509
              # Done
            # We default auth mechanism to scram256, but if user specified
            # scram explicitly we may be able to authenticate speculatively
            # with scram.
            when :scram, :scram256
              authenticate!(
                speculative_auth_client_nonce: speculative_auth.conversation.client_nonce,
                speculative_auth_mech: speculative_auth_user.mechanism,
                speculative_auth_result: speculative_auth_result,
              )
            else
              raise Error::InternalDriverError, "Speculative auth unexpectedly succeeded for mechanism #{speculative_auth_user.mechanism.inspect}"
            end
          elsif !description.arbiter?
            authenticate!
          end
        rescue Mongo::Error, Mongo::Error::AuthError => exc
          exc.service_id = service_id
          raise
        end

        if description.unknown?
          raise Error::InternalDriverError, "Connection description cannot be unknown after successful authentication: #{description.inspect}"
        end

        if server.load_balancer? && !description.mongos?
          raise Error::BadLoadBalancerTarget, "Load-balanced operation requires being connected a mongos, but the server at #{address.seed} reported itself as #{description.server_type.to_s.gsub('_', ' ')}"
        end
      end

      private

      # Sends the hello command to the server, then receive and deserialize
      # the response.
      #
      # This method is extracted to be mocked in the tests.
      #
      # @param [ Protocol::Message ] Command that should be sent to a server
      #   for handshake purposes.
      #
      # @return [ Mongo::Protocol::Reply ] Deserialized server response.
      def get_handshake_response(hello_command)
        @server.round_trip_time_calculator.measure do
          add_server_diagnostics do
            socket.write(hello_command.serialize.to_s)
            Protocol::Message.deserialize(socket, Protocol::Message::MAX_MESSAGE_SIZE)
          end
        end
      end

      # @param [ BSON::Document | nil ] speculative_auth_doc The document to
      #   provide in speculativeAuthenticate field of handshake command.
      #
      # @return [ BSON::Document ] The document of the handshake response for
      #   this particular connection.
      def handshake!(speculative_auth_doc: nil)
        unless socket
          raise Error::InternalDriverError, "Cannot handshake because there is no usable socket (for #{address})"
        end

        hello_command = handshake_command(
          handshake_document(
            app_metadata,
            speculative_auth_doc: speculative_auth_doc,
            load_balancer: server.load_balancer?,
            server_api: options[:server_api]
          )
        )
        doc = nil
        @server.handle_handshake_failure! do
          begin
            response = get_handshake_response(hello_command)
            result = Operation::Result.new([response])
            result.validate!
            doc = result.documents.first
          rescue => exc
            msg = "Failed to handshake with #{address}"
            Utils.warn_bg_exception(msg, exc,
              logger: options[:logger],
              log_prefix: options[:log_prefix],
              bg_error_backtrace: options[:bg_error_backtrace],
            )
            raise
          end
        end

        if @server.force_load_balancer?
          doc['serviceId'] ||= "fake:#{rand(2**32-1)+1}"
        end

        post_handshake(
          doc,
          @server.round_trip_time_calculator.average_round_trip_time,
          @server.round_trip_time_calculator.minimum_round_trip_time
        )

        doc
      end

      # @param [ String | nil ] speculative_auth_client_nonce The client
      #   nonce used in speculative auth on this connection that
      #   produced the specified speculative auth result.
      # @param [ Symbol | nil ] speculative_auth_mech Auth mechanism used
      #   for speculative auth, if speculative auth succeeded. If speculative
      #   auth was not performed or it failed, this must be nil.
      # @param [ BSON::Document | nil ] speculative_auth_result The
      #   value of speculativeAuthenticate field of hello response of
      #   the handshake on this connection.
      def authenticate!(
        speculative_auth_client_nonce: nil,
        speculative_auth_mech: nil,
        speculative_auth_result: nil
      )
        if options[:user] || options[:auth_mech]
          @server.handle_auth_failure! do
            begin
              auth = Auth.get(
                resolved_user(speculative_auth_mech: speculative_auth_mech),
                self,
                speculative_auth_client_nonce: speculative_auth_client_nonce,
                speculative_auth_result: speculative_auth_result,
              )
              auth.login
            rescue => exc
              msg = "Failed to authenticate to #{address}"
              Utils.warn_bg_exception(msg, exc,
                logger: options[:logger],
                log_prefix: options[:log_prefix],
                bg_error_backtrace: options[:bg_error_backtrace],
              )
              raise
            end
          end
        end
      end

      def ensure_connected
        yield @socket
      end

      # This is a separate method to keep the nesting level down.
      #
      # @return [ Server::Description ] The server description calculated from
      #   the handshake response for this particular connection.
      def post_handshake(response, average_rtt, minimum_rtt)
        if response["ok"] == 1
          # Auth mechanism is entirely dependent on the contents of
          # hello response *for this connection*.
          # Hello received by the monitoring connection should advertise
          # the same wire protocol, but if it doesn't, we use whatever
          # the monitoring connection advertised for filling out the
          # server description and whatever the non-monitoring connection
          # (that's this one) advertised for performing auth on that
          # connection.
          @sasl_supported_mechanisms = response['saslSupportedMechs']
          set_compressor!(response)
        else
          @sasl_supported_mechanisms = nil
        end

        @description = Description.new(
          address, response,
          average_round_trip_time: average_rtt,
          load_balancer: server.load_balancer?,
          force_load_balancer: options[:connect] == :load_balanced,
        ).tap do |new_description|
          @server.cluster.run_sdam_flow(@server.description, new_description)
        end
      end

      # The user as going to be used for authentication. This user has the
      # auth mechanism set and, if necessary, auth source.
      #
      # @param [ Symbol | nil ] speculative_auth_mech Auth mechanism used
      #   for speculative auth, if speculative auth succeeded. If speculative
      #   auth was not performed or it failed, this must be nil.
      #
      # @return [ Auth::User ] The resolved user.
      def resolved_user(speculative_auth_mech: nil)
        @resolved_user ||= begin
          unless options[:user] || options[:auth_mech]
            raise Mongo::Error, 'No authentication information specified in the client'
          end

          user_options = Options::Redacted.new(
            # When speculative auth is performed, we always use SCRAM-SHA-256.
            # At the same time we perform SCRAM mechanism negotiation in the
            # hello request.
            # If the credentials we are trying to authenticate with do not
            # map to an existing user, SCRAM mechanism negotiation will not
            # return anything which would cause the driver to use
            # SCRAM-SHA-1. However, on 4.4+ servers speculative auth would
            # succeed (technically just the first round-trip, not the entire
            # authentication flow) and we would be continuing it here;
            # in this case, we must use SCRAM-SHA-256 as the mechanism since
            # that is what the conversation was started with, even though
            # SCRAM mechanism negotiation did not return SCRAM-SHA-256 as a
            # valid mechanism to use for these credentials.
            :auth_mech => speculative_auth_mech || default_mechanism,
          ).merge(options)
          if user_options[:auth_mech] == :mongodb_x509
            user_options[:auth_source] = '$external'
          end
          Auth::User.new(user_options)
        end
      end

      def default_mechanism
        if description.nil?
          raise Mongo::Error, 'Trying to query default mechanism when handshake has not completed'
        end

        if description.features.scram_sha_1_enabled?
          if @sasl_supported_mechanisms&.include?('SCRAM-SHA-256')
            :scram256
          else
            :scram
          end
        else
          :mongodb_cr
        end
      end
    end
  end
end
