# frozen_string_literal: true

module Mongoid
  module Association
    module Referenced
      class HasAndBelongsToMany
        # Transparent proxy for has_and_belongs_to_many associations.
        # An instance of this class is returned when calling
        # the association getter method on the subject document.
        # This class inherits from Mongoid::Association::Proxy and
        # forwards most of its methods to the target of the association,
        # i.e. the array of documents on the opposite-side collection
        # which must be loaded.
        class Proxy < Referenced::HasMany::Proxy
          # class-level methods for HasAndBelongsToMany::Proxy
          module ClassMethods
            # Get the Eager object for this type of association.
            #
            # @example Get the eager loader object
            #
            # @param [ Mongoid::Association::Relatable ] association The association metadata.
            # @param [ Array<Document> ] docs The array of documents.
            def eager_loader(association, docs)
              Eager.new(association, docs)
            end

            # Returns true if the association is an embedded one. In this case
            # always false.
            #
            # @example Is this association embedded?
            #   Referenced::ManyToMany.embedded?
            #
            # @return [ false ] Always false.
            def embedded?
              false
            end
          end

          extend ClassMethods

          # Appends a document or array of documents to the association. Will set
          # the parent and update the index in the process.
          #
          # @example Append a document.
          #   person.posts << post
          #
          # @example Push a document.
          #   person.posts.push(post)
          #
          # @example Concat with other documents.
          #   person.posts.concat([ post_one, post_two ])
          #
          # @param [ Document... ] *args Any number of documents.
          #
          # @return [ Array<Document> ] The loaded docs.
          #
          # rubocop:disable Metrics/AbcSize
          def <<(*args)
            docs = args.flatten
            return concat(docs) if docs.size > 1

            if (doc = docs.first)
              append(doc) do
                # We ignore the changes to the value for the foreign key in the
                # changed_attributes hash in this block of code for two reasons:
                #
                # 1) The add_to_set method deletes the value for the foreign
                #    key in the changed_attributes hash, but if we enter this
                #    method with a value for the foreign key in the
                #    changed_attributes hash, then we want it to exist outside
                #    this method as well. It's used later on in the Syncable
                #    module to set the inverse foreign keys.
                # 2) The reset_unloaded method accesses the value for the foreign
                #    key on _base, which causes it to get added to the
                #    changed_attributes hash. This happens because when reading
                #    a "resizable" attribute, it is automatically added to the
                #    changed_attributes hash. This is true only for the foreign
                #    key value for HABTM associations as the other associations
                #    use strings for their foreign key values. For consistency
                #    with the other associations, we ignore this addition to
                #    the changed_attributes hash.
                #    See MONGOID-4843 for a longer discussion about this.
                reset_foreign_key_changes do
                  _base.add_to_set(foreign_key => doc.public_send(_association.primary_key))
                  doc.save if child_persistable?(doc)
                  reset_unloaded
                end
              end
            end
            unsynced(_base, foreign_key) and self
          end
          # rubocop:enable Metrics/AbcSize

          alias push <<

          # Appends an array of documents to the association. Performs a batch
          # insert of the documents instead of persisting one at a time.
          #
          # @example Concat with other documents.
          #   person.posts.concat([ post_one, post_two ])
          #
          # @param [ Array<Document> ] documents The docs to add.
          #
          # @return [ Array<Document> ] The documents.
          def concat(documents)
            ids, docs, inserts = {}, [], []
            documents.each { |doc| append_document(doc, ids, docs, inserts) }
            _base.push(foreign_key => ids.keys) if persistable? || _creating?
            persist_delayed(docs, inserts)
            self
          end

          # Build a new document from the attributes and append it to this
          # association without saving.
          #
          # @example Build a new document on the association.
          #   person.posts.build(:title => "A new post")
          #
          # @param [ Hash ] attributes The attributes of the new document.
          # @param [ Class ] type The optional subclass to build.
          #
          # @return [ Document ] The new document.
          def build(attributes = {}, type = nil)
            doc = Factory.execute_build(type || klass, attributes, execute_callbacks: false)
            append(doc)
            doc.apply_post_processed_defaults
            _base.public_send(foreign_key).push(doc.public_send(_association.primary_key))
            unsynced(doc, inverse_foreign_key)
            yield(doc) if block_given?
            doc.run_pending_callbacks
            doc
          end

          alias new build

          # Delete the document from the association. This will set the foreign key
          # on the document to nil. If the dependent options on the association are
          # :delete_all or :destroy the appropriate removal will occur.
          #
          # @example Delete the document.
          #   person.posts.delete(post)
          #
          # @param [ Document ] document The document to remove.
          #
          # @return [ Document ] The matching document.
          def delete(document)
            doc = super
            if doc && persistable?
              _base.pull(foreign_key => doc.public_send(_association.primary_key))
              _target._unloaded = criteria
              unsynced(_base, foreign_key)
            end
            doc
          end

          # Mongoid::Extensions::Array defines Array#delete_one, so we need
          # to make sure that method behaves reasonably on proxies, too.
          alias delete_one delete

          # Removes all associations between the base document and the target
          # documents by deleting the foreign keys and the references, orphaning
          # the target documents in the process.
          #
          # @example Nullify the association.
          #   person.preferences.nullify
          #
          # @param [ Array<Document> ] replacement The replacement documents.
          def nullify(replacement = [])
            _target.each { |doc| execute_callback :before_remove, doc }
            cleanup_inverse_for(replacement) unless _association.forced_nil_inverse?
            _base.set(foreign_key => _base.public_send(foreign_key).clear) if persistable?
            clear_target_for_nullify
          end

          alias nullify_all nullify
          alias clear nullify
          alias purge nullify

          # Substitutes the supplied target documents for the existing documents
          # in the association. If the new target is nil, perform the necessary
          # deletion.
          #
          # @example Replace the association.
          # person.preferences.substitute([ new_post ])
          #
          # @param [ Array<Document> ] replacement The replacement target.
          #
          # @return [ Many ] The association.
          def substitute(replacement)
            purge(replacement)
            if replacement.blank?
              reset_unloaded
              clear_foreign_key_changes
            else
              push(replacement.compact.uniq)
            end
            self
          end

          # Get a criteria for the documents without the default scoping
          # applied.
          #
          # @example Get the unscoped criteria.
          #   person.preferences.unscoped
          #
          # @return [ Criteria ] The unscoped criteria.
          def unscoped
            klass.unscoped.any_in(_id: _base.public_send(foreign_key))
          end

          private

          # Clears the foreign key from the changed_attributes hash.
          #
          # This is, in general, used to clear the foreign key from the
          # changed_attributes hash for consistency with the other referenced
          # associations.
          #
          # @api private
          def clear_foreign_key_changes
            _base.changed_attributes.delete(foreign_key)
          end

          # Reset the value in the changed_attributes hash for the foreign key
          # to its value before executing the given block.
          #
          # @api private
          def reset_foreign_key_changes
            prior_fk_change = _base.changed_attributes.key?(foreign_key)
            fk = _base.changed_attributes[foreign_key].dup
            yield if block_given?
            _base.changed_attributes[foreign_key] = fk
            clear_foreign_key_changes unless prior_fk_change
          end

          # Appends the document to the target array, updating the index on the
          # document at the same time.
          #
          # @example Append the document to the association.
          #   relation.append(document)
          #
          # @param [ Document ] document The document to append to the target.
          def append(document)
            execute_callbacks_around(:add, document) do
              _target.push(document)
              characterize_one(document)
              bind_one(document)
              yield if block_given?
            end
          end

          # Instantiate the binding associated with this association.
          #
          # @example Get the binding.
          #   relation.binding([ address ])
          #
          # @return [ Binding ] The binding.
          def binding
            HasAndBelongsToMany::Binding.new(_base, _target, _association)
          end

          # Determine if the child document should be persisted.
          #
          # @api private
          #
          # @example Is the child persistable?
          #   relation.child_persistable?(doc)
          #
          # @param [ Document ] doc The document.
          #
          # @return [ true | false ] If the document can be persisted.
          def child_persistable?(doc)
            (persistable? || _creating?) &&
              !(doc.persisted? && _association.forced_nil_inverse?)
          end

          # Returns the criteria object for the target class with its documents set
          # to target.
          #
          # @example Get a criteria for the association.
          #   relation.criteria
          #
          # @return [ Criteria ] A new criteria.
          def criteria(id_list = nil)
            _association.criteria(_base, id_list)
          end

          # Flag the base as unsynced with respect to the foreign key.
          #
          # @api private
          #
          # @example Flag as unsynced.
          #   relation.unsynced(doc, :preference_ids)
          #
          # @param [ Document ] doc The document to flag.
          # @param [ Symbol ] key The key to flag on the document.
          #
          # @return [ true ] true.
          def unsynced(doc, key)
            doc._synced[key] = false
            true
          end

          # Does the cleanup for the inverse of the association when
          # replacing the relation with another list of documents.
          #
          # @param [ Array<Document> | nil ] replacement the list of documents
          #   that will replace the current list.
          def cleanup_inverse_for(replacement)
            if replacement
              new_ids = replacement.collect { |doc| doc.public_send(_association.primary_key) }
              objects_to_clear = _base.public_send(foreign_key) - new_ids
              criteria(objects_to_clear).pull(inverse_foreign_key => inverse_primary_key)
            else
              criteria.pull(inverse_foreign_key => inverse_primary_key)
            end
          end

          # The inverse primary key
          #
          # @return [ Object ] the inverse primary key
          def inverse_primary_key
            if (field = _association.options[:inverse_primary_key])
              _base.public_send(field)
            else
              _base._id
            end
          end

          # Clears the _target list and executes callbacks for each document.
          # If an exception occurs in an after_remove hook, the exception is
          # saved, the processing completes, and *then* the exception is
          # re-raised.
          #
          # @return [ Array<Document> ] the replacement documents
          def clear_target_for_nullify
            after_remove_error = nil
            many_to_many = _target.clear do |doc|
              unbind_one(doc)
              doc.changed_attributes.delete(inverse_foreign_key) unless _association.forced_nil_inverse?

              begin
                execute_callback :after_remove, doc
              rescue StandardError => e
                after_remove_error = e
              end
            end

            raise after_remove_error if after_remove_error

            many_to_many
          end

          # Processes a single document as part of a ``concat`` command.
          #
          # @param [ Mongoid::Document ] doc the document to append
          # @param [ Hash ] ids the mapping of primary keys that have been
          #   visited
          # @param [ Array ] docs the list of new docs to be inserted later,
          #   in bulk
          # @param [ Array ] inserts the list of Hashes of attributes that will
          #   be inserted (corresponding to the ``docs`` list)
          #
          # rubocop:disable Metrics/AbcSize
          def append_document(doc, ids, docs, inserts)
            return unless doc

            append(doc)

            pk = doc.public_send(_association.primary_key)
            if persistable? || _creating?
              ids[pk] = true
              save_or_delay(doc, docs, inserts)
            else
              existing = _base.public_send(foreign_key)
              return if existing.include?(pk)

              existing.push(pk)
              unsynced(_base, foreign_key)
            end
          end
          # rubocop:enable Metrics/AbcSize
        end
      end
    end
  end
end
