module Mongoid
  module Tree
    ##
    # = Mongoid::Tree::Ordering
    #
    # Mongoid::Tree doesn't order the tree by default. To enable ordering of children
    # include both Mongoid::Tree and Mongoid::Tree::Ordering into your document.
    #
    # == Utility methods
    #
    # This module adds methods to get related siblings depending on their position:
    #
    #    node.lower_siblings
    #    node.higher_siblings
    #    node.first_sibling_in_list
    #    node.last_sibling_in_list
    #
    # There are several methods to move nodes around in the list:
    #
    #    node.move_up
    #    node.move_down
    #    node.move_to_top
    #    node.move_to_bottom
    #    node.move_above(other)
    #    node.move_below(other)
    #
    # Additionally there are some methods to check aspects of the document
    # in the list of children:
    #
    #    node.at_top?
    #    node.at_bottom?
    module Ordering
      extend ActiveSupport::Concern

      included do
        field :position, :type => Integer

        default_scope ->{ asc(:position) }

        before_save :assign_default_position, :if => :assign_default_position?
        before_save :reposition_former_siblings, :if => :sibling_reposition_required?
        after_destroy :move_lower_siblings_up
      end

      ##
      # Returns a chainable criteria for this document's ancestors
      #
      # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's ancestors
      def ancestors
        base_class.unscoped { super }
      end

      ##
      # Returns siblings below the current document.
      # Siblings with a position greater than this document's position.
      #
      # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's lower siblings
      def lower_siblings
        self.siblings.where(:position.gt => self.position)
      end

      ##
      # Returns siblings above the current document.
      # Siblings with a position lower than this document's position.
      #
      # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's higher siblings
      def higher_siblings
        self.siblings.where(:position.lt => self.position)
      end

      ##
      # Returns siblings between the current document and the other document
      # Siblings with a position between this document's position and the other document's position.
      #
      # @return [Mongoid::Criteria] Mongoid criteria to retrieve the documents between this and the other document
      def siblings_between(other)
        range = [self.position, other.position].sort
        self.siblings.where(:position.gt => range.first, :position.lt => range.last)
      end

      ##
      # Returns the lowest sibling (could be self)
      #
      # @return [Mongoid::Document] The lowest sibling
      def last_sibling_in_list
        siblings_and_self.last
      end

      ##
      # Returns the highest sibling (could be self)
      #
      # @return [Mongoid::Document] The highest sibling
      def first_sibling_in_list
        siblings_and_self.first
      end

      ##
      # Is this the highest sibling?
      #
      # @return [Boolean] Whether the document is the highest sibling
      def at_top?
        higher_siblings.empty?
      end

      ##
      # Is this the lowest sibling?
      #
      # @return [Boolean] Whether the document is the lowest sibling
      def at_bottom?
        lower_siblings.empty?
      end

      ##
      # Move this node above all its siblings
      #
      # @return [undefined]
      def move_to_top
        return true if at_top?
        move_above(first_sibling_in_list)
      end

      ##
      # Move this node below all its siblings
      #
      # @return [undefined]
      def move_to_bottom
        return true if at_bottom?
        move_below(last_sibling_in_list)
      end

      ##
      # Move this node one position up
      #
      # @return [undefined]
      def move_up
        switch_with_sibling_at_offset(-1) unless at_top?
      end

      ##
      # Move this node one position down
      #
      # @return [undefined]
      def move_down
        switch_with_sibling_at_offset(1) unless at_bottom?
      end

      ##
      # Move this node above the specified node
      #
      # This method changes the node's parent if nescessary.
      #
      # @param [Mongoid::Tree] other document to move this document above
      #
      # @return [undefined]
      def move_above(other)
        ensure_to_be_sibling_of(other)

        if position > other.position
          new_position = other.position
          self.siblings_between(other).inc(:position => 1)
          other.inc(:position => 1)
        else
          new_position = other.position - 1
          self.siblings_between(other).inc(:position => -1)
        end

        self.position = new_position
        save!
      end

      ##
      # Move this node below the specified node
      #
      # This method changes the node's parent if nescessary.
      #
      # @param [Mongoid::Tree] other document to move this document below
      #
      # @return [undefined]
      def move_below(other)
        ensure_to_be_sibling_of(other)

        if position > other.position
          new_position = other.position + 1
          self.siblings_between(other).inc(:position => 1)
        else
          new_position = other.position
          self.siblings_between(other).inc(:position => -1)
          other.inc(:position => -1)
        end

        self.position = new_position
        save!
      end

    private

      def switch_with_sibling_at_offset(offset)
        siblings.where(:position => self.position + offset).first.inc(:position => -offset)
        inc(:position => offset)
      end

      def ensure_to_be_sibling_of(other)
        return if sibling_of?(other)
        self.parent_id = other.parent_id
        save!
      end

      def move_lower_siblings_up
        lower_siblings.inc(:position => -1)
      end

      def reposition_former_siblings
        former_siblings = base_class.where(:parent_id => attribute_was('parent_id')).
                                     and(:position.gt => (attribute_was('position') || 0)).
                                     excludes(:id => self.id)
        former_siblings.inc(:position => -1)
      end

      def sibling_reposition_required?
        parent_id_changed? && persisted?
      end

      def assign_default_position
        self.position = if self.siblings.where(:position.ne => nil).any?
          self.last_sibling_in_list.position + 1
        else
          0
        end
      end

      def assign_default_position?
        self.position.nil? || self.parent_id_changed?
      end
    end
  end
end
