/*******************************************************************************
 * Copyright (C) 2011, 2013 Dariusz Luksza <dariusz@luksza.org> 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
 *******************************************************************************/
package org.eclipse.egit.core.synchronize;

import static org.eclipse.jgit.lib.ObjectId.zeroId;
import static org.eclipse.jgit.treewalk.filter.TreeFilter.ANY_DIFF;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.jgit.lib.AbbreviatedObjectId;
import org.eclipse.jgit.lib.MutableObjectId;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevFlag;
import org.eclipse.jgit.revwalk.RevFlagSet;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.EmptyTreeIterator;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilter;

/**
 * Retrieves list of commits and the changes associated with each commit
 */
public class GitCommitsModelCache {

	/**
	 * Constant copied from org.eclipse.compare.structuremergeviewer.Differencer.ADDITION
	 * in order to avoid UI dependencies introduced by the org.eclipse.compare bundle
	 */
	public static final int ADDITION = 1;

	/**
	 * Constant copied from org.eclipse.compare.structuremergeviewer.Differencer.DELETION
	 * in order to avoid UI dependencies introduced by the org.eclipse.compare bundle
	 */
	public static final int DELETION = 2;

	/**
	 * Constant copied from org.eclipse.compare.structuremergeviewer.Differencer.CHANGE
	 * in order to avoid UI dependencies introduced by the org.eclipse.compare bundle
	 */
	public static final int CHANGE = 3;

	/**
	 * Bit mask (value 3) for extracting the kind of difference.
	 */
	private static final int CHANGE_TYPE_MASK = 3;

	/**
	 * Constant copied from org.eclipse.compare.structuremergeviewer.Differencer.LEFT
	 * in order to avoid UI dependencies introduced by the org.eclipse.compare bundle
	 * <p>
	 * Note that in the context of synchronization, this means "INCOMING", shown
	 * by an arrow pointing from right to left.
	 */
	public static final int LEFT = 4;

	/**
	 * Constant copied from org.eclipse.compare.structuremergeviewer.Differencer.RIGHT
	 * in order to avoid UI dependencies introduced by the org.eclipse.compare bundle
	 * <p>
	 * Note that in the context of synchronization, this means "OUTGOING", shown
	 * by an arrow pointing from left to right.
	 */
	public static final int RIGHT = 8;

	/**
	 * Corresponds to {@link RevCommit} object, but contains only those data
	 * that are required by Synchronize view Change Set
	 */
	public static class Commit {
		private int direction;

		private String shortMessage;

		private AbbreviatedObjectId commitId;

		private Date commitDate;

		private String authorName;

		private String committerName;

		private Map<String, Change> children;

		private Commit() {
			// reduce the visibility of the default constructor
		}

		/**
		 * Indicates if this commit is incoming or outgoing. Returned value
		 * corresponds to org.eclipse.compare.structuremergeviewer.Differencer#LEFT for incoming and
		 * org.eclipse.compare.structuremergeviewer.Differencer#RIGHT for outgoing changes
		 *
		 * @return change direction
		 */
		public int getDirection() {
			return direction;
		}

		/**
		 * @return commit id
		 */
		public AbbreviatedObjectId getId() {
			return commitId;
		}

		/**
		 * @return commit author
		 */
		public String getAuthorName() {
			return authorName;
		}

		/**
		 * @return the committer name
		 */
		public String getCommitterName() {
			return committerName;
		}

		/**
		 * @return commit date
		 */
		public Date getCommitDate() {
			return commitDate;
		}

		/**
		 * @return commit short message
		 */
		public String getShortMessage() {
			return shortMessage;
		}

		/**
		 * @return list of changes made by this commit or {@code null} when
		 *         commit doesn't have any changes
		 */
		public Map<String, Change> getChildren() {
			return children;
		}

		/**
		 * Disposes nested resources
		 */
		public void dispose() {
			children.clear();
		}

	}

	/**
	 * Describes single tree or blob change in commit.
	 */
	public static class Change {
		int kind;

		String name;

		AbbreviatedObjectId objectId;

		AbbreviatedObjectId commitId;

		AbbreviatedObjectId remoteCommitId;

		AbbreviatedObjectId remoteObjectId;

		Change() {
			// reduce the visibility of the default constructor
		}

