The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
require "English"
require "time"
require "digest/sha2"
require "tempfile"

require "nkf"
begin
  require "uconv"
rescue LoadError
  module Uconv
    class Error < StandardError
    end
    def self.u8toeuc(str)
      raise Error
    end
  end
end

require "svn/core"
require "svn/repos"
require "svn/fs"
require "svn/delta"

module Svn
  class Info

    attr_reader :author, :log, :date, :changed_dirs
    attr_reader :added_files, :deleted_files, :updated_files, :copied_files
    attr_reader :added_dirs, :deleted_dirs, :updated_dirs, :copied_dirs
    attr_reader :path, :revision, :diffs
    attr_reader :sha256, :entire_sha256

    def initialize(path, rev)
      setup(path, rev)
      get_info
      get_dirs_changed
      get_changed
      get_diff
      get_sha256
      teardown
    end

    def paths
      files + directories
    end

    def files
      @added_files + @deleted_files + @updated_files + @copied_files
    end

    def directories
      @added_dirs + @deleted_dirs + @updated_dirs + @copied_dirs
    end

    def sub_paths(prefix)
      prefixes = prefix.split(/\/+/)
      results = []
      paths.each do |path,|
        paths = path.split(/\/+/)
        if prefixes.size < paths.size and prefixes == paths[0, prefixes.size]
          results << paths[prefixes.size]
        end
      end
      results
    end

    private
    def setup(path, rev)
      @path = path
      @revision = Integer(rev)
      @prev_rev = @revision - 1
      @repos = Repos.open(@path)
      @fs = @repos.fs
      @root = @fs.root(@revision)
    end

    def teardown
      @repos.close
      @repos = @root = @fs = nil
    end

    def get_info
      @author = force_to_utf8(prop(Core::PROP_REVISION_AUTHOR))
      @date = prop(Core::PROP_REVISION_DATE)
      @log = force_to_utf8(prop(Core::PROP_REVISION_LOG))
    end

    def get_dirs_changed
      editor = traverse(Delta::ChangedDirsEditor)
      @changed_dirs = editor.changed_dirs
    end

    def get_changed
      editor = traverse(Delta::ChangedEditor, true)
      @copied_files = editor.copied_files
      @copied_dirs = editor.copied_dirs
      @added_files = editor.added_files
      @added_dirs = editor.added_dirs
      @deleted_files = editor.deleted_files
      @deleted_dirs = editor.deleted_dirs
      @updated_files = editor.updated_files
      @updated_dirs = editor.updated_dirs
    end

    def get_diff
      tree = @repos.delta_tree(@root, @prev_rev)
      @diffs = {}
      get_diff_recurse(tree, @fs.root(@prev_rev), "", "")
      @diffs.each do |key, values|
        values.each do |type, diff|
          diff.body = force_to_utf8(diff.body)
        end
      end
    end

    TYPE_TABLE = [
      [:copy?, :copied],
      [:add?, :added],
      [:delete?, :deleted],
      [:replace?, :modified],
    ]

    def get_type(node)
      TYPE_TABLE.each do |meth, type|
        return type if node.__send__(meth)
      end
      nil
    end

    def get_diff_recurse(node, base_root, path, base_path)
      if node.copy?
        base_path = node.copyfrom_path.sub(/\A\//, '')
        base_root = base_root.fs.root(node.copyfrom_rev)
      end

      if node.file?
        try_diff(node, base_root, path, base_path)
      end

      if node.prop_mod? and !node.delete?
        get_prop_diff(node, base_root, path, base_path)
      end

      node = node.child
      if node
        get_diff_recurse(node, base_root,
                         "#{path}/#{node.name}",
                         "#{base_path}/#{node.name}")
        while node.sibling
          node = node.sibling
          get_diff_recurse(node, base_root,
                           "#{path}/#{node.name}",
                           "#{base_path}/#{node.name}")
        end
      end
    end

    def get_prop_diff(node, base_root, path, base_path)
      local_props = @root.node_prop_list(path)
      if node.add?
        base_props = {}
      else
        base_props = base_root.node_prop_list(base_path)
      end
      prop_changes = Core::Property.diffs2(local_props, base_props)
      prop_changes.each do |name, value|
        entry = diff_entry(path, :property_changed)
        entry.body << "Name: #{force_to_utf8(name)}\n"
        orig_value = base_props[name]
        entry.body << "   - #{force_to_utf8(orig_value)}\n" if orig_value
        entry.body << "   + #{force_to_utf8(value)}\n" if value
      end
    end

    def try_diff(node, base_root, path, base_path)
      if node.replace? and node.text_mod?
        differ = Fs::FileDiff.new(base_root, base_path, @root, path)
        do_diff(node, base_root, path, base_path, differ)
      elsif node.add? and node.text_mod?
        differ = Fs::FileDiff.new(nil, base_path, @root, path)
        do_diff(node, base_root, path, base_path, differ)
      elsif node.delete?
        differ = Fs::FileDiff.new(base_root, base_path, nil, path)
        do_diff(node, base_root, path, base_path, differ)
      elsif node.copy?
        diff_entry(path, get_type(node))
      end
    end

    def do_diff(node, base_root, path, base_path, differ)
      entry = diff_entry(path, get_type(node))

      if differ.binary?
        entry.body = "(Binary files differ)\n"
      else
        base_rev = base_root.revision
        rev = @root.revision
        base_date = base_root.fs.prop(Core::PROP_REVISION_DATE, base_rev)
        date = @root.fs.prop(Core::PROP_REVISION_DATE, rev)
        base_date = format_date(base_date)
        date = format_date(date)
        stripped_base_path = base_path.sub(/\A\//, '')
        stripped_path = path.sub(/\A\//, '')
        base_label = "#{stripped_base_path}\t#{base_date} (rev #{base_rev})"
        label = "#{stripped_path}\t#{date} (rev #{rev})"
        entry.body = differ.unified(base_label, label, "UTF-8")
        parse_diff_unified(entry)
      end
    end

    def parse_diff_unified(entry)
      in_content = false
      entry.body.each do |line|
        case line
        when /^@@/
          in_content = true
        else
          if in_content
            case line
            when /^\+/
              entry.count_up_added_line!
            when /^-/
              entry.count_up_deleted_line!
            end
          end
        end
      end
    end

    def dump_contents(root, path)
      root.file_contents(path) do |f|
        if block_given?
          yield f
        else
          f.read
        end
      end
    end

    def diff_entry(target, type)
      target = target.sub(/\A\//, '')
      @diffs[target] ||= {}
      @diffs[target][type] ||= DiffEntry.new(type)
      @diffs[target][type]
    end

    def get_sha256
      sha = Digest::SHA256.new
      @sha256 = {}
      [
        @added_files,
#        @deleted_files,
        @updated_files,
      ].each do |files|
        files.each do |file|
          content = dump_contents(@root, file)
          sha << content
          @sha256[file] = {
            :file => file,
            :revision => @revision,
            :sha256 => Digest::SHA256.hexdigest(content),
          }
        end
      end
      @entire_sha256 = sha.hexdigest
    end

    def prop(name)
      @fs.prop(name, @revision)
    end

    def force_to_utf8(str)
      str = str.to_s
      begin
        # check UTF-8 or not
        Uconv.u8toeuc(str)
        str
      rescue Uconv::Error
        NKF.nkf("-w", str)
      end
    end

    def traverse(editor_class, pass_root=false)
      base_rev = @prev_rev
      base_root = @fs.root(base_rev)
      if pass_root
        editor = editor_class.new(@root, base_root)
      else
        editor = editor_class.new
      end
      base_root.dir_delta("", "", @root, "", editor)
      editor
    end

    def format_date(date)
      formatted = date.strftime("%Y-%m-%d %H:%M:%S")
      formatted << (" %+03d:00" % (date.hour - date.getutc.hour))
      formatted
    end

    class DiffEntry
      attr_reader :type, :added_line, :deleted_line
      attr_accessor :body

      def initialize(type)
        @type = type
        @added_line = 0
        @deleted_line = 0
        @body = ""
      end

      def count_up_added_line!
        @added_line += 1
      end

      def count_up_deleted_line!
        @deleted_line += 1
      end
    end
  end
end