#!/usr/bin/env ruby

require "optparse"
require "ostruct"
require "nkf"
require "shellwords"

CommitEmailInfo = Struct.new(
  :author,
  :author_email,
  :revision,
  :entire_sha256,
  :date,
  :log,
  :branch,
  :diffs,
  :added_files, :deleted_files, :updated_files,
  :added_dirs, :deleted_dirs, :updated_dirs,
)

class GitInfoBuilder
  GitCommandFailure = Class.new(RuntimeError)

  def initialize(repo_path)
    @repo_path = repo_path
  end

  def build(oldrev, newrev, refname)
    diffs = build_diffs(oldrev, newrev)

    info = CommitEmailInfo.new
    info.author = git_show(newrev, format: '%an')
    info.author_email = normalize_email(git_show(newrev, format: '%aE'))
    info.revision = newrev[0...10]
    info.entire_sha256 = newrev
    info.date = Time.at(Integer(git_show(newrev, format: '%at')))
    info.log = git_show(newrev, format: '%B')
    info.branch = git('rev-parse', '--symbolic', '--abbrev-ref', refname).strip
    info.diffs = diffs
    info.added_files = find_files(diffs, status: :added)
    info.deleted_files = find_files(diffs, status: :deleted)
    info.updated_files = find_files(diffs, status: :modified)
    info.added_dirs = [] # git does not deal with directory
    info.deleted_dirs = [] # git does not deal with directory
    info.updated_dirs = [] # git does not deal with directory
    info
  end

  private

  # Force git-svn email address to @ruby-lang.org to avoid email bounce by invalid email address.
  def normalize_email(email)
    if email.match(/\A[^@]+@\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/) # git-svn
      svn_user, _ = email.split('@', 2)
      "#{svn_user}@ruby-lang.org"
    else
      email
    end
  end

  def find_files(diffs, status:)
    files = []
    diffs.each do |path, values|
      if values.keys.first == status
        files << path
      end
    end
    files
  end

  # SVN version:
  # {
  #   "filename" => {
  #     "[modified|added|deleted|copied|property_changed]" => {
  #       type: "[modified|added|deleted|copied|property_changed]",
  #       body: "diff body", # not implemented because not used
  #       added: Integer,
  #       deleted: Integer,
  #     }
  #   }
  # }
  def build_diffs(oldrev, newrev)
    diffs = {}

    numstats = git('diff', '--numstat', oldrev, newrev).lines.map { |l| l.strip.split("\t", 3) }
    git('diff', '--name-status', oldrev, newrev).each_line do |line|
      status, path, _newpath = line.strip.split("\t", 3)
      diff = build_diff(path, numstats)

      case status
      when 'A'
        diffs[path] = { added: { type: :added, **diff } }
      when 'M'
        diffs[path] = { modified: { type: :modified, **diff } }
      when 'C'
        diffs[path] = { copied: { type: :copied, **diff } }
      when 'D'
        diffs[path] = { deleted: { type: :deleted, **diff } }
      when /\AR/ # R100 (which does not exist in git.ruby-lang.org's git 2.1.4)
        # TODO: implement something
      else
        $stderr.puts "unexpected git diff status: #{status}"
      end
    end

    diffs
  end

  def build_diff(path, numstats)
    diff = { added: 0, deleted: 0 } # :body not implemented because not used
    line = numstats.find { |(_added, _deleted, file, *)| file == path }
    return diff if line.nil?

    added, deleted, _ = line
    if added
      diff[:added] = Integer(added)
    end
    if deleted
      diff[:deleted] = Integer(deleted)
    end
    diff
  end

  def git_show(revision, format:)
    git('show', "--pretty=#{format}", '--no-patch', revision).strip
  end

  def git(*args)
    command = ['git', '-C', @repo_path, *args]
    output = with_gitenv { IO.popen(command, external_encoding: 'UTF-8', &:read) }
    unless $?.success?
      raise GitCommandFailure, "failed to execute '#{command.join(' ')}':\n#{output}"
    end
    output
  end

  def with_gitenv
    orig = ENV.to_h.dup
    begin
      ENV.delete('GIT_DIR')
      yield
    ensure
      ENV.replace(orig)
    end
  end
end

