#######################################################################
#                  Licensing Information                              #
#                                                                     #
#  The following code is a derivative work of the code from the       #
#  Mercurial project, which is licensed GPLv2. This code therefore    #
#  is also licensed under the terms of the GNU Public License,        #
#  verison 2.                                                         #
#                                                                     #
#  For information on the license of this code when distributed       #
#  with and used in conjunction with the other modules in the         #
#  Amp project, please see the root-level LICENSE file.               #
#                                                                     #
#  © Michael J. Edgar and Ari Brown, 2009-2010                        #
#                                                                     #
#######################################################################

module Amp
  module Diffs
    module Mercurial
      
      ##
      # = MercurialDiff
      # Mercurial has it's own implementation of the unified diff, because windows
      # boxes don't have diff -u. Plus code is faster than the shell.
      # Lame. That's ok, it's pretty easy to do. And we can also add flags and 
      # change default settings.
      #
      # Mainly, you're only going to use MercurialDiff.unified_diff(). It's usage
      # is described below.
      module MercurialDiff
        extend self
        ##
        # These are the default options you can modify. Grab them, clone them,
        # change them. Notice: You have to *clone* this when you use it, or
        # you will be changing the default options!
        DEFAULT_OPTIONS = {:context => 3, :text => false, :show_func => false,
                           :git => false, :no_dates => false, :ignore_ws => false,
                           :ignore_ws_amount => false, :ignore_blank_lines => false,
                           :pretty => false}
        
        ##
        # Clear up whitespace in the text if we have any options relating
        # to getting rid of whitespace.
        # 
        # @param [String] text the text to modify
        # @param [Hash] options the options to use when deciding how to clean text
        # @option options [Boolean] :ignore_ws (false) do we ignore all whitespace?
        #   this has the net effect of removing all whitespace.
        # @option options [Boolean] :ignore_ws_amount (false) when this option is
        #   true, we only remove "excessive" whitespace - more than 1 space or tab.
        #   we then substitute it all with 1 space.
        # @option options [Boolean] :ignore_blank_lines (false) when this option
        #   is true, we remove all extra blank lines.
        def whitespace_clean(text, options=DEFAULT_OPTIONS)
          if options[:ignore_ws]
            text.gsub!(/[ \t]+/, "") #warnings made me use parens
          elsif options[:ignore_ws_amount]
            text.gsub!(/[ \t]+/, ' ')
            text.gsub!(/[ \t]+\n/, "\n")
          end
          text.gsub!(/\n+/, '') if options[:ignore_blank_lines]
          text
        end
        
        ##
        # Given a line, returns a string that represents "adding that line" in a diff,
        # based on the options.
        #
        # @param [String] input the input line
        # @return [String] the output line, in a format indicating it is "added"
        def add_line(input, options)
          options[:pretty] ? "+#{input.chomp}".green+"\n" : "+#{input}"
        end
        
        ##
        # Given a line, returns a string that represents "removing that line" in a diff,
        # based on the options.
        #
        # @param [String] input the input line
        # @return [String] the output line, in a format indicating it is "removed"
        def remove_line(input, options)
          options[:pretty] ? "-#{input.chomp}".red+"\n" : "-#{input}"
        end
        
        ##
        # Creates a header or something? Not sure what this is used for, no code
        # references it. I think it's for git or something. eh.
        def diff_line(revisions, a, b, options=DEFAULT_OPTIONS)
          options = DEFAULT_OPTIONS.merge options
          parts = ['diff']
          
          parts << '--git' if options[:git]
          parts << revisions.map {|r| "-r #{r}"}.join(' ') if revisions && !options[:git]
          if options[:git]
            parts << "a/#{a}"
            parts << "b/#{b}"
          else
            parts << a
          end
          parts.join(' ') + "\n"
        end
        
        ##
        # Creates a date tag appropriate for diffs. Not all diff types use
        # dates though (namely git, apparently), so the options matter.
        # 
        # @param [Time] date the time that we want to make a spiffy date line for
        # @param [String] fn1 the filename of the file being stamped. Only
        #   used if the addtab option is on.
        # @param [Boolean] addtab (false) whether or not to add a tab in the
        #   line or not. Only used if we're in git mode or no-date mode.
        # @param options the options to use while creating the date line.
        # @option options [Boolean] :git (false) are we creating a git diff?
        #   this will deactivate dates.
        # @option options [Boolean] :nodates (false) should we never print dates?
        def date_tag(date, fn1, addtab = true, options = DEFAULT_OPTIONS)
          return "\t#{date.to_diff}\n" if !(options[:git]) && !(options[:nodates])
          return "\t\n" if addtab && fn1 =~ / /
          return "\n"
        end
        
        ##
        # Returns a unified diff based on the 2 blocks of text, their modification
        # times, their filenames, and the options. 
        #
        # This is a self-contained replacement for diffs.
        # 
        # @param a the original text
        # @param [Time] ad the modification timestamp for the old file
        # @param b the new text
        # @param [Time] bd the modification timestamp for the new file
        # @param fn1 the old filename
        # @param fn2 the new filename
        # @param r not sure what this does
        # @param options the options we will be using. There's a lot of settings,
        #   see the descriptions for {whitespace_clean} and {date_tag}.
        def unified_diff(a, ad, b, bd, fn1, fn2, r=nil, options=DEFAULT_OPTIONS)
          return "" if (a.nil? || a.empty?) && (b.nil? || b.empty?)
          options = DEFAULT_OPTIONS.merge(options) # overlay the defaults with the supplied opts
          epoch = Time.at(0)
          if !options[:text] && (!a.nil? && a.binary? || !b.nil? && b.binary?)
            return "" if a.any? && b.any? && a.size == b.size && a == b #DERR
            l = ["Binary file #{fn1} has changed\n"]
          elsif a.nil? || a.empty?
            b = b.split_lines_better
            header = []
            if options[:pretty]
              l1 = a.nil? ? "Added file " : "Changed file "
              l1 += "#{fn2} at #{date_tag(bd,fn1,true,options)}"
              l1 = l1.cyan
              header << l1
            else
              if a.nil?
                header << "--- /dev/null#{date_tag(epoch, fn1, false, options)}"
              else
                header << "--- #{"a/" + fn1}#{date_tag(ad,fn1,true,options)}"
              end
              header << "+++ #{"b/" + fn2}#{date_tag(bd,fn1,true,options)}"
              header << "@@ -0,0 +1,#{b.size} @@\n"
            end
            l = header + (b.map {|line| add_line(line, options)})
          elsif b.nil? || b.empty?
            a = b.split_lines_better if b
            header = []
            if options[:pretty]
              l1 = b.nil? ? "Removed file " : "Changed file "
              l1 += "#{fn2} at #{date_tag(bd,fn1,true,options)}"
              l1 = l1.cyan
              header << l1
            else
              header << "--- #{"a/" + fn1}#{date_tag(ad,fn1,true,options)}"
              if b.nil?
                header << "+++ /dev/null#{date_tag(epoch, fn1, false, options)}"
              else
                header << "+++ #{"b/" + fn2}#{date_tag(bd,fn1,true,options)}"
              end
              header << "@@ -1,#{a.size} +0,0 @@\n"
            end
            l = header + (a.map {|line| remove_line(line, options)})
          else
            al = a.split_lines_better
            bl = b.split_lines_better
            l = bunidiff(a, b, al, bl, "a/"+fn1, "b/"+fn2, options)
            return "" if l.nil? || l.empty?
            if options[:pretty]
              l.shift
              if fn1 == fn2
                l[0] = "Changed file #{fn1.cyan} at #{date_tag(bd,fn1,true,options).lstrip}"
              else
                l[0] = "Moved file from #{fn1.cyan} to #{fn2.cyan}"
              end
            else
              l[0] = "#{l[0][0 .. -3]}#{date_tag(ad,fn1,true,options)}"
              l[1] = "#{l[1][0 .. -3]}#{date_tag(bd,fn1,true,options)}"
            end
          end
          
          l.size.times do |ln|
            if l[ln][-1,1] != "\n"
              l[ln] << "\n\\ No newline at end of file\n"
            end
          end
          
          if r
            l.unshift diff_line(r, fn1, fn2, options)
          end
          
          l.join
        end
        
        ##
        # Starts a block ending context for a change - part of the unified diff
        # format.
        def context_end(l, len, options)
          ret = l + options[:context]
          ret = len if ret > len
          ret
        end
        
        ##
        # Starts a block starting context for a change - part of the unified diff
        # format.
        def context_start(l, options)
          ret = l - options[:context]
          return 0 if ret < 0
          ret
        end
        
        ##
        # Given a hunk of changes, yield each line we need to write to the diff.
        # 
        # @param [Hash] hunk specifies a block of lines that changed between
        #   the two files.
        # @param header the header for the block, if we have one.
        # @param l1 the original lines - used for context (unified diff format)
        # @param delta the lines that have changed thus far
        # @param options settings for the unified diff action. unused mostly here.
        def yield_hunk(hunk, header, l1, delta, options)
          header.each {|x| yield x} if header && header.any?
          delta = hunk[:delta]
          astart, a2, bstart, b2 = hunk[:start_a], hunk[:end_a], hunk[:start_b], hunk[:end_b]
          aend = context_end(a2,l1.size,options)
          alen = aend - astart
          blen = b2 - bstart + aend - a2
          
          # i seriously don't know what this does.
          func = ""
          if options[:show_func]
            (astart - 1).downto(0) do |x|
              t = l1[x].rstrip
              if t =~ /\w/
                func = ' ' + t[0 .. 39]
                break
              end
            end
          end
          
          # yield the header
          if options[:pretty]
            yield "From original lines #{astart + 1}-#{alen+astart+1}".yellow + "\n"
          else
            yield "@@ -%d,%d +%d,%d @@%s\n" % [astart + 1, alen,
                                              bstart + 1, blen, func]
          end
                                             
          # then yield each line of changes
          delta.each {|x| yield x}
          # then yield some context or something?
          a2.upto(aend-1) {|x| yield ' ' + l1[x] }
        end
        
        ##
        # Helper method for creating unified diffs.
        # 
        # @param [String] t1 original text
        # @param [String] t2 new text
        # @param [String] l1 the original text broke into lines?
        # @param [String] l2 the new etxt broken into lines?
        # @param [String] header1 the original file's header
        # @param [String] header2 the new file's header
        # @param opts options for the method
        def bunidiff(t1,t2, l1, l2, header1, header2, opts=DEFAULT_OPTIONS)
          header = [ "--- #{header1}\t\n", "+++ #{header2}\t\n" ]
    
          diff = BinaryDiff.blocks(t1,t2)
          hunk = nil
          return_hunks = []
          saved_delta = []
          delta = []
          diff.size.times do |i|
            s = (i > 0) ? diff[i-1] : {:start_a => 0, :end_a => 0, :start_b => 0, :end_b => 0}
            saved_delta += delta unless delta.empty?
            delta = []
            s1 = diff[i]
            a1 = s[:end_a]
            a2 = s1[:start_a]
            b1 = s[:end_b]
            b2 = s1[:start_b]
            
            old = (a2 == 0) ? [] : l1[a1..(a2-1)]
            newb = (b2 == 0) ? [] : l2[b1..(b2-1)] #stands for new "b"
            
            next if old.empty? && newb.empty?
            if opts[:ignore_ws] || opts[:ignore_blank_lines] || opts[:ignore_ws_amount]
              next if whitespace_clean(old.join,opts) == whitespace_clean(newb.join,opts)
            end
            
            astart = context_start(a1,opts)
            bstart = context_start(b1,opts)
            prev = nil
            if hunk
              if astart < hunk[:end_a] + opts[:context] + 1
                prev = hunk
                astart = hunk[:end_a]
                bstart = hunk[:end_b]
              else
                yield_hunk(hunk, header, l1, delta, opts) {|x| return_hunks << x}
    
                header = nil
              end
            end
            # move this inside previous nested if statements
            if prev
              hunk[:end_a] = a2
              hunk[:end_b] = b2
              delta = hunk[:delta]
            else
              hunk = {:start_a => astart, :end_a => a2, :start_b => bstart, :end_b => b2, :delta => delta}
            end
            
            hunk[:delta] += l1[astart..(a1-1)].map {|x| ' ' + x } if a1 > 0
            hunk[:delta] += old.map  {|x| remove_line(x, opts) }
            hunk[:delta] += newb.map {|x| add_line(x, opts) } 
    
          end
          saved_delta += delta
    
          yield_hunk(hunk, header, l1, saved_delta, opts) {|x| return_hunks << x} if hunk
          return_hunks
        end
        
        ##
        # Unpacks a binary-compressed patch.
        # 
        # @param [String] binary the packed binary text to unpack
        def patch_text(binary)
          pos = 0
          t = []
          while pos < binary.size
            p1, p2, l = binary[pos..(pos+11)].unpack("NNN")
            pos += 12
            t << binary[pos..(pos + l - 1)]
            pos += l
          end
          t.join
        end
        
        ##
        # Applies the patch _bin_ to the text _a_.
        # 
        # @param [String] a the text to patch
        # @param [String] bin the binary patch to apply
        def patch(a, bin)
          MercurialPatch.apply_patches(a, [bin])
        end
        
        ##
        # Gets the matching blocks between the two texts.
        # 
        # @param [String] a the original text
        # @param [String] b the final text
        # @return [[Hash]] The blocks of changes between the two
        def get_matching_blocks(a, b)
          an = a.split_lines_better
          bn = b.split_lines_better
          
          SequenceMatcher.new(an, bn).get_matching_blocks
        end
        
        ## 
        # Returns the obvious header for when we create a new file
        # 
        # @param [Fixnum] length the length of the file
        # @return [String] the obvious header
        def trivial_diff_header(length)
          [0, 0, length].pack("NNN")
        end
        
        ##
        # Returns a text diff between a and b. This returns the packed, binary
        # kind of diff.
        def text_diff a,b
          BinaryDiff.bdiff a,b
        end
      end
    end
  end
end