lib/canon/diff_formatter/by_line/json_formatter.rb



# frozen_string_literal: true

require "strscan"

module Canon
  class DiffFormatter
    module ByLine
      # JSON formatter with semantic token-level highlighting
      # Pretty-prints JSON before diffing for better structure awareness
      class JsonFormatter < BaseFormatter
        # Format semantic JSON diff with token-level highlighting
        #
        # @param doc1 [String] First JSON document
        # @param doc2 [String] Second JSON document
        # @return [String] Formatted diff
        def format(doc1, doc2)
          compute_line_num_width(doc1, doc2)
          output = []

          begin
            # Pretty print both JSON files
            require "canon/pretty_printer/json"
            formatter = Canon::PrettyPrinter::Json.new(indent: 2)
            pretty1 = formatter.format(doc1)
            pretty2 = formatter.format(doc2)

            lines1 = pretty1.split("\n")
            lines2 = pretty2.split("\n")

            # Get LCS diff
            diffs = ::Diff::LCS.sdiff(lines1, lines2)

            # Format with semantic token highlighting
            output << format_semantic_diff(diffs, lines1, lines2)
          rescue StandardError => e
            output << colorize(
              "Warning: JSON parsing failed (#{e.message}), using simple diff", :yellow
            )
            simple = SimpleFormatter.new(
              use_color: @use_color,
              context_lines: @context_lines,
              diff_grouping_lines: @diff_grouping_lines,
              visualization_map: @visualization_map,
            )
            output << simple.format(doc1, doc2)
          end

          output.join("\n")
        end

        private

        # Format semantic diff with token-level highlighting
        #
        # @param diffs [Array] LCS diff array
        # @param lines1 [Array<String>] Lines from first document
        # @param lines2 [Array<String>] Lines from second document
        # @return [String] Formatted diff
        def format_semantic_diff(diffs, lines1, lines2)
          output = []

          # Detect non-ASCII characters in the diff
          all_text = (lines1 + lines2).join
          non_ascii = Legend.detect_non_ascii(all_text, @visualization_map)

          # Add Unicode legend if any non-ASCII characters detected
          unless non_ascii.empty?
            output << Legend.build_legend(non_ascii, use_color: @use_color)
            output << ""
          end

          diffs.each do |change|
            old_line = change.old_position ? change.old_position + 1 : nil
            new_line = change.new_position ? change.new_position + 1 : nil

            case change.action
            when "="
              # Unchanged line
              output << format_unified_line(old_line, new_line, " ",
                                            change.old_element)
            when "-"
              # Deletion
              output << format_unified_line(old_line, nil, "-",
                                            change.old_element,
                                            theme_color(:removed, :content) || :red)
            when "+"
              # Addition
              output << format_unified_line(nil, new_line, "+",
                                            change.new_element,
                                            theme_color(:added, :content) || :green)
            when "!"
              # Change - show with semantic token highlighting
              old_text = change.old_element
              new_text = change.new_element

              # Tokenize JSON
              old_tokens = tokenize_json(old_text)
              new_tokens = tokenize_json(new_text)

              # Get token-level diff
              token_diffs = ::Diff::LCS.sdiff(old_tokens, new_tokens)

              # Build highlighted versions
              old_highlighted = build_token_highlighted_text(token_diffs, :old)
              new_highlighted = build_token_highlighted_text(token_diffs, :new)

              # Format both lines
              output << format_token_diff_line(old_line, new_line,
                                               old_highlighted,
                                               new_highlighted)
            end
          end

          output.join("\n")
        end

        # Format token diff lines
        #
        # @param old_line [Integer] Old line number
        # @param new_line [Integer] New line number
        # @param old_highlighted [String] Highlighted old text
        # @param new_highlighted [String] Highlighted new text
        # @return [String] Formatted lines
        def format_token_diff_line(old_line, new_line, old_highlighted,
                                    new_highlighted)
          output = []
          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)
            removed_color = theme_color(:removed, :content) || :red
            added_color = theme_color(:added, :content) || :green
            red_marker = colorize("-", removed_color)
            green_marker = colorize("+", added_color)

            output << "#{ln_old}#{pipe1}#{blank}#{red_marker} #{pipe2} #{old_highlighted}"
            output << "#{blank}#{pipe1}#{ln_new}#{green_marker} #{pipe2} #{new_highlighted}"
          else
            output << "#{fmt % old_line}|#{blank}- | #{old_highlighted}"
            output << "#{blank}|#{fmt % new_line}+ | #{new_highlighted}"
          end

          output.join("\n")
        end

        # Tokenize JSON line into meaningful tokens
        #
        # @param line [String] JSON line to tokenize
        # @return [Array<String>] Tokens
        def tokenize_json(line)
          tokens = []
          scanner = StringScanner.new(line)

          until scanner.eos?
            tokens << if scanner.scan(/\s+/)
                        # Whitespace
                        scanner.matched
                      elsif scanner.scan(/"(?:[^"\\]|\\.)*"/)
                        # String values (with quotes)
                        scanner.matched
                      elsif scanner.scan(/-?\d+\.?\d*(?:[eE][+-]?\d+)?/)
                        # Numbers
                        scanner.matched
                      elsif scanner.scan(/\b(?:true|false|null)\b/)
                        # Booleans and null
                        scanner.matched
                      elsif scanner.scan(/[{}\[\]:,]/)
                        # Structural characters
                        scanner.matched
                      else
                        # Any other character
                        scanner.getch
                      end
          end

          tokens
        end

        # Build highlighted text from token diff
        #
        # @param token_diffs [Array] Token-level diff
        # @param side [Symbol] Which side (:old or :new)
        # @return [String] Highlighted text
        def build_token_highlighted_text(token_diffs, side)
          parts = []

          token_diffs.each do |change|
            case change.action
            when "="
              # Unchanged token - apply visualization with explicit reset
              visual = change.old_element.chars.map do |char|
                @visualization_map.fetch(char, char)
              end.join

              parts << if @use_color
                         colorize(visual, :default)
                       else
                         visual
                       end
            when "-"
              # Deleted token (only show on old side)
              if side == :old
                token = change.old_element
                parts << apply_visualization(token,
                                             theme_color(:removed,
                                                         :content) || :red)
              end
            when "+"
              # Added token (only show on new side)
              if side == :new
                token = change.new_element
                parts << apply_visualization(token,
                                             theme_color(:added,
                                                         :content) || :green)
              end
            when "!"
              # Changed token
              if side == :old
                token = change.old_element
                parts << apply_visualization(token,
                                             theme_color(:removed,
                                                         :content) || :red)
              else
                token = change.new_element
                parts << apply_visualization(token,
                                             theme_color(:added,
                                                         :content) || :green)
              end
            end
          end

          parts.join
        end

        # Apply character visualization
        #
        # @param token [String] Token to visualize
        # @param color [Symbol, nil] Optional color
        # @return [String] Visualized token
        def apply_visualization(token, color = nil)
          visual = token.chars.map do |char|
            @visualization_map.fetch(char, char)
          end.join

          if color && @use_color
            require "rainbow"
            Rainbow(visual).public_send(color).bright.to_s
          else
            visual
          end
        end
      end
    end
  end
end