CommitEmail = Module.new
class << CommitEmail
  SENDMAIL = ENV.fetch('SENDMAIL', '/usr/sbin/sendmail')
  private_constant :SENDMAIL

  def parse(args)
    options = OpenStruct.new
    options.error_to = nil
    options.viewvc_uri = nil

    opts = OptionParser.new do |opts|
      opts.separator('')

      opts.on('-e', '--error-to [TO]',
              'Add [TO] to to address when error is occurred') do |to|
        options.error_to = to
      end

      opts.on('--viewer-uri [URI]',
              'Use [URI] as URI of revision viewer') do |uri|
        options.viewer_uri = uri
      end

      opts.on_tail('--help', 'Show this message') do
        puts opts
        exit
      end
    end

    return opts.parse(args), options
  end

  def main(repo_path, to, rest)
    args, options = parse(rest)

    infos = args.each_slice(3).flat_map do |oldrev, newrev, refname|
      revisions = IO.popen(['git', 'log', '--reverse', '--pretty=%H', "#{oldrev}^..#{newrev}"], &:read).lines.map(&:strip)
      revisions[0..-2].zip(revisions[1..-1]).map do |old, new|
        GitInfoBuilder.new(repo_path).build(old, new, refname)
      end
    end

    infos.each do |info|
      next if info.branch.start_with?('notes/')
      puts "#{info.branch}: #{info.revision} (#{info.author})"

      from = make_from(name: info.author, email: "noreply@ruby-lang.org")
      sendmail(to, from, make_mail(to, from, info, viewer_uri: options.viewer_uri))
    end
  end

  def sendmail(to, from, mail)
    IO.popen([*SENDMAIL.shellsplit, to], 'w') do |f|
      f.print(mail)
    end
    unless $?.success?
      raise "Failed to run `#{SENDMAIL} #{to}` with: '#{mail}'"
    end
  end

  private

  def b_encode(str)
    NKF.nkf('-WwM', str)
  end

  def make_body(info, viewer_uri:)
    body = ''
    body << "#{info.author}\t#{format_time(info.date)}\n"
    body << "\n"
    body << "  New Revision: #{info.revision}\n"
    body << "\n"
    body << "  #{viewer_uri}#{info.revision}\n"
    body << "\n"
    body << "  Log:\n"
    body << info.log.lstrip.gsub(/^\t*/, '    ').rstrip
    body << "\n\n"
    body << added_dirs(info)
    body << added_files(info)
    body << deleted_dirs(info)
    body << deleted_files(info)
    body << modified_dirs(info)
    body << modified_files(info)
    [body.rstrip].pack('M')
  end

  def format_time(time)
    time.strftime('%Y-%m-%d %X %z (%a, %d %b %Y)')
  end

  def changed_items(title, type, items)
    rv = ''
    unless items.empty?
      rv << "  #{title} #{type}:\n"
      rv << items.collect {|item| "    #{item}\n"}.join('')
    end
    rv
  end

  def changed_files(title, files)
    changed_items(title, 'files', files)
  end

  def added_files(info)
    changed_files('Added', info.added_files)
  end

  def deleted_files(info)
    changed_files('Removed', info.deleted_files)
  end

  def modified_files(info)
    changed_files('Modified', info.updated_files)
  end

  def changed_dirs(title, files)
    changed_items(title, 'directories', files)
  end

  def added_dirs(info)
    changed_dirs('Added', info.added_dirs)
  end

  def deleted_dirs(info)
    changed_dirs('Removed', info.deleted_dirs)
  end

  def modified_dirs(info)
    changed_dirs('Modified', info.updated_dirs)
  end

  def changed_dirs_info(info, uri)
    rev = info.revision
    (info.added_dirs.collect do |dir|
       "  Added: #{dir}\n"
     end + info.deleted_dirs.collect do |dir|
       "  Deleted: #{dir}\n"
     end + info.updated_dirs.collect do |dir|
       "  Modified: #{dir}\n"
     end).join("\n")
  end

  def diff_info(info, uri)
    info.diffs.collect do |key, values|
      [
        key,
        values.collect do |type, value|
          case type
          when :added
            command = 'cat'
            rev = "?revision=#{info.revision}&view=markup"
          when :modified, :property_changed
            command = 'diff'
            prev_revision = (info.revision.is_a?(Integer) ? info.revision - 1 : "#{info.revision}^")
            rev = "?r1=#{info.revision}&r2=#{prev_revision}&diff_format=u"
          when :deleted, :copied
            command = 'cat'
            rev = ''
          else
            raise "unknown diff type: #{value[:type]}"
          end

          link = [uri, key.sub(/ .+/, '') || ''].join('/') + rev

          desc = ''

          [desc, link]
        end
      ]
    end
  end

  def make_header(to, from, info)
    headers = []
    headers << x_author(info)
    headers << x_repository(info)
    headers << x_revision(info)
    headers << x_id(info)
    headers << 'Mime-Version: 1.0'
    headers << 'Content-Type: text/plain; charset=utf-8'
    headers << 'Content-Transfer-Encoding: quoted-printable'
    headers << "From: #{from}"
    headers << "To: #{to}"
    headers << "Subject: #{make_subject(info)}"
    headers.find_all do |header|
      /\A\s*\z/ !~ header
    end.join("\n")
  end

  def make_subject(info)
    subject = ''
    subject << "#{info.revision}"
    subject << " (#{info.branch})"
    subject << ': '
    subject << info.log.lstrip.lines.first.to_s.strip
    b_encode(subject)
  end

  # https://tools.ietf.org/html/rfc822#section-4.1
  # https://tools.ietf.org/html/rfc822#section-6.1
  # https://tools.ietf.org/html/rfc822#appendix-D
  # https://tools.ietf.org/html/rfc2047
  def make_from(name:, email:)
    if name.ascii_only?
      escaped_name = name.gsub(/["\\\n]/) { |c| "\\#{c}" }
      %Q["#{escaped_name}" <#{email}>]
    else
      escaped_name = "=?UTF-8?B?#{NKF.nkf('-WwMB', name)}?="
      %Q[#{escaped_name} <#{email}>]
    end
  end

  def x_author(info)
    "X-SVN-Author: #{b_encode(info.author)}"
  end

  def x_repository(info)
    'X-SVN-Repository: XXX'
  end

  def x_id(info)
    "X-SVN-Commit-Id: #{info.entire_sha256}"
  end

  def x_revision(info)
    "X-SVN-Revision: #{info.revision}"
  end

  def make_mail(to, from, info, viewer_uri:)
    "#{make_header(to, from, info)}\n#{make_body(info, viewer_uri: viewer_uri)}"
  end
end

repo_path, to, *rest = ARGV
begin
  CommitEmail.main(repo_path, to, rest)
rescue StandardError => e
  $stderr.puts "#{e.class}: #{e.message}"
  $stderr.puts e.backtrace

  _, options = CommitEmail.parse(rest)
  to = options.error_to
  CommitEmail.sendmail(to, to, <<-MAIL)
From: #{to}
To: #{to}
Subject: Error

#{$!.class}: #{$!.message}
#{$@.join("\n")}
MAIL
  exit 1
end
