#!/usr/bin/env ruby
# Inspired by diff-so-fancy; wrapper around diff-highlight
# https://github.com/stevemao/diff-so-fancy
# [commit: 0ea7c129420c57ec0384a704325e27c41f8f450d,
#  last commit checked: 3adf0114da99643ec53a16253a3d6f42390e4c19 (2017-04-04)]
#TODO: use git-config
#TODO: work with 'git log -p --graph'

require "simplecolor"
SimpleColor.mix_in_string
begin
	require "shell_helpers"
rescue LoadError
end

class GitDiff
	def self.output(gdiff, **opts)
		if gdiff.respond_to?(:each_line)
			enum=gdiff.each_line
		else
			enum=gdiff.each
		end
		self.new(enum, **opts).output
	end

	attr_reader :output
	include Enumerable
	NoNewLine="\\ No newline at end of file\n"

	def initialize(diff,**opts)
		@diff=diff #Assume diff is a line iterator ['gitdiff'.each_line]
		@current=0
		@mode=:unknown
		@opts=opts
		@opts[:color]=@opts.fetch(:color,true)
		#modes: 
		#- unknown (temp mode)
		#- commit
		#- meta
		#- submodule_header
		#- submodule
		#- diff_header
		#- hunk
		@colors={meta: [:bold]}
	end

	def output_line(l)
		@output << l.chomp+"\n"
	end
	def output_lines(lines)
		lines.each {|l| output_line l}
	end
	def output
		each {|l| puts l}
	end

	def next_mode(nmode)
		@next_mode=nmode
	end
	def update_mode
		@start_mode=false
		@next_mode && change_mode(@next_mode)
		@next_mode=nil
	end
	def change_mode(nmode)
		@start_mode=true
		send :"end_#{@mode}" unless @mode==:unknown
		@mode=nmode
		send :"new_#{@mode}" unless @mode==:unknown
	end

	def new_commit; @commit={}; end
	def end_commit; end
	def new_meta; end
	def end_meta; end
	def new_hunk; end
	def end_hunk; end
	def new_submodule_header; @submodule={}; end
	def end_submodule_header; end
	def new_submodule; end
	def end_submodule; end
	def new_diff_header; @file={mode: :modify} end
	def end_diff_header; end

	def detect_new_diff_header
		@line =~ /^diff\s/
	end
	def detect_end_diff_header
		@line =~ /^\+\+\+\s/
	end

	def detect_new_hunk
		@line.match(/^@@+\s.*\s@@/)
	end
	def detect_end_hunk
		@hunk[:lines_seen].each_with_index.all? { |v,i| v==@hunk[:lines][i].first }
	end

	def handle_meta
		handle_line
	end

	def parse_hunk_header
		m=@line.match(/^@@+\s(.*)\s@@\s*(.*)/)
		hunks=m[1]
		@hunk={lines: []}
		@hunk[:header]=m[2]
		filenumber=0
		hunks.split.each do |hunk|
			hunkmode=hunk[0]
			hunk=hunk[1..-1]
			line,length=hunk.split(',').map(&:to_i)
			#handle hunks of the form @@ -1 +0,0 @@
			length,line=line,length unless length
			case hunkmode
			when '-'
				filenumber+=1
				@hunk[:lines][filenumber]=[length,line]
			when '+'
				@hunk[:lines][0]=[length,line]
			end
		end
		@hunk[:n]=@hunk[:lines].length
		@hunk[:lines_seen]=Array.new(@hunk[:n],0)
	end

	def handle_hunk
		if @start_mode
			parse_hunk_header
		else
			#'The 'No new line at end of file' is sort of part of the hunk, but
			#is not considerer in the hunkheader
			unless @line == NoNewLine
				#we need to wait for a NoNewLine to be sure we are at the end of the hunk
				return reparse(:unknown) if detect_end_hunk
				linemodes=@line[0...@hunk[:n]-1]
				newline=true
				#the line is on the new file unless there is a '-' somewhere
				if linemodes=~/-/
					newline=false
				else
					@hunk[:lines_seen][0]+=1
				end
				(1...@hunk[:n]).each do |i|
					linemode=linemodes[i-1]
					case linemode
					when '-'
						@hunk[:lines_seen][i]+=1
					when ' '
						@hunk[:lines_seen][i]+=1 if newline
					end
				end
			end
		end
		handle_line
	end

	def get_file_name(file)
		#remove prefix (todo handle the no-prefix option)
		file.gsub(/^[abciow12]\//,'')
	end

	def detect_filename
		if m=@line.match(/^---\s(.*)/)
			@file[:old_name]=get_file_name(m[1])
			return true
		end
		if m=@line.match(/^\+\+\+\s(.*)/)
			@file[:name]=get_file_name(m[1])
			return true
		end
		false
	end

	def detect_perm
		if m=@line.match(/^old mode\s+(.*)/)
			@file[:old_perm]=m[1]
			return true
		end
		if m=@line.match(/^new mode\s+(.*)/)
			@file[:new_perm]=m[1]
			return true
		end
		false
	end

	def detect_index
		if m=@line.match(/^index\s+(.*)\.\.(.*)/)
			@file[:oldhash]=m[1].split(',')
			@file[:hash],perm=m[2].split
			@file[:perm]||=perm
			return true
		end
		false
	end

	def detect_delete
		if m=@line.match(/^deleted file mode\s+(.*)/)
			@file[:old_perm]=m[1]
			@file[:mode]=:delete
			return true
		end
		false
	end

	def detect_newfile
		if m=@line.match(/^new file mode\s+(.*)/)
			@file[:new_perm]=m[1]
			@file[:mode]=:new
			return true
		end
		false
	end

	def detect_rename_copy
		if m=@line.match(/^similarity index\s+(.*)/)
			@file[:similarity]=m[1]
			return true
		end
		if m=@line.match(/^dissimilarity index\s+(.*)/)
			@file[:mode]=:rewrite
			@file[:dissimilarity]=m[1]
			return true
		end
		#if we have a rename with 100% similarity, there won't be any hunks so
		#we need to detect the filenames there
		if m=@line.match(/^(?:rename|copy) from\s+(.*)/)
			@file[:old_name]=m[1]
		end
		if m=@line.match(/^(?:rename|copy) to\s+(.*)/)
			@file[:name]=m[1]
		end
		if m=@line.match(/^rename\s+(.*)/)
			@file[:mode]=:rename
			return true
		end
		if m=@line.match(/^copy\s+(.*)/)
			@file[:mode]=:copy
			return true
		end
		false
	end

	def detect_diff_header
		if @start_mode
			if m=@line.chomp.match(/^diff\s--git\s(.*)\s(.*)/)
				@file[:old_name]=get_file_name(m[1])
				@file[:name]=get_file_name(m[2])
			elsif
				m=@line.match(/^diff\s--(?:cc|combined)\s(.*)/)
				@file[:name]=get_file_name(m[1])
			end
			true
		end
	end

	def handle_diff_header
		if detect_diff_header
		elsif detect_filename
		elsif detect_perm
		elsif detect_index
		elsif detect_delete
		elsif detect_newfile
		elsif detect_rename_copy
		else
			return reparse(:unknown)
		end
		next_mode(:unknown) if detect_end_diff_header
		handle_line
	end

	def detect_new_submodule_header
		if m=@line.chomp.match(/^Submodule\s(.*)\s(.*)/)
			subname=m[1];
			return not(@submodule && @submodule[:name]==subname)
		end
		false
	end

	def handle_submodule_header
		if m=@line.chomp.match(/^Submodule\s(\S*)\s(.*)/)
			subname=m[1]
			if @submodule[:name]
				#we may be dealing with a new submodule
				#require 'pry'; binding.pry
				return reparse(:submodule_header) if subname != @submodule[:name]
			else
				@submodule[:name]=m[1]
			end
			subinfo=m[2]
			if subinfo == "contains untracked content"
				@submodule[:untracked]=true
			elsif subinfo == "contains modified content"
				@submodule[:modified]=true
			else
				(@submodule[:info]||="") << subinfo
				next_mode(:submodule) if subinfo =~ /^.......\.\.\.?........*:$/
			end
			handle_line
		else
			return reparse(:unknown)
		end
	end

	def submodule_line
		@line=~/^  [><] /
	end

	def handle_submodule
		#we have lines indicating new commits
		#they always end by a new line except when followed by another submodule
		return reparse(:unknown) if !submodule_line
		handle_line
	end

	def detect_new_commit
		@line=~/^commit\b/
	end

	def handle_commit
		if m=@line.match(/^(\w+):\s(.*)/)
			@commit[m[1]]=m[2]
			handle_line
		else
			@start_mode ? handle_line : reparse(:unknown)
		end
	end

	def reparse(nmode)
		change_mode(nmode)
		parse_line
	end

	def handle_line
	end


	def parse_line
		case @mode
		when :unknown, :meta
			if detect_new_hunk
				return reparse(:hunk)
			elsif detect_new_diff_header
				return reparse(:diff_header)
			elsif detect_new_submodule_header
				return reparse(:submodule_header)
			elsif detect_new_commit
				return reparse(:commit)
			else
				change_mode(:meta) if @mode==:unknown
				handle_meta
			end
		when :commit
			handle_commit
		when :submodule_header
			handle_submodule_header
		when :submodule
			handle_submodule
		when :diff_header
			handle_diff_header
			#=> mode=unknown if we detect we are not a diff header anymore
		when :hunk
			handle_hunk
			#=> mode=unknown at end of hunk
		end
	end

	def prepare_new_line(line)
		@orig_line=line
		@line=@orig_line.uncolor
		update_mode
	end

	def parse
		Enumerator.new do |y|
			@output=y
			@diff.each do |line|
				prepare_new_line(line)
				parse_line
				yield if block_given?
			end
			change_mode(:unknown) #to trigger the last end_* hook
		end
	end

	def each(&b)
		parse.each(&b)
	end
end

class GitDiffDebug < GitDiff
	def initialize(*args,&b)
		super
		@cols=`tput cols`.to_i
	end

	def center(msg)
		msg.center(@cols,'─')
	end

	def handle_line
		super
		output_line "#{@mode}: #{@orig_line}"
		#p @hunk if @mode==:hunk
	end

	%i(commit meta diff_header hunk submodule_header submodule).each do |meth|
		define_method(:"new_#{meth}") do |*a,&b|
			super(*a,&b)
			output_line(center("New #{meth}"))
		end
		define_method(:"end_#{meth}") do |*a,&b|
			super(*a,&b)
			output_line(center("End #{meth}"))
		end
	end
end

#stolen from diff-highlight git contrib script
class GitDiffHighlight < GitDiff
	def new_hunk
		super
		@accumulator=[[],[]]
	end
	def end_hunk
		super
		show_hunk
	end

	def highlight_pair(old,new)
		oldc=SimpleColor.color_entities(old).each_with_index
		newc=SimpleColor.color_entities(new).each_with_index
		seen_pm=false
		#find common prefix
		loop do
			a=oldc.grep {|c| ! SimpleColor.color?(c)}
			b=newc.grep {|c| ! SimpleColor.color?(c)}
			if !seen_pm and a=="-" and b=="+"
				seen_pm=true
			elsif a==b
			else
				last
			end
		#rescue StopIteration
		end
	end

	def show_hunk
		old,new=@accumulator
		if old.length != new.length
			output_lines(old+new)
		else
			newhunk=[]
			(0...old.length).each do |i|
				oldi,newi=highlight_pair(old[i],new[i])
				output_line oldi
				newhunk << newi
			end
			output_lines(newhunk)
		end
	end

	def handle_line
		if @mode == :hunk && @hunk[:n]==2
			linemode=@line[0]
			case linemode
			when "-"
				@accumulator[0] << @orig_line
			when "+"
				@accumulator[1] << @orig_line
			else
				show_hunk
				@accumulator=[[],[]]
				output_line @orig_line
			end
		else
			output_line @orig_line
		end
	end
end

class GitFancyDiff < GitDiff

	def initialize(*args,&b)
		super
		#when run inside a pager I get one more column so the line overflow
		#I don't know why
		cols=`tput cols`.to_i
		cols==0 && cols=80 #if TERM is not defined `tput cols` returns ''
		@cols=cols-1
	end

	def hline
		'─'*@cols
	end
	def hhline
		#'⬛'*@cols
		#"━"*@cols
		"═"*@cols
	end

	def short_perm_mode(m, prefix: '+')
		case m
		when "040000"
			prefix+"d" #directory
		when "100644"
			"" #file
		when "100755"
			prefix+"x" #executable
		when "120000"
			prefix+"l" #symlink
		when "160000"
			prefix+"g" #gitlink
		end
	end
	def perm_mode(m, prefix: ' ')
		case m
		when "040000"
			prefix+"directory"
		when "100644"
			"" #file
		when "100755"
			prefix+"executable"
		when "120000"
			prefix+"symlink"
		when "160000"
			prefix+"gitlink"
		end
	end

	def diff_header_summary
		r=case @file[:mode]
			when :modify
				"modified: #{@file[:name]}"
			when :rewrite
				"rewrote: #{@file[:name]} (dissimilarity: #{@file[:dissimilarity]})"
			when :new
				"added#{perm_mode(@file[:new_perm])}: #{@file[:name]}"
			when :delete
				"deleted#{perm_mode(@file[:old_perm])}: #{@file[:old_name]}"
			when :rename
				"renamed: #{@file[:old_name]} to #{@file[:name]} (similarity: #{@file[:similarity]})"
			when :copy
				"copied: #{@file[:old_name]} to #{@file[:name]} (similarity: #{@file[:similarity]})"
			end
		r<<" [#{short_perm_mode(@file[:old_perm],prefix:'-')}#{short_perm_mode(@file[:new_perm])}]" if @file[:old_perm] && @file[:new_perm]
		r
	end

	def meta_colorize(l)
		if @opts[:color]
			l.color(*@colors[:meta])
		else
			l
		end
	end

	def new_diff_header
		super
		output_line meta_colorize(hline)
	end

	def end_diff_header
		super
		output_line meta_colorize(diff_header_summary)
		output_line meta_colorize(hline)
	end

	def submodule_header_summary
		r="Submodule #{@submodule[:name]}"
		extra=[@submodule[:modified] && "modified", @submodule[:untracked] && "untracked"].compact.join("+")
		r<<" [#{extra}]" unless extra.empty?
		r << " #{@submodule[:info]}" if @submodule[:info]
		r
	end

	def new_submodule_header
		super
		output_line meta_colorize(hline)
	end

	def end_submodule_header
		super
		output_line meta_colorize(submodule_header_summary)
		output_line meta_colorize(hline)
	end

	def nonewline_clean
			@mode==:hunk && @file && (@file[:perm]=="120000" or @file[:old_perm]=="120000" or @file[:new_perm]=="120000") && @line==NoNewLine
	end

	def new_commit
		super
		output_line meta_colorize(hhline)
	end
	def end_commit
		super
		output_line meta_colorize(hhline)
	end

	def clean_hunk_col
		if @opts[:color] && @mode==:hunk && !@start_mode && @hunk[:n]==2
			bcolor,ecolor,line=SimpleColor.current_colors(@orig_line)
			m=line.scrub.match(/^([+-])?(.*)/)
			mode=m[1]
			cline=m[2]
			if mode && cline !~ /[^[:space:]]/ #detect blank line
				output_line SimpleColor.color(bcolor.to_s + (cline.empty? ? " ": cline)+ecolor.to_s,:inverse)
			else
				cline.sub!(/^\s/,'') unless mode #strip one blank character
				output_line bcolor.to_s+cline+ecolor.to_s
			end
			true
		end
	end

	def hunk_header
		if @mode==:hunk && @start_mode
			if @hunk[:lines][0][1] && @hunk[:lines][0][1] != 0
				header="#{@file[:name]}:#{@hunk[:lines][0][1]}"
				output_line @orig_line.sub(/(@@+\s)(.*)(\s@@+)/,"\\1#{header}\\3")
			end
			true
		end
	end

	def binary_file_differ
		@file and (@file[:mode]==:new && @line =~ %r{^Binary files /dev/null and ./#{@file[:name]} differ$} or
							 @file[:mode]==:delete && @line =~ %r{^Binary files ./#{@file[:old_name]} and /dev/null differ$})
	end

	def handle_line
		super
		#:diff_header and submodule_header are handled at end_*
		case @mode
		when :meta
			if binary_file_differ
			else output_line @orig_line
			end
		when :hunk
			if hunk_header
			elsif nonewline_clean
			elsif clean_hunk_col
			else output_line @orig_line
			end
		when :submodule,:commit
			output_line @orig_line
		end
	end
end

if __FILE__ == $0
	require 'optparse'

	@opts={pager: true, diff_highlight: true, color: true, debug: false}
	optparse = OptionParser.new do |opt|
		opt.banner = "fancy git diff"
		opt.on("--[no-]pager", "launch the pager [true]") do |v|
			@opts[:pager]=v
		end
		opt.on("--[no-]highlight", "run the diff through diff-highlight [true]") do |v|
			@opts[:diff_highlight]=v
		end
		opt.on("--[no-]color", "color output [true]") do |v|
			@opts[:color]=v
		end
		opt.on("--raw", "Only parse diff headers") do |v|
			@opts[:color]=false
			@opts[:pager]=false
			@opts[:diff_highlight]=false
		end
		opt.on("--[no-]debug", "Debug mode") do |v|
			@opts[:debug]=v
		end
	end
	optparse.parse!
	@opts[:pager]=false unless Module.const_defined?('ShellHelpers')
	@opts[:pager] && ShellHelpers.run_pager #("--pattern '^(Date|added|deleted|modified): '")

	diff_highlight=ENV['DIFF_HIGHLIGHT']||"#{File.dirname(__FILE__)}/contrib/diff-highlight"

	args=ARGF
	if @opts[:debug]
		GitDiffDebug.new(args,**@opts).output
	elsif @opts[:diff_highlight]
		IO.popen(diff_highlight,'r+') do |f|
			Thread.new do
				args.each_line do |l|
					f.write(l)
				end
				f.close_write
			end
			GitFancyDiff.new(f,**@opts).output
		end
	else
		#diff=GitDiffHighlight.new(args,**@opts).parse
		GitFancyDiff.new(args,**@opts).output
	end
end