# frozen_string_literal: true

# 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
  module Crypt
    # An AutoEncrypter is an object that encapsulates the behavior of
    # automatic encryption. It controls all resources associated with
    # auto-encryption, including the libmongocrypt handle, key vault client
    # object, mongocryptd client object, and encryption I/O.
    #
    # The AutoEncrypter is kept as an instance on a Mongo::Client. Client
    # objects with the same auto_encryption_options Hash may share
    # AutoEncrypters.
    #
    # @api private
    class AutoEncrypter
      attr_reader :mongocryptd_client, :key_vault_client, :metadata_client, :options

      # A Hash of default values for the :extra_options option
      DEFAULT_EXTRA_OPTIONS = Options::Redacted.new({
                                                      mongocryptd_uri: 'mongodb://localhost:27020',
                                                      mongocryptd_bypass_spawn: false,
                                                      mongocryptd_spawn_path: 'mongocryptd',
                                                      mongocryptd_spawn_args: [ '--idleShutdownTimeoutSecs=60' ],
                                                    })

      # Set up encryption-related options and instance variables
      # on the class that includes this module. Calls the same method
      # on the Mongo::Crypt::Encrypter module.
      #
      # @param [ Hash ] options
      #
      # @option options [ Mongo::Client ] :client A client connected to the
      #   encrypted collection.
      # @option options [ Mongo::Client | nil ] :key_vault_client A client connected
      #   to the MongoDB instance containing the encryption key vault; optional.
      #   If not provided, will default to :client option.
      # @option options [ String ] :key_vault_namespace The namespace of the key
      #   vault in the format database.collection.
      # @option options [ Hash | nil ] :schema_map The JSONSchema of the collection(s)
      #   with encrypted fields. This option is mutually exclusive with :schema_map_path.
      # @option options [ String | nil ] :schema_map_path A path to a file contains the JSON schema
      #   of the collection that stores auto encrypted documents. This option is
      #   mutually exclusive with :schema_map.
      # @option options [ Boolean | nil ] :bypass_auto_encryption When true, disables
      #   auto-encryption. Default is false.
      # @option options [ Hash | nil ] :extra_options Options related to spawning
      #   mongocryptd. These are set to default values if no option is passed in.
      # @option options [ Hash ] :kms_providers A hash of key management service
      #   configuration information.
      #   @see Mongo::Crypt::KMS::Credentials for list of options for every
      #   supported provider.
      #   @note There may be more than one KMS provider specified.
      # @option options [ Hash ] :kms_tls_options TLS options to connect to KMS
      #   providers. Keys of the hash should be KSM provider names; values
      #   should be hashes of TLS connection options. The options are equivalent
      #   to TLS connection options of Mongo::Client.
      #   @see Mongo::Client#initialize for list of TLS options.
      # @option options [ Hash | nil ] :encrypted_fields_map maps a collection
      #   namespace to an encryptedFields.
      #   - Note: If a collection is present on both the encryptedFieldsMap
      #     and schemaMap, an error will be raised.
      # @option options [ Boolean | nil ] :bypass_query_analysis When true
      #   disables automatic analysis of outgoing commands.
      # @option options [ String | nil ] :crypt_shared_lib_path Path that should
      #   be  the used to load the crypt shared library. Providing this option
      #   overrides default crypt shared library load paths for libmongocrypt.
      # @option options [ Boolean | nil ] :crypt_shared_lib_required Whether
      #   crypt shared library is required. If 'true', an error will be raised
      #   if a crypt_shared library cannot be loaded by libmongocrypt.
      #
      # @raise [ ArgumentError ] If required options are missing or incorrectly
      #   formatted.
      def initialize(options)
        Crypt.validate_ffi!
        # Note that this call may eventually, via other method invocations,
        # create additional clients which have to be cleaned up.
        @options = set_default_options(options).freeze

        @crypt_handle = Crypt::Handle.new(
          Crypt::KMS::Credentials.new(@options[:kms_providers]),
          Crypt::KMS::Validations.validate_tls_options(@options[:kms_tls_options]),
          schema_map: @options[:schema_map],
          schema_map_path: @options[:schema_map_path],
          encrypted_fields_map: @options[:encrypted_fields_map],
          bypass_query_analysis: @options[:bypass_query_analysis],
          crypt_shared_lib_path: @options[:extra_options][:crypt_shared_lib_path],
          crypt_shared_lib_required: @options[:extra_options][:crypt_shared_lib_required],
          disable_crypt_shared_lib_search: @options[:extra_options][:disable_crypt_shared_lib_search]
        )

        @mongocryptd_options = @options[:extra_options].slice(
          :mongocryptd_uri,
          :mongocryptd_bypass_spawn,
          :mongocryptd_spawn_path,
          :mongocryptd_spawn_args
        )
        @mongocryptd_options[:mongocryptd_bypass_spawn] = @options[:bypass_auto_encryption] ||
                                                          @options[:extra_options][:mongocryptd_bypass_spawn] ||
                                                          @crypt_handle.crypt_shared_lib_available? ||
                                                          @options[:extra_options][:crypt_shared_lib_required]

        unless @options[:extra_options][:crypt_shared_lib_required] || @crypt_handle.crypt_shared_lib_available? || @options[:bypass_query_analysis]
          @mongocryptd_client = Client.new(
            @options[:extra_options][:mongocryptd_uri],
            monitoring_io: @options[:client].options[:monitoring_io],
            populator_io: @options[:client].options[:populator_io],
            server_selection_timeout: 10,
            database: @options[:client].options[:database]
          )
        end

        begin
          @encryption_io = EncryptionIO.new(
            client: @options[:client],
            mongocryptd_client: @mongocryptd_client,
            key_vault_namespace: @options[:key_vault_namespace],
            key_vault_client: @key_vault_client,
            metadata_client: @metadata_client,
            mongocryptd_options: @mongocryptd_options
          )
        rescue StandardError
          begin
            @mongocryptd_client&.close
          rescue StandardError => e
            log_warn("Error closing mongocryptd client in auto encrypter's constructor: #{e.class}: #{e}")
            # Drop this exception so that the original exception is raised
          end
          raise
        end
      rescue StandardError
        if @key_vault_client && @key_vault_client != options[:client] &&
           @key_vault_client.cluster != options[:client].cluster
          begin
            @key_vault_client.close
          rescue StandardError => e
            log_warn("Error closing key vault client in auto encrypter's constructor: #{e.class}: #{e}")
            # Drop this exception so that the original exception is raised
          end
        end

        if @metadata_client && @metadata_client != options[:client] &&
           @metadata_client.cluster != options[:client].cluster
          begin
            @metadata_client.close
          rescue StandardError => e
            log_warn("Error closing metadata client in auto encrypter's constructor: #{e.class}: #{e}")
            # Drop this exception so that the original exception is raised
          end
        end

        raise
      end

      # Whether this encrypter should perform encryption (returns false if
      # the :bypass_auto_encryption option is set to true).
      #
      # @return [ Boolean ] Whether to perform encryption.
      def encrypt?
        !@options[:bypass_auto_encryption]
      end

      # Encrypt a database command.
      #
      # @param [ String ] database_name The name of the database on which the
      #   command is being run.
      # @param [ Hash ] command The command to be encrypted.
      #
      # @return [ BSON::Document ] The encrypted command.
      def encrypt(database_name, command, timeout_holder)
        AutoEncryptionContext.new(
          @crypt_handle,
          @encryption_io,
          database_name,
          command
        ).run_state_machine(timeout_holder)
      end

      # Decrypt a database command.
      #
      # @param [ Hash ] command The command with encrypted fields.
      #
      # @return [ BSON::Document ] The decrypted command.
      def decrypt(command, timeout_holder)
        AutoDecryptionContext.new(
          @crypt_handle,
          @encryption_io,
          command
        ).run_state_machine(timeout_holder)
      end

      # Close the resources created by the AutoEncrypter.
      #
      # @return [ true ] Always true.
      def close
        @mongocryptd_client.close if @mongocryptd_client

        if @key_vault_client && @key_vault_client != options[:client] &&
           @key_vault_client.cluster != options[:client].cluster
          @key_vault_client.close
        end

        if @metadata_client && @metadata_client != options[:client] &&
           @metadata_client.cluster != options[:client].cluster
          @metadata_client.close
        end

        true
      end

      private

      # Returns a new set of options with the following changes:
      # - sets default values for all extra_options
      # - adds --idleShutdownTimeoutSecs=60 to extra_options[:mongocryptd_spawn_args]
      #   if not already present
      # - sets bypass_auto_encryption to false
      # - sets default key vault client
      def set_default_options(options)
        opts = options.dup

        extra_options = opts.delete(:extra_options) || Options::Redacted.new
        extra_options = DEFAULT_EXTRA_OPTIONS.merge(extra_options)

        has_timeout_string_arg = extra_options[:mongocryptd_spawn_args].any? do |elem|
          elem.is_a?(String) && elem.match(/\A--idleShutdownTimeoutSecs=\d+\z/)
        end

        timeout_int_arg_idx = extra_options[:mongocryptd_spawn_args].index('--idleShutdownTimeoutSecs')
        has_timeout_int_arg = timeout_int_arg_idx && extra_options[:mongocryptd_spawn_args][timeout_int_arg_idx + 1].is_a?(Integer)

        unless has_timeout_string_arg || has_timeout_int_arg
          extra_options[:mongocryptd_spawn_args] << '--idleShutdownTimeoutSecs=60'
        end

        opts[:bypass_auto_encryption] ||= false
        set_or_create_clients(opts)
        opts[:key_vault_client] = @key_vault_client

        Options::Redacted.new(opts).merge(extra_options: extra_options)
      end

      # Create additional clients for auto encryption, if necessary
      #
      # @param [ Hash ] options Auto encryption options.
      def set_or_create_clients(options)
        client = options[:client]
        @key_vault_client = if options[:key_vault_client]
                              options[:key_vault_client]
                            elsif client.options[:max_pool_size] == 0
                              client
                            else
                              internal_client(client)
                            end

        @metadata_client = if options[:bypass_auto_encryption]
                             nil
                           elsif client.options[:max_pool_size] == 0
                             client
                           else
                             internal_client(client)
                           end
      end

      # Creates or return already created internal client to be used for
      # auto encryption.
      #
      # @param [ Mongo::Client ] client  A client connected to the
      #   encrypted collection.
      #
      # @return [ Mongo::Client ] Client to be used as internal client for
      # auto encryption.
      def internal_client(client)
        @internal_client ||= client.with(
          auto_encryption_options: nil,
          min_pool_size: 0,
          monitoring: client.monitoring
        )
      end
    end
  end
end
