# frozen_string_literal: true

require 'rugged'
require 'ostruct'
require 'mime-types'

module Gollum

  def self.set_git_timeout(time)
  end

  def self.set_git_max_filesize(size)
  end

  module Git

    DEFAULT_MIME_TYPE = "text/plain"
    class NoSuchShaFound < StandardError; end

    class Actor

      attr_accessor :name, :email, :time

      def self.default_actor
        self.new("Gollum", "Gollum@wiki")
      end

      def initialize(name, email, time = nil)
        @name = name
        @email = email
        @time = time
      end

      def output(time)
        # implementation from grit
        offset = time.utc_offset / 60
        "%s <%s> %d %+.2d%.2d" % [
        @name,
        @email || "null",
        time.to_i,
        offset / 60,
        offset.abs % 60]
      end

      def to_h
        {:name => @name, :email => @email, :time => @time}
      end

    end

    class Blob

      attr_reader :mode
      attr_reader :name
      attr_reader :id

      def self.create(repo, options)
        blob = repo.git.lookup(options[:id])
        self.new(blob, **options)
      end

      def initialize(blob, options = {})
        @blob = blob
        @mode = options[:mode]
        @name = options[:name]
        @size = options[:size]
        @id = blob.oid
      end

      def data
        @content ||= @blob.content
      end

      def is_symlink
        @mode == 0120000
      end

      def mime_type
        guesses = MIME::Types.type_for(self.name) rescue []
        guesses.first ? guesses.first.simplified : DEFAULT_MIME_TYPE
      end

      def size
        @size || @blob.size
      end

      def symlink_target(base_path = nil)
        target = data
        new_path = ::File.expand_path(::File.join('..', target), base_path)
        return new_path if ::File.file? new_path
        nil
      end
    end

    class Commit

      def initialize(commit, tracked_pathname = nil)
        @commit = commit
        @tracked_pathname = tracked_pathname
      end

      def id
        @commit.oid
      end
      alias_method :sha, :id
      alias_method :to_s, :id

      attr_reader :commit, :tracked_pathname

      def author
        @author ||= Gollum::Git::Actor.new(@commit.author[:name], @commit.author[:email])
      end

      def authored_date
        @commit.author[:time]
      end

      def message
        @commit.message
      end

      def tree
        Gollum::Git::Tree.new(@commit.tree)
      end

      def stats
        @stats ||= build_stats
      end

      def parent
        @commit.parents.empty? ? nil : Gollum::Git::Commit.new(@commit.parents.first)
      end

      private

      def build_stats
        additions = 0
        deletions = 0
        total = 0
        files = []
        parent = @commit.parents.first
        diff = Rugged::Tree.diff(@commit.tree.repo, parent ? parent.tree : nil, @commit.tree)
        diff = diff.each_patch do |patch|
          new_additions = patch.additions
          new_deletions = patch.deletions
          additions += new_additions
          deletions += new_deletions
          total += patch.changes
          files << [patch.delta.new_file[:path].force_encoding("UTF-8"), new_additions, new_deletions, patch.changes]
        end
        OpenStruct.new(:additions => additions, :deletions => deletions, :files => files, :id => id, :total => total)
      end

    end

    class Git

      # Rugged does not have a Git class, but the Repository class should allows us to do what's necessary.
      def initialize(repo)
        @repo = repo
      end

      def exist?
        ::File.exists?(@repo.path)
      end

      def grep(query, options={})
        ref = options[:ref] ? options[:ref] : "HEAD"
        tree = @repo.lookup(sha_from_ref(ref)).tree
        tree = @repo.lookup(tree[options[:path]][:oid]) if options[:path]
        enc = options.fetch(:encoding, 'utf-8')
        results = []
        tree.walk_blobs(:postorder) do |root, entry|
          blob = @repo.lookup(entry[:oid])
          count = 0
          next if blob.binary?
          blob.content.force_encoding(enc).each_line do |line|
            next unless line.match(/#{query}/i)
            count += 1
          end
          path = options[:path] ? ::File.join(options[:path], root, entry[:name]) : "#{root}#{entry[:name]}"
          results << {:name => path, :count => count} unless count == 0
        end
        results
      end

      def rm(path, options = {})
        index = @repo.index
        index.write
        ::File.unlink ::File.join(@repo.workdir, path)
      end

      def cat_file(options, sha)
        @repo.lookup(sha).read_raw
      end

      def apply_patch(head_sha = 'HEAD', patch=nil)
        false # Rewrite gollum-lib's revert so that it doesn't require a direct equivalent of Grit's apply_patch
      end

      def revert(path, sha1, sha2, ref)
        # FIXME: See https://github.com/gollum/grit_adapter/pull/14
        fail NotImplementedError
      end

      def checkout(path, ref = 'HEAD', options = {})
        path = path.nil? ? path : [path]
        options = options.merge({:paths => path, :strategy => :force})
        if ref == 'HEAD'
          @repo.checkout_head(**options)
        else
          ref = "refs/heads/#{ref}" unless ref =~ /^refs\/heads\//
          @repo.checkout_tree(sha_from_ref(ref), **options)
        end
      end

      def log(ref = 'refs/heads/master', path = nil, options = {})
        default_options = {
          :limit => options[:max_count] ? options[:max_count] : 10,
          :offset => options[:skip] ? options[:skip] : 0,
          :path => path,
          :follow => false,
          :skip_merges => false
        }
        options = default_options.merge(**options)
        options[:limit] ||= 0
        options[:offset] ||= 0
        sha = sha_from_ref(ref)
        return [] if sha.nil?
        begin
          build_log(sha, **options)
        rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError
        # Return an empty array if the ref wasn't found
          []
        end
      end

      def versions_for_path(path = nil, ref = nil, options = {})
        log(ref, path, **options)
      end

      def ls_files(query, options = {})
        ref = options[:ref] || "refs/heads/master"
        tree = @repo.lookup(sha_from_ref(ref)).tree
        tree = @repo.lookup(tree[options[:path]][:oid]) if options[:path]
        results = []
        tree.walk_blobs do |root, blob|
          next unless blob[:name] =~ /#{query}/
          path = options[:path] ? ::File.join(options[:path], root, blob[:name]) : "#{root}#{blob[:name]}"
          results << path
        end
        results
      end

      def lookup(id)
        @repo.lookup(id)
      end

      def ref_to_sha(query)
        return query if sha?(query)
        query = "refs/heads/#{query}" if !query.nil? && !(query =~ /^refs\/heads\//) && !(query == "HEAD")
        begin
          return @repo.rev_parse_oid(query)
        rescue Rugged::ReferenceError, Rugged::InvalidError
          return nil
        end
      end

      def sha_or_commit_from_ref(ref, request_kind = nil)
        sha = ref_to_sha(ref)
        return nil if sha.nil?
        object = @repo.lookup(sha)
        if object.kind_of?(Rugged::Commit) then
          return Gollum::Git::Commit.new(object) if request_kind == :commit
          sha
        elsif object.respond_to?(:target)
          sha_or_commit_from_ref(object.target.oid, request_kind)
        end
      end
      alias_method :sha_from_ref, :sha_or_commit_from_ref

      def commit_from_ref(ref)
        sha_or_commit_from_ref(ref, :commit)
      end

      def push(remote, branches = nil, options = {})
        branches = [branches].flatten.map {|branch| "refs/heads/#{branch}" unless branch =~ /^refs\/heads\//}
        @repo.push(remote, branches, **options)
      end

      def pull(remote, branches = nil, options = {})
        branches = [branches].flatten.map {|branch| "refs/heads/#{branch}" unless branch =~ /^refs\/heads\//}
        r = @repo.remotes[remote]
        r.fetch(branches, **options)
        branches.each do |branch|
          branch_name = branch.match(/^refs\/heads\/(.*)/)[1]
          remote_name = remote.match(/^(refs\/heads\/)?(.*)/)[2]
          remote_ref = @repo.branches["#{remote_name}/#{branch_name}"].target
          local_ref = @repo.branches[branch].target
          index = @repo.merge_commits(local_ref, remote_ref)
          options = { author: Actor.default_actor.to_h,
            committer:  Actor.default_actor.to_h,
            message:    "Merged branch #{branch} of #{remote}.",
            parents: [local_ref, remote_ref],
            tree: index.write_tree(@repo),
            update_ref: branch
          }
          Rugged::Commit.create @repo, **options
          @repo.checkout(@repo.head.name, :strategy => :force) if !@repo.bare? && branch == @repo.head.name
        end
      end

      private

      def sha?(str)
        !!(str =~ /^[0-9a-f]{40}$/)
      end

      # Return an array of log commits, given a SHA hash and a hash of
      # options. From Gitlab::Git
      def build_log(sha, options)
        # Instantiate a Walker and add the SHA hash
        walker = Rugged::Walker.new(@repo)
        walker.push(sha)
        commits = []
        skipped = 0
        current_path = options[:path].dup if options[:path]
        current_path = nil if current_path == ''
        renamed_path = current_path.nil? ? nil : current_path.dup
        track_pathnames = true if current_path && options[:follow]
        limit = options[:limit].to_i
        offset = options[:offset].to_i
        skip_merges = options[:skip_merges]
        walker.sorting(Rugged::SORT_DATE)

          walker.each do |c|
            break if limit > 0 && commits.length >= limit
              if skip_merges
                # Skip merge commits
                next if c.parents.length > 1
              end
              if !current_path || commit_touches_path?(c, current_path, options[:follow], walker)
                # This is a commit we care about, unless we haven't skipped enough
                # yet
                skipped += 1

                commits.push(Gollum::Git::Commit.new(c, track_pathnames ? renamed_path : nil)) if skipped > offset
                renamed_path = current_path.nil? ? nil : current_path.dup
              end
          end
        walker.reset
        commits
      end

      # Returns true if +commit+ introduced changes to +path+, using commit
      # trees to make that determination. Uses the history simplification
      # rules that `git log` uses by default, where a commit is omitted if it
      # is TREESAME to any parent.
      #
      # If the +follow+ option is true and the file specified by +path+ was
      # renamed, then the path value is set to the old path.
      def commit_touches_path?(commit, path, follow, walker)
        entry = tree_entry(commit, path)

          if commit.parents.empty?
            # This is the root commit, return true if it has +path+ in its tree
            return entry != nil
          end

        num_treesame = 0
        commit.parents.each do |parent|
          parent_entry = tree_entry(parent, path)

          # Only follow the first TREESAME parent for merge commits
          if num_treesame > 0
            walker.hide(parent.oid)
            next
          end

          if entry.nil? && parent_entry.nil?
            num_treesame += 1
          elsif entry && parent_entry && entry[:oid] == parent_entry[:oid]
            num_treesame += 1
          end
        end

        case num_treesame
          when 0
            detect_rename(commit, commit.parents.first, path) if follow
            true
          else false
        end
      end

      # Find the entry for +path+ in the tree for +commit+
      def tree_entry(commit, path)
        pathname = Pathname.new(path)
        tmp_entry = nil

        pathname.each_filename do |dir|
          tmp_entry = tmp_entry ? @repo.lookup(tmp_entry[:oid])[dir] : commit.tree[dir]
          return nil unless tmp_entry
        end
        tmp_entry
      end

      # Compare +commit+ and +parent+ for +path+. If +path+ is a file and was
      # renamed in +commit+, then set +path+ to the old filename.
      def detect_rename(commit, parent, path)
        diff = parent.diff(commit, paths: [path], disable_pathspec_match: true)

        # If +path+ is a filename, not a directory, then we should only have
        # one delta. We don't need to follow renames for directories.
        return nil if diff.each_delta.count > 1

        delta = diff.each_delta.first
        if delta.added?
          full_diff = parent.diff(commit)
          full_diff.find_similar!

          full_diff.each_delta do |full_delta|
            if full_delta.renamed? && path == full_delta.new_file[:path]
              # Look for the old path in ancestors
              path.replace(full_delta.old_file[:path])
            end
          end
        end
      end

    end

    class Index

      def initialize(index, repo)
        @index = index
        @rugged_repo = repo
        @treemap = {}
      end

      def delete(path)
        @index.remove(path)
        update_treemap(path, false)
        false
      end

      def delete_all(glob)
        # if the path start with escape it and avoid treating it as a comment
        escaped_glob = glob[0] == '#' ? glob.sub('#', '\#') : glob

        @index.remove_all(escaped_glob)
        update_treemap(glob, false)
        false
      end

      def add(path, data)
        blob = @rugged_repo.write(data, :blob)
        @index.add(:path => path, :oid => blob, :mode => 0100644)
        update_treemap(path, data)
        data
      end

      def index
        @index
      end

      def commit(message, parents = nil, actor = nil, last_tree = nil, head = 'refs/heads/master')
        commit_options = {}
        head = "refs/heads/#{head}" if head && head !~ %r(^refs/heads/)
        parents = get_parents(parents, head) || []
        actor = Gollum::Git::Actor.default_actor if actor.nil?
        commit_options[:tree] = @index.write_tree
        commit_options[:author] = actor.to_h
        commit_options[:committer] = actor.to_h
        commit_options[:message] = message.to_s
        commit_options[:parents] = parents
        commit_options[:update_ref] = head
        Rugged::Commit.create(@rugged_repo, commit_options)
      end

      def tree
        @treemap
      end

      def read_tree(id)
        id = Gollum::Git::Git.new(@rugged_repo).ref_to_sha(id)
        return nil if id.nil?
        begin
          current_tree = @rugged_repo.lookup(id)
          current_tree = current_tree.tree unless current_tree.is_a?(Rugged::Tree)
          @index.read_tree(current_tree)
        rescue
          raise Gollum::Git::NoSuchShaFound
        end
        @current_tree = Gollum::Git::Tree.new(current_tree)
      end

      def current_tree
        @current_tree
      end

      private

      def get_parents(parents, head)
        if parents
          parents.map{|parent| parent.commit}
        elsif ref = @rugged_repo.references[head]
          ref = ref.target
          ref = ref.target if ref.respond_to?(:target)
          [ref]
        end
      end

      def update_treemap(path, data)
        # From RJGit::Plumbing::Index
        path = path[1..-1] if path[0] == ::File::SEPARATOR
        path = path.split(::File::SEPARATOR)
        last = path.pop

        current = @treemap

        path.each do |dir|
          current[dir] ||= {}
          node = current[dir]
          current = node
        end

        current[last] = data
        @treemap
      end

    end

    class Ref
      def initialize(ref)
        @ref = ref
      end

      def name
        @ref.name
      end

      def commit
        Gollum::Git::Commit.new(@ref.target)
      end

    end

    class Repo

      def initialize(path, options)
        @repo = Rugged::Repository.new(path, **options)
      end

      def self.init(path)
        Rugged::Repository.init_at(path, false)
        self.new(path, :is_bare => false)
      end

      def self.init_bare(path)
        Rugged::Repository.init_at(path, true)
        self.new(path, :is_bare => true)
      end

      def bare
        @repo.bare?
      end

      def config
        @repo.config
      end

      def git
        @git ||= Gollum::Git::Git.new(@repo)
      end

      def commit(id)
        begin
          git.commit_from_ref(id)
        rescue
          raise Gollum::Git::NoSuchShaFound
        end
      end

      def commits(start = 'refs/heads/master', max_count = 10, skip = 0)
        git.log(start, nil, :max_count => max_count, :skip => skip)
      end

      def head
        begin
          return Gollum::Git::Ref.new(@repo.head)
        rescue Rugged::ReferenceError
          return nil
        end
      end

      def index
        @index ||= Gollum::Git::Index.new(@repo.index, @repo)
      end

      def diff(sha1, sha2, *paths)
        opts = paths.nil? ? {} : {:paths => paths}
        @repo.diff(sha1, sha2, opts).patch
      end

      def log(commit = 'refs/heads/master', path = nil, options = {})
        git.log(commit, path, **options)
      end

      def lstree(sha, options = {})
        results = []
        @repo.lookup(sha).tree.walk(:postorder) do |root, entry|
          entry[:sha] = entry[:oid]
          entry[:mode] = entry[:filemode].to_s(8)
          entry[:type] = entry[:type].to_s
          entry[:path] = "#{root}#{entry[:name]}"
          results << entry
        end
        results
      end

      def path
        @repo.path
      end

      # Checkout branch and if necessary first create it. Currently used only in gollum-lib's tests.
      def update_ref(ref, commit_sha)
        ref = "refs/heads/#{ref}" unless ref =~ /^refs\/heads\//
        if _ref = @repo.references[ref]
          @repo.references.update(_ref, commit_sha)
        else
          @repo.create_branch(ref, commit_sha)
          @repo.checkout(ref, :strategy => :force) unless @repo.bare?
        end
      end

      def files_sorted_by_created_at(sha = nil)
        sha ||= @repo.head.target.oid

        file_renamings = {}
        sorting = Rugged::SORT_DATE | Rugged::SORT_TOPO

        @repo.walk(sha, sorting).with_object([]) do |commit, files|
          parent = commit.parents.first

          diff = commit.diff(parent, reverse: true)
          diff.find_similar!
          diff.each_delta do |delta|
            name = delta.new_file[:path]

            if delta.added?
              files << (file_renamings[name] || name)
            elsif delta.renamed?
              file_renamings[delta.old_file[:path]] = file_renamings[name] || name
            end
          end
        end
      end
    end

    class Tree

      def initialize(tree)
        @tree = tree
      end

      def keys
        @tree.map{|entry| entry[:name]}
      end

      def [](i)
        @tree[i]
      end

      def id
        @tree.oid
      end

      def /(file)
        return self if file == '/'
        begin
        obj = @tree.path(file)
        rescue Rugged::TreeError
          return nil
        end
        return nil if obj.nil?
        obj = @tree.owner.lookup(obj[:oid])
        obj.is_a?(Rugged::Tree) ? Gollum::Git::Tree.new(obj) : Gollum::Git::Blob.new(obj)
      end

      def blobs
        blobs = []
        @tree.each_blob {|blob| blobs << Gollum::Git::Blob.new(@tree.owner.lookup(blob[:oid]), blob) }
        blobs
      end

    end

  end
end
