module Grit
  class Repo
    include POSIX::Spawn

    DAEMON_EXPORT_FILE = 'git-daemon-export-ok'
    BATCH_PARSERS      = {
      'commit' => ::Grit::Commit
    }

    # Public: The String path of the Git repo.
    attr_accessor :path

    # Public: The String path to the working directory of the repo, or nil if
    # there is no working directory.
    attr_accessor :working_dir

    # Public: The Boolean of whether or not the repo is bare.
    attr_reader :bare

    # Public: The Grit::Git command line interface object.
    attr_accessor :git

    # Public: Create a new Repo instance.
    #
    # path    - The String path to either the root git directory or the bare
    #           git repo. Bare repos are expected to end with ".git".
    # options - A Hash of options (default: {}):
    #           :is_bare - Boolean whether to consider the repo as bare even
    #                      if the repo name does not end with ".git".
    #
    # Examples
    #
    #   r = Repo.new("/Users/tom/dev/normal")
    #   r = Repo.new("/Users/tom/public/bare.git")
    #   r = Repo.new("/Users/tom/public/bare", {:is_bare => true})
    #
    # Returns a newly initialized Grit::Repo.
    # Raises Grit::InvalidGitRepositoryError if the path exists but is not
    #   a Git repository.
    # Raises Grit::NoSuchPathError if the path does not exist.
    def initialize(path, options = {})
      epath = File.expand_path(path)

      if File.exist?(File.join(epath, '.git'))
        self.working_dir = epath
        self.path = File.join(epath, '.git')
        @bare = false
      elsif File.exist?(epath) && (epath =~ /\.git$/ || options[:is_bare])
        self.path = epath
        @bare = true
      elsif File.exist?(epath)
        raise InvalidGitRepositoryError.new(epath)
      else
        raise NoSuchPathError.new(epath)
      end

      self.git = Git.new(self.path, work_tree: self.working_dir)
    end

    # Public: Initialize a git repository (create it on the filesystem). By
    # default, the newly created repository will contain a working directory.
    # If you would like to create a bare repo, use Grit::Repo.init_bare.
    #
    # path         - The String full path to the repo. Traditionally ends with
    #                "/<name>.git".
    # git_options  - A Hash of additional options to the git init command
    #                (default: {}).
    # repo_options - A Hash of additional options to the Grit::Repo.new call
    #                (default: {}).
    #
    # Examples
    #
    #   Grit::Repo.init('/var/git/myrepo.git')
    #
    # Returns the newly created Grit::Repo.
    def self.init(path, git_options = {}, repo_options = {})
      git_options = {:base => false}.merge(git_options)
      git = Git.new(path)
      git.fs_mkdir('..')
      git.init(git_options, path)
      self.new(path, repo_options)
    end

    # Public: Initialize a bare git repository (create it on the filesystem).
    #
    # path         - The String full path to the repo. Traditionally ends with
    #                "/<name>.git".
    # git_options  - A Hash of additional options to the git init command
    #                (default: {}).
    # repo_options - A Hash of additional options to the Grit::Repo.new call
    #                (default: {}).
    #
    # Examples
    #
    #   Grit::Repo.init_bare('/var/git/myrepo.git')
    #
    # Returns the newly created Grit::Repo.
    def self.init_bare(path, git_options = {}, repo_options = {})
      git_options = {:bare => true}.merge(git_options)
      git = Git.new(path)
      git.fs_mkdir('..')
      git.init(git_options)
      repo_options = {:is_bare => true}.merge(repo_options)
      self.new(path, repo_options)
    end

    # Public: Initialize a bare Git repository (create it on the filesystem)
    # or, if the repo already exists, simply return it.
    #
    # path         - The String full path to the repo. Traditionally ends with
    #                "/<name>.git".
    # git_options  - A Hash of additional options to the git init command
    #                (default: {}).
    # repo_options - A Hash of additional options to the Grit::Repo.new call
    #                (default: {}).
    #
    # Returns the new or existing Grit::Repo.
    def self.init_bare_or_open(path, git_options = {}, repo_options = {})
      git = Git.new(path)

      unless git.exist?
        git.fs_mkdir(path)
        git.init(git_options)
      end

      self.new(path, repo_options)
    end

    # Public: Create a bare fork of this repository.
    #
    # path    - The String full path of where to create the new fork.
    #           Traditionally ends with "/<name>.git".
    # options - The Hash of additional options to the git clone command.
    #           These options will be merged on top of the default Hash:
    #           {:bare => true, :shared => true}.
    #
    # Returns the newly forked Grit::Repo.
    def fork_bare(path, options = {})
      default_options = {:bare => true, :shared => true}
      real_options = default_options.merge(options)
      Git.new(path).fs_mkdir('..')
      self.git.clone(real_options, self.path, path)
      Repo.new(path)
    end

    # Public: Fork a bare git repository from another repo.
    #
    # path    - The String full path of the repo from which to fork..
    #           Traditionally ends with "/<name>.git".
    # options - The Hash of additional options to the git clone command.
    #           These options will be merged on top of the default Hash:
    #           {:bare => true, :shared => true}.
    #
    # Returns the newly forked Grit::Repo.
    def fork_bare_from(path, options = {})
      default_options = {:bare => true, :shared => true}
      real_options = default_options.merge(options)
      Git.new(self.path).fs_mkdir('..')
      self.git.clone(real_options, path, self.path)
      Repo.new(self.path)
    end

    # Public: Return the full Git objects from the given SHAs.  Only Commit
    # objects are parsed for now.
    #
    # *shas - Array of String SHAs.
    #
    # Returns an Array of Grit objects (Grit::Commit).
    def batch(*shas)
      shas.flatten!
      text = git.native(:cat_file, {:batch => true, :input => (shas * "\n")})
      parse_batch(text)
    end

    # Parses `git cat-file --batch` output, returning an array of Grit objects.
    #
    # text - Raw String output.
    #
    # Returns an Array of Grit objects (Grit::Commit).
    def parse_batch(text)
      io = StringIO.new(text)
      objects = []
      while line = io.gets
        sha, type, size = line.split(" ", 3)
        parser = BATCH_PARSERS[type]
        if type == 'missing' || !parser
          io.seek(size.to_i + 1, IO::SEEK_CUR)
          objects << nil
          next
        end

        object   = io.read(size.to_i + 1)
        objects << parser.parse_batch(self, sha, size, object)
      end
      objects
    end

    # The project's description. Taken verbatim from GIT_REPO/description
    #
    # Returns String
    def description
      self.git.fs_read('description').chomp
    end

    def blame(file, commit = nil)
      Blame.new(self, file, commit)
    end

    # An array of Head objects representing the branch heads in
    # this repo
    #
    # Returns Grit::Head[] (baked)
    def heads
      Head.find_all(self)
    end

    def head_count
      Head.count_all(self)
    end

    alias_method :branches, :heads
    alias_method :branch_count, :head_count

    def get_head(head_name)
      heads.find { |h| h.name == head_name }
    end

    def is_head?(head_name)
      get_head(head_name)
    end

    # Object reprsenting the current repo head.
    #
    # Returns Grit::Head (baked)
    def head
      Head.current(self)
    end


    # Commits current index
    #
    # Returns true/false if commit worked
    def commit_index(message)
      self.git.commit({}, '-m', message)
    end

    # Commits all tracked and modified files
    #
    # Returns true/false if commit worked
    def commit_all(message)
      self.git.commit({}, '-a', '-m', message)
    end

    # Adds files to the index
    def add(*files)
      self.git.add({}, *files.flatten)
    end

    # Remove files from the index
    def remove(*files)
      self.git.rm({}, *files.flatten)
    end


    def blame_tree(commit, path = nil)
      commit_array = self.git.blame_tree(commit, path)

      final_array = {}
      commit_array.each do |file, sha|
        final_array[file] = commit(sha)
      end
      final_array
    end

    def status
      Status.new(self)
    end


    # An array of Tag objects that are available in this repo
    #
    # Returns Grit::Tag[] (baked)
    def tags
      Tag.find_all(self)
    end

    def tag_count
      Tag.count_all(self)
    end

    # Finds the most recent annotated tag name that is reachable from a commit.
    #
    #   @repo.recent_tag_name('master')
    #   # => "v1.0-0-abcdef"
    #
    # committish - optional commit SHA, branch, or tag name.
    # options    - optional hash of options to pass to git.
    #              Default: {:always => true}
    #              :tags => true      # use lightweight tags too.
    #              :abbrev => Integer # number of hex digits to form the unique
    #                name.  Defaults to 7.
    #              :long => true      # always output tag + commit sha
    #              # see `git describe` docs for more options.
    #
    # Returns the String tag name, or just the commit if no tag is
    # found.  If there have been updates since the tag was made, a
    # suffix is added with the number of commits since the tag, and
    # the abbreviated object name of the most recent commit.
    # Returns nil if the committish value is not found.
    def recent_tag_name(committish = nil, options = {})
      value = git.describe({:always => true}.update(options), committish.to_s).to_s.strip
      value.size.zero? ? nil : value
    end

    # An array of Remote objects representing the remote branches in
    # this repo
    #
    # Returns Grit::Remote[] (baked)
    def remotes
      Remote.find_all(self)
    end

    def remote_count
      Remote.count_all(self)
    end

    def remote_list
      self.git.list_remotes
    end

    def remote_add(name, url)
      self.git.remote({}, 'add', name, url)
    end

    def remote_fetch(name)
      self.git.fetch({}, name)
    end

    # takes an array of remote names and last pushed dates
    # fetches from all of the remotes where the local fetch
    # date is earlier than the passed date, then records the
    # last fetched date
    #
    # { 'origin' => date,
    #   'peter => date,
    # }
    def remotes_fetch_needed(remotes)
      remotes.each do |remote, date|
        # TODO: check against date
        self.remote_fetch(remote)
      end
    end


    # An array of Ref objects representing the refs in
    # this repo
    #
    # Returns Grit::Ref[] (baked)
    def refs
      [ Head.find_all(self), Tag.find_all(self), Remote.find_all(self) ].flatten
    end

    # returns an array of hashes representing all references
    def refs_list
      refs = self.git.for_each_ref
      refarr = refs.split("\n").map do |line|
        shatype, ref = line.split("\t")
        sha, type = shatype.split(' ')
        [ref, sha, type]
      end
      refarr
    end

    def delete_ref(ref)
      self.git.native(:update_ref, {:d => true}, ref)
    end

    def commit_stats(start = 'master', max_count = 10, skip = 0)
      options = {:max_count => max_count,
                 :skip => skip}

      CommitStats.find_all(self, start, options)
    end

    # An array of Commit objects representing the history of a given ref/commit
    #   +start+ is the branch/commit name (default 'master')
    #   +max_count+ is the maximum number of commits to return (default 10, use +false+ for all)
    #   +skip+ is the number of commits to skip (default 0)
    #
    # Returns Grit::Commit[] (baked)
    def commits(start = 'master', max_count = 10, skip = 0)
      options = {:max_count => max_count,
                 :skip => skip}

      Commit.find_all(self, start, options)
    end

    # The Commits objects that are reachable via +to+ but not via +from+
    # Commits are returned in chronological order.
    #   +from+ is the branch/commit name of the younger item
    #   +to+ is the branch/commit name of the older item
    #
    # Returns Grit::Commit[] (baked)
    def commits_between(from, to)
      Commit.find_all(self, "#{from}..#{to}").reverse
    end

    def fast_forwardable?(to, from)
      mb = self.git.native(:merge_base, {}, [to, from]).strip
      mb == from
    end

    # The Commits objects that are newer than the specified date.
    # Commits are returned in chronological order.
    #   +start+ is the branch/commit name (default 'master')
    #   +since+ is a string representing a date/time
    #   +extra_options+ is a hash of extra options
    #
    # Returns Grit::Commit[] (baked)
    def commits_since(start = 'master', since = '1970-01-01', extra_options = {})
      options = {:since => since}.merge(extra_options)

      Commit.find_all(self, start, options)
    end

    # The number of commits reachable by the given branch/commit
    #   +start+ is the branch/commit name (default 'master')
    #
    # Returns Integer
    def commit_count(start = 'master')
      Commit.count(self, start)
    end

    # The Commit object for the specified id
    #   +id+ is the SHA1 identifier of the commit
    #
    # Returns Grit::Commit (baked)
    def commit(id)
      options = {:max_count => 1}

      Commit.find_all(self, id, options).first
    end

    # Returns a list of commits that is in +other_repo+ but not in self
    #
    # Returns Grit::Commit[]
    def commit_deltas_from(other_repo, ref = "master", other_ref = "master")
      # TODO: we should be able to figure out the branch point, rather than
      # rev-list'ing the whole thing
      repo_refs       = self.git.rev_list({}, ref).strip.split("\n")
      other_repo_refs = other_repo.git.rev_list({}, other_ref).strip.split("\n")

      (other_repo_refs - repo_refs).map do |refn|
        Commit.find_all(other_repo, refn, {:max_count => 1}).first
      end
    end

    def objects(refs)
      refs = refs.split(/\s+/) if refs.respond_to?(:to_str)
      self.git.rev_list({:objects => true, :timeout => false}, *refs).
        split("\n").map { |a| a[0, 40] }
    end

    def commit_objects(refs)
      refs = refs.split(/\s+/) if refs.respond_to?(:to_str)
      self.git.rev_list({:timeout => false}, *refs).split("\n").map { |a| a[0, 40] }
    end

    def objects_between(ref1, ref2 = nil)
      if ref2
        refs = "#{ref2}..#{ref1}"
      else
        refs = ref1
      end
      self.objects(refs)
    end

    def diff_objects(commit_sha, parents = true)
      revs = []
      Grit.no_quote = true
      if parents
        # PARENTS:
        revs = self.git.diff_tree({:timeout => false, :r => true, :t => true, :m => true}, commit_sha).
          strip.split("\n").map{ |a| r = a.split(' '); r[3] if r[1] != '160000' }
      else
        # NO PARENTS:
        revs = self.git.native(:ls_tree, {:timeout => false, :r => true, :t => true}, commit_sha).
          split("\n").map{ |a| a.split("\t").first.split(' ')[2] }
      end
      revs << self.commit(commit_sha).tree.id
      Grit.no_quote = false
      return revs.uniq.compact
    end

    # The Tree object for the given treeish reference
    #   +treeish+ is the reference (default 'master')
    #   +paths+ is an optional Array of directory paths to restrict the tree (default [])
    #
    # Examples
    #   repo.tree('master', ['lib/'])
    #
    # Returns Grit::Tree (baked)
    def tree(treeish = 'master', paths = [])
      Tree.construct(self, treeish, paths)
    end

    # quick way to get a simple array of hashes of the entries
    # of a single tree or recursive tree listing from a given
    # sha or reference
    #   +treeish+ is the reference (default 'master')
    #   +options+ is a hash or options - currently only takes :recursive
    #
    # Examples
    #   repo.lstree('master', :recursive => true)
    #
    # Returns array of hashes - one per tree entry
    def lstree(treeish = 'master', options = {})
      # check recursive option
      opts = {:timeout => false, :l => true, :t => true}
      if options[:recursive]
        opts[:r] = true
      end
      # mode, type, sha, size, path
      revs = self.git.native(:ls_tree, opts, treeish)
      lines = revs.split("\n")
      revs = lines.map do |a|
        stuff, path = a.split("\t")
        mode, type, sha, size = stuff.split(" ")
        entry = {:mode => mode, :type => type, :sha => sha, :path => path}
        entry[:size] = size.strip.to_i if size.strip != '-'
        entry
      end
      revs
    end

    def object(sha)
      obj = git.get_git_object(sha)
      raw = Grit::GitRuby::Internal::RawObject.new(obj[:type], obj[:content])
      object = Grit::GitRuby::GitObject.from_raw(raw)
      object.sha = sha
      object
    end

    # The Blob object for the given id
    #   +id+ is the SHA1 id of the blob
    #
    # Returns Grit::Blob (unbaked)
    def blob(id)
      Blob.create(self, :id => id)
    end

    # The commit log for a treeish
    #
    # Returns Grit::Commit[]
    def log(commit = 'master', path = nil, options = {})
      default_options = {:pretty => "raw"}
      actual_options  = default_options.merge(options)
      arg = path ? [commit, '--', path] : [commit]
      commits = self.git.log(actual_options, *arg)
      Commit.list_from_string(self, commits)
    end

    # The diff from commit +a+ to commit +b+, optionally restricted to the given file(s)
    #   +a+ is the base commit
    #   +b+ is the other commit
    #   +paths+ is an optional list of file paths on which to restrict the diff
    def diff(a, b, *paths)
      diff = self.git.native('diff', {}, a, b, '--', *paths)

      if diff =~ /diff --git a/
        diff = diff.sub(/.*?(diff --git a)/m, '\1')
      else
        diff = ''
      end
      Diff.list_from_string(self, diff)
    end

    # The commit diff for the given commit
    #   +commit+ is the commit name/id
    #
    # Returns Grit::Diff[]
    def commit_diff(commit)
      Commit.diff(self, commit)
    end

    # Write an archive directly to a file
    #   +treeish+ is the treeish name/id (default 'master')
    #   +prefix+ is the optional prefix (default nil)
    #   +filename+ is the name of the file (default 'archive.tar.gz')
    #   +format+ is the optional format (default nil)
    #   +compress_cmd+ is the command to run the output through (default ['gzip'])
    #
    # Returns nothing
    def archive_to_file(treeish = 'master', prefix = nil, filename = 'archive.tar.gz', format = nil, compress_cmd = %W(gzip))
      git_archive_cmd = %W(#{Git.git_binary} --git-dir=#{self.git.git_dir} archive)
      git_archive_cmd << "--prefix=#{prefix}" if prefix
      git_archive_cmd << "--format=#{format}" if format
      git_archive_cmd += %W(-- #{treeish})

      open(filename, 'w') do |file|
        pipe_rd, pipe_wr = IO.pipe
        git_archive_pid = spawn(*git_archive_cmd, :out => pipe_wr)
        pipe_wr.close
        compress_pid = spawn(*compress_cmd, :in => pipe_rd, :out => file)
        pipe_rd.close
        Process.waitpid(git_archive_pid)
        Process.waitpid(compress_pid)
      end
    end

    # Enable git-daemon serving of this repository by writing the
    # git-daemon-export-ok file to its git directory
    #
    # Returns nothing
    def enable_daemon_serve
      self.git.fs_write(DAEMON_EXPORT_FILE, '')
    end

    # Disable git-daemon serving of this repository by ensuring there is no
    # git-daemon-export-ok file in its git directory
    #
    # Returns nothing
    def disable_daemon_serve
      self.git.fs_delete(DAEMON_EXPORT_FILE)
    end

    def gc_auto
      self.git.gc({:auto => true})
    end

    # The list of alternates for this repo
    #
    # Returns Array[String] (pathnames of alternates)
    def alternates
      alternates_path = "objects/info/alternates"
      self.git.fs_read(alternates_path).strip.split("\n")
    rescue Errno::ENOENT
      []
    end

    # Sets the alternates
    #   +alts+ is the Array of String paths representing the alternates
    #
    # Returns nothing
    def alternates=(alts)
      alts.each do |alt|
        unless File.exist?(alt)
          raise "Could not set alternates. Alternate path #{alt} must exist"
        end
      end

      if alts.empty?
        self.git.fs_write('objects/info/alternates', '')
      else
        self.git.fs_write('objects/info/alternates', alts.join("\n"))
      end
    end

    def config
      @config ||= Config.new(self)
    end

    def index
      Index.new(self)
    end

    def update_ref(head, commit_sha)
      return nil if !commit_sha || (commit_sha.size != 40)
      self.git.fs_write("refs/heads/#{head}", commit_sha)
      commit_sha
    end

    # Rename the current repository directory.
    #   +name+ is the new name
    #
    # Returns nothing
    def rename(name)
      if @bare
        self.git.fs_move('/', "../#{name}")
      else
        self.git.fs_move('/', "../../#{name}")
      end
    end

    def grep(searchtext, contextlines = 3, branch = 'master')
      context_arg = '-C ' + contextlines.to_s
      result = git.native(:grep, {}, '-n', '-E', '-i', '-z', '--heading', '--break', context_arg, searchtext, branch).encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
      greps = []
      filematches = result.split("\n\n")
      filematches.each do |filematch|
        binary = false
        file = ''
        matches = filematch.split("--\n")
        matches.each_with_index do |match, i|
          content = []
          startline = 0
          lines = match.split("\n")
          if i == 0
            text = lines.first
            lines.slice!(0)
            file = text[/^Binary file (.+) matches$/]
            if file
              binary = true
            else
              text.slice! /^#{branch}:/
              file = text
            end
          end
          lines.each_with_index do |line, j|
            line.chomp!
            number, text = line.split("\0", 2)
            if j == 0
              startline = number.to_i
            end
            content << text
          end
          greps << Grit::Grep.new(self, file, startline, content, binary)
        end
      end
      greps
    end

    def final_escape(str)
      str.gsub('"', '\\"')
    end

    # Accepts quoted strings and negation (-) operator in search strings
    def advanced_grep(searchtext, contextlines = 3, branch = 'master')

      # If there's not an even number of quote marks, get rid of them all
      searchtext = searchtext.gsub('"', '') if searchtext.count('"') % 2 == 1

      # Escape the text, but allow spaces and quote marks (by unescaping them)
      searchtext = Shellwords.shellescape(searchtext).gsub('\ ',' ').gsub('\\"','"')

      # Shellwords happens to parse search terms really nicely!
      terms = Shellwords.split(searchtext)

      term_args = []
      negative_args = []

      # For each search term (either a word or a quoted string), add the relevant term argument to either the term
      # args array, or the negative args array, used for two separate calls to git grep
      terms.each do |term|
        arg_array = term_args
        if term[0] == '-'
          arg_array = negative_args
          term = term[1..-1]
        end
        arg_array.push '-e'
        arg_array.push final_escape(term)
      end

      context_arg = '-C ' + contextlines.to_s
      result = git.native(:grep, {pipeline: false}, '-n', '-F', '-i', '-z', '--heading', '--break', '--all-match', context_arg, *term_args, branch).encode('UTF-8', invalid: :replace, undef: :replace, replace: '')

      # Find files that match the negated terms; these will be excluded from the results
      excluded_files = Array.new
      unless negative_args.empty?
        negative = git.native(:grep, {pipeline: false}, '-F', '-i', '--files-with-matches', *negative_args, branch).encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
        excluded_files = negative.split("\n").map {|text| text.partition(':')[2]}
      end

      greps = []
      filematches = result.split("\n\n")
      filematches.each do |filematch|
        binary = false
        file = ''
        matches = filematch.split("--\n")
        matches.each_with_index do |match, i|
          content = []
          startline = 0
          lines = match.split("\n")
          if i == 0
            text = lines.first
            lines.slice!(0)
            file = text[/^Binary file (.+) matches$/]
            if file
              puts "BINARY #{file}"
              binary = true
            else
              text.slice! /^#{branch}:/
              file = text
            end
          end

          # Skip this result if the file is to be ignored (due to a negative match)
          next if excluded_files.include? file || ( excluded_files.include? text[/^Binary file (.+) matches$/, 1].partition(':')[2] )

          lines.each_with_index do |line, j|
            line.chomp!
            number, text = line.split("\0", 2)
            if j == 0
              startline = number.to_i
            end
            content << text
          end
          greps << Grit::Grep.new(self, file, startline, content, binary)
        end
      end
      greps
    end

    # Pretty object inspection
    def inspect
      %Q{#<Grit::Repo "#{@path}">}
    end
  end # Repo
end # Grit
