# 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
  class Server
    # Represents a description of the server, populated by the result of the
    # hello command.
    #
    # Note: Unknown servers do not have wire versions, but for legacy reasons
    # we return 0 for min_wire_version and max_wire_version of any server that does
    # not have them. Presently the driver sometimes constructs commands when the
    # server is unknown, so references to min_wire_version and max_wire_version
    # should not be nil. When driver behavior is changed
    # (https://jira.mongodb.org/browse/RUBY-1805), this may no longer be necessary.
    #
    # @since 2.0.0
    class Description
      # Constant for reading arbiter info from config.
      #
      # @since 2.0.0
      # @deprecated
      ARBITER = 'arbiterOnly'

      # Constant for reading arbiters info from config.
      #
      # @since 2.0.0
      ARBITERS = 'arbiters'

      # Constant for reading hidden info from config.
      #
      # @since 2.0.0
      HIDDEN = 'hidden'

      # Constant for reading hosts info from config.
      #
      # @since 2.0.0
      HOSTS = 'hosts'

      # Constant for the key for the message value.
      #
      # @since 2.0.0
      # @deprecated
      MESSAGE = 'msg'

      # Constant for the message that indicates a sharded cluster.
      #
      # @since 2.0.0
      # @deprecated
      MONGOS_MESSAGE = 'isdbgrid'

      # Constant for determining ghost servers.
      #
      # @since 2.0.0
      # @deprecated
      REPLICA_SET = 'isreplicaset'

      # Constant for reading max bson size info from config.
      #
      # @since 2.0.0
      MAX_BSON_OBJECT_SIZE = 'maxBsonObjectSize'

      # Constant for reading max message size info from config.
      #
      # @since 2.0.0
      MAX_MESSAGE_BYTES = 'maxMessageSizeBytes'

      # Constant for the max wire version.
      #
      # @since 2.0.0
      MAX_WIRE_VERSION = 'maxWireVersion'

      # Constant for min wire version.
      #
      # @since 2.0.0
      MIN_WIRE_VERSION = 'minWireVersion'

      # Constant for reading max write batch size.
      #
      # @since 2.0.0
      MAX_WRITE_BATCH_SIZE = 'maxWriteBatchSize'

      # Constant for the lastWrite subdocument.
      #
      # @since 2.4.0
      LAST_WRITE = 'lastWrite'

      # Constant for the lastWriteDate field in the lastWrite subdocument.
      #
      # @since 2.4.0
      LAST_WRITE_DATE = 'lastWriteDate'

      # Constant for reading the me field.
      #
      # @since 2.1.0
      ME = 'me'

      # Default max write batch size.
      #
      # @since 2.0.0
      DEFAULT_MAX_WRITE_BATCH_SIZE = 1000

      # The legacy wire protocol version.
      #
      # @since 2.0.0
      # @deprecated Will be removed in 3.0.
      LEGACY_WIRE_VERSION = 0

      # Constant for reading passive info from config.
      #
      # @since 2.0.0
      PASSIVE = 'passive'

      # Constant for reading the passive server list.
      #
      # @since 2.0.0
      PASSIVES = 'passives'

      # Constant for reading primary info from config.
      #
      # @since 2.0.0
      # @deprecated
      PRIMARY = 'ismaster'

      # Constant for reading primary host field from config.
      #
      # @since 2.5.0
      PRIMARY_HOST = 'primary'

      # Constant for reading secondary info from config.
      #
      # @since 2.0.0
      # @deprecated
      SECONDARY = 'secondary'

      # Constant for reading replica set name info from config.
      #
      # @since 2.0.0
      SET_NAME = 'setName'

      # Constant for reading tags info from config.
      #
      # @since 2.0.0
      TAGS = 'tags'

      # Constant for reading electionId info from config.
      #
      # @since 2.1.0
      ELECTION_ID = 'electionId'

      # Constant for reading setVersion info from config.
      #
      # @since 2.2.2
      SET_VERSION = 'setVersion'

      # Constant for reading localTime info from config.
      #
      # @since 2.1.0
      LOCAL_TIME = 'localTime'

      # Constant for reading operationTime info from config.
      #
      # @since 2.5.0
      OPERATION_TIME = 'operationTime'

      # Constant for reading logicalSessionTimeoutMinutes info from config.
      #
      # @since 2.5.0
      LOGICAL_SESSION_TIMEOUT_MINUTES = 'logicalSessionTimeoutMinutes'

      # Constant for reading connectionId info from config.
      #
      # @api private
      CONNECTION_ID = 'connectionId'

      # Constant for reading the modern primary flag from config.
      #
      # @api private
      IS_WRITABLE_PRIMARY = 'isWritablePrimary'

      # Constant for reading the helloOk capability flag from config.
      #
      # @api private
      HELLO_OK = 'helloOk'

      # Fields to exclude when comparing two descriptions.
      #
      # The PRIMARY (legacy `ismaster`), IS_WRITABLE_PRIMARY (modern `hello`),
      # and HELLO_OK keys are excluded because the driver does a one-time
      # legacy-`isMaster` to modern-`hello` protocol switch on the initial
      # handshake (per the SDAM Server Monitoring spec). Two responses for the
      # same logical role differ only in which of these keys is populated.
      # The role itself is still differentiated by the SECONDARY flag and the
      # remaining replica-set metadata.
      #
      # @since 2.0.6
      EXCLUDE_FOR_COMPARISON = [ LOCAL_TIME,
                                 LAST_WRITE,
                                 OPERATION_TIME,
                                 Operation::CLUSTER_TIME,
                                 CONNECTION_ID,
                                 PRIMARY,
                                 IS_WRITABLE_PRIMARY,
                                 HELLO_OK, ].freeze

      # Instantiate the new server description from the result of the hello
      # command or fabricate a placeholder description for Unknown and
      # LoadBalancer servers.
      #
      # @example Instantiate the new description.
      #   Description.new(address, { 'isWritablePrimary' => true }, 0.5)
      #
      # @param [ Address ] address The server address.
      # @param [ Hash ] config The result of the hello command.
      # @param [ Float ] average_round_trip_time The moving average time (sec) the hello
      #   command took to complete.
      # @param [ Float ] minimum_round_trip_time The minimum round trip time
      #   of ten last hello commands.
      # @param [ true | false ] load_balancer Whether the server is treated as
      #   a load balancer.
      # @param [ true | false ] force_load_balancer Whether the server is
      #   forced to be a load balancer.
      #
      # @api private
      def initialize(address, config = {}, average_round_trip_time: nil,
                     minimum_round_trip_time: 0, load_balancer: false,
                     force_load_balancer: false)
        @address = address
        @config = config
        @load_balancer = !!load_balancer
        @force_load_balancer = !!force_load_balancer
        @features = Features.new(wire_versions, me || @address.to_s)
        @average_round_trip_time = average_round_trip_time
        @minimum_round_trip_time = minimum_round_trip_time
        @last_update_time = Time.now.freeze
        @last_update_monotime = Utils.monotonic_time

        # When loadBalanced=true URI option is set, the driver will refuse
        # to work if the server it communicates with does not set serviceId
        # in ismaster/hello response.
        #
        # At the moment we cannot run a proper load balancer setup on evergreen
        #
        # Therefore, when connect=:load_balanced Ruby option is used instead
        # of the loadBalanced=true URI option, if serviceId is not set in
        # ismaster/hello response, the driver fabricates a serviceId and
        # proceeds to treat a server that does not report itself as being
        # behind a load balancer as a server that is behind a load balancer.
        #
        # 5.0+ servers should provide topologyVersion.processId which
        # is specific to the particular process instance. We can use that
        # field as a proxy for serviceId.
        #
        # If the topologyVersion isn't provided for whatever reason, we
        # fabricate a serviceId locally.
        #
        # In either case, a serviceId provided by an actual server behind
        # a load balancer is supposed to be a BSON::ObjectId. The fabricated
        # service ids are strings, to distinguish them from the real ones.
        # In particular processId is also a BSON::ObjectId, but will be
        # mapped to a string for clarity that this is a fake service id.
        #
        # TODO: Remove this when https://jira.mongodb.org/browse/RUBY-2881 is done.
        if load_balancer && ok? && !service_id
          unless force_load_balancer
            raise Error::MissingServiceId,
                  "The server at #{address.seed} did not provide a service id in handshake response"
          end

          fake_service_id = if (process_id = topology_version && topology_version['processId'])
                              "process:#{process_id}"
                            else
                              "fake:#{rand((2**32) - 1) + 1}"
                            end
          @config = @config.merge('serviceId' => fake_service_id)
        end

        return unless Mongo::Lint.enabled?

        # prepopulate cache instance variables
        hosts
        arbiters
        passives
        topology_version

        freeze
      end

      # @return [ Address ] address The server's address.
      attr_reader :address

      # @return [ Hash ] The actual result from the hello command.
      attr_reader :config

      # Returns whether this server is a load balancer.
      #
      # @return [ true | false ] Whether this server is a load balancer.
      def load_balancer?
        @load_balancer
      end

      # @return [ Features ] features The features for the server.
      attr_reader :features

      # @return [ Float ] The moving average time the hello call took to complete.
      attr_reader :average_round_trip_time

      # @return [ Float ] The minimum time that the ten last hello calls took
      #   to complete.
      attr_reader :minimum_round_trip_time

      # Returns whether this server is an arbiter, per the SDAM spec.
      #
      # @example Is the server an arbiter?
      #   description.arbiter?
      #
      # @return [ true, false ] If the server is an arbiter.
      #
      # @since 2.0.0
      def arbiter?
        ok? &&
          config['arbiterOnly'] == true &&
          !!config['setName']
      end

      # Get a list of all arbiters in the replica set.
      #
      # @example Get the arbiters in the replica set.
      #   description.arbiters
      #
      # @return [ Array<String> ] The arbiters in the set.
      #
      # @since 2.0.0
      def arbiters
        @arbiters ||= (config[ARBITERS] || []).map { |s| s.downcase }
      end

      # Whether this server is a ghost, per the SDAM spec.
      #
      # @example Is the server a ghost?
      #   description.ghost?
      #
      # @return [ true, false ] If the server is a ghost.
      #
      # @since 2.0.0
      def ghost?
        ok? &&
          config['isreplicaset'] == true
      end

      # Will return true if the server is hidden.
      #
      # @example Is the server hidden?
      #   description.hidden?
      #
      # @return [ true, false ] If the server is hidden.
      #
      # @since 2.0.0
      def hidden?
        ok? && !!config[HIDDEN]
      end

      # Get a list of all servers in the replica set.
      #
      # @example Get the servers in the replica set.
      #   description.hosts
      #
      # @return [ Array<String> ] The servers in the set.
      #
      # @since 2.0.0
      def hosts
        @hosts ||= (config[HOSTS] || []).map { |s| s.downcase }
      end

      # Inspect the server description.
      #
      # @example Inspect the server description
      #   description.inspect
      #
      # @return [ String ] The inspection.
      #
      # @since 2.0.0
      def inspect
        "#<Mongo::Server:Description:0x#{object_id} config=#{config} average_round_trip_time=#{average_round_trip_time}>"
      end

      # Get the max BSON object size for this server version.
      #
      # @example Get the max BSON object size.
      #   description.max_bson_object_size
      #
      # @return [ Integer ] The maximum object size in bytes.
      #
      # @since 2.0.0
      def max_bson_object_size
        config[MAX_BSON_OBJECT_SIZE]
      end

      # Get the max message size for this server version.
      #
      # @example Get the max message size.
      #   description.max_message_size
      #
      # @return [ Integer ] The maximum message size in bytes.
      #
      # @since 2.0.0
      def max_message_size
        config[MAX_MESSAGE_BYTES]
      end

      # Get the maximum batch size for writes.
      #
      # @example Get the max batch size.
      #   description.max_write_batch_size
      #
      # @return [ Integer ] The max batch size.
      #
      # @since 2.0.0
      def max_write_batch_size
        config[MAX_WRITE_BATCH_SIZE] || DEFAULT_MAX_WRITE_BATCH_SIZE
      end

      # Get the maximum wire version. Defaults to zero.
      #
      # @example Get the max wire version.
      #   description.max_wire_version
      #
      # @return [ Integer ] The max wire version supported.
      #
      # @since 2.0.0
      def max_wire_version
        config[MAX_WIRE_VERSION] || 0
      end

      # Get the minimum wire version. Defaults to zero.
      #
      # @example Get the min wire version.
      #   description.min_wire_version
      #
      # @return [ Integer ] The min wire version supported.
      #
      # @since 2.0.0
      def min_wire_version
        config[MIN_WIRE_VERSION] || 0
      end

      # Get the me field value.
      #
      # @note The value in me field may differ from the server description's
      #   address. This can happen, for example, in split horizon configurations.
      #   The SDAM spec only requires removing servers whose me does not match
      #   their address in some of the situations (e.g. when the server in
      #   question is an RS member but not a primary).
      #
      # @return [ String ] The me field.
      #
      # @since 2.1.0
      def me
        config[ME]
      end

      # Get the tags configured for the server.
      #
      # @example Get the tags.
      #   description.tags
      #
      # @return [ Hash ] The tags of the server.
      #
      # @since 2.0.0
      def tags
        config[TAGS] || {}
      end

      # Get the electionId from the config.
      #
      # @example Get the electionId.
      #   description.election_id
      #
      # @return [ BSON::ObjectId ] The election id.
      #
      # @since 2.1.0
      def election_id
        config[ELECTION_ID]
      end

      # Get the setVersion from the config.
      #
      # @example Get the setVersion.
      #   description.set_version
      #
      # @return [ Integer ] The set version.
      #
      # @since 2.2.2
      def set_version
        config[SET_VERSION]
      end

      # @return [ TopologyVersion | nil ] The topology version.
      def topology_version
        unless defined?(@topology_version)
          @topology_version = config['topologyVersion'] &&
                              TopologyVersion.new(config['topologyVersion'])
        end
        @topology_version
      end

      # Returns whether topology version in this description is potentially
      # newer than or equal to topology version in another description.
      #
      # @param [ Server::Description ] other_desc The other server description.
      #
      # @return [ true | false ] Whether topology version in this description
      #   is potentially newer or equal.
      # @api private
      def topology_version_gt?(other_desc)
        if topology_version.nil? || other_desc.topology_version.nil?
          true
        else
          topology_version.gt?(other_desc.topology_version)
        end
      end

      # Returns whether topology version in this description is potentially
      # newer than topology version in another description.
      #
      # @param [ Server::Description ] other_desc The other server description.
      #
      # @return [ true | false ] Whether topology version in this description
      #   is potentially newer.
      # @api private
      def topology_version_gte?(other_desc)
        if topology_version.nil? || other_desc.topology_version.nil?
          true
        else
          topology_version.gte?(other_desc.topology_version)
        end
      end

      # Get the lastWriteDate from the lastWrite subdocument in the config.
      #
      # @example Get the lastWriteDate value.
      #   description.last_write_date
      #
      # @return [ Time ] The last write date.
      #
      # @since 2.4.0
      def last_write_date
        config[LAST_WRITE][LAST_WRITE_DATE] if config[LAST_WRITE]
      end

      # Get the logicalSessionTimeoutMinutes from the config.
      #
      # @example Get the logicalSessionTimeoutMinutes value in minutes.
      #   description.logical_session_timeout
      #
      # @return [ Integer, nil ] The logical session timeout in minutes.
      #
      # @since 2.5.0
      def logical_session_timeout
        config[LOGICAL_SESSION_TIMEOUT_MINUTES] if config[LOGICAL_SESSION_TIMEOUT_MINUTES]
      end

      # Returns whether this server is a mongos, per the SDAM spec.
      #
      # @example Is the server a mongos?
      #   description.mongos?
      #
      # @return [ true, false ] If the server is a mongos.
      #
      # @since 2.0.0
      def mongos?
        ok? && config['msg'] == 'isdbgrid'
      end

      # Returns whether the server is an other, per the SDAM spec.
      #
      # @example Is the description of type other.
      #   description.other?
      #
      # @return [ true, false ] If the description is other.
      #
      # @since 2.0.0
      def other?
        # The SDAM spec is slightly confusing on what "other" means,
        # but it's referred to it as "RSOther" which means a non-RS member
        # cannot be "other".
        ok? &&
          !!config['setName'] && (
          config['hidden'] == true ||
          (!primary? && !secondary? && !arbiter?)
        )
      end

      # Will return true if the server is passive.
      #
      # @example Is the server passive?
      #   description.passive?
      #
      # @return [ true, false ] If the server is passive.
      #
      # @since 2.0.0
      def passive?
        ok? && !!config[PASSIVE]
      end

      # Get a list of the passive servers in the cluster.
      #
      # @example Get the passives.
      #   description.passives
      #
      # @return [ Array<String> ] The list of passives.
      #
      # @since 2.0.0
      def passives
        @passives ||= (config[PASSIVES] || []).map { |s| s.downcase }
      end

      # Get the address of the primary host.
      #
      # @example Get the address of the primary.
      #   description.primary_host
      #
      # @return [ String | nil ] The address of the primary.
      #
      # @since 2.6.0
      def primary_host
        config[PRIMARY_HOST] && config[PRIMARY_HOST].downcase
      end

      # Returns whether this server is a primary, per the SDAM spec.
      #
      # @example Is the server a primary?
      #   description.primary?
      #
      # @return [ true, false ] If the server is a primary.
      #
      # @since 2.0.0
      def primary?
        ok? &&
          (config['ismaster'] == true || config['isWritablePrimary'] == true) &&
          !!config['setName']
      end

      # Get the name of the replica set the server belongs to, returns nil if
      # none.
      #
      # @example Get the replica set name.
      #   description.replica_set_name
      #
      # @return [ String, nil ] The name of the replica set.
      #
      # @since 2.0.0
      def replica_set_name
        config[SET_NAME]
      end

      # Get a list of all servers known to the cluster.
      #
      # @example Get all servers.
      #   description.servers
      #
      # @return [ Array<String> ] The list of all servers.
      #
      # @since 2.0.0
      def servers
        hosts + arbiters + passives
      end

      # Returns whether this server is a secondary, per the SDAM spec.
      #
      # @example Is the server a secondary?
      #   description.secondary?
      #
      # @return [ true, false ] If the server is a secondary.
      #
      # @since 2.0.0
      def secondary?
        ok? &&
          config['secondary'] == true &&
          !!config['setName']
      end

      # Returns the server type as a symbol.
      #
      # @example Get the server type.
      #   description.server_type
      #
      # @return [ Symbol ] The server type.
      #
      # @since 2.4.0
      def server_type
        return :load_balancer if load_balancer?
        return :arbiter if arbiter?
        return :ghost if ghost?
        return :sharded if mongos?
        return :primary if primary?
        return :secondary if secondary?
        return :standalone if standalone?
        return :other if other?

        :unknown
      end

      # Returns whether this server is a standalone, per the SDAM spec.
      #
      # @example Is the server standalone?
      #   description.standalone?
      #
      # @return [ true, false ] If the server is standalone.
      #
      # @since 2.0.0
      def standalone?
        ok? &&
          config['msg'] != 'isdbgrid' &&
          config['setName'].nil? &&
          config['isreplicaset'] != true
      end

      # Returns whether this server is an unknown, per the SDAM spec.
      #
      # @example Is the server description unknown?
      #   description.unknown?
      #
      # @return [ true, false ] If the server description is unknown.
      #
      # @since 2.0.0
      def unknown?
        return false if load_balancer?

        config.empty? || config.keys == %w[topologyVersion] || !ok?
      end

      # @api private
      def ok?
        config[Operation::Result::OK] == 1
      end

      # Get the range of supported wire versions for the server.
      #
      # @example Get the wire version range.
      #   description.wire_versions
      #
      # @return [ Range ] The wire version range.
      #
      # @since 2.0.0
      def wire_versions
        min_wire_version..max_wire_version
      end

      # Is this description from the given server.
      #
      # @example Check if the description is from a given server.
      #   description.is_server?(server)
      #
      # @return [ true, false ] If the description is from the server.
      #
      # @since 2.0.6
      # @deprecated
      def is_server?(server)
        address == server.address
      end

      # Is a server included in this description's list of servers.
      #
      # @example Check if a server is in the description list of servers.
      #   description.lists_server?(server)
      #
      # @return [ true, false ] If a server is in the description's list
      #   of servers.
      #
      # @since 2.0.6
      # @deprecated
      def lists_server?(server)
        servers.include?(server.address.to_s)
      end

      # Does this description correspond to a replica set member.
      #
      # @example Check if the description is from a replica set member.
      #   description.replica_set_member?
      #
      # @return [ true, false ] If the description is from a replica set
      #   member.
      #
      # @since 2.0.6
      def replica_set_member?
        ok? && !(standalone? || mongos?)
      end

      # Whether this description is from a data-bearing server
      # (standalone, mongos, primary or secondary).
      #
      # @return [ true, false ] Whether the description is from a data-bearing
      #   server.
      #
      # @since 2.7.0
      def data_bearing?
        mongos? || primary? || secondary? || standalone?
      end

      # Check if there is a mismatch between the address host and the me field.
      #
      # @example Check if there is a mismatch.
      #   description.me_mismatch?
      #
      # @return [ true, false ] If there is a mismatch between the me field and the address host.
      #
      # @since 2.0.6
      def me_mismatch?
        !!(address.to_s.downcase != me.downcase if me)
      end

      # Whether this description is from a mongocryptd server.
      #
      # @return [ true, false ] Whether this description is from a mongocryptd
      #   server.
      def mongocryptd?
        ok? && config['iscryptd'] == true
      end

      # opTime in lastWrite subdocument of the hello response.
      #
      # @return [ BSON::Timestamp ] The timestamp.
      #
      # @since 2.7.0
      def op_time
        return unless config['lastWrite'] && config['lastWrite']['opTime']

        config['lastWrite']['opTime']['ts']
      end

      # Time when this server description was created.
      #
      # @note This time does not indicate when a successful server check
      # completed, because marking a server unknown updates its description
      # and last_update_time. Use Server#last_scan to find out when the server
      # was last successfully checked by its Monitor.
      #
      # @return [ Time ] Server description creation time.
      #
      # @since 2.7.0
      attr_reader :last_update_time

      # Time when this server description was created according to monotonic clock.
      #
      # @see Description::last_updated_time for more detail
      #
      # @return [ Float ] Server description creation monotonic time.
      #
      # @api private
      attr_reader :last_update_monotime

      # @api experimental
      def server_connection_id
        config['connectionId']
      end

      # @return [ nil | Object ] The service id, if any.
      #
      # @api experimental
      def service_id
        config['serviceId']
      end

      # Check equality of two descriptions.
      #
      # @example Check description equality.
      #   description == other
      #
      # @param [ Object ] other The other description.
      #
      # @return [ true, false ] Whether the objects are equal.
      #
      # @since 2.0.6
      def ==(other)
        return false if self.class != other.class
        return false if unknown? || other.unknown?
        return false if server_type != other.server_type

        (config.keys + other.config.keys).uniq.all? do |k|
          config[k] == other.config[k] || EXCLUDE_FOR_COMPARISON.include?(k)
        end
      end
      alias eql? ==

      # @api private
      SERVER_VERSION_WIRE_VERSION_MAP = {
        '8.2' => 27,
        '8.0' => 25,
        '7.0' => 21,
        '6.0' => 17,
        '5.2' => 15,
        '5.1' => 14,
        '5.0' => 12,
        '4.4' => 9,
        '4.2' => 8,
      }.freeze

      # @api private
      def server_version_gte?(version)
        if load_balancer?
          # If we are talking to a load balancer, there is no monitoring
          # and we don't know what server is behind the load balancer.
          # Assume everything is supported.
          # TODO remove this when RUBY-2220 is implemented.
          return true
        end

        required_wv = SERVER_VERSION_WIRE_VERSION_MAP[version]
        raise NotImplementedError, "Unexpected server version #{version.inspect}" unless required_wv

        required_wv <= max_wire_version
      end
    end
  end
end

require 'mongo/server/description/features'
require 'mongo/server/description/load_balancer'
