lib/canon/diff_formatter/by_line/base_formatter.rb



# frozen_string_literal: true

require "diff/lcs" unless RUBY_ENGINE == "opal"
require "diff/lcs/hunk" unless RUBY_ENGINE == "opal"

module Canon
  class DiffFormatter
    module ByLine
      # Base formatter for line-by-line diffs
      # Provides common LCS diff logic and hunk building
      class BaseFormatter
        attr_reader :use_color, :context_lines, :diff_grouping_lines,
                    :visualization_map, :show_diffs, :diff_mode, :legacy_terminal

        # Create a format-specific by-line formatter
        #
        # @param format [Symbol] Format type (:xml, :html, :html4, :html5, :json, :yaml, :simple)
        # @param options [Hash] Formatting options
        # @return [BaseFormatter] Format-specific formatter instance
        def self.for_format(format, **options)
          case format
          when :xml
            XmlFormatter.new(**options)
          when :html, :html4, :html5
            # Determine HTML version from format
            version = case format
                      when :html5 then :html5
                      when :html4 then :html4
                      else :html4 # default to html4
                      end
            HtmlFormatter.new(html_version: version, **options)
          when :json
            JsonFormatter.new(**options)
          when :yaml
            YamlFormatter.new(**options)
          else
            SimpleFormatter.new(**options)
          end
        end

        # rubocop:disable Metrics/ParameterLists
        def initialize(use_color: true, context_lines: 3,
                       diff_grouping_lines: nil, visualization_map: nil,
                       show_diffs: :all, differences: [],
                       diff_mode: :separate, legacy_terminal: false,
                       equivalent: nil, theme: nil,
                       character_visualization: true)
          @use_color = use_color
          @context_lines = context_lines
          @diff_grouping_lines = diff_grouping_lines
          @visualization_map = visualization_map
          @show_diffs = show_diffs
          @differences = differences
          @line_num_width = 4
          @diff_mode = legacy_terminal ? :separate : diff_mode
          @legacy_terminal = legacy_terminal
          @equivalent = equivalent
          @theme = theme
          @character_visualization = character_visualization
        end
        # rubocop:enable Metrics/ParameterLists

        # Get the resolved theme hash
        # @return [Hash] Theme hash
        def theme
          @theme ||= Theme.resolver(Canon::Config.instance).resolve
        end

        # Get theme by section and type
        # @param section [Symbol] e.g., :diff, :xml, :structure
        # @param diff_type [Symbol] e.g., :removed, :added, :changed
        # @param element [Symbol] e.g., :marker, :content
        # @return [Hash] Style properties
        def theme_style(section, diff_type, element)
          theme.dig(section, diff_type, element) || {}
        end

        # Apply full theme styling to text
        # @param text [String] Text to style
        # @param style [Hash] Style properties from theme (color, bg, bold, underline, strikethrough)
        # @return [String] Styled text
        def apply_theme_style(text, style)
          return text if style.empty? || !@use_color

          color = style[:color]
          bg = style[:bg]
          bold = style[:bold]
          underline = style[:underline]
          strikethrough = style[:strikethrough]

          # Apply visualization first
          visual = apply_visualization(text)

          return visual unless color || bg || bold || underline || strikethrough

          require "rainbow"
          rainbow = Rainbow.new
          rainbow.enabled = true
          presenter = rainbow.wrap(visual)

          if color && color != :default
            presenter = apply_color(presenter,
                                    color)
          end
          presenter = apply_bg(presenter, bg) if bg
          presenter = presenter.bold if bold
          presenter = presenter.underline if underline
          presenter = presenter.cross_out if strikethrough

          presenter.to_s
        end

        # Compute line number column width from document line counts
        def compute_line_num_width(doc1, doc2)
          max_lines = [doc1.count("\n"), doc2.count("\n")].max
          @line_num_width = [max_lines.to_s.length, 4].max
        end

        # =====================================================================
        # Theme Style Helpers
        # These methods look up theme styles for different diff types
        # =====================================================================

        # Get marker style for a diff type
        # @param diff_type [Symbol] :removed, :added, :changed, :formatting, :informative
        # @return [Hash] Style properties
        def marker_style(diff_type)
          theme_style(:diff, diff_type, :marker)
        end

        # Get content style for a diff type
        # @param diff_type [Symbol] :removed, :added, :formatting, :informative
        # @return [Hash] Style properties
        def content_style(diff_type)
          theme_style(:diff, diff_type, :content)
        end

        # Get changed content styles (old and new)
        # @return [Hash] Keys: :content_old, :content_new
        def changed_content_styles
          {
            content_old: theme_style(:diff, :changed, :content_old),
            content_new: theme_style(:diff, :changed, :content_new),
          }
        end

        # Get style for unchanged content
        # @return [Hash] Style properties
        def unchanged_content_style
          theme_style(:diff, :unchanged, :content)
        end

        # Get structure styles
        # @return [Hash] Keys: :line_number, :pipe, :context
        def structure_styles
          theme[:structure] || {}
        end

        # Get visualization characters
        # @return [Hash] Keys: :space, :tab, :newline, :nbsp
        def visualization_chars
          theme[:visualization] || {}
        end

        # Get display mode
        # @return [Symbol] :separate, :inline, :mixed
        def display_mode
          theme[:display_mode] || :separate
        end

        # Apply marker styling using theme
        # @param text [String] Marker text (e.g., "-", "+", "*")
        # @param diff_type [Symbol] Type of diff
        # @return [String] Styled marker
        def styled_marker(text, diff_type)
          style = marker_style(diff_type)
          return text unless @use_color && style[:color]

          apply_theme_style(text, style)
        end

        # Get theme color for a specific diff type and element
        # @param diff_type [Symbol] :removed, :added, :changed, :formatting, :informative
        # @param element [Symbol] :marker, :content, :content_old, :content_new
        # @return [Symbol, nil] Color value
        def theme_color(diff_type, element)
          theme_style(:diff, diff_type, element)[:color]
        end

        # Get structure color
        # @param element [Symbol] :line_number, :pipe, :context
        # @return [Symbol, nil] Color value
        def structure_color(element)
          theme.dig(:structure, element, :color)
        end

        # Normalize a color symbol for Rainbow presenter.
        # Rainbow doesn't support :bright_blue directly - instead it uses
        # chained methods like .blue.bright or .bright.blue.
        # This returns an array of method symbols to chain.
        #
        # @param color [Symbol] Color like :bright_blue, :light_red, etc.
        # @return [Array<Symbol>] Method chain for Rainbow
        def normalize_color_for_rainbow(color)
          return [] if color.nil?

          case color.to_s
          when /^bright_(.+)$/
            # :bright_blue -> [:blue, :bright]
            base = $1.to_sym
            [base, :bright]
          when /^light_(.+)$/
            # :light_red -> Rainbow doesn't support light_, treat as white
            [:white]
          when "default", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
            [color]
          else
            # Unknown color, return as-is and let Rainbow raise
            [color]
          end
        end

        # Apply a color to a Rainbow presenter, normalizing bright_/light_ colors.
        # @param presenter [Rainbow::Presenter] The presenter to colorize
        # @param color [Symbol] Color like :bright_blue, :red, etc.
        # @return [Rainbow::Presenter] Colorized presenter
        def apply_color(presenter, color)
          valid_colors = normalize_color_for_rainbow(color)
          valid_colors.each { |c| presenter = presenter.public_send(c) }
          presenter
        end

        # Apply a background color to a Rainbow presenter.
        # @param presenter [Rainbow::Presenter] The presenter to colorize
        # @param bg_color [Symbol] Background color like :red, :light_blue, etc.
        # @return [Rainbow::Presenter] Colorized presenter
        def apply_bg(presenter, bg_color)
          return presenter unless bg_color

          case bg_color.to_s
          when /^light_(.+)$/
            # Rainbow doesn't support light_ backgrounds, use the base color
            base = $1.to_sym
            presenter.background(base)
          when "default", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
            presenter.background(bg_color)
          else
            # Try as-is and let Rainbow handle unknown colors
            presenter.background(bg_color)
          end
        end

        # Format line-by-line diff
        # Subclasses must implement this method
        #
        # @param doc1 [String] First document
        # @param doc2 [String] Second document
        # @return [String] Formatted diff
        def format(doc1, doc2)
          raise NotImplementedError,
                "Subclasses must implement the format method"
        end

        protected

        # Filter differences for display based on show_diffs setting
        #
        # @param differences [Array<Canon::Diff::DiffNode>] Array of differences
        # @return [Array<Canon::Diff::DiffNode>] Filtered differences
        def filter_differences_for_display(differences)
          return differences if @show_diffs.nil? || @show_diffs == :all

          differences.select do |diff|
            # Handle both DiffNode objects and legacy Hash format
            is_normative = if diff.is_a?(Canon::Diff::DiffNode)
                             diff.normative?
                           elsif diff.is_a?(Hash) && diff.key?(:normative)
                             diff[:normative]
                           else
                             # Default to normative if unknown
                             true
                           end

            case @show_diffs
            when :normative
              is_normative
            when :informative
              !is_normative
            else
              true # Unknown value, show all
            end
          end
        end

        # Build hunks from diff with context lines
        #
        # @param diffs [Array] LCS diff array
        # @param lines1 [Array<String>] Lines from first document
        # @param lines2 [Array<String>] Lines from second document
        # @param context_lines [Integer] Number of context lines
        # @return [Array<Array>] Array of hunks
        def build_hunks(diffs, _lines1, _lines2, context_lines: 3)
          hunks = []
          current_hunk = []
          last_change_index = -context_lines - 1

          diffs.each_with_index do |change, index|
            # Check if we should start a new hunk
            if !current_hunk.empty? && index - last_change_index > context_lines * 2
              # Trim trailing context lines before finalizing hunk
              trim_trailing_context!(current_hunk, last_change_index,
                                     context_lines)
              hunks << current_hunk
              current_hunk = []
            end

            # Add context before first change or after gap
            if current_hunk.empty? && change.action != "="
              start_context = [index - context_lines, 0].max
              (start_context...index).each do |i|
                current_hunk << diffs[i] if i < diffs.length
              end
            end

            current_hunk << change

            # Track last change for hunk grouping
            last_change_index = index if change.action != "="
          end

          # Trim trailing context lines and add final hunk if any
          unless current_hunk.empty?
            trim_trailing_context!(current_hunk, last_change_index,
                                   context_lines)
            hunks << current_hunk
          end

          hunks
        end

        # Trim trailing context lines from a hunk
        # Removes context lines beyond context_lines after the last change
        #
        # @param hunk [Array] The hunk to trim
        # @param last_change_index [Integer] Index of last change in original diffs
        # @param context_lines [Integer] Number of context lines to keep
        def trim_trailing_context!(hunk, _last_change_index, context_lines)
          # Find the position of the last change in this hunk
          last_change_pos = nil
          hunk.each_with_index do |change, i|
            last_change_pos = i if change.action != "="
          end

          return if last_change_pos.nil?

          # Keep only context_lines after the last change
          keep_until = [last_change_pos + context_lines, hunk.length - 1].min
          hunk.slice!((keep_until + 1)..-1) if keep_until < hunk.length - 1
        end

        # Colorize text if color is enabled
        # RSpec-aware: resets any existing ANSI codes before applying new colors
        #
        # @param text [String] Text to colorize
        # @param colors [Array<Symbol>] Rainbow color/effect arguments
        # @return [String] Colorized or plain text
        def colorize(text, *colors)
          return text unless @use_color

          # Filter out nil colors and normalize bright_/light_ colors
          valid_colors = colors.compact.flat_map do |c|
            normalize_color_for_rainbow(c)
          end
          return text if valid_colors.empty?

          require "rainbow"
          # Use a local Rainbow instance that ignores global TTY detection
          rainbow = Rainbow.new
          rainbow.enabled = true
          presenter = rainbow.wrap(text)
          valid_colors.each { |c| presenter = presenter.public_send(c) }
          presenter.to_s
        end

        # Identify contiguous diff blocks
        #
        # @param diffs [Array] LCS diff array
        # @return [Array<Canon::Diff::DiffBlock>] Array of diff blocks
        def identify_diff_blocks(diffs)
          blocks = []
          current_start = nil
          current_types = []

          diffs.each_with_index do |change, idx|
            if change.action != "="
              if current_start.nil?
                current_start = idx
                current_types = [change.action]
              else
                current_types << change.action unless current_types.include?(change.action)
              end
            elsif current_start
              blocks << Canon::Diff::DiffBlock.new(
                start_idx: current_start,
                end_idx: idx - 1,
                types: current_types,
              )
              current_start = nil
              current_types = []
            end
          end

          # Don't forget the last block
          if current_start
            blocks << Canon::Diff::DiffBlock.new(
              start_idx: current_start,
              end_idx: diffs.length - 1,
              types: current_types,
            )
          end

          # Filter blocks based on show_diffs setting
          filter_diff_blocks(blocks)
        end

        # Group diff blocks into contexts
        #
        # @param blocks [Array<Canon::Diff::DiffBlock>] Array of diff blocks
        # @param grouping_lines [Integer] Maximum gap between blocks to group
        # @return [Array<Array<Canon::Diff::DiffBlock>>] Array of block groups
        def group_diff_blocks_into_contexts(blocks, grouping_lines)
          return [] if blocks.empty?

          contexts = []
          current_context = [blocks[0]]

          blocks[1..].each do |block|
            last_block = current_context.last
            gap = block.start_idx - last_block.end_idx - 1

            if gap <= grouping_lines
              current_context << block
            else
              contexts << current_context
              current_context = [block]
            end
          end

          contexts << current_context unless current_context.empty?
          contexts
        end

        # Expand contexts with context lines
        #
        # @param contexts [Array<Array<Canon::Diff::DiffBlock>>] Block groups
        # @param context_lines [Integer] Number of context lines to add
        # @param total_lines [Integer] Total number of lines in diff
        # @return [Array<Canon::Diff::DiffContext>] Array of diff contexts
        def expand_contexts_with_context_lines(contexts, context_lines,
                                                total_lines)
          contexts.map do |context|
            first_block = context.first
            last_block = context.last

            start_idx = [first_block.start_idx - context_lines, 0].max
            end_idx = [last_block.end_idx + context_lines, total_lines - 1].min

            Canon::Diff::DiffContext.new(
              start_idx: start_idx,
              end_idx: end_idx,
              blocks: context,
            )
          end
        end

        # Format a context
        #
        # @param context [Canon::Diff::DiffContext] The context to format
        # @param diffs [Array] LCS diff array
        # @param base_line1 [Integer] Base line number for old file
        # @param base_line2 [Integer] Base line number for new file
        # @return [String] Formatted context
        def format_context(context, diffs, base_line1, base_line2)
          output = []
          max_lines = get_max_diff_lines

          (context.start_idx..context.end_idx).each do |idx|
            change = diffs[idx]

            line1 = change.old_position ? base_line1 + change.old_position + 1 : nil
            line2 = change.new_position ? base_line2 + change.new_position + 1 : nil

            case change.action
            when "="
              output << format_unified_line(line1, line2, " ",
                                            change.old_element)
            when "-"
              output << format_unified_line(line1, nil, "-",
                                            change.old_element, :red)
            when "+"
              output << format_unified_line(nil, line2, "+",
                                            change.new_element, :green)
            when "!"
              # Format changed line
              output << format_changed_line(line1, line2,
                                            change.old_element,
                                            change.new_element)
            end

            # Check if we've exceeded the line limit
            if max_lines&.positive? && output.size >= max_lines
              output << ""
              output << colorize(
                "... Output truncated at #{max_lines} lines ...", :yellow, :bold
              )
              output << colorize(
                "Increase limit via CANON_MAX_DIFF_LINES or config.diff.max_diff_lines", :yellow
              )
              break
            end
          end

          output.join("\n")
        end

        # Filter diff blocks based on show_diffs setting
        #
        # @param blocks [Array<Canon::Diff::DiffBlock>] Array of diff blocks
        # @return [Array<Canon::Diff::DiffBlock>] Filtered array
        def filter_diff_blocks(blocks)
          case @show_diffs
          when :normative
            blocks.select(&:normative?)
          when :informative
            blocks.select(&:informative?)
          else # :all or nil
            blocks
          end
        end

        # Format a unified diff line
        #
        # @param old_num [Integer, nil] Line number in old file
        # @param new_num [Integer, nil] Line number in new file
        # @param marker [String] Diff marker (' ', '-', '+', '<', '>', '[', ']')
        # @param content [String] Line content
        # @param color [Symbol, nil] Color for diff lines
        # @param informative [Boolean] Whether this is an informative diff
        # @param formatting [Boolean] Whether this is a formatting-only diff
        # @return [String] Formatted line
        def format_unified_line(old_num, new_num, marker, content, color = nil,
                                informative: false, formatting: false)
          old_str = old_num ? "%#{@line_num_width}d" % old_num : " " * @line_num_width
          new_str = new_num ? "%#{@line_num_width}d" % new_num : " " * @line_num_width

          # Formatting and informative diffs use directional colors already passed in
          # No need to override since callers set the correct color
          effective_color = color

          marker_part = "#{marker} "

          visualized_content = if effective_color
                                 apply_visualization(content, effective_color)
                               else
                                 content
                               end

          if @use_color
            ln_color = structure_color(:line_number) || :yellow
            pipe_color = structure_color(:pipe) || :yellow
            ln_old = colorize(old_str, ln_color)
            pipe1 = colorize("|", pipe_color)
            ln_new = colorize(new_str, ln_color)
            pipe2 = colorize("|", pipe_color)

            if effective_color
              colored_marker = colorize(marker, effective_color)
              "#{ln_old}#{pipe1}#{ln_new}#{colored_marker} #{pipe2} #{visualized_content}"
            else
              "#{ln_old}#{pipe1}#{ln_new}#{marker} #{pipe2} #{visualized_content}"
            end
          else
            "#{old_str}|#{new_str}#{marker_part}| #{visualized_content}"
          end
        end

        # Format changed lines (default implementation without token-level diff)
        #
        # @param old_line [Integer] Line number in old file
        # @param new_line [Integer] Line number in new file
        # @param old_text [String] Old line text
        # @param new_text [String] New line text
        # @param informative [Boolean] Whether this is an informative diff
        # @return [String] Formatted change
        def format_changed_line(old_line, new_line, old_text, new_text,
                                informative: false)
          output = []

          # For informative diffs, use cyan color and ~ marker
          if informative
            old_marker = "~"
            new_marker = "~"
            old_color = :cyan
            new_color = :cyan
          else
            old_marker = "-"
            new_marker = "+"
            old_color = :red
            new_color = :green
          end

          old_visualized = apply_visualization(old_text, old_color)
          new_visualized = apply_visualization(new_text, new_color)

          fmt = "%#{@line_num_width}d"
          blank = " " * @line_num_width

          if @use_color
            ln_color = structure_color(:line_number) || :yellow
            pipe_color = structure_color(:pipe) || :yellow
            ln_old = colorize(fmt % old_line, ln_color)
            pipe1 = colorize("|", pipe_color)
            ln_new = colorize(fmt % new_line, ln_color)
            pipe2 = colorize("|", pipe_color)
            old_marker_colored = colorize(old_marker, old_color)
            new_marker_colored = colorize(new_marker, new_color)

            output << "#{ln_old}#{pipe1}#{blank}#{old_marker_colored} #{pipe2} #{old_visualized}"
            output << "#{blank}#{pipe1}#{ln_new}#{new_marker_colored} #{pipe2} #{new_visualized}"
          else
            output << "#{fmt % old_line}|#{blank}#{old_marker} | #{old_visualized}"
            output << "#{blank}|#{fmt % new_line}#{new_marker} | #{new_visualized}"
          end

          output.join("\n")
        end

        # Apply character visualization
        #
        # When +character_visualization+ is +:content_only+, leading
        # structural whitespace (indentation) is left plain while content
        # whitespace is visualized.
        #
        # @param token [String] The token to apply visualization to
        # @param color [Symbol, nil] Optional color to apply
        # @return [String] Visualized and optionally colored token
        def apply_visualization(token, color = nil)
          return "" if token.nil?

          visual = if @character_visualization == :content_only
                     visualize_content_only(token.to_s)
                   else
                     token.to_s.chars.map do |char|
                       @visualization_map.fetch(char, char)
                     end.join
                   end

          if color && @use_color
            require "rainbow"
            rainbow = Rainbow.new
            rainbow.enabled = true
            presenter = rainbow.wrap(visual)

            # Handle Rainbow color methods - :bright_blue -> .blue.bright, etc.
            if color.to_s.start_with?("bright_")
              base_color = color.to_s.sub(/^bright_/, "").to_sym
              presenter = presenter.public_send(base_color).bright
            elsif color.to_s.start_with?("light_")
              # Rainbow doesn't have light_ versions, treat as white on bg
              base_color = color.to_s.sub(/^light_/, "").to_sym
              presenter = presenter.public_send(base_color)
            else
              presenter = presenter.public_send(color)
            end

            presenter.to_s
          else
            visual
          end
        end

        # Visualize only content portion, leaving structural indentation plain.
        #
        # Splits the token into leading whitespace (structural indentation)
        # and the rest (content). Only the content portion gets character
        # visualization.
        #
        # @param token [String] The full line token
        # @return [String] Token with content-only visualization
        def visualize_content_only(token)
          # Leading whitespace is structural indentation — keep it plain
          indent_end = token.index(/[^\s]/) || token.length
          indent = token[0...indent_end]
          content = token[indent_end..]

          if content.nil? || content.empty?
            indent
          else
            indent + content.chars.map { |char|
              @visualization_map.fetch(char, char)
            }.join
          end
        end

        # Get max diff lines limit
        #
        # @return [Integer, nil] Max diff output lines
        def get_max_diff_lines
          # Try to get from config if available
          config = Canon::Config.instance
          # Default to 10,000 if config not available
          config&.xml&.diff&.max_diff_lines || 10_000 # rubocop:disable Style/SafeNavigationChainLength
        end

        # Build set of children of matched parents
        #
        # @param matches [Array<Match>] Element matches
        # @return [Set] Set of child elements
        def build_children_set(matches)
          require "set"

          children = Set.new

          matches.each do |match|
            next unless match.status == :matched

            [match.elem1, match.elem2].compact.each do |elem|
              elem.children.each do |child|
                children.add(child) if Canon::Comparison::NodeInspector.element_node?(child)
              end
            end
          end

          children
        end

        # Build set of individual elements that have semantic diffs
        #
        # @return [Set] Set of elements with semantic diffs
        def build_elements_with_semantic_diffs_set
          require "set"

          elements = Set.new

          return elements if @differences.nil? || @differences.empty?

          @differences.each do |diff|
            next unless diff.is_a?(Canon::Diff::DiffNode)

            # Add both nodes if they exist
            elements.add(diff.node1) if diff.node1
            elements.add(diff.node2) if diff.node2
          end

          elements
        end

        # Check if an element or its children have semantic diffs
        #
        # @param element [Object] Element to check
        # @param elements_with_semantic_diffs [Set] Set of elements with diffs
        # @return [Boolean] True if element or descendants have semantic diffs
        def has_semantic_diff_in_subtree?(element, elements_with_semantic_diffs)
          # Check the element itself
          return true if elements_with_semantic_diffs.include?(element)

          # Check all descendants
          if Canon::Comparison::NodeInspector.element_node?(element)
            Canon::Comparison::NodeInspector.children(element).any? do |child|
              has_semantic_diff_in_subtree?(child, elements_with_semantic_diffs)
            end
          else
            false
          end
        end

        # Check if diff display should be skipped
        # Returns true when:
        # 1. show_diffs is :normative AND there are no normative differences
        # 2. show_diffs is :informative AND there are no informative differences
        #
        # @return [Boolean] True if diff display should be skipped
        def should_skip_diff_display?
          # If documents are equivalent and there are no normative diffs,
          # skip display entirely - showing even informative diffs when
          # equivalent is misleading
          if @equivalent == true
            return @differences.none? do |diff|
              diff.is_a?(Canon::Diff::DiffNode) && diff.normative?
            end
          end

          return false if @differences.nil? || @differences.empty?

          case @show_diffs
          when :normative
            # Skip if no normative diffs
            @differences.none? do |diff|
              diff.is_a?(Canon::Diff::DiffNode) && diff.normative?
            end
          when :informative
            # Skip if no informative diffs
            @differences.none? do |diff|
              diff.is_a?(Canon::Diff::DiffNode) && diff.informative?
            end
          else
            # :all or other - never skip
            false
          end
        end

        # Group diff sections by proximity
        #
        # @param sections [Array<Hash>] Diff sections
        # @param grouping_lines [Integer] Maximum gap to group
        # @return [Array<Array>] Grouped sections
        def group_diff_sections(sections, grouping_lines)
          return [] if sections.empty?

          groups = []
          current_group = [sections[0]]

          sections[1..].each do |section|
            last_section = current_group.last

            # Calculate gap
            gap1 = if last_section[:end_line1] && section[:start_line1]
                     section[:start_line1] - last_section[:end_line1] - 1
                   else
                     Float::INFINITY
                   end

            gap2 = if last_section[:end_line2] && section[:start_line2]
                     section[:start_line2] - last_section[:end_line2] - 1
                   else
                     Float::INFINITY
                   end

            max_gap = [gap1, gap2].max

            if max_gap <= grouping_lines
              current_group << section
            else
              groups << current_group
              current_group = [section]
            end
          end

          groups << current_group unless current_group.empty?
          groups
        end

        # Format groups of diffs
        #
        # @param groups [Array<Array>] Grouped diff sections
        # @return [String] Formatted groups
        def format_diff_groups(groups)
          output = []

          groups.each_with_index do |group, group_idx|
            output << "" if group_idx.positive?

            if group.length > 1
              output << colorize("Context block has #{group.length} diffs",
                                 :yellow, :bold)
              output << ""
              group.each do |section|
                output << section[:formatted] if section[:formatted]
              end
            elsif group[0][:formatted]
              output << group[0][:formatted]
            end
          end

          output.join("\n")
        end

        # Format matched element with metadata
        # Subclasses may override to customize behavior
        #
        # @param match [Match] Element match
        # @param map1 [Hash] Line range map for doc1
        # @param map2 [Hash] Line range map for doc2
        # @param lines1 [Array<String>] Lines from doc1
        # @param lines2 [Array<String>] Lines from doc2
        # @return [Hash, nil] Metadata hash or nil
        def format_matched_element_with_metadata(match, map1, map2, lines1,
lines2)
          range1 = map1[match.elem1]
          range2 = map2[match.elem2]
          return nil unless range1 && range2

          # Subclasses must implement format_matched_element
          formatted = format_matched_element(match, map1, map2, lines1, lines2)
          return nil unless formatted

          {
            formatted: formatted,
            start_line1: range1.start_line,
            end_line1: range1.end_line,
            start_line2: range2.start_line,
            end_line2: range2.end_line,
            path: match.path.join("/"),
          }
        end

        # Format deleted element with metadata
        # Subclasses may override to customize behavior
        #
        # @param match [Match] Element match
        # @param map1 [Hash] Line range map for doc1
        # @param lines1 [Array<String>] Lines from doc1
        # @return [Hash, nil] Metadata hash or nil
        def format_deleted_element_with_metadata(match, map1, lines1)
          range1 = map1[match.elem1]
          return nil unless range1

          # Subclasses must implement format_deleted_element
          formatted = format_deleted_element(match, map1, lines1)
          return nil unless formatted

          {
            formatted: formatted,
            start_line1: range1.start_line,
            end_line1: range1.end_line,
            start_line2: nil,
            end_line2: nil,
            path: match.path.join("/"),
          }
        end

        # Format inserted element with metadata
        # Subclasses may override to customize behavior
        #
        # @param match [Match] Element match
        # @param map2 [Hash] Line range map for doc2
        # @param lines2 [Array<String>] Lines from doc2
        # @return [Hash, nil] Metadata hash or nil
        def format_inserted_element_with_metadata(match, map2, lines2)
          range2 = map2[match.elem2]
          return nil unless range2

          # Subclasses must implement format_inserted_element
          formatted = format_inserted_element(match, map2, lines2)
          return nil unless formatted

          {
            formatted: formatted,
            start_line1: nil,
            end_line1: nil,
            start_line2: range2.start_line,
            end_line2: range2.end_line,
            path: match.path.join("/"),
          }
        end

        # Subclasses must implement these element formatting methods
        def format_matched_element(_match, _map1, _map2, _lines1, _lines2)
          raise NotImplementedError,
                "Subclasses must implement format_matched_element"
        end

        def format_deleted_element(_match, _map1, _lines1)
          raise NotImplementedError,
                "Subclasses must implement format_deleted_element"
        end

        def format_inserted_element(_match, _map2, _lines2)
          raise NotImplementedError,
                "Subclasses must implement format_inserted_element"
        end
      end
    end
  end
end