# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

module Contrast
  module Utils
    # Utility methods for working with tag ranges
    class TagUtil
      class << self
        # Determine if the given array of tags is covered by the other
        #
        # @param remaining_ranges [Array<Contrast::Agent::Assess::Tag>] the tags left that haven't been covered by
        #   those given
        # @param ranges Array<Contrast::Agent::Assess::Tag> the tags that are covering the first
        def covered? remaining_ranges, ranges
          return true unless remaining_ranges&.any?

          tag = remaining_ranges[0]
          ranges.each do |range|
            # If we covered the tag before using all the ranges, break
            break if tag.length <= 0

            relationship = tag.compare_range(range.start_idx, range.end_idx)
            case relationship
              # since the tags are ordered, if we're below, nope out
            when Contrast::Agent::Assess::Tag::BELOW,
                # if we ever get a low span, that means a low part
                # won't be covered. there's no need to continue
                Contrast::Agent::Assess::Tag::LOW_SPAN,
                # if we ever get a without, that means a low part won't
                # be covered. there's no need to continue
                Contrast::Agent::Assess::Tag::WITHOUT

              return false
            when Contrast::Agent::Assess::Tag::WITHIN
              # if we're within, then 0 out this tag since it is
              # completely covered
              tag.update_start(0)
              tag.update_end(0)
            when Contrast::Agent::Assess::Tag::HIGH_SPAN
              tag.update_start(range.end_idx)
            when Contrast::Agent::Assess::Tag::ABOVE
              # The tag's above where we are, it doesn't cover us but a later
              # one may
            end
          end
          return false unless tag.length <= 0

          remaining_ranges.shift
          covered?(remaining_ranges, ranges)
        end

        # Given an array of tags, add all new tags to that array
        #
        # The addition is done such that the new entry(ies)
        # are inserted so that the range they cover is in order
        # Any overlapping ranges are merged before returning
        #
        # arr: the array of tags to which we are adding
        # add_arr:  array of Tags or a single Tag to be added
        def ordered_merge arr, add_arr
          # [Contrast::Agent::Assess::Tag, ...]
          if add_arr.is_a?(Array)
            return arr unless add_arr.any?
            return add_arr unless arr&.any?

            add_arr.each { |new_element| single_ordered_merge(arr, new_element) }
          # Contrast::Agent::Assess::Tag
          else
            return arr unless add_arr
            return [add_arr] unless arr

            single_ordered_merge(arr, add_arr)
          end
          smallerize(arr)
        end

        # Given a collection of tags, merge any tags that are continuous
        #
        # If tags is a hash, it should be in the format label => [tags]. The array of tags will each be merged
        # If tags is an array in the format [tags], the array will be merged
        #
        # The original object is returned, although setters should not be necessary since tags is a collection in
        # either case
        #
        # @param tags [Hash{String => Array<Contrast::Agent::Assess::Tag>}, Array<Contrast::Agent::Assess::Tag>]
        # @return [Hash{String => Array<Contrast::Agent::Assess::Tag>}, Array<Contrast::Agent::Assess::Tag>]
        def merge_tags tags
          if tags.is_a?(Hash)
            tags.each_value { |value| smallerize(value) }
          else
            smallerize(tags)
          end
        end

        # Merge the given set of tags such that any overlap combines. For any tag which extends beyond the size of the
        # target_object, the end will be updated to the target_object's length.
        #
        # @param target_object [Object] the thing to which the tags apply
        # @param tags [Hash{String => Array<Contrast::Agent::Assess::Tag>}, Array<Contrast::Agent::Assess::Tag>]
        # @return [Hash{String => Array<Contrast::Agent::Assess::Tag>}, Array<Contrast::Agent::Assess::Tag>]
        def size_aware_merge target_object, tags
          max_size = target_object.to_s.length
          tags = merge_tags(tags)
          tags.each do |tag|
            tag.update_end(max_size) if tag.extends_beyond_string_size?(max_size)
          end
        end

        private

        # Add one new element to the given array. The addition is done such that the new entry is inserted so that the
        # range they cover is in order. Any overlapping ranges are merged before returning.
        #
        # @param arr [Array<Contrast::Agent::Assess::Tag>]
        # @param new_element []Contrast::Agent::Assess::Tag]
        # @return [Array<Contrast::Agent::Assess::Tag>]
        def single_ordered_merge arr, new_element
          idx = 0
          arr.each do |existing|
            break unless existing.start_idx < new_element.start_idx

            if existing.overlaps?(new_element.start_idx, new_element.end_idx)
              existing.merge(new_element)
              return # rubocop:disable Lint/NonLocalExitFromIterator
            end
            idx += 1
          end
          arr.insert(idx, new_element)
        end

        # Given an arry of tags, merge any that overlap. The tag that was higher up is removed from the list of tags.
        # ranges like [0-3][3-6]-6-9] become [0-9]
        #
        # @param tags [Array<Contrast::Agent::Assess::Tag>]
        # @return [Array<Contrast::Agent::Assess::Tag>]
        def smallerize tags
          smallered = []
          curr = nil
          tags.sort_by! { |tag| [tag.start_idx, tag.end_idx] }
          tags.each do |tag|
            if curr && tag.start_idx <= curr.end_idx
              curr.update_end(tag.end_idx) if tag.end_idx > curr.end_idx
            else
              curr = tag
              smallered << curr
            end
          end
          tags.delete_if { |tag| !smallered.include?(tag) }
        end
      end
    end
  end
end