/*******************************************************************************
 * Copyright (c) 2015 Google and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     Scott McMaster - initial implementation
 *******************************************************************************/
package com.google.appraise.eclipse.core.client.git;

import com.google.appraise.eclipse.core.client.data.Review;
import com.google.appraise.eclipse.core.client.data.ReviewComment;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

import org.apache.commons.codec.digest.DigestUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ListNotesCommand;
import org.eclipse.jgit.api.PushCommand;
import org.eclipse.jgit.api.ShowNoteCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.RefUpdate.Result;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.notes.DefaultNoteMerger;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.notes.NoteMapMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevSort;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.filter.RevFilter;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.treewalk.AbstractTreeIterator;
import org.eclipse.jgit.treewalk.CanonicalTreeParser;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

/**
 * Jgit-based utility routines for working with Appraise-style reviews.
 */
public class AppraiseGitReviewClient {
  /**
   * The wildcard refspec to fetch all git notes updates.
   */
  private static final String DEVTOOLS_PULL_REFSPEC =
      "+refs/notes/devtools/*:refs/notes/origin/devtools/*";

  /**
   * The wildcard refspec to push all git notes commits.
   */
  private static final String DEVTOOLS_PUSH_REFSPEC = "refs/notes/devtools/*:refs/notes/devtools/*";

  private static final Logger logger = Logger.getLogger(AppraiseGitReviewClient.class.getName());

  // Ref defines the git-notes ref that we expect to contain review requests.
  private static final String REVIEWS_REF = "refs/notes/devtools/reviews";

  // Ref defines the git-notes origin ref for review requests.
  private static final String REVIEWS_ORIGIN_REF = "refs/notes/origin/devtools/reviews";

  // Ref defines the git-notes ref that we expect to contain review comments.
  private static final String COMMENTS_REF = "refs/notes/devtools/discuss";

  // Ref defines the git-notes origin ref for review comments.
  private static final String COMMENTS_ORIGIN_REF = "refs/notes/origin/devtools/discuss";

  /**
   * The git repository to be accessed.
   */
  private final Repository repo;

  /**
   * The indentity used for commits. From the config of the current repository.
   */
  private final PersonIdent author;

  /**
   * Creates a new client for the given git repository.
   */
  public AppraiseGitReviewClient(Repository repo) {
    this.repo = repo;
    this.author = new PersonIdent(repo);
  }

  /**
   * Gets the review commit, which is the first commit on the review branch
   * after the merge base.
   */
  public RevCommit getReviewCommit(String reviewBranch, String targetBranch)
      throws GitClientException {
    try (RevWalk walk = new RevWalk(repo)) {
      walk.markStart(walk.parseCommit(repo.resolve(reviewBranch)));
      walk.markUninteresting(walk.parseCommit(repo.resolve(targetBranch)));
      walk.sort(RevSort.REVERSE);
      return walk.next();
    } catch (Exception e) {
      throw new GitClientException(
          "Failed to get review commit for " + reviewBranch + " and " + targetBranch, e);
    }
  }

  /**
   * Retrieves all the reviews in the current project's repository by commit hash.
   */
  public Map<String, Review> listReviews() throws GitClientException {
    // Get the most up-to-date list of reviews.
    syncCommentsAndReviews();

    Map<String, Review> reviews = new LinkedHashMap<>();

    Git git = new Git(repo);
    try {
      ListNotesCommand cmd = git.notesList();
      cmd.setNotesRef(REVIEWS_REF);
      List<Note> notes = cmd.call();
      for (Note note : notes) {
        String rawNoteDataStr = noteToString(repo, note);
        Review latest = extractLatestReviewFromNotes(rawNoteDataStr);
        if (latest != null) {
          reviews.put(note.getName(), latest);
        }
      }
    } catch (Exception e) {
      throw new GitClientException(e);
    } finally {
      git.close();
    }
    return reviews;
  }

  /**
   * Pulls the most recent notes data for a review out of the raw notes data string, leveraging
   * the timestamp.
   */
  private Review extractLatestReviewFromNotes(String rawNoteDataStr) throws GitClientException {
    String[] noteDataStrs = rawNoteDataStr.split("\n");
    Review latest = parseReviewJson(noteDataStrs[0]);
    for (int i = 1; i < noteDataStrs.length; i++) {
      Review anotherOne = parseReviewJson(noteDataStrs[i]);
      try {
        if (latest == null
            || ((anotherOne != null && anotherOne.getTimestamp() > latest.getTimestamp()))) {
          latest = anotherOne;
        }
      } catch (Exception e) {
        throw new GitClientException(e);
      }
    }
    return latest;
  }

