# frozen_string_literal: true
# rubocop:todo all

module Mongoid
  module Association
    module Referenced

      # This module handles the behavior for synchronizing foreign keys between
      # both sides of a many to many associations.
      module Syncable

        # Is the document able to be synced on the inverse side? This is only if
        # the key has changed and the association bindings have not been run.
        #
        # @example Are the foreign keys syncable?
        #   document._syncable?(association)
        #
        # @param [ Mongoid::Association::Relatable ] association The association metadata.
        #
        # @return [ true | false ] If we can sync.
        def _syncable?(association)
          !_synced?(association.foreign_key) && send(association.foreign_key_check)
        end

        # Get the synced foreign keys.
        #
        # @example Get the synced foreign keys.
        #   document._synced
        #
        # @return [ Hash ] The synced foreign keys.
        def _synced
          @_synced ||= {}
        end

        # Has the document been synced for the foreign key?
        #
        # @example Has the document been synced?
        #   document._synced?
        #
        # @param [ String ] foreign_key The foreign key.
        #
        # @return [ true | false ] If we can sync.
        def _synced?(foreign_key)
          !!_synced[foreign_key]
        end

        # Update the inverse keys on destroy.
        #
        # @example Update the inverse keys.
        #   document.remove_inverse_keys(association)
        #
        # @param [ Mongoid::Association::Relatable ] association The association metadata.
        #
        # @return [ Object ] The updated values.
        def remove_inverse_keys(association)
          foreign_keys = send(association.foreign_key)
          unless foreign_keys.nil? || foreign_keys.empty?
            association.criteria(self, foreign_keys).pull(association.inverse_foreign_key => _id)
          end
        end

        # Update the inverse keys for the association.
        #
        # @example Update the inverse keys
        #   document.update_inverse_keys(association)
        #
        # @param [ Mongoid::Association::Relatable ] association The association metadata.
        #
        # @return [ Object ] The updated values.
        def update_inverse_keys(association)
          if previous_changes.has_key?(association.foreign_key)
            old, new = previous_changes[association.foreign_key]
            adds, subs = new - (old || []), (old || []) - new

            # If we are autosaving we don't want a duplicate to get added - the
            # $addToSet would run previously and then the $push and $each from the
            # inverse on the autosave would cause this. We delete each id from
            # what's in memory in case a mix of id addition and object addition
            # had occurred.
            if association.autosave?
              send(association.name).in_memory.each do |doc|
                adds.delete_one(doc._id)
              end
            end

            unless adds.empty?
              association.criteria(self, adds).without_options.add_to_set(association.inverse_foreign_key => _id)
            end
            unless subs.empty?
              association.criteria(self, subs).without_options.pull(association.inverse_foreign_key => _id)
            end
          end
        end

        module ClassMethods

          # Set up the syncing of many to many foreign keys.
          #
          # @example Set up the syncing.
          #   Person._synced(association)
          #
          # @param [ Mongoid::Association::Relatable ] association The association metadata.
          def _synced(association)
            unless association.forced_nil_inverse?
              synced_save(association)
              synced_destroy(association)
            end
          end

          private

          # Set up the sync of inverse keys that needs to happen on a save.
          #
          # If the foreign key field has changed and the document is not
          # synced, $addToSet the new ids, $pull the ones no longer in the
          # array from the inverse side.
          #
          # @example Set up the save syncing.
          #   Person.synced_save(association)
          #
          # @param [ Mongoid::Association::Relatable ] association The association metadata.
          #
          # @return [ Class ] The class getting set up.
          def synced_save(association)
            set_callback(
                :save,
                :after,
                if: ->(doc) { doc._syncable?(association) }
            ) do |doc|
              doc.update_inverse_keys(association)
            end
            self
          end

          # Set up the sync of inverse keys that needs to happen on a destroy.
          #
          # @example Set up the destroy syncing.
          #   Person.synced_destroy(association)
          #
          # @param [ Mongoid::Association::Relatable ] association The association metadata.
          #
          # @return [ Class ] The class getting set up.
          def synced_destroy(association)
            set_callback(
                :destroy,
                :after
            ) do |doc|
              doc.remove_inverse_keys(association)
            end
            self
          end
        end
      end
    end
  end
end