		/**
		 * Describes if this change is incoming/outgoing addition, deletion or
		 * change.
		 *
		 * It uses static values of LEFT, RIGHT, ADDITION, DELETION, CHANGE from
		 * org.eclipse.compare.structuremergeviewer.Differencer class.
		 *
		 * @return kind
		 */
		public int getKind() {
			return kind;
		}

		/**
		 * @return object name
		 */
		public String getName() {
			return name;
		}

		/**
		 * @return id of commit containing this change
		 */
		public AbbreviatedObjectId getCommitId() {
			return commitId;
		}

		/**
		 * @return id of parent commit
		 */
		public AbbreviatedObjectId getRemoteCommitId() {
			return remoteCommitId;
		}

		/**
		 * @return object id
		 */
		public AbbreviatedObjectId getObjectId() {
			return objectId;
		}

		/**
		 * @return remote object id
		 */
		public AbbreviatedObjectId getRemoteObjectId() {
			return remoteObjectId;
		}

		@Override
		public int hashCode() {
			final int prime = 31;
			int result = 1;
			result = prime * result
					+ ((objectId == null) ? 0 : objectId.hashCode());
			result = prime
					* result
					+ ((remoteObjectId == null) ? 0 : remoteObjectId.hashCode());
			return result;
		}

		@Override
		public boolean equals(Object obj) {
			if (this == obj)
				return true;
			if (obj == null)
				return false;
			if (getClass() != obj.getClass())
				return false;
			Change other = (Change) obj;
			if (objectId == null) {
				if (other.objectId != null)
					return false;
			} else if (!objectId.equals(other.objectId))
				return false;
			if (remoteObjectId == null) {
				if (other.remoteObjectId != null)
					return false;
			} else if (!remoteObjectId.equals(other.remoteObjectId))
				return false;
			return true;
		}

		@Override
		public String toString() {
			StringBuilder change = new StringBuilder("Change("); //$NON-NLS-1$
			if ((kind & LEFT) != 0)
				change.append("INCOMING "); //$NON-NLS-1$
			else
				// should be RIGHT
				change.append("OUTGOING "); //$NON-NLS-1$
			int changeType = kind & CHANGE_TYPE_MASK;
			if (changeType == CHANGE)
				change.append("CHANGE "); //$NON-NLS-1$
			else if (changeType == ADDITION)
				change.append("ADDITION "); //$NON-NLS-1$
			else if (changeType == DELETION)
				change.append("DELETION "); //$NON-NLS-1$

			change.append(name);
			change.append(";\n\tcurrent objectId: "); //$NON-NLS-1$
			change.append(getObjectId(objectId));
			change.append(";\n\tparent objectId: "); //$NON-NLS-1$
			change.append(getObjectId(remoteObjectId));
			change.append(";\n\tcurrent commit: "); //$NON-NLS-1$
			change.append(getObjectId(commitId));
			change.append(";\n\tparent commit: "); //$NON-NLS-1$
			change.append(getObjectId(remoteCommitId));
			change.append("\n)"); //$NON-NLS-1$

			return change.toString();
		}

		private String getObjectId(AbbreviatedObjectId object) {
			if (object != null)
				return object.toObjectId().getName();
			else
				return ObjectId.zeroId().getName();
		}

	}

	static final AbbreviatedObjectId ZERO_ID = AbbreviatedObjectId
			.fromObjectId(zeroId());