  /**
   * Gets a specific review. Returns null if it is not found.
   */
  public Review getReview(String reviewCommitHash) throws GitClientException {
    try (Git git = new Git(repo)) {
      String noteDataStr = readOneNote(git, REVIEWS_REF, reviewCommitHash);
      return extractLatestReviewFromNotes(noteDataStr);
    }
  }

  /**
   * Helper method that parses the given JSON data for a review and returns
   * null if the parsing fails for any reason.
   */
  private Review parseReviewJson(String noteDataStr) {
    try {
      return new Gson().fromJson(noteDataStr, Review.class);
    } catch (JsonSyntaxException jse) {
      logger.warning("Weird data in review note: " + noteDataStr);
      return null;
    }
  }

  /**
   * Reads a single note out as a string from the given commit hash.
   * Returns null if the note isn't found.
   */
  private String readOneNote(Git git, String notesRef, String hash) throws GitClientException {
    try (RevWalk walker = new RevWalk(git.getRepository())) {
      ShowNoteCommand cmd = git.notesShow();
      cmd.setNotesRef(notesRef);
      ObjectId ref = git.getRepository().resolve(hash);
      RevCommit commit = walker.parseCommit(ref);
      cmd.setObjectId(commit);
      Note note = cmd.call();
      if (note == null) {
        return null;
      }
      return noteToString(repo, note);
    } catch (Exception e) {
      throw new GitClientException(e);
    }
  }

  /**
   * Adds a new comment to the review and writes it to the notes.
   * @param reviewCommitHash Is the review commit hash in our model.
   * @param commentData The comment to append.
   */
  public void writeComment(String reviewCommitHash, String commentData) throws GitClientException {
    ReviewComment comment = new ReviewComment();
    comment.setDescription(commentData);
    // Will fill in the time and author.
    writeComment(reviewCommitHash, comment);
  }

  /**
   * Writes the given comment to the given review, automatically filling in
   * the author and timestamp.
   */
  public void writeComment(String reviewCommitHash, ReviewComment comment)
      throws GitClientException {
    // Sync to minimize the chances of non-linear merges.
    syncCommentsAndReviews();

    // Commit.
    commitCommentNote(reviewCommitHash, comment);

    // Push.
    try {
      pushCommentsAndReviews();
    } catch (Exception e) {
      throw new GitClientException("Error pushing, review is " + reviewCommitHash, e);
    }
  }

  /**
   * Helper method that commits a new comment to the git notes.
   */
  private void commitCommentNote(String reviewCommitHash, ReviewComment comment) {
    try (GitNoteWriter<ReviewComment> writer =
        GitNoteWriter.createNoteWriter(reviewCommitHash, repo, author, COMMENTS_REF)) {
      // We store time in seconds in the notes.
      comment.setTimestamp(System.currentTimeMillis() / 1000);
      comment.setAuthor(author.getEmailAddress());

      List<ReviewComment> comments = new ArrayList<ReviewComment>();
      comments.add(comment);
      writer.create("Writing comment for " + reviewCommitHash, comments);
    }
  }

  /**
   * Writes a new {@link Review} based on the given task data.
   * @return the new review's hash.
   */
  public String createReview(String reviewCommitHash, Review review) throws GitClientException {
    // Sync to minimize the chances of non-linear merges.
    syncCommentsAndReviews();

    // Push the code under review, or the user won't be able to access the commit with the
    // notes.
    try (Git git = new Git(repo)) {
      assert !"master".equals(review.getReviewRef());
      RefSpec reviewRefSpec = new RefSpec(review.getReviewRef());
      PushCommand pushCommand = git.push();
      pushCommand.setRefSpecs(reviewRefSpec);
      try {
        pushCommand.call();
      } catch (Exception e) {
        throw new GitClientException("Error pushing review commit(s) to origin", e);
      }
    }

    // Commit.
    commitReviewNote(reviewCommitHash, review);

    // Push.
    try {
      pushCommentsAndReviews();
    } catch (Exception e) {
      throw new GitClientException("Error pushing, review is " + reviewCommitHash, e);
    }

    return reviewCommitHash;
  }

