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

require 'contrast/agent/assess/tag'
require 'contrast/utils/object_share'
require 'contrast/utils/string_utils'
require 'contrast/utils/tag_util'
require 'contrast/utils/assess/property/tagged_utils'

module Contrast
  module Agent
    module Assess
      module Property
        # This module serves to hold the functionality required for the
        # management of our dataflow tags.
        module Tagged
          include Contrast::Utils::Assess::TaggedUtils
          # Is any tag present?
          # Creating Tags is expensive and we check for Tags all the time on
          # untracked things. ALWAYS!!! call this method before checking if an
          # object has tags
          # @return [Boolean]
          def tracked?
            instance_variable_defined?(:@_tags) && tags.any?
          end

          # Remove all tags within the given ranges.
          # This does not delete an entire tag if part of that tag is
          # outside this range, meaning we may reduce sizes of tags
          # or split them.
          #
          # If shift is true, it is assumed the characters at those ranges were
          # removed. If shift is false, it is assumed those ranges were replaced
          # by the same number of characters and no shift is needed.
          #
          # current tags: 0-15
          # range:        5-10
          # result:       0-5, 10-15
          #
          # @param ranges [Array<Range>] the ranges to delete
          # @param shift [Boolean] move remaining tags to the left to account
          #   for the deletion
          def delete_tags_at_ranges ranges, shift = true
            return unless tracked?

            # Stage one - delete the tags w/o changing their
            # location.
            ranges.each do |range|
              remove_tags(range)
            end
            return unless shift

            # the amount we've already removed from the string
            shift = 0
            # Stage two - shift the tags to the left to account
            # for the sections that were deleted.
            ranges.each do |range|
              shift_tags_for_deletion(range, shift)
              shift += (range.end - range.begin)
            end

            # Clean up and merge any touching tags
            Contrast::Utils::TagUtil.merge_tags(tags)
          end

          # Shift all the tags in this object by the given ranges.
          # This method assumes the ranges are sorted, meaning
          # the leftmost (lowest) range is first
          #
          # current tags: 0-15
          # range:        5-10
          # result:       0-5, 10-20
          def shift_tags ranges
            return unless tracked?

            ranges.each do |range|
              shift_tags_for_insertion(range)
            end
          end

          # Remove the tag ranges covering the given range
          # and appends any trailing value that might
          # exist after removal of range
          def remove_tags range
            return unless tracked?

            full_delete = []
            tags.each_pair do |key, value|
              remove = []
              add = []
              value.each do |tag|
                comparison = tag.compare_range(range.begin, range.end)
                # ABOVE and BELOW are not affected by this check
                tags_remove_comparison comparison, tag, remove, add, range
              end
              value.delete_if { |tag| remove.include?(tag) }
              Contrast::Utils::TagUtil.ordered_merge(value, add)
              full_delete << key if value.empty?
            end
            full_delete.each { |key| tags.delete(key) }
          end

          # This method is for the tags comparison
          # the idea is to move the whole case here
          # @param comparison [String] indicates type of removal is to occur
          # @param tag Contrast::Agent::Assess::Tag
          # @param remove [String] holds removed Tag if exists
          # @param add [String] holds trailing Tag if exists
          # @param range [Range] start and stop for idx for removal
          def tags_remove_comparison comparison, tag, remove, add, range
            case comparison
            when Contrast::Agent::Assess::Tag::LOW_SPAN
              tag.update_end(range.begin)
            when Contrast::Agent::Assess::Tag::WITHIN
              remove << tag
            when Contrast::Agent::Assess::Tag::WITHOUT
              new_tag = tag.clone
              new_tag.update_start(range.end)
              add << new_tag
              tag.update_end(range.begin)
            when Contrast::Agent::Assess::Tag::HIGH_SPAN
              tag.update_start(range.end)
            end
          end

          # Shift the tag ranges covering the given range
          # We assume this is for a deletion, meaning we
          # have to move tags to the left
          # @param range [Range] the range to delete
          # @param shift [Boolean] move remaining tags to the left to account
          #   for the deletion
          def shift_tags_for_deletion range, shift
            return unless tracked?

            tags.each_value do |value|
              value.each do |tag|
                comparison = tag.compare_range(range.begin - shift, range.end - shift)
                # this is really the only thing we need to shift
                next unless comparison == Contrast::Agent::Assess::Tag::ABOVE

                length = range.end - range.begin
                tag.shift(0 - length)
              end
            end
          end

          # Shift the tag ranges covering the given range to account for new
          # data being added. We assume this is for a insertion, meaning we
          # have to move tags out of the range and to the right. For example,
          # given current tags:     0-15
          # when we insert a range: 5-10
          # then the result is:     0-5, 10-20
          #
          # Note that we disable Lint/DuplicateBranch in this branch in order
          # list out all tag range cases in the proper order to make this
          # easier to understand
          #
          # @param range [Range] the range of new information that's been
          #   inserted
          def shift_tags_for_insertion range
            return unless tracked?

            tags.each_value do |value|
              add = []
              value.each do |tag|
                comparison = tag.compare_range(range.begin, range.end)
                length = range.end - range.begin
                # BELOW is not affected by this check
                shift_tags_comparison comparison, add, tag, length, range
              end
              Contrast::Utils::TagUtil.ordered_merge(value, add)
            end
          end

          def shift_tags_comparison comparison, add, tag, length, range
            case comparison
              # part of the tag is being inserted on
            when Contrast::Agent::Assess::Tag::LOW_SPAN
              new_tag = tag.clone
              new_tag.update_start(range.begin)
              new_tag.shift(length)
              add << new_tag
              tag.update_end(range.begin)
              # the tag exists in the inserted range. it is partially shifted
            when Contrast::Agent::Assess::Tag::WITHIN
              tag.shift(length)
              # the tag spans the range. leave the part below alone
            when Contrast::Agent::Assess::Tag::WITHOUT # rubocop:disable Lint/DuplicateBranch
              new_tag = tag.clone
              new_tag.update_start(range.begin)
              new_tag.shift(length)
              add << new_tag
              tag.update_end(range.begin)
            when Contrast::Agent::Assess::Tag::HIGH_SPAN, Contrast::Agent::Assess::Tag::ABOVE # rubocop:disable Lint/DuplicateBranch
              tag.shift(length)
            end
          end

          private

          # Because of the auto-fill thing, we should not allow direct access to
          # the tags hash. Instead, the methods above should be used to do
          # operations like add, delete, and fetch.
          #
          # CONTRAST-22914
          # please do NOT expose this w/ an attr_reader / accessor. there are
          # helper methods in this class that safely access the hash. the tags
          # method is private to avoid the side effect of a direct lookup with
          # `[]` adding an empty array to the hash.
          def tags
            @_tags ||= Hash.new { |h, k| h[k] = [] }
          end

          # Given a tag, compare it to a given range and, if any part of that tag is within the range, return a new tag
          # covering the union of the original tag and the range. This new tag will start at the
          # max(tag.start, range.start) and end at min(tag.end, range.end)
          #
          # @param tag [Contrast::Agent::Assess::Tag] the Tag that may be in this range
          # @param range [Range] the span to check, inclusive to exclusive
          # @return [Contrast::Agent::Assess::Tag, nil] a new tag, truncated to only span within the given range or nil
          #   if no overlap exists
          def resize_to_range tag, range
            comparison = tag.compare_range(range.begin, range.end)
            # BELOW and ABOVE are not applicable to this check and result in nil
            case comparison
              # part of the tag is being selected
            when Contrast::Agent::Assess::Tag::LOW_SPAN
              Contrast::Agent::Assess::Tag.new(tag.label, range.size)
              # the tag exists in the requested range, figure out the boundaries
            when Contrast::Agent::Assess::Tag::WITHIN
              start = tag.start_idx - range.begin
              finish = range.size - start
              Contrast::Agent::Assess::Tag.new(tag.label, finish, start)
              # the tag spans the requested range.
            when Contrast::Agent::Assess::Tag::WITHOUT # rubocop:disable Lint/DuplicateBranch
              Contrast::Agent::Assess::Tag.new(tag.label, range.size)
              # part of the tag is being selected
            when Contrast::Agent::Assess::Tag::HIGH_SPAN # rubocop:disable Lint/DuplicateBranch
              Contrast::Agent::Assess::Tag.new(tag.label, range.size)
            end
          end
        end
      end
    end
  end
end