# frozen_string_literal: true
# rubocop:todo all

module Mongoid

  # Object encapsulating logic for setting/getting a collection and database name
  # and a client with particular options to use when persisting models.
  class PersistenceContext
    extend Forwardable

    # Delegate the cluster method to the client.
    def_delegators :client, :cluster

    # Delegate the storage_options method to the object.
    def_delegators :@object, :storage_options

    # The options defining this persistence context.
    #
    # @return [ Hash ] The persistence context options.
    attr_reader :options

    # Extra options in addition to driver client options that determine the
    # persistence context.
    #
    # @return [ Array<Symbol> ] The list of extra options besides client options
    #   that determine the persistence context.
    EXTRA_OPTIONS = [ :client,
                      :collection,
                      :collection_options
                    ].freeze

    # The full list of valid persistence context options.
    #
    # @return [ Array<Symbol> ] The full list of options defining the persistence
    #   context.
    VALID_OPTIONS = ( Mongo::Client::VALID_OPTIONS + EXTRA_OPTIONS ).freeze

    # Initialize the persistence context object.
    #
    # @example Create a new persistence context.
    #   PersistenceContext.new(model, collection: 'other')
    #
    # @param [ Object ] object The class or model instance for which a persistence context
    #   should be created.
    # @param [ Hash ] opts The persistence context options.
    def initialize(object, opts = {})
      @object = object
      set_options!(opts)
    end

    # Returns a new persistence context that is consistent with the given
    # child document, inheriting most appropriate settings.
    #
    # @param [ Mongoid::Document | Class ] document the child document
    #
    # @return [ PersistenceContext ] the new persistence context
    #
    # @api private
    def for_child(document)
      if document.is_a?(Class)
        return self if document == (@object.is_a?(Class) ? @object : @object.class)
      elsif document.is_a?(Mongoid::Document)
        return self if document.class == (@object.is_a?(Class) ? @object : @object.class)
      else
        raise ArgumentError, 'must specify a class or a document instance'
      end

      PersistenceContext.new(document, options.merge(document.storage_options))
    end

    # Get the collection for this persistence context.
    #
    # @example Get the collection for this persistence context.
    #   context.collection
    #
    # @param [ Object ] parent The parent object whose collection name is used
    #   instead of this persistence context's collection name.
    #
    # @return [ Mongo::Collection ] The collection for this persistence
    #   context.
    def collection(parent = nil)
      parent ?
        parent.collection.with(client_options.except(:database, "database")) :
        client[collection_name.to_sym]
    end

    # Get the collection name for this persistence context.
    #
    # @example Get the collection name for this persistence context.
    #   context.collection_name
    #
    # @return [ String ] The collection name for this persistence
    #   context.
    def collection_name
      @collection_name ||= (__evaluate__(options[:collection] ||
                             storage_options[:collection]))
    end

    # Get the database name for this persistence context.
    #
    # @example Get the database name for this persistence context.
    #   context.database_name
    #
    # @return [ String ] The database name for this persistence
    #   context.
    def database_name
      __evaluate__(database_name_option) || client.database.name
    end

    # Get the client for this persistence context.
    #
    # @example Get the client for this persistence context.
    #   context.client
    #
    # @return [ Mongo::Client ] The client for this persistence
    #   context.
    def client
      @client ||= begin
        client = Clients.with_name(client_name)
        options = client_options

        if database_name_option
          client = client.use(database_name)
          options = options.except(:database, 'database')
        end

        client = client.with(options) unless options.empty?

        client
      end
    end

    # Get the client name for this persistence context.
    #
    # @example Get the client name for this persistence context.
    #   context.client_name
    #
    # @return [ Symbol ] The client name for this persistence
    #   context.
    def client_name
      @client_name ||= __evaluate__(options[:client]) ||
                         Threaded.client_override ||
                         __evaluate__(storage_options[:client])
    end

    # Determine if this persistence context is equal to another.
    #
    # @example Compare two persistence contexts.
    #   context == other_context
    #
    # @param [ Object ] other The object to be compared with this one.
    #
    # @return [ true | false ] Whether the two persistence contexts are equal.
    def ==(other)
      return false unless other.is_a?(PersistenceContext)
      options == other.options
    end

    # Whether the client of the context can be reused later, and therefore should
    # not be closed.
    #
    # If the persistence context is requested with :client option only, it means
    # that the context should use a client configured in mongoid.yml.
    # Such clients should not be closed when the context is cleared since they
    # will be reused later.
    #
    # @return [ true | false ] True if client can be reused, otherwise false.
    #
    # @api private
    def reusable_client?
      @options.keys == [:client]
    end

    # The subset of provided options that may be used as storage
    # options.
    #
    # @return [ Hash | nil ] the requested storage options, or nil if
    #   none were specified.
    #
    # @api private
    def requested_storage_options
      slice = @options.slice(*Mongoid::Clients::Validators::Storage::VALID_OPTIONS)
      slice.any? ? slice : nil
    end

    private

    def set_options!(opts)
      @options ||= opts.each.reduce({}) do |_options, (key, value)|
                     unless VALID_OPTIONS.include?(key.to_sym)
                       raise Errors::InvalidPersistenceOption.new(key.to_sym, VALID_OPTIONS)
                     end
                     value ? _options.merge!(key => value) : _options
                   end
    end

    def __evaluate__(name)
      return nil unless name
      name.respond_to?(:call) ? name.call.to_sym : name.to_sym
    end

    def client_options
      @client_options ||= begin
        opts = options.select do |k, v|
                              Mongo::Client::VALID_OPTIONS.include?(k.to_sym)
                            end
        if opts[:read].is_a?(Symbol)
          opts[:read] = {mode: opts[:read]}
        end
        opts
      end
    end

    def database_name_option
      @database_name_option ||= options[:database] ||
                                  Threaded.database_override ||
                                  storage_options[:database]
    end

    class << self

      # Set the persistence context for a particular class or model instance.
      #
      # If there already is a persistence context set, options in the existing
      # context are combined with options given to the set call.
      #
      # @example Set the persistence context for a class or model instance.
      #  PersistenceContext.set(model)
      #
      # @param [ Object ] object The class or model instance.
      # @param [ Hash | Mongoid::PersistenceContext ] options_or_context The persistence
      #   options or a persistence context object.
      #
      # @return [ Mongoid::PersistenceContext ] The persistence context for the object.
      def set(object, options_or_context)
        existing_context = get_context(object)
        existing_options = if existing_context
          existing_context.options
        else
          {}
        end
        if options_or_context.is_a?(PersistenceContext)
          options_or_context = options_or_context.options
        end
        new_options = existing_options.merge(options_or_context)
        context = PersistenceContext.new(object, new_options)
        store_context(object, context)
      end

      # Get the persistence context for a particular class or model instance.
      #
      # @example Get the persistence context for a class or model instance.
      #  PersistenceContext.get(model)
      #
      # @param [ Object ] object The class or model instance.
      #
      # @return [ Mongoid::PersistenceContext ] The persistence context for the object.
      def get(object)
        get_context(object)
      end

      # Clear the persistence context for a particular class or model instance.
      #
      # @example Clear the persistence context for a class or model instance.
      #  PersistenceContext.clear(model)
      #
      # @param [ Class | Object ] object The class or model instance.
      # @param [ Mongo::Cluster ] cluster The original cluster before this context was used.
      # @param [ Mongoid::PersistenceContext ] original_context The original persistence
      #   context that was set before this context was used.
      def clear(object, cluster = nil, original_context = nil)
        if context = get(object)
          unless cluster.nil? || context.cluster.equal?(cluster)
            context.client.close unless context.reusable_client?
          end
        end
      ensure
        store_context(object, original_context)
      end

      private

      # Key to store persistence contexts in the thread local storage.
      #
      # @api private
      PERSISTENCE_CONTEXT_KEY = :"[mongoid]:persistence_context"

      def context_store
        Threaded.get(PERSISTENCE_CONTEXT_KEY) { {} }
      end

      # Get the persistence context for a given object from the thread local
      #   storage.
      #
      # @param [ Object ] object Object to get the persistence context for.
      #
      # @return [ Mongoid::PersistenceContext | nil ] The persistence context
      #   for the object if previously stored, otherwise nil.
      #
      # @api private
      def get_context(object)
        context_store[object.object_id]
      end

      # Store persistence context for a given object in the thread local
      #   storage.
      #
      # @param [ Object ] object Object to store the persistence context for.
      # @param [ Mongoid::PersistenceContext ] context Context to store
      #
      # @api private
      def store_context(object, context)
        if context.nil?
          context_store.delete(object.object_id)
        else
          context_store[object.object_id] = context
        end
      end
    end
  end
end