  /**
   * Helper method that commits a new comment to the git notes.
   */
  private void commitReviewNote(String reviewCommitHash, Review review) {
    try (GitNoteWriter<Review> writer =
        GitNoteWriter.createNoteWriter(reviewCommitHash, repo, author, REVIEWS_REF)) {
      List<Review> reviews = new ArrayList<Review>();
      reviews.add(review);
      writer.create("Writing review for " + reviewCommitHash, reviews);
    }
  }

  /**
   * Pushes the local comments and reviews back to the origin.
   */
  private void pushCommentsAndReviews() throws Exception {
    try (Git git = new Git(repo)) {
      RefSpec spec = new RefSpec(DEVTOOLS_PUSH_REFSPEC);
      PushCommand pushCommand = git.push();
      pushCommand.setRefSpecs(spec);
      pushCommand.call();
    }
  }

  /**
   * Gets the diff entries associated with a specific review commit.
   * The review commit is the commit hash at which the review was requested.
   * Subsequent commits on that review can be inferred from the append-only comments.
   */
  public List<DiffEntry> getDiff(String requestCommitHash)
      throws GitClientException, IOException, GitAPIException {
    Review review = getReview(requestCommitHash);

    // If the target ref is missing or the corresponding branch does not exist,
    // the review is bogus.
    if (review.getTargetRef() == null || review.getTargetRef().isEmpty()) {
      throw new GitClientException("Review target ref not set: " + requestCommitHash);
    }

    try (Git git = new Git(repo)) {
      if (!isBranchExists(review.getTargetRef())) {
        throw new GitClientException("Review target ref does not exist: " + requestCommitHash + ", "
            + review.getTargetRef());
      }

      if (review.getReviewRef() == null || review.getReviewRef().isEmpty()) {
        // If there is no review ref, then show the diff from the single commit.
        RevCommit revCommit = resolveRevCommit(requestCommitHash);
        return calculateCommitDiffs(git, resolveParentRevCommit(revCommit), revCommit);
      } else if (isBranchExists(review.getReviewRef())
          && !areAncestorDescendent(review.getReviewRef(), review.getTargetRef())) {
        // If the review ref branch exists and is not already submitted,
        // then show the diff between review ref and target ref.
        return calculateBranchDiffs(git, review.getTargetRef(), review.getReviewRef());
      } else {
        // If the review ref points to a non-existent branch, the review is over, so read the
        // comments and diff between the parent and the "last" (chronologically) one.
        Map<String, ReviewComment> comments = listCommentsForReview(git, requestCommitHash);
        RevCommit revCommit = resolveRevCommit(requestCommitHash);
        RevCommit parent = resolveParentRevCommit(revCommit);
        RevCommit last = findLastCommitInComments(comments.values(), revCommit);
        return calculateCommitDiffs(git, parent, last);
      }
    }
  }

  /**
   * Fetches review and comment git notes and updates the local refs, performing
   * merges if necessary.
   */
  public void syncCommentsAndReviews() throws GitClientException {
    RevWalk revWalk = null;
    try (Git git = new Git(repo)) {
      revWalk = new RevWalk(repo);

      // Fetch the latest.
      RefSpec spec = new RefSpec(DEVTOOLS_PULL_REFSPEC);
      git.fetch().setRefSpecs(spec).call();

      syncNotes(revWalk, COMMENTS_REF, COMMENTS_ORIGIN_REF);
      revWalk.reset();
      syncNotes(revWalk, REVIEWS_REF, REVIEWS_ORIGIN_REF);
    } catch (Exception e) {
      throw new GitClientException("Error syncing notes", e);
    } finally {
      if (revWalk != null) {
        revWalk.close();
      }
    }
  }

