# frozen_string_literal: true

require 'mongoid/atomic/modifiers'
require 'mongoid/atomic/paths'

module Mongoid
  # This module contains the logic for supporting atomic operations against the
  # database.
  module Atomic
    extend ActiveSupport::Concern

    UPDATES = %i[
      atomic_array_pushes
      atomic_array_pulls
      atomic_array_add_to_sets
      atomic_pulls
      delayed_atomic_sets
      delayed_atomic_pulls
      delayed_atomic_unsets
    ].freeze

    included do
      # When MongoDB finally fully implements the positional operator, we can
      # get rid of all indexing related code in Mongoid.
      attr_accessor :_index
    end

    # Add the document as an atomic pull.
    #
    # @example Add the atomic pull.
    #   person.add_atomic_pull(address)
    #
    # @param [ Document ] document The embedded document to pull.
    def add_atomic_pull(document)
      document.flagged_for_destroy = true
      key = document.association_name.to_s
      delayed_atomic_pulls[key] ||= []
      delayed_atomic_pulls[key] << document
    end

    # Add an atomic unset for the document.
    #
    # @example Add an atomic unset.
    #   document.add_atomic_unset(doc)
    #
    # @param [ Document ] document The child document.
    #
    # @return [ Array<Document> ] The children.
    def add_atomic_unset(document)
      document.flagged_for_destroy = true
      key = document.association_name.to_s
      delayed_atomic_unsets[key] ||= []
      delayed_atomic_unsets[key] << document
    end

    # Returns path of the attribute for modification
    #
    # @example Get path of the attribute
    #   address.atomic_attribute_name(:city)
    #
    # @return [ String ] The path to the document attribute in the database
    def atomic_attribute_name(name)
      embedded? ? "#{atomic_position}.#{name}" : name
    end

    # For array fields these are the pushes that need to happen.
    #
    # @example Get the array pushes.
    #   person.atomic_array_pushes
    #
    # @return [ Hash ] The array pushes.
    def atomic_array_pushes
      @atomic_array_pushes ||= {}
    end

    # For array fields these are the pulls that need to happen.
    #
    # @example Get the array pulls.
    #   person.atomic_array_pulls
    #
    # @return [ Hash ] The array pulls.
    def atomic_array_pulls
      @atomic_array_pulls ||= {}
    end

    # For array fields these are the unique adds that need to happen.
    #
    # @example Get the array unique adds.
    #   person.atomic_array_add_to_sets
    #
    # @return [ Hash ] The array add_to_sets.
    def atomic_array_add_to_sets
      @atomic_array_add_to_sets ||= {}
    end

    # Get all the atomic updates that need to happen for the current
    # +Document+. This includes all changes that need to happen in the
    # entire hierarchy that exists below where the save call was made.
    #
    # @note MongoDB does not allow "conflicting modifications" to be
    #   performed in a single operation. Conflicting modifications are
    #   detected by the 'haveConflictingMod' function in MongoDB.
    #   Examination of the code suggests that two modifications (a $set
    #   and a $push with $each, for example) conflict if:
    #     (1) the key paths being modified are equal.
    #     (2) one key path is a prefix of the other.
    #   So a $set of 'addresses.0.street' will conflict with a $push and $each
    #   to 'addresses', and we will need to split our update into two
    #   pieces. We do not, however, attempt to match MongoDB's logic
    #   exactly. Instead, we assume that two updates conflict if the
    #   first component of the two key paths matches.
    #
    # @example Get the updates that need to occur.
    #   person.atomic_updates(children)
    #
    # @return [ Hash ] The updates and their modifiers.
    #
    # rubocop:disable Style/OptionalBooleanParameter
    def atomic_updates(_use_indexes = false)
      process_flagged_destroys
      mods = Modifiers.new
      generate_atomic_updates(mods, self)
      _descendants.each do |child|
        child.process_flagged_destroys
        generate_atomic_updates(mods, child)
      end
      mods
    end
    alias _updates atomic_updates
    # rubocop:enable Style/OptionalBooleanParameter

    # Get the removal modifier for the document. Will be nil on root
    # documents, $unset on embeds_one, $set on embeds_many.
    #
    # @example Get the removal operator.
    #   name.atomic_delete_modifier
    #
    # @return [ String ] The pull or unset operation.
    def atomic_delete_modifier
      atomic_paths.delete_modifier
    end

    # Get the insertion modifier for the document. Will be nil on root
    # documents, $set on embeds_one, $push on embeds_many.
    #
    # @example Get the insert operation.
    #   name.atomic_insert_modifier
    #
    # @return [ String ] The pull or set operator.
    def atomic_insert_modifier
      atomic_paths.insert_modifier
    end

    # Return the path to this +Document+ in JSON notation, used for atomic
    # updates via $set in MongoDB.
    #
    # @example Get the path to this document.
    #   address.atomic_path
    #
    # @return [ String ] The path to the document in the database.
    def atomic_path
      atomic_paths.path
    end

    # Returns the positional operator of this document for modification.
    #
    # @example Get the positional operator.
    #   address.atomic_position
    #
    # @return [ String ] The positional operator with indexes.
    def atomic_position
      atomic_paths.position
    end

    # Get the atomic paths utility for this document.
    #
    # @example Get the atomic paths.
    #   document.atomic_paths
    #
    # @return [ Object ] The associated path.
    def atomic_paths
      return @atomic_paths if @atomic_paths

      paths = if _association
                _association.path(self)
              else
                Atomic::Paths::Root.new(self)
              end

      paths.tap { @atomic_paths = paths unless new_record? }
    end

    # Get all the attributes that need to be pulled.
    #
    # @example Get the pulls.
    #   person.atomic_pulls
    #
    # @return [ Array<Hash> ] The $pullAll operations.
    def atomic_pulls
      pulls = {}
      delayed_atomic_pulls.each_pair do |_, docs|
        path = nil
        ids = docs.map do |doc|
          path ||= doc.flag_as_destroyed
          doc._id
        end
        pulls[path] = { '_id' => { '$in' => ids } } and path = nil
      end
      pulls
    end

    # Get all the push attributes that need to occur.
    #
    # @example Get the pushes.
    #   person.atomic_pushes
    #
    # @return [ Hash ] The $push and $each operations.
    def atomic_pushes
      pushable? ? { atomic_position => as_attributes } : {}
    end

    # Get all the attributes that need to be set.
    #
    # @example Get the sets.
    #   person.atomic_sets
    #
    # @return [ Hash ] The $set operations.
    def atomic_sets
      if updateable?
        setters
      elsif settable?
        { atomic_path => as_attributes }
      else
        {}
      end
    end

    # Get all the attributes that need to be unset.
    #
    # @example Get the unsets.
    #   person.atomic_unsets
    #
    # @return [ Array<Hash> ] The $unset operations.
    def atomic_unsets
      unsets = []
      delayed_atomic_unsets.each_pair do |name, docs|
        path = nil
        docs.each do |doc|
          path ||= doc.flag_as_destroyed
        end
        unsets.push(path || name)
      end
      unsets
    end

    # Get all the atomic sets that have had their saves delayed.
    #
    # @example Get the delayed atomic sets.
    #   person.delayed_atomic_sets
    #
    # @return [ Hash ] The delayed $sets.
    def delayed_atomic_sets
      @delayed_atomic_sets ||= {}
    end

    # Get a hash of atomic pulls that are pending.
    #
    # @example Get the atomic pulls.
    #   document.delayed_atomic_pulls
    #
    # @return [ Hash ] name/document pairs.
    def delayed_atomic_pulls
      @delayed_atomic_pulls ||= {}
    end

    # Get the delayed atomic unsets.
    #
    # @example Get the delayed atomic unsets.
    #   document.delayed_atomic_unsets
    #
    # @return [ Hash ] The atomic unsets
    def delayed_atomic_unsets
      @delayed_atomic_unsets ||= {}
    end

    # Flag the document as destroyed and return the atomic path.
    #
    # @example Flag destroyed and return path.
    #   document.flag_as_destroyed
    #
    # @return [ String ] The atomic path.
    def flag_as_destroyed
      self.destroyed = true
      self.flagged_for_destroy = false
      atomic_path
    end

    # Get the flagged destroys.
    #
    # @example Get the flagged destroy.
    #   document.flagged_destroys
    #
    # @return [ Array<Proc> ] The flagged destroys.
    def flagged_destroys
      @flagged_destroys ||= []
    end

    # Process all the pending flagged destroys from nested attributes.
    #
    # @example Process all the pending flagged destroys.
    #   document.process_flagged_destroys
    #
    # @return [ Array ] The cleared array.
    def process_flagged_destroys
      _assigning do
        flagged_destroys.each(&:call)
      end
      flagged_destroys.clear
    end

    private

    # Clears all pending atomic updates.
    def reset_atomic_updates!
      Atomic::UPDATES.each do |update|
        send(update).clear
      end
    end

    # Generates the atomic updates in the correct order.
    #
    # @example Generate the updates.
    #   model.generate_atomic_updates(mods, doc)
    #
    # @param [ Modifiers ] mods The atomic modifications.
    # @param [ Document ] doc The document to update for.
    def generate_atomic_updates(mods, doc)
      mods.unset(doc.atomic_unsets)
      mods.pull(doc.atomic_pulls)
      mods.set(doc.atomic_sets)
      mods.set(doc.delayed_atomic_sets)
      mods.push(doc.atomic_pushes)
      mods.push(doc.atomic_array_pushes)
      mods.add_to_set(doc.atomic_array_add_to_sets)
      mods.pull_all(doc.atomic_array_pulls)
    end
  end
end
