# Copyright 2016-2025 The Khronos Group Inc.
#
# SPDX-License-Identifier: Apache-2.0

require 'asciidoctor/extensions' unless RUBY_ENGINE == 'opal'

include ::Asciidoctor

module Asciidoctor

# Duplicate of "AnyListRx" defined by asciidoctor
# Detects the start of any list item.
#
# NOTE we only have to check as far as the blank character because we know it means non-whitespace follows.
HighlighterAnyListRx = /^(?:#{CG_BLANK}*(?:-|([*.\u2022])\1{0,4}|\d+\.|[a-zA-Z]\.|[IVXivx]+\))#{CG_BLANK}|#{CG_BLANK}*.*?(?::{2,4}|;;)(?:$|#{CG_BLANK})|<?\d+>#{CG_BLANK})/

class ExtensionHighlighterPreprocessorReader < PreprocessorReader
  def initialize document, diff_extensions, data = nil, cursor = nil
    super(document, data, cursor)
    @status_stack = []
    @diff_extensions = diff_extensions
    @tracking_target = nil
  end

  # This overrides the default preprocessor reader conditional logic such
  # that any extensions which need highlighting and are enabled have their
  # ifdefs left intact.
  def preprocess_conditional_directive directive, target, delimiter, text
    # If we are tracking a target for highlighting already, we do not need to do
    # additional processing unless we hit the end of that conditional
    # section
    # NOTE: This will break if for some absurd reason someone nests the same
    # conditional inside itself.
    if @tracking_target != nil && directive == 'endif' && @tracking_target == target.downcase
      @tracking_target = nil
    elsif @tracking_target
      return super(directive, target, delimiter, text)
    end

    # If it is an ifdef or ifndef, push the directive onto a stack
    # If it is an endif, pop the last one off.
    # This is done to apply the next bit of logic to both the start and end
    # of a conditional block correctly
    status = directive
    if directive == 'endif'
      status = @status_stack.pop
    else
      @status_stack.push status
    end

    # If the status is negative, we need to still include the conditional
    # text for the highlighter, so we replace the requirement for the
    # extension attribute in question to be not defined with an
    # always-undefined attribute, so that it evaluates to true when it needs
    # to.
    # Undefined attribute is currently just the extension with "_undefined"
    # appended to it.
    modified_target = target.downcase
    if status == 'ifndef'
      @diff_extensions.each do | extension |
        modified_target.gsub!(extension, extension + '_undefined')
      end
    end

    # Call the original preprocessor
    result = super(directive, modified_target, delimiter, text)

    # If any of the extensions are in the target, and the conditional text
    # is not flagged to be skipped, return false to prevent the preprocessor
    # from removing the line from the processed source.
    unless @skipping
      @diff_extensions.each do | extension |
        if target.downcase.include?(extension)
          if directive != 'endif'
            @tracking_target = target.downcase
          end
          return false
        end
      end
    end
    return result
  end

  # Identical to preprocess_conditional_directive, but older versions of
  # Asciidoctor used a different name, so this is there to override the same
  # method in older versions.
  # This is a pure c+p job for awkward inheritance reasons (see use of
  # the super() keyword :|)
  # At some point, will rewrite to avoid this mess, but this fixes things
  # for now without breaking things for anyone.
  def preprocess_conditional_inclusion directive, target, delimiter, text
    # If we are tracking a target for highlighting already, do not need to do
    # additional processing unless we hit the end of that conditional
    # section
    # NOTE: This will break if for some absurd reason someone nests the same
    # conditional inside itself.
    if @tracking_target != nil && directive == 'endif' && @tracking_target == target.downcase
      @tracking_target = nil
    elsif @tracking_target
      return super(directive, target, delimiter, text)
    end

    # If it is an ifdef or ifndef, push the directive onto a stack
    # If it is an endif, pop the last one off.
    # This is done to apply the next bit of logic to both the start and end
    # of a conditional block correctly
    status = directive
    if directive == 'endif'
      status = @status_stack.pop
    else
      @status_stack.push status
    end

    # If the status is negative, we need to still include the conditional
    # text for the highlighter, so we replace the requirement for the
    # extension attribute in question to be not defined with an
    # always-undefined attribute, so that it evaluates to true when it needs
    # to.
    # Undefined attribute is currently just the extension with "_undefined"
    # appended to it.
    modified_target = target.downcase
    if status == 'ifndef'
      @diff_extensions.each do | extension |
        modified_target.gsub!(extension, extension + '_undefined')
      end
    end

    # Call the original preprocessor
    result = super(directive, modified_target, delimiter, text)

    # If any of the extensions are in the target, and the conditional text
    # is not flagged to be skipped, return false to prevent the preprocessor
    # from removing the line from the processed source.
    unless @skipping
      @diff_extensions.each do | extension |
        if target.downcase.include?(extension)
          if directive != 'endif'
            @tracking_target = target.downcase
          end
          return false
        end
      end
    end
    return result
  end
end

class Highlighter
  def initialize
    @delimiter_stack = []
    @current_anchor = 1
  end

  def highlight_marks line, previous_line, next_line
    if !(line.start_with? 'endif')
      # Any intact "ifdefs" are sections added by an extension, and
      # "ifndefs" are sections removed.
      # Currently do not track *which* extension(s) is/are responsible for
      # the addition or removal - though it would be possible to add it.
      if line.start_with? 'ifdef'
        role = 'added'
      else # if line.start_with? 'ifndef'
        role = 'removed'
      end

      # Create an anchor with the current anchor number
      anchor = '[[difference' + @current_anchor.to_s + ']]'

      # Figure out which markup to use based on the surrounding text
      # This is robust enough as far as I can tell, though we may want to do
      # something more generic later since currently it relies on the fact
      # that if you start inside a list or paragraph, you will end in the same
      # list or paragraph and not cross to other blocks.
      # In practice it *might just work* but it also might not.
      # May need to consider what to do about this in future - maybe just
      # use open blocks for everything?
      highlight_delimiter = :inline
      if (HighlighterAnyListRx.match(next_line) != nil)
        # NOTE: There is a corner case here that should never be hit (famous last words)
        # If a line in the middle of a paragraph begins with an asterisk and
        # then whitespace, this will think it is a list item and use the
        # wrong delimiter.
        # That should not be a problem in practice though, it just might look
        # a little weird.
        highlight_delimiter = :list
      elsif previous_line.strip.empty?
        highlight_delimiter = :block
      end

      # Add the delimiter to the stack for the matching 'endif' to consume
      @delimiter_stack.push highlight_delimiter

      # Add an appropriate method of delimiting the highlighted areas based
      # on the surrounding text determined above.
      if highlight_delimiter == :block
        return ['', anchor, ":role: #{role}", '']
      elsif highlight_delimiter == :list
        return ['', anchor, "[.#{role}]", '~~~~~~~~~~~~~~~~~~~~', '']
      else #if highlight_delimiter == :inline
        return [anchor + ' [.' + role + ']##']
      end
    else  # if !(line.start_with? 'endif')
      # Increment the anchor when we see a matching endif, and generate a
      # link to the next diff section
      @current_anchor = @current_anchor + 1
      anchor_link = '<<difference' + @current_anchor.to_s + ', =>>>'

      # Close the delimited area according to the previously determined
      # delimiter
      highlight_delimiter = @delimiter_stack.pop
      if highlight_delimiter == :block
        return [anchor_link, '', ':role:', '']
      elsif highlight_delimiter == :list
        return [anchor_link, '~~~~~~~~~~~~~~~~~~~~', '']
      else #if highlight_delimiter == :inline
        return [anchor_link + '##']
      end
    end
  end
end

# Preprocessor hook to iterate over ifdefs to prevent them from affecting asciidoctor's processing.
class ExtensionHighlighterPreprocessor < Extensions::Preprocessor
  def process document, reader

    # Only attempt to highlight extensions that are also enabled - if one
    # is not, warn about it and skip highlighting that extension.
    diff_extensions = document.attributes['diff_extensions'].downcase.split(' ')
    actual_diff_extensions = []
    diff_extensions.each do | extension |
      if document.attributes.has_key?(extension)
        actual_diff_extensions << extension
      else
        puts 'The ' + extension + ' extension is not enabled - changes will not be highlighted.'
      end
    end

    # Create a new reader to return, which leaves extension ifdefs that need highlighting intact beyond the preprocess step.
    extension_preprocessor_reader = ExtensionHighlighterPreprocessorReader.new(document, actual_diff_extensions, reader.lines)

    highlighter = Highlighter.new
    new_lines = []

    # Store the old lines so we can reference them in a non-trivial fashion
    old_lines = extension_preprocessor_reader.read_lines()
    old_lines.each_index do | index |

      # Grab the previously processed line
      # This is used by the highlighter to figure out if the highlight will
      # be inline, or part of a block.
      if index > 0
        previous_line = old_lines[index - 1]
      else
        previous_line = ''
      end

      # Current line to process
      line = old_lines[index]

      # Grab the next line to process
      # This is used by the highlighter to figure out if the highlight is
      # between list elements or not - which need special handling.
      if index < (old_lines.length - 1)
        next_line = old_lines[index + 1]
      else
        next_line = ''
      end

      # Highlight any preprocessor directives that were left intact by the
      # custom preprocessor reader.
      if line.start_with?( 'ifdef::', 'ifndef::', 'endif::')
        new_lines += highlighter.highlight_marks(line, previous_line, next_line)
      else
        new_lines << line
      end
    end

    # Return a new reader after preprocessing - this takes care of creating
    # the AST from the new source.
    Reader.new(new_lines)
  end
end

class AddHighlighterCSS < Extensions::Postprocessor
  HighlighterStyleCSS = [
    '.added {',
    '    background-color: lime;',
    '    border-color: green;',
    '    padding:1px;',
    '}',
    '.removed {',
    '    background-color: pink;',
    '    border-color: red;',
    '    padding:1px;',
    '}',
    '</style>']

  def process document, output
    output.sub! '</style>', HighlighterStyleCSS.join("\n")
  end
end

end