  /**
   * Helper method that syncs the notes between the given ref names.
   */
  private void syncNotes(RevWalk revWalk, String localRefName, String originRefName)
      throws Exception {
    Ref originRef = repo.getRef(originRefName);
    if (originRef == null) {
      // Most likely nobody has ever pushed anything to the devtools notes in this repo.
      return;
    }

    RevCommit originCommit = revWalk.parseCommit(originRef.getObjectId());

    Ref localRef = repo.getRef(localRefName);
    if (localRef == null) {
      // Update the local ref to the origin commit. This happens the first time a new repo is set
      // up.
      Result result = JgitUtils.updateRef(repo, originCommit, null, localRefName).update();
      if (!result.equals(Result.FAST_FORWARD)) {
        throw new GitClientException("Invalid result initializing the local ref: " + result);
      }
      return;
    }

    RevCommit localCommit = revWalk.parseCommit(localRef.getObjectId());
    RevCommit baseCommit = getMergeBase(revWalk, localCommit, originCommit);

    // If the commits are the same, there is nothing to do.
    if (localCommit.equals(originCommit)) {
      return;
    }

    if (originCommit.equals(baseCommit)) {
      // If the merge base is the same as the origin, we should push our changes to the origin,
      // because we have local ones.
      // Note that this pushes both comments and notes. Since we are typically synchronizing
      // them in close succession, it's expected that this push will happen the first time,
      // and the next time the commits will be the same in most cases.
      pushCommentsAndReviews();
    } else if (localCommit.equals(baseCommit)) {
      // If the merge base is the same as the local, we should advance our ref in a fast-forward.
      Result result = JgitUtils.updateRef(repo, originCommit, localCommit, localRefName).update();
      if (!result.equals(Result.FAST_FORWARD) && !result.equals(Result.NO_CHANGE)) {
        throw new GitClientException("Invalid result advancing the local ref: " + result);
      }
    } else {
      // If the merge base is not equal to either, we need to do a merge.
      mergeNotesAndPush(revWalk, localRefName, baseCommit, localCommit, originCommit);
    }
  }

  /**
   * Merges the notes from local and origin commits with the given merge base.
   */
  private void mergeNotesAndPush(RevWalk revWalk, String refName, RevCommit baseCommit,
      RevCommit localCommit, RevCommit originCommit) throws GitClientException {
    int remainingLockFailureCalls = JgitUtils.MAX_LOCK_FAILURE_CALLS;

    // Merge and commit.
    while (true) {
      try {
        NoteMap theirNoteMap = NoteMap.read(revWalk.getObjectReader(), originCommit);
        NoteMap ourNoteMap = NoteMap.read(revWalk.getObjectReader(), localCommit);
        NoteMap baseNoteMap;
        if (baseCommit != null) {
          baseNoteMap = NoteMap.read(revWalk.getObjectReader(), baseCommit);
        } else {
          baseNoteMap = NoteMap.newEmptyMap();
        }

        NoteMapMerger merger =
            new NoteMapMerger(repo, new DefaultNoteMerger(), MergeStrategy.RESOLVE);
        NoteMap merged = merger.merge(baseNoteMap, ourNoteMap, theirNoteMap);
        try (ObjectInserter inserter = repo.newObjectInserter()) {
          RevCommit mergeCommit = createNotesCommit(
              merged, inserter, revWalk, "Merged note commits\n", localCommit, originCommit);

          RefUpdate update = JgitUtils.updateRef(repo, mergeCommit, localCommit, refName);
          Result result = update.update();
          if (result == Result.LOCK_FAILURE) {
            if (--remainingLockFailureCalls > 0) {
              Thread.sleep(JgitUtils.SLEEP_ON_LOCK_FAILURE_MS);
            } else {
              throw new GitClientException("Failed to lock the ref: " + refName);
            }
          } else if (result == Result.REJECTED) {
            throw new GitClientException("Rejected update to " + refName + ", this is unexpected");
          } else if (result == Result.IO_FAILURE) {
            throw new GitClientException("I/O failure merging notes");
          } else {
            // OK.
            break;
          }
        }
      } catch (Exception e) {
        throw new GitClientException("Error merging notes commits", e);
      }
    }

    // And push.
    try {
      pushCommentsAndReviews();
    } catch (Exception e) {
      throw new GitClientException("Error pushing merge commit", e);
    }
  }

  /**
   * Creates a merged notes commit.
   */
  private RevCommit createNotesCommit(NoteMap map, ObjectInserter inserter, RevWalk revWalk,
      String message, RevCommit... parents) throws IOException {
    CommitBuilder commitBuilder = new CommitBuilder();
    commitBuilder.setTreeId(map.writeTree(inserter));
    commitBuilder.setAuthor(author);
    commitBuilder.setCommitter(author);
    if (parents.length > 0) {
      commitBuilder.setParentIds(parents);
    }
    commitBuilder.setMessage(message);
    ObjectId commitId = inserter.insert(commitBuilder);
    inserter.flush();
    return revWalk.parseCommit(commitId);
  }