	/**
	 * Scans given {@code repo} and build list of commits between two given
	 * RevCommit objectId's. Each commit contains list of changed resources
	 *
	 * @param repo
	 *            repository that should be scanned
	 * @param srcId
	 *            commit id that is considered the "local" version (e.g. from
	 *            master)
	 * @param dstId
	 *            commit id that is considered the "remote" version (e.g. from
	 *            origin/master)
	 * @param pathFilter
	 *            path filter definition or {@code null} when all paths should
	 *            be included
	 * @return list of {@link Commit} object's between {@code srcId} and
	 *         {@code dstId}
	 * @throws IOException
	 */
	public static List<Commit> build(Repository repo, ObjectId srcId,
			ObjectId dstId, TreeFilter pathFilter) throws IOException {
		if (dstId.equals(srcId))
			return new ArrayList<Commit>(0);

		final RevWalk rw = new RevWalk(repo);

		final RevFlag localFlag = rw.newFlag("local"); //$NON-NLS-1$
		final RevFlag remoteFlag = rw.newFlag("remote"); //$NON-NLS-1$
		final RevFlagSet allFlags = new RevFlagSet();
		allFlags.add(localFlag);
		allFlags.add(remoteFlag);
		rw.carry(allFlags);

		RevCommit srcCommit = rw.parseCommit(srcId);
		srcCommit.add(localFlag);
		rw.markStart(srcCommit);
		srcCommit = null; // free not needed resources

		RevCommit dstCommit = rw.parseCommit(dstId);
		dstCommit.add(remoteFlag);
		rw.markStart(dstCommit);
		dstCommit = null; // free not needed resources

		if (pathFilter != null)
			rw.setTreeFilter(pathFilter);

		List<Commit> result = new ArrayList<Commit>();
		for (RevCommit revCommit : rw) {
			if (revCommit.hasAll(allFlags))
				break;

			Commit commit = new Commit();
			commit.shortMessage = revCommit.getShortMessage();
			commit.commitId = AbbreviatedObjectId.fromObjectId(revCommit);
			commit.authorName = revCommit.getAuthorIdent().getName();
			commit.committerName = revCommit.getCommitterIdent().getName();
			commit.commitDate = revCommit.getAuthorIdent().getWhen();

			RevCommit parentCommit = getParentCommit(revCommit);
			if (revCommit.has(localFlag))
				// Outgoing
				commit.direction = RIGHT;
			else if (revCommit.has(remoteFlag))
				// Incoming
				commit.direction = LEFT;
			else
				throw new GitCommitsModelDirectionException();

			commit.children = getChangedObjects(repo, revCommit,
					parentCommit, pathFilter, commit.direction);

			if (commit.children != null)
				result.add(commit);
		}
		rw.dispose();

		return result;
	}

	private static RevCommit getParentCommit(RevCommit commit) {
		if (commit.getParents().length > 0)
			return commit.getParents()[0];
		else
			return null;
	}

	private static Map<String, Change> getChangedObjects(Repository repo,
			RevCommit commit, RevCommit parentCommit,
			TreeFilter pathFilter, final int direction) throws IOException {
		final TreeWalk tw = new TreeWalk(repo);
		int commitIndex = addTree(tw, commit);
		int parentCommitIndex = addTree(tw, parentCommit);

		tw.setRecursive(true);
		if (pathFilter == null)
			tw.setFilter(ANY_DIFF);
		else
			tw.setFilter(AndTreeFilter.create(ANY_DIFF, pathFilter));

		final Map<String, Change> result = new HashMap<String, GitCommitsModelCache.Change>();
		final AbbreviatedObjectId commitId = getAbbreviatedObjectId(commit);
		final AbbreviatedObjectId parentCommitId = getAbbreviatedObjectId(parentCommit);

		MutableObjectId idBuf = new MutableObjectId();
		while (tw.next()) {
			Change change = new Change();
			change.commitId = commitId;
			change.remoteCommitId = parentCommitId;
			change.name = tw.getNameString();
			tw.getObjectId(idBuf, commitIndex);
			change.objectId = AbbreviatedObjectId.fromObjectId(idBuf);
			tw.getObjectId(idBuf, parentCommitIndex);
			change.remoteObjectId = AbbreviatedObjectId.fromObjectId(idBuf);

			calculateAndSetChangeKind(direction, change);

			result.put(tw.getPathString(), change);
		}
		tw.release();

		return result.size() > 0 ? result : null;
	}

	private static int addTree(TreeWalk tw, RevCommit commit)
			throws IOException {
		if (commit != null)
			return tw.addTree(commit.getTree());
		else
			return tw.addTree(new EmptyTreeIterator());
	}

	private static AbbreviatedObjectId getAbbreviatedObjectId(RevCommit commit) {
		if (commit != null)
			return AbbreviatedObjectId.fromObjectId(commit);
		else
			return ZERO_ID;
	}

	static void calculateAndSetChangeKind(final int direction, Change change) {
		if (ZERO_ID.equals(change.objectId)) { // removed in commit
			change.objectId = null; // clear zero id;
			change.kind = direction | DELETION;
		} else if (ZERO_ID.equals(change.remoteObjectId)) { // added in commit
			change.remoteObjectId = null; // clear zero id;
			change.kind = direction | ADDITION;
		} else
			change.kind = direction | CHANGE;
	}

}
