/*******************************************************************************
 * Copyright (C) 2007, Robin Rosenberg <robin.rosenberg@dewire.com>
 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
 * Copyright (c) 2010, Stefan Lay <stefan.lay@sap.com>
 * Copyright (C) 2012, Robin Stocker <robin@nibor.org>
 *
 * 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.ui.internal.history;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

import org.eclipse.core.resources.IResource;
import org.eclipse.egit.core.internal.util.ResourceUtil;
import org.eclipse.egit.ui.UIUtils;
import org.eclipse.egit.ui.internal.DecorationOverlayDescriptor;
import org.eclipse.egit.ui.internal.UIIcons;
import org.eclipse.jface.resource.ImageDescriptor;
import org.eclipse.jface.viewers.IDecoration;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.EditList;
import org.eclipse.jgit.diff.MyersDiff;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.diff.RenameDetector;
import org.eclipse.jgit.errors.CorruptObjectException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.treewalk.EmptyTreeIterator;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilterMarker;
import org.eclipse.ui.model.WorkbenchAdapter;

/**
 * A class with information about the changes to a file introduced in a
 * commit.
 */
public class FileDiff extends WorkbenchAdapter {

	/**
	 * Comparator for sorting FileDiffs based on getPath().
	 */
	public static Comparator<FileDiff> PATH_COMPARATOR = new Comparator<FileDiff>() {
		public int compare(FileDiff o1, FileDiff o2) {
			return o1.getPath().compareTo(o2.getPath());
		}
	};

	private final RevCommit commit;

	private DiffEntry diffEntry;

	static ObjectId[] trees(final RevCommit commit, final RevCommit[] parents) {
		final ObjectId[] r = new ObjectId[parents.length + 1];
		for (int i = 0; i < r.length - 1; i++)
			r[i] = parents[i].getTree().getId();
		r[r.length - 1] = commit.getTree().getId();
		return r;
	}

	/**
	 * Computer file diffs for specified tree walk and commit
	 *
	 * @param repository
	 * @param walk
	 * @param commit
	 * @param markTreeFilters
	 *            optional filters for marking entries, see
	 *            {@link #isMarked(int)}
	 * @return non-null but possibly empty array of file diffs
	 * @throws MissingObjectException
	 * @throws IncorrectObjectTypeException
	 * @throws CorruptObjectException
	 * @throws IOException
	 */
	public static FileDiff[] compute(final Repository repository,
			final TreeWalk walk, final RevCommit commit,
			final TreeFilter... markTreeFilters) throws MissingObjectException,
			IncorrectObjectTypeException, CorruptObjectException, IOException {
		return compute(repository, walk, commit, commit.getParents(),
				markTreeFilters);
	}

	/**
	 * Computer file diffs for specified tree walk and commit
	 *
	 * @param repository
	 * @param walk
	 * @param commit
	 * @param parents
	 * @param markTreeFilters
	 *            optional filters for marking entries, see
	 *            {@link #isMarked(int)}
	 * @return non-null but possibly empty array of file diffs
	 * @throws MissingObjectException
	 * @throws IncorrectObjectTypeException
	 * @throws CorruptObjectException
	 * @throws IOException
	 */
	public static FileDiff[] compute(final Repository repository,
			final TreeWalk walk, final RevCommit commit,
			final RevCommit[] parents,
			final TreeFilter... markTreeFilters) throws MissingObjectException,
			IncorrectObjectTypeException, CorruptObjectException, IOException {
		final ArrayList<FileDiff> r = new ArrayList<FileDiff>();

		if (parents.length > 0) {
			walk.reset(trees(commit, parents));
		} else {
			walk.reset();
			walk.addTree(new EmptyTreeIterator());
			walk.addTree(commit.getTree());
		}

		if (walk.getTreeCount() <= 2) {
			List<DiffEntry> entries = DiffEntry.scan(walk, false, markTreeFilters);
			List<DiffEntry> xentries = new LinkedList<DiffEntry>(entries);
			RenameDetector detector = new RenameDetector(repository);
			detector.addAll(entries);
			List<DiffEntry> renames = detector.compute(walk.getObjectReader(),
					org.eclipse.jgit.lib.NullProgressMonitor.INSTANCE);
			for (DiffEntry m : renames) {
				final FileDiff d = new FileDiff(commit, m);
				r.add(d);
				for (Iterator<DiffEntry> i = xentries.iterator(); i.hasNext();) {
					DiffEntry n = i.next();
					if (m.getOldPath().equals(n.getOldPath()))
						i.remove();
					else if (m.getNewPath().equals(n.getNewPath()))
						i.remove();
				}
			}
			for (DiffEntry m : xentries) {
				final FileDiff d = new FileDiff(commit, m);
				r.add(d);
			}
		}
		else { // DiffEntry does not support walks with more than two trees
			final int nTree = walk.getTreeCount();
			final int myTree = nTree - 1;

			TreeFilterMarker treeFilterMarker = new TreeFilterMarker(
					markTreeFilters);

			while (walk.next()) {
				if (matchAnyParent(walk, myTree))
					continue;

				int treeFilterMarks = treeFilterMarker.getMarks(walk);

				final FileDiffForMerges d = new FileDiffForMerges(commit,
						treeFilterMarks);
				d.path = walk.getPathString();
				int m0 = 0;
				for (int i = 0; i < myTree; i++)
					m0 |= walk.getRawMode(i);
				final int m1 = walk.getRawMode(myTree);
				d.change = ChangeType.MODIFY;
				if (m0 == 0 && m1 != 0)
					d.change = ChangeType.ADD;
				else if (m0 != 0 && m1 == 0)
					d.change = ChangeType.DELETE;
				else if (m0 != m1 && walk.idEqual(0, myTree))
					d.change = ChangeType.MODIFY; // there is no ChangeType.TypeChanged
				d.blobs = new ObjectId[nTree];
				d.modes = new FileMode[nTree];
				for (int i = 0; i < nTree; i++) {
					d.blobs[i] = walk.getObjectId(i);
					d.modes[i] = walk.getFileMode(i);
				}


				r.add(d);
			}

		}

		final FileDiff[] tmp = new FileDiff[r.size()];
		r.toArray(tmp);
		return tmp;
	}

	private static boolean matchAnyParent(final TreeWalk walk, final int myTree) {
		final int m = walk.getRawMode(myTree);
		for (int i = 0; i < myTree; i++)
			if (walk.getRawMode(i) == m && walk.idEqual(i, myTree))
				return true;
		return false;
	}

	/**
	 * Creates a textual diff together with meta information.
	 * TODO So far this works only in case of one parent commit.
	 *
	 * @param d
	 *            the StringBuilder where the textual diff is added to
	 * @param db
	 *            the Repo
	 * @param diffFmt
	 *            the DiffFormatter used to create the textual diff
	 * @param gitFormat
	 *            if false, do not show any source or destination prefix,
	 *            and the paths are calculated relative to the eclipse
	 *            project, otherwise relative to the git repository
	 * @throws IOException
	 */
	public void outputDiff(final StringBuilder d, final Repository db,
			final DiffFormatter diffFmt, boolean gitFormat) throws IOException {
		if (gitFormat) {
			diffFmt.setRepository(db);
			diffFmt.format(diffEntry);
			return;
		}

		ObjectReader reader = db.newObjectReader();
		try {
			outputEclipseDiff(d, db, reader, diffFmt);
		} finally {
			reader.release();
		}
	}

	private void outputEclipseDiff(final StringBuilder d, final Repository db,
			final ObjectReader reader, final DiffFormatter diffFmt)
			throws IOException {
		if (!(getBlobs().length == 2))
			throw new UnsupportedOperationException(
					"Not supported yet if the number of parents is different from one"); //$NON-NLS-1$

		String projectRelativeNewPath = getProjectRelativePath(db, getNewPath());
		String projectRelativeOldPath = getProjectRelativePath(db, getOldPath());
		d.append("diff --git ").append(projectRelativeOldPath).append(" ") //$NON-NLS-1$ //$NON-NLS-2$
				.append(projectRelativeNewPath).append("\n"); //$NON-NLS-1$
		final ObjectId id1 = getBlobs()[0];
		final ObjectId id2 = getBlobs()[1];
		final FileMode mode1 = getModes()[0];
		final FileMode mode2 = getModes()[1];

		if (id1.equals(ObjectId.zeroId())) {
			d.append("new file mode " + mode2).append("\n"); //$NON-NLS-1$//$NON-NLS-2$
		} else if (id2.equals(ObjectId.zeroId())) {
			d.append("deleted file mode " + mode1).append("\n"); //$NON-NLS-1$//$NON-NLS-2$
		} else if (!mode1.equals(mode2)) {
			d.append("old mode " + mode1); //$NON-NLS-1$
			d.append("new mode " + mode2).append("\n"); //$NON-NLS-1$//$NON-NLS-2$
		}
		d.append("index ").append(reader.abbreviate(id1).name()). //$NON-NLS-1$
				append("..").append(reader.abbreviate(id2).name()). //$NON-NLS-1$
				append(mode1.equals(mode2) ? " " + mode1 : "").append("\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		if (id1.equals(ObjectId.zeroId()))
			d.append("--- /dev/null\n"); //$NON-NLS-1$
		else {
			d.append("--- "); //$NON-NLS-1$
			d.append(getProjectRelativePath(db, getOldPath()));
			d.append("\n"); //$NON-NLS-1$
		}

		if (id2.equals(ObjectId.zeroId()))
			d.append("+++ /dev/null\n"); //$NON-NLS-1$
		else {
			d.append("+++ "); //$NON-NLS-1$
			d.append(getProjectRelativePath(db, getNewPath()));
			d.append("\n"); //$NON-NLS-1$
		}

		final RawText a = getRawText(id1, reader);
		final RawText b = getRawText(id2, reader);
		EditList editList = MyersDiff.INSTANCE
				.diff(RawTextComparator.DEFAULT, a, b);
		diffFmt.format(editList, a, b);
	}

	private String getProjectRelativePath(Repository db, String repoPath) {
		IResource resource = ResourceUtil.getFileForLocation(db, repoPath);
		if (resource == null)
			return null;
		return resource.getProjectRelativePath().toString();
	}

	private RawText getRawText(ObjectId id, ObjectReader reader)
			throws IOException {
		if (id.equals(ObjectId.zeroId()))
			return new RawText(new byte[] {});
		ObjectLoader ldr = reader.open(id, Constants.OBJ_BLOB);
		return new RawText(ldr.getCachedBytes(Integer.MAX_VALUE));
	}

	/**
	 * Get commit
	 *
	 * @return commit
	 */
	public RevCommit getCommit() {
		return commit;
	}

	/**
	 * @return the old path in case of a delete, the new path otherwise, but
	 *         never null or <code>/dev/null</code>
	 * @see #getNewPath()
	 * @see #getOldPath()
	 */
	public String getPath() {
		if (ChangeType.DELETE.equals(diffEntry.getChangeType()))
			return diffEntry.getOldPath();
		return diffEntry.getNewPath();
	}

	/**
	 * @return the old path or <code>/dev/null</code> for a completely new file
	 * @see #getPath() for getting the new or old path depending on change type
	 */
	public String getOldPath() {
		return diffEntry.getOldPath();
	}

	/**
	 * @return the new path or <code>/dev/null</code> for a deleted file
	 * @see #getPath() for getting the new or old path depending on change type
	 */
	public String getNewPath() {
		return diffEntry.getNewPath();
	}

	/**
	 * Get change type
	 *
	 * @return type
	 */
	public ChangeType getChange() {
		return diffEntry.getChangeType();
	}

	/**
	 * Get blob object ids
	 *
	 * @return non-null but possibly empty array of object ids
	 */
	public ObjectId[] getBlobs() {
		List<ObjectId> objectIds = new ArrayList<ObjectId>();
		if (diffEntry.getOldId() != null)
			objectIds.add(diffEntry.getOldId().toObjectId());
		if (diffEntry.getNewId() != null)
			objectIds.add(diffEntry.getNewId().toObjectId());
		return objectIds.toArray(new ObjectId[]{});
	}

	/**
	 * Get file modes
	 *
	 * @return non-null but possibly empty array of file modes
	 */
	public FileMode[] getModes() {
		List<FileMode> modes = new ArrayList<FileMode>();
		if (diffEntry.getOldMode() != null)
			modes.add(diffEntry.getOldMode());
		if (diffEntry.getOldMode() != null)
			modes.add(diffEntry.getOldMode());
		return modes.toArray(new FileMode[]{});
	}

	/**
	 * Whether the mark tree filter with the specified index matched during scan
	 * or not, see
	 * {@link #compute(Repository, TreeWalk, RevCommit, RevCommit[], TreeFilter...)}
	 * .
	 *
	 * @param index
	 *            the tree filter index to check
	 * @return true if it was marked, false otherwise
	 */
	public boolean isMarked(int index) {
		return diffEntry != null && diffEntry.isMarked(index);
	}

	/**
	 * Create a file diff for a specified {@link RevCommit} and
	 * {@link DiffEntry}
	 *
	 * @param c
	 * @param entry
	 */
	public FileDiff(final RevCommit c, final DiffEntry entry) {
		diffEntry = entry;
		commit = c;
	}

	/**
	 * Is this diff a submodule?
	 *
	 * @return true if submodule, false otherwise
	 */
	public boolean isSubmodule() {
		if (diffEntry == null)
			return false;
		return diffEntry.getOldMode() == FileMode.GITLINK
				|| diffEntry.getNewMode() == FileMode.GITLINK;
	}

	public ImageDescriptor getImageDescriptor(Object object) {
		final ImageDescriptor base;
		if (!isSubmodule())
			base = UIUtils.getEditorImage(getPath());
		else
			base = UIIcons.REPOSITORY;
		switch (getChange()) {
		case ADD:
			return new DecorationOverlayDescriptor(base,
					UIIcons.OVR_STAGED_ADD, IDecoration.BOTTOM_RIGHT);
		case DELETE:
			return new DecorationOverlayDescriptor(base,
					UIIcons.OVR_STAGED_REMOVE, IDecoration.BOTTOM_RIGHT);
		case RENAME:
			return new DecorationOverlayDescriptor(base,
					UIIcons.OVR_STAGED_RENAME, IDecoration.BOTTOM_RIGHT);
		default:
			return base;
		}
	}

	public String getLabel(Object object) {
		return getPath();
	}

	private static class FileDiffForMerges extends FileDiff {
		private String path;

		private ChangeType change;

		private ObjectId[] blobs;

		private FileMode[] modes;

		private final int treeFilterMarks;

		private FileDiffForMerges(final RevCommit c, int treeFilterMarks) {
			super (c, null);
			this.treeFilterMarks = treeFilterMarks;
		}

		@Override
		public String getPath() {
			return path;
		}

		@Override
		public String getNewPath() {
			return path;
		}

		@Override
		public ChangeType getChange() {
			return change;
		}

		@Override
		public ObjectId[] getBlobs() {
			return blobs;
		}

		@Override
		public FileMode[] getModes() {
			return modes;
		}

		@Override
		public boolean isMarked(int index) {
			return (treeFilterMarks & (1L << index)) != 0;
		}
	}
}