  /**
   * Gets the merge base for the two given commits.
   * Danger -- the commits need to be from the given RevWalk or this will
   * fail in a not-completely-obvious way.
   */
  private RevCommit getMergeBase(RevWalk walk, RevCommit commit1, RevCommit commit2)
      throws GitClientException {
    try {
      walk.setRevFilter(RevFilter.MERGE_BASE);
      walk.markStart(commit1);
      walk.markStart(commit2);
      return walk.next();
    } catch (Exception e) {
      throw new GitClientException(
          "Failed to get merge base commit for " + commit1 + " and " + commit2, e);
    }
  }

  /**
   * Checks to see if two branches/commits are in an ancestor-descendent relationship.
   */
  public boolean areAncestorDescendent(String ancestor, String descendent)
      throws GitClientException {
    try (RevWalk revWalk = new RevWalk(repo)) {
      RevCommit ancestorHead = revWalk.parseCommit(repo.resolve(ancestor));
      RevCommit descendentHead = revWalk.parseCommit(repo.resolve(descendent));
      return revWalk.isMergedInto(ancestorHead, descendentHead);
    } catch (Exception e) {
      throw new GitClientException(
          "Error checking ancestor/descendent for " + ancestor + " and " + descendent, e);
    }
  }

  /**
   * Resolves the (first) parent commit.
   */
  private RevCommit resolveParentRevCommit(RevCommit revCommit)
      throws MissingObjectException, IncorrectObjectTypeException, IOException {
    RevCommit parent = null;
    try (RevWalk walker = new RevWalk(repo)) {
      parent = walker.parseCommit(revCommit.getParents()[0].getId());
    }
    return parent;
  }

  /**
   * Gets the chronologically-last commit from a set of review comments.
   */
  private RevCommit findLastCommitInComments(
      Collection<ReviewComment> collection, RevCommit defaultCommit)
      throws MissingObjectException, IncorrectObjectTypeException, IOException {
    RevCommit lastCommit = defaultCommit;
    for (ReviewComment comment : collection) {
      if (comment.getLocation() == null || comment.getLocation().getCommit() == null
          || comment.getLocation().getCommit().isEmpty()) {
        continue;
      }
      RevCommit currentCommit = resolveRevCommit(comment.getLocation().getCommit());
      if (currentCommit != null && currentCommit.getCommitTime() > lastCommit.getCommitTime()) {
        lastCommit = currentCommit;
      }
    }
    return lastCommit;
  }

  /**
   * Gets all the comments for a specific review hash, by comment id.
   * The comment id is conventionally the SHA-1 hash of its JSON string.
   */
  public Map<String, ReviewComment> listCommentsForReview(String requestCommitHash)
      throws GitClientException {
    try (Git git = new Git(repo)) {
      return listCommentsForReview(git, requestCommitHash);
    }
  }

  /**
   * Gets all the comments for a specific review hash, by comment id.
   * The comment id is conventionally the SHA-1 hash of its JSON string.
   */
  private Map<String, ReviewComment> listCommentsForReview(Git git, String requestCommitHash)
      throws GitClientException {
    // Get the most up-to-date list of comments.
    syncCommentsAndReviews();

    Map<String, ReviewComment> comments = new LinkedHashMap<>();
    try {
      String noteDataStr = readOneNote(git, COMMENTS_REF, requestCommitHash);
      if (noteDataStr != null) {
        for (String commentStr : noteDataStr.split("\n")) {
          try {
            String commentId = DigestUtils.shaHex(commentStr);
            ReviewComment comment = new Gson().fromJson(commentStr, ReviewComment.class);
            if (comment != null) {
              comments.put(commentId, comment);
            }
          } catch (JsonSyntaxException jse) {
            logger.warning("Failed to parse comment " + noteDataStr);
          }
        }
      }
    } catch (Exception e) {
      throw new GitClientException(e);
    }
    return comments;
  }

  private AbstractTreeIterator prepareTreeParser(String ref)
      throws IOException, MissingObjectException, IncorrectObjectTypeException {
    // from the commit we can build the tree which allows us to construct the TreeParser
    Ref head = repo.getRef(ref);
    try (RevWalk walk = new RevWalk(repo)) {
      RevCommit commit = walk.parseCommit(head.getObjectId());
      return prepareTreeParserHelper(walk, commit);
    }
  }

