1# frozen_string_literal: true
2
3module Gitlab
4  module RelativePositioning
5    class Mover
6      attr_reader :range, :start_position
7
8      def initialize(start, range)
9        @range = range
10        @start_position = start
11      end
12
13      def move_to_end(object)
14        focus = context(object, ignoring: object)
15        max_pos = focus.max_relative_position
16
17        move_to_range_end(focus, max_pos)
18      end
19
20      def move_to_start(object)
21        focus = context(object, ignoring: object)
22        min_pos = focus.min_relative_position
23
24        move_to_range_start(focus, min_pos)
25      end
26
27      def move(object, first, last)
28        raise ArgumentError, 'object is required' unless object
29
30        lhs = context(first, ignoring: object)
31        rhs = context(last, ignoring: object)
32        focus = context(object)
33        range = RelativePositioning.range(lhs, rhs)
34
35        if range.cover?(focus)
36          # Moving a object already within a range is a no-op
37        elsif range.open_on_left?
38          move_to_range_start(focus, range.rhs.relative_position)
39        elsif range.open_on_right?
40          move_to_range_end(focus, range.lhs.relative_position)
41        else
42          pos_left, pos_right = create_space_between(range)
43          desired_position = position_between(pos_left, pos_right)
44          focus.place_at_position(desired_position, range.lhs)
45        end
46      end
47
48      def context(object, ignoring: nil)
49        return unless object
50
51        ItemContext.new(object, range, ignoring: ignoring)
52      end
53
54      private
55
56      def gap_too_small?(pos_a, pos_b)
57        return false unless pos_a && pos_b
58
59        (pos_a - pos_b).abs < MIN_GAP
60      end
61
62      def move_to_range_end(context, max_pos)
63        range_end = range.last + 1
64
65        new_pos = if max_pos.nil?
66                    start_position
67                  elsif gap_too_small?(max_pos, range_end)
68                    max = context.max_sibling
69                    max.ignoring = context.object
70                    max.shift_left
71                    position_between(max.relative_position, range_end)
72                  else
73                    position_between(max_pos, range_end)
74                  end
75
76        context.object.relative_position = new_pos
77      end
78
79      def move_to_range_start(context, min_pos)
80        range_end = range.first - 1
81
82        new_pos = if min_pos.nil?
83                    start_position
84                  elsif gap_too_small?(min_pos, range_end)
85                    sib = context.min_sibling
86                    sib.ignoring = context.object
87                    sib.shift_right
88                    position_between(sib.relative_position, range_end)
89                  else
90                    position_between(min_pos, range_end)
91                  end
92
93        context.object.relative_position = new_pos
94      end
95
96      def create_space_between(range)
97        pos_left = range.lhs&.relative_position
98        pos_right = range.rhs&.relative_position
99
100        return [pos_left, pos_right] unless gap_too_small?(pos_left, pos_right)
101
102        gap = range.rhs.create_space_left
103        [pos_left - gap.delta, pos_right]
104      rescue NoSpaceLeft
105        gap = range.lhs.create_space_right
106        [pos_left, pos_right + gap.delta]
107      end
108
109      # This method takes two integer values (positions) and
110      # calculates the position between them. The range is huge as
111      # the maximum integer value is 2147483647.
112      #
113      # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION].
114      #
115      # Then we handle one of three cases:
116      #  - If the gap is too small, we raise NoSpaceLeft
117      #  - If the gap is larger than MAX_GAP, we place the new position at most
118      #    IDEAL_DISTANCE from the edge of the gap.
119      #  - otherwise we place the new position at the midpoint.
120      #
121      # The new position will always satisfy: pos_before <= midpoint <= pos_after
122      #
123      # As a precondition, the gap between pos_before and pos_after MUST be >= 2.
124      # If the gap is too small, NoSpaceLeft is raised.
125      #
126      # @raises NoSpaceLeft
127      def position_between(pos_before, pos_after)
128        pos_before ||= range.first
129        pos_after ||= range.last
130
131        pos_before, pos_after = [pos_before, pos_after].sort
132
133        gap_width = pos_after - pos_before
134
135        if gap_too_small?(pos_before, pos_after)
136          raise NoSpaceLeft
137        elsif gap_width > MAX_GAP
138          if pos_before <= range.first
139            pos_after - IDEAL_DISTANCE
140          elsif pos_after >= range.last
141            pos_before + IDEAL_DISTANCE
142          else
143            midpoint(pos_before, pos_after)
144          end
145        else
146          midpoint(pos_before, pos_after)
147        end
148      end
149
150      def midpoint(lower_bound, upper_bound)
151        ((lower_bound + upper_bound) / 2.0).ceil.clamp(lower_bound, upper_bound - 1)
152      end
153    end
154  end
155end
156