# frozen_string_literal: true
# :markup: markdown
#--
# rbs_inline: enabled

module Prism
  class ParseResult < Result
    # When we've parsed the source, we have both the syntax tree and the list of
    # comments that we found in the source. This class is responsible for
    # walking the tree and finding the nearest location to attach each comment.
    #
    # It does this by first finding the nearest locations to each comment.
    # Locations can either come from nodes directly or from location fields on
    # nodes. For example, a `ClassNode` has an overall location encompassing the
    # entire class, but it also has a location for the `class` keyword.
    #
    # Once the nearest locations are found, it determines which one to attach
    # to. If it's a trailing comment (a comment on the same line as other source
    # code), it will favor attaching to the nearest location that occurs before
    # the comment. Otherwise it will favor attaching to the nearest location
    # that is after the comment.
    class Comments
      # @rbs!
      #    # An internal interface for a target that comments can be attached
      #    # to. This is either going to be a NodeTarget or a CommentTarget.
      #    interface _CommentTarget
      #      def start_offset: () -> Integer
      #      def end_offset: () -> Integer
      #      def encloses?: (Comment) -> bool
      #      def leading_comment: (Comment) -> void
      #      def trailing_comment: (Comment) -> void
      #    end

      # A target for attaching comments that is based on a specific node's
      # location.
      class NodeTarget # :nodoc:
        attr_reader :node #: node

        #: (node node) -> void
        def initialize(node)
          @node = node
        end

        #: () -> Integer
        def start_offset
          node.start_offset
        end

        #: () -> Integer
        def end_offset
          node.end_offset
        end

        #: (Comment comment) -> bool
        def encloses?(comment)
          start_offset <= comment.location.start_offset &&
            comment.location.end_offset <= end_offset
        end

        #: (Comment comment) -> void
        def leading_comment(comment)
          node.location.leading_comment(comment)
        end

        #: (Comment comment) -> void
        def trailing_comment(comment)
          node.location.trailing_comment(comment)
        end
      end

      # A target for attaching comments that is based on a location field on a
      # node. For example, the `end` token of a ClassNode.
      class LocationTarget # :nodoc:
        attr_reader :location #: Location

        #: (Location location) -> void
        def initialize(location)
          @location = location
        end

        #: () -> Integer
        def start_offset
          location.start_offset
        end

        #: () -> Integer
        def end_offset
          location.end_offset
        end

        #: (Comment comment) -> bool
        def encloses?(comment)
          false
        end

        #: (Comment comment) -> void
        def leading_comment(comment)
          location.leading_comment(comment)
        end

        #: (Comment comment) -> void
        def trailing_comment(comment)
          location.trailing_comment(comment)
        end
      end

      # The parse result that we are attaching comments to.
      attr_reader :parse_result #: ParseResult

      # Create a new Comments object that will attach comments to the given
      # parse result.
      #--
      #: (ParseResult parse_result) -> void
      def initialize(parse_result)
        @parse_result = parse_result
      end

      # Attach the comments to their respective locations in the tree by
      # mutating the parse result.
      #--
      #: () -> void
      def attach!
        parse_result.comments.each do |comment|
          preceding, enclosing, following = nearest_targets(parse_result.value, comment)

          if comment.trailing?
            if preceding
              preceding.trailing_comment(comment)
            else
              (following || enclosing || NodeTarget.new(parse_result.value)).leading_comment(comment)
            end
          else
            # If a comment exists on its own line, prefer a leading comment.
            if following
              following.leading_comment(comment)
            elsif preceding
              preceding.trailing_comment(comment)
            else
              (enclosing || NodeTarget.new(parse_result.value)).leading_comment(comment)
            end
          end
        end
      end

      private

      # Responsible for finding the nearest targets to the given comment within
      # the context of the given encapsulating node.
      #--
      #: (node node, Comment comment) -> [_CommentTarget?, _CommentTarget?, _CommentTarget?]
      def nearest_targets(node, comment)
        comment_start = comment.location.start_offset
        comment_end = comment.location.end_offset

        targets = [] #: Array[_CommentTarget]
        node.comment_targets.map do |value|
          case value
          when StatementsNode
            targets.concat(value.body.map { |node| NodeTarget.new(node) })
          when Node
            targets << NodeTarget.new(value)
          when Location
            targets << LocationTarget.new(value)
          end
        end

        targets.sort_by!(&:start_offset)
        preceding = nil #: _CommentTarget?
        following = nil #: _CommentTarget?

        left = 0
        right = targets.length

        # This is a custom binary search that finds the nearest nodes to the
        # given comment. When it finds a node that completely encapsulates the
        # comment, it recurses downward into the tree.
        while left < right
          middle = (left + right) / 2
          target = targets[middle]

          target_start = target.start_offset
          target_end = target.end_offset

          if target.encloses?(comment)
            # @type var target: NodeTarget
            # The comment is completely contained by this target. Abandon the
            # binary search at this level.
            return nearest_targets(target.node, comment)
          end

          if target_end <= comment_start
            # This target falls completely before the comment. Because we will
            # never consider this target or any targets before it again, this
            # target must be the closest preceding target we have encountered so
            # far.
            preceding = target
            left = middle + 1
            next
          end

          if comment_end <= target_start
            # This target falls completely after the comment. Because we will
            # never consider this target or any targets after it again, this
            # target must be the closest following target we have encountered so
            # far.
            following = target
            right = middle
            next
          end

          # This should only happen if there is a bug in this parser.
          raise "Comment location overlaps with a target location"
        end

        [preceding, NodeTarget.new(node), following]
      end
    end
  end
end