  private AbstractTreeIterator prepareTreeParser(RevCommit commit)
      throws IOException, MissingObjectException, IncorrectObjectTypeException {
    // from the commit we can build the tree which allows us to construct the TreeParser
    try (RevWalk walk = new RevWalk(repo)) {
      return prepareTreeParserHelper(walk, commit);
    }
  }

  private AbstractTreeIterator prepareTreeParserHelper(RevWalk walk, RevCommit commit)
      throws IOException, MissingObjectException, IncorrectObjectTypeException {
    RevTree tree = walk.parseTree(commit.getTree().getId());
    CanonicalTreeParser oldTreeParser = new CanonicalTreeParser();
    try (ObjectReader oldReader = repo.newObjectReader()) {
      oldTreeParser.reset(oldReader, tree.getId());
    }
    return oldTreeParser;
  }

  private RevCommit resolveRevCommit(String commitHash)
      throws MissingObjectException, IncorrectObjectTypeException, IOException {
    ObjectId ref = repo.resolve(commitHash);
    try (RevWalk walker = new RevWalk(repo)) {
      return walker.parseCommit(ref);
    }
  }

  /**
   * Gets the diff between heads on two branches.
   * See
   * https://github.com/centic9/jgit-cookbook/blob/master/src/main/java/org/dstadler/jgit/porcelain/ShowBranchDiff.java.
   */
  private List<DiffEntry> calculateBranchDiffs(Git git, String targetRef, String reviewRef)
      throws IOException, GitAPIException {
    AbstractTreeIterator oldTreeParser = prepareTreeParser(targetRef);
    AbstractTreeIterator newTreeParser = prepareTreeParser(reviewRef);
    return git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).call();
  }

  /**
   * Gets the diff between heads on two branches.
   */
  public List<DiffEntry> calculateBranchDiffs(String targetRef, String reviewRef)
      throws GitClientException {
    try (Git git = new Git(repo)) {
      return calculateBranchDiffs(git, targetRef, reviewRef);
    } catch (Exception e) {
      throw new GitClientException(
          "Error loading branch diffs for " + reviewRef + " and " + targetRef, e);
    }
  }

  /**
   * Gets the diff between two commits.
   */
  private List<DiffEntry> calculateCommitDiffs(Git git, RevCommit first, RevCommit last)
      throws IOException, GitAPIException {
    AbstractTreeIterator oldTreeParser = prepareTreeParser(first);
    AbstractTreeIterator newTreeParser = prepareTreeParser(last);
    return git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).call();
  }

  /**
   * Returns whether or not a specific named branch exists in the repo.
   */
  private boolean isBranchExists(String ref) throws IOException {
    return (repo.getRef(ref) != null);
  }

  /**
   * Utility method that converts a note to a string (assuming it's UTF-8).
   */
  private String noteToString(Repository repo, Note note)
      throws MissingObjectException, IOException, UnsupportedEncodingException {
    ObjectLoader loader = repo.open(note.getData());
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    loader.copyTo(baos);
    return new String(baos.toByteArray(), "UTF-8");
  }

  /**
   * Confirms that the user is on a ref that is valid for creating a new review.
   */
  public boolean canRequestReviewOnReviewRef(String reviewRef, String targetRef) {
    // Confirm that the user is NOT targeting the same ref.
    // TODO: Should we also confirm that they are not on master?
    if (targetRef.equals(reviewRef)) {
      return false;
    }
    return true;
  }

  /**
   * Updates the given review if it has changed, and writes out a new comment if supplied.
   * Assumes the code under review has already been pushed.
   * @return the review's hash.
   */
  public String updateReviewWithComment(String reviewCommitHash, Review review, String newComment)
      throws GitClientException {
    // Sync to minimize the chances of non-linear merges.
    syncCommentsAndReviews();

    boolean needPush = false;
    Review existingReview = getReview(reviewCommitHash);
    if (!review.equals(existingReview)) {
      // Need to update the review.
      commitReviewNote(reviewCommitHash, review);
      needPush = true;
    }

    if (newComment != null && !newComment.isEmpty()) {
      // Write the new comment.
      ReviewComment comment = new ReviewComment();
      comment.setDescription(newComment);
      commitCommentNote(reviewCommitHash, comment);
      needPush = true;
    }

    // Push.
    if (needPush) {
      try {
        pushCommentsAndReviews();
      } catch (Exception e) {
        throw new GitClientException("Error pushing, review is " + reviewCommitHash, e);
      }
    }

    return reviewCommitHash;
  }
}