require 'latex/parser'
require 'latex/externaldiff'

module LaTeX

  # Elements in the parse tree may respond to these messages:
  # 
  #  ignore?:: Changes will be treated as unchanged. Additions and
  #    deletions will be added directly without the open/close
  #    wrappers using the to_s representation.
  #
  #  atomic?:: Additions/deletions will be added *inside* the open/close wrapper
  #    using the to_s representation.
  #
  #  comment_if_deleted?:: Rather than including at all, the deleted element
  #    will be commented out. This works for elements containing line breaks.
  #
  # Arrays have each element processed individually.
  # 
  # CompositeElements are enumerated using each. This is expected to yield
  # the element and a kind symbol:
  # 
  #   atomic:: content added as-is
  #   recurse:: content processed as a normal addition/deletion. For comparing
  #     in copy_elt 
  #   recurse_add_del:: as :recurse for add/delete, as :atomic for element compare
  class Diff

    HYPERREF_PREAMBLE = File.read(__FILE__).gsub(/\A.*\n__END__\n/m,'')
    STY_DIR = File.expand_path(File.join(File.dirname(__FILE__),"..","..","sty"))

    DEFAULT_OPTIONS = {
      :addbegin => "\\DIFaddbegin{$id}",
      :addend => "\\DIFaddend{$id}",
      :delbegin => "\\DIFdelbegin{$id}",
      :delend => "\\DIFdelend{$id}",
      :addopen => "\\DIFadd{",
      :addclose => "}",
      :delopen => "\\DIFdel{",
      :delclose => "}",
      :delcmdopen => "%%DIFDELCMD ",
      :delcmdclose => "\n",
      :omit_del_content => false,
      :omit_del => false,
      :opaque_add => false
    }

    attr_reader :stree1, :stree2

    DEBUG_OPTS = (ENV['LTD_DEBUG'] or "")
    DEBUG_COPY = DEBUG_OPTS.include?("c")
    DEBUG_DIFF = DEBUG_OPTS.include?("f")
    DEBUG_ADD = DEBUG_OPTS.include?("a")
    DEBUG_DELETE = DEBUG_OPTS.include?("d")

    def self.diff(file_tree1,file_tree2,preamble,opts = {})
      body1 = file_tree1[1]
      prologue, body2, epilogue = file_tree2

      ltd = self.new(body1,body2,opts)

      result = prologue + ["\n",preamble,"\n",HYPERREF_PREAMBLE,"\n"] + ltd.diff + epilogue
      result.join
    end

    def self.interactive_diff(file_tree1, file_tree2, opts = {})
      body1 = file_tree1[1]
      prologue, body2, epilogue = file_tree2

      ltd = self.new(body1,body2,opts)
      ltd.interactive_diff
    end

    def initialize(tree1,tree2,opts = {})
      @opts = DEFAULT_OPTIONS.dup.merge(opts)

      @ws1, @tree1 = separate_whitespace(tree1)
      @ws2, @tree2 = separate_whitespace(tree2)

      @stree1 = tree_to_strings(@tree1)
      @stree2 = tree_to_strings(@tree2)

      @change_id = 1

      @result = []

      @in = Hash.new(0)
    end

    attr_accessor :change_id


    # Need to coalesce matching punctuation surrounding changed sections
    # or, tie punctuation into ends of words...

    def interactive_diff
      diffs = ExternalDiff.diff(@stree1,@stree2)
      diffs = simplify(diffs)

      @ws1 << [10000000, nil]
      @ws2 << [10000000, nil]

      diffs.each do |kind, l1, l2, r1, r2|
        #$stderr.puts "#{kind} #{l1} #{l2} #{r1} #{r2}"

        left = []
        right = []
        
        if kind == "e" then
          
          while r1 <= r2
            right.push(@ws2.shift[1]) while @ws2.first[0] <= r1
            left.push(@ws1.shift[1]) while @ws1.first[0] <= l1

            left << @tree1[l1].diff_string
            right << @tree2[r1].diff_string

            r1 += 1
            l1 += 1
          end

          output_nl("=",left,right)
          next
        end

        if kind == "d" or kind == "c" then
          (l1..l2).each do |idx|
            left.push(@ws1.shift[1]) while @ws1.first[0] <= idx
            left << @tree1[idx].diff_string
          end
        end

        if kind == "a" or kind == "c" then
          (r1..r2).each do |idx|
            right.push(@ws2.shift[1]) while @ws2.first[0] <= idx
            right << @tree2[idx].diff_string
          end
        end
        output_nl({"a"=>">","d"=>"<","c"=>"|"}[kind],left,right)
      end
    end


    def diff
      diffs = ExternalDiff.diff(@stree1,@stree2)
      #diffs[0,30].each do |z|
      #  $stderr.puts z.inspect
      #end
      diffs = simplify(diffs)

      @ws1 << [10000000, nil]
      @ws2 << [10000000, nil]

      diffs.each do |kind, l1, l2, r1, r2|
        #$stderr.puts "#{kind} #{l1} #{l2} #{r1} #{r2}"
        #
        case kind
        when "e" then copy_same(l1,l2,r1,r2)
        when "c" then process_change(l1,l2,r1,r2)
        when "a" then process_add(r1,r2)
        when "d" then process_del(l1,l2)
        end
      end
      @result
    end

    private

    # Handle deleted elements from l1 to l2
    def process_del(l1,l2)
      do_del(@result) { |deleter| copy_elements(@result,(l1..l2),@ws1,@tree1,&deleter) }
    end

    def process_add(r1,r2)
       do_add(@result) { |adder| copy_elements(@result,(r1..r2),@ws2,@tree2,&adder) }
    end

    def process_change(l1,l2,r1,r2)
      process_del(l1,l2)
      process_add(r1,r2)
    end

    # Appends whitespace to result array, 
    # passes other elements through given block
    def copy_elements(result, range, ws, tree)
      range.each do |idx|
        # Add any white space
        result.push(ws.shift[1]) while ws.first[0] <= idx
        # Add actual deleted stuff
        result.concat yield(tree[idx])
      end   
    end

    def wrap_begin(result,which)
      return if @in_wrap
      result << subst_id(@opts["#{which}begin".to_sym])
      @in[which] = 0
      @in[:delcmd] = 0
      @in_wrap = true
    end

    def wrap_end(result,which)
      return unless @in_wrap
      close_wrapper(result,:delcmd)
      close_wrapper(result,which)
      result << subst_id(@opts["#{which}end".to_sym])
      @change_id += 1
      @in_wrap = false
    end

    def wrap(result,which)
      wrap_begin(result,which)
      yield
      wrap_end(result,which)
    end
    
    def unwrap(result,which)
      wrap_end(result,which)
      yield
      wrap_begin(result,which)
    end

    # Append deleted items to result array
    # Yields to given block do actually process the elements
    # Block should invoke the block argument on each element
    def do_del(result)
      if not @opts[:omit_del] then
        wrap(result,:del) do
          if not @opts[:omit_del_content] then
            yield proc { |elt| added_or_deleted(elt,:del) }
          end
        end
      end
    end

    def do_add(result)
      wrap(result,:add) do
        if @opts[:opaque_add] then
          yield proc { |elt| [elt.to_s] }
        else
          yield proc { |elt| added_or_deleted(elt,:add) }
        end
      end
    end

    def output_nl(tag,left,right)
      left.join.split("\n").zip(right.join.split("\n")) do |a,b|
        a = "*" if a.nil?
        b = "*" if b.nil?
        puts "#{a.ljust(80)}#{tag}#{b.ljust(80)}"
      end
    end

    def coalesce(diffs)
      result = []
      last = nil
      diffs.each do |kind,l1,l2,r1,r2|
        if last and kind == last[0] and l1 == last[2]+1 and r1 == last[4] + 1 then
          last[2] = l2
          last[4] = r2
          next
        end
        last = [kind,l1,l2,r1,r2]
        result << last
      end
      result
    end

    def simplify(diffs)
      result = []
      diffs.each do |kind,l1,l2,r1,r2|
        # Remove matches for a single word
        if kind == 'e' and l2 - l1 == 0 then
          if Word === @tree2[r1] then
            kind = 'c'
          end
        end

        # Remove deletions if omitting old entirely
        if @opts[:omit_del] then
          case kind
          when 'c'
            kind = 'a'
          when 'd'
            kind = nil
          end
        end
          
        result << [kind,l1,l2,r1,r2] if kind
      end
      coalesce(result)
    end

    def subst_id(s)
      s.gsub(/\$id/,@change_id.to_s)
    end


    # Split a parse tree containing Whitespace and other elements
    # into two arrays: one containing only the non-Whitespace elements
    # and the other containing pairs of [index,whitespace] where
    # index is the corresponding index in the first array where the whitespace
    # should appear
    def separate_whitespace(tree)
      ws = []
      result = []
      tree.each do |elt|
        case elt
        when Whitespace
          ws << [result.length, elt.content]
        else
          result << elt
        end
      end

      [ws, result]
    end

    # Convert a parse tree into an array of strings suitable for doing line-diffing on
    def tree_to_strings(tree)
      tree.map { |elt| elt.diff_string.inspect }
    end

    def copy_elt(other,elt)
      if DEBUG_COPY then
        $stderr.puts "Copying..."
        $stderr.puts other.inspect
        $stderr.puts elt.inspect
        $stderr.puts
      end
      if other == elt or (elt.respond_to?(:ignore?) and elt.ignore?) then
        return [elt.to_s]
      else
        if Array === elt then
          if DEBUG_DIFF then
            $stderr.puts "Diffing..."
            $stderr.puts other.inspect
            $stderr.puts elt.inspect
            $stderr.puts
          end
          ltd = self.class.new(other, elt, @opts)
          ltd.change_id = @change_id
          result = ltd.diff
          @change_id = ltd.change_id
          return result

        elsif CompositeElement === elt then
          # elt might be a ControlSequence, Environment, Group
          left = other.to_a
          right = elt.to_a
          local_result = []
          too_different = false

          if left.length != right.length then
            too_different = true
          else
            left.zip(right) do |(left_elt,left_kind), (right_elt,right_kind)|
              # Give up on diffing if any uninterpreted bits are different
              if left_kind!=right_kind or (right_kind!=:recurse and left_elt!=right_elt) then
                too_different = true
                break
              end

            # Strange ruby-vim indenting here
            if right_kind != :recurse or left_elt == right_elt then
              local_result << right_elt
            else
              local_result.concat copy_elt(left_elt, right_elt)
            end
            end
          end
          if too_different then
            local_result = []
            do_del(local_result) { |deleter| local_result.concat deleter.call(other) }
            do_add(local_result) { |adder| local_result.concat adder.call(elt) }
          end
          return local_result
        else
          # Shouldn't get here
          $stderr.puts "other: #{other.inspect} elt: #{elt.inspect}"
          raise "Can't deal with this different element"
        end
      end
    end

    def copy_same(cur_l, sync_l, cur_r,  sync_r)
      while cur_l <= sync_l # also cur_r <= sync_r 
        # Skip whitespace from left
        @ws1.shift while @ws1.first[0] <= cur_l
        # Copy whitespace from right
        @result.push(@ws2.shift[1]) while @ws2.first[0] <= cur_r
        # Copy elements
        @result.concat copy_elt(@tree1[cur_l],@tree2[cur_r])
        cur_l += 1
        cur_r += 1
      end
    end

    def close_wrapper(result,which)
      if @in[which] > 0 then
        @in[which] -= 1
        result << @opts["#{which}close".to_sym]
      end
    end

    def open_wrapper(result,which)
      if @in[which] == 0 then
        @in[which] += 1
        close_wrapper(result,:del) if which == :delcmd
        result << @opts["#{which}open".to_sym]
      end
    end

    # Add comment characters after newlines in added elements when they are deleted
    # if the whole element is being commented out
    def comment_deleted(e)
      s = e.to_s
      if @in[:delcmd] > 0 then
        s.gsub!(/\n/m,"\n%")
      end
      s
    end

    # Append given element to the output, marking it as
    # added. Must be called from within a wrap_added block
    def added_or_deleted(elt,which)
      case which
      when :add
        if DEBUG_ADD then
          $stderr.puts "Adding"
          $stderr.puts @result[-10,10].inspect
          $stderr.puts elt.inspect
          $stderr.puts 
        end
      when :del
        if DEBUG_DELETE then
          $stderr.puts "Deleting #{elt.inspect}"
        end

        elt.deleted! if elt.respond_to?(:deleted!)
      end

      local_result = []
      if Array === elt then
        elt.each do |e|
          local_result.concat added_or_deleted(e,which)
        end
        close_wrapper(local_result,which) # add wasn't doing this..?
      elsif CompositeElement === elt then
        # It's an Environment, ControlSequence, Group, Argument, or a plain Array
        # If this element is atomic then we can start the wrapper, else must cancel it
        if elt.respond_to?(:ignore?) and elt.ignore? then
          close_wrapper(local_result,which)
          local_result << comment_deleted(elt)
        elsif elt.respond_to?(:atomic?) and elt.atomic? then
          open_wrapper(local_result,which)
          local_result << comment_deleted(elt)
        elsif Environment === elt and elt.name == "DIFunwrap" then
          close_wrapper(local_result,which)
          unwrap(local_result,which) { local_result << comment_deleted(elt) }
        else
          opened_delcmd = false
          if which == :del then
            if elt.respond_to?(:comment_if_deleted?) and elt.comment_if_deleted? then
              open_wrapper(local_result,:delcmd)
              opened_delcmd = true
            end
          end
          close_wrapper(local_result,which)
          elt.each do |e,kind|
            case kind
            when :atomic
              local_result << comment_deleted(e)
            when :recurse, :recurse_add_del
              local_result.concat added_or_deleted(e,which)
              close_wrapper(local_result,which)
            end
          end
          if opened_delcmd then
            close_wrapper(local_result,:delcmd)
          end
        end
      else
        # Plain string or Whitespace or Ampersand
        if elt.respond_to?(:atomic?) and not elt.atomic? then
          close_wrapper(local_result,which)
        else
          open_wrapper(local_result,which) unless Whitespace === elt
        end
        local_result << comment_deleted(elt)
      end
      return local_result
    end

  end
end

__END__

% The following turns off any diff commands inside pdf strings
% as generated by hyperref package.
\makeatletter
\@ifundefined{pdfstringdefDisableCommands}{}{%
    \pdfstringdefDisableCommands{%
      \let\DIFdelbegin\@gobble
      \let\DIFdelend\@gobble
      \def\DIFdel#1{#1}%
      \let\DIFaddbegin\@gobble
      \let\DIFaddend\@gobble
      \def\DIFadd#1{#1}%
    }
}
\makeatother
