/*******************************************************************************
 * Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
 * Copyright (C) 2011, Mathias Kinzler <mathias.kinzler@sap.com>
 * Copyright (C) 2011, Jens Baumgart <jens.baumgart@sap.com>
 * Copyright (C) 2011, Stefan Lay <stefan.lay@sap.com>
 *
 * 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.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.egit.core.internal.CompareCoreUtils;
import org.eclipse.egit.ui.Activator;
import org.eclipse.egit.ui.UIPreferences;
import org.eclipse.egit.ui.internal.UIText;
import org.eclipse.egit.ui.internal.history.CommitMessageViewer.ObjectLink;
import org.eclipse.egit.ui.internal.trace.GitTraceLocation;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revplot.PlotCommit;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.RevWalkUtils;
import org.eclipse.jgit.util.io.SafeBufferedOutputStream;
import org.eclipse.osgi.util.NLS;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyleRange;
import org.eclipse.swt.graphics.Color;

/**
 * Class to build and format commit info in History View
 */
public class CommitInfoBuilder {

	private static final String SPACE = " "; //$NON-NLS-1$

	private static final String LF = "\n"; //$NON-NLS-1$

	private static final int MAXBRANCHES = 20;

	private final DateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); //$NON-NLS-1$

	private PlotCommit<?> commit;

	private final Repository db;

	private final boolean fill;

	// set by selecting files in the file list
	private final List<FileDiff> currentDiffs;

	private Color linkColor;

	private Color darkGrey;

	private Color hunkheaderColor;

	private Color linesAddedColor;

	private Color linesRemovedColor;

	private final Collection<Ref> allRefs;

	/**
	 * @param db the repository
	 * @param commit the commit the info should be shown for
	 * @param currentDiffs list of current diffs
	 * @param fill whether to fill the available space
	 * @param allRefs all Ref's to examine regarding marge bases
	 */
	public CommitInfoBuilder(Repository db, PlotCommit commit, List<FileDiff> currentDiffs, boolean fill, Collection<Ref> allRefs) {
		this.db = db;
		this.commit = commit;
		this.fill = fill;
		this.allRefs = allRefs;
		this.currentDiffs = new ArrayList<FileDiff>(currentDiffs);
	}

	/**
	 * set colors for formatting
	 *
	 * @param linkColor
	 * @param darkGrey
	 * @param hunkheaderColor
	 * @param linesAddedColor
	 * @param linesRemovedColor
	 */
	public void setColors(Color linkColor, Color darkGrey, Color hunkheaderColor, Color linesAddedColor, Color linesRemovedColor) {
		this.linkColor = linkColor;
		this.darkGrey = darkGrey;
		this.hunkheaderColor = hunkheaderColor;
		this.linesAddedColor = linesAddedColor;
		this.linesRemovedColor = linesRemovedColor;
	}

	/**
	 * Format the commit info
	 *
	 * @param styles styles for text formatting
	 * @param monitor
	 * @return formatted commit info
	 * @throws IOException
	 */
	public String format(final List<StyleRange> styles,
			IProgressMonitor monitor) throws IOException {
		boolean trace = GitTraceLocation.HISTORYVIEW.isActive();
		if (trace)
			GitTraceLocation.getTrace().traceEntry(
					GitTraceLocation.HISTORYVIEW.getLocation());
		monitor.setTaskName(UIText.CommitMessageViewer_FormattingMessageTaskName);
		final StringBuilder d = new StringBuilder();
		final PersonIdent author = commit.getAuthorIdent();
		final PersonIdent committer = commit.getCommitterIdent();
		d.append(UIText.CommitMessageViewer_commit);
		d.append(SPACE);
		d.append(commit.getId().name());
		d.append(LF);

		if (author != null) {
			d.append(UIText.CommitMessageViewer_author);
			d.append(": "); //$NON-NLS-1$
			d.append(author.getName());
			d.append(" <"); //$NON-NLS-1$
			d.append(author.getEmailAddress());
			d.append("> "); //$NON-NLS-1$
			d.append(fmt.format(author.getWhen()));
			d.append(LF);
		}

		if (committer != null) {
			d.append(UIText.CommitMessageViewer_committer);
			d.append(": "); //$NON-NLS-1$
			d.append(committer.getName());
			d.append(" <"); //$NON-NLS-1$
			d.append(committer.getEmailAddress());
			d.append("> "); //$NON-NLS-1$
			d.append(fmt.format(committer.getWhen()));
			d.append(LF);
		}

		for (int i = 0; i < commit.getParentCount(); i++) {
			final SWTCommit p = (SWTCommit)commit.getParent(i);
			p.parseBody();
			d.append(UIText.CommitMessageViewer_parent);
			d.append(": "); //$NON-NLS-1$
			addLink(d, styles, p);
			d.append(" ("); //$NON-NLS-1$
			d.append(p.getShortMessage());
			d.append(")"); //$NON-NLS-1$
			d.append(LF);
		}

		for (int i = 0; i < commit.getChildCount(); i++) {
			final SWTCommit p = (SWTCommit)commit.getChild(i);
			p.parseBody();
			d.append(UIText.CommitMessageViewer_child);
			d.append(": "); //$NON-NLS-1$
			addLink(d, styles, p);
			d.append(" ("); //$NON-NLS-1$
			d.append(p.getShortMessage());
			d.append(")"); //$NON-NLS-1$
			d.append(LF);
		}

		try {
			List<Ref> branches = getBranches(commit, allRefs, db);
			if (!branches.isEmpty()) {
				d.append(UIText.CommitMessageViewer_branches);
				d.append(": "); //$NON-NLS-1$
				int count = 0;
				for (Iterator<Ref> i = branches.iterator(); i.hasNext();) {
					Ref head = i.next();
					RevCommit p;
					p = new RevWalk(db).parseCommit(head.getObjectId());
					addLink(d, formatHeadRef(head), styles, p);
					if (i.hasNext()) {
						if (count++ <= MAXBRANCHES) {
							d.append(", "); //$NON-NLS-1$
						} else {
							d.append(NLS.bind(UIText.CommitMessageViewer_MoreBranches, Integer.valueOf(branches.size() - MAXBRANCHES)));
							break;
						}
					}
				}
				d.append(LF);
			}
		} catch (IOException e) {
			Activator.logError(e.getMessage(), e);
		}

		String tagsString = getTagsString();
		if (tagsString.length() > 0) {
			d.append(UIText.CommitMessageViewer_tags);
			d.append(": "); //$NON-NLS-1$
			d.append(tagsString);
			d.append(LF);
		}

		if (Activator.getDefault().getPreferenceStore().getBoolean(
				UIPreferences.HISTORY_SHOW_TAG_SEQUENCE)) {
			try {
				monitor.setTaskName(UIText.CommitMessageViewer_GettingPreviousTagTaskName);
				Ref followingTag = getNextTag(false, monitor);
				if (followingTag != null) {
					d.append(UIText.CommitMessageViewer_follows);
					d.append(": "); //$NON-NLS-1$
					RevCommit p = new RevWalk(db).parseCommit(followingTag
							.getObjectId());
					addLink(d, formatTagRef(followingTag), styles, p);
					d.append(LF);
				}
			} catch (IOException e) {
				Activator.logError(e.getMessage(), e);
			}

			try {
				monitor.setTaskName(UIText.CommitMessageViewer_GettingNextTagTaskName);
				Ref precedingTag = getNextTag(true, monitor);
				if (precedingTag != null) {
					d.append(UIText.CommitMessageViewer_precedes);
					d.append(": "); //$NON-NLS-1$
					RevCommit p = new RevWalk(db).parseCommit(precedingTag
							.getObjectId());
					addLink(d, formatTagRef(precedingTag), styles, p);
					d.append(LF);
				}
			} catch (IOException e) {
				Activator.logError(e.getMessage(), e);
			}
		}

		makeGrayText(d, styles);
		d.append(LF);
		String msg = commit.getFullMessage();
		Pattern p = Pattern.compile("\n([A-Z](?:[A-Za-z]+-)+by: [^\n]+)"); //$NON-NLS-1$
		if (fill) {
			Matcher spm = p.matcher(msg);
			if (spm.find()) {
				String subMsg = msg.substring(0, spm.end());
				msg = subMsg.replaceAll("([\\w.,; \t])\n(\\w)", "$1 $2") //$NON-NLS-1$ //$NON-NLS-2$
						+ msg.substring(spm.end());
			}
		}
		int h0 = d.length();
		d.append(msg);
		d.append(LF);

		Matcher matcher = p.matcher(msg);
		while (matcher.find()) {
			styles.add(new StyleRange(h0 + matcher.start(), matcher.end()
					- matcher.start(), null, null, SWT.ITALIC));
		}

		if (!currentDiffs.isEmpty())
			buildDiffs(d, styles, monitor, trace);

		if (trace)
			GitTraceLocation.getTrace().traceExit(
					GitTraceLocation.HISTORYVIEW.getLocation());
		return d.toString();
	}

	private void addLink(final StringBuilder d, String linkLabel,
			final List<StyleRange> styles, final RevCommit to) {
		final ObjectLink sr = new ObjectLink();
		sr.targetCommit = to;
		sr.foreground = linkColor;
		sr.underline = true;
		sr.start = d.length();
		d.append(linkLabel);
		sr.length = d.length() - sr.start;
		styles.add(sr);
	}

	private void addLink(final StringBuilder d, final List<StyleRange> styles,
			final RevCommit to) {
		addLink(d, to.getId().name(), styles, to);
	}

	/**
	 * @param commit
	 * @param allRefs
	 * @param db
	 * @return List of heads from those current commit is reachable
	 * @throws MissingObjectException
	 * @throws IncorrectObjectTypeException
	 * @throws IOException
	 */
	private static List<Ref> getBranches(RevCommit commit,
			Collection<Ref> allRefs, Repository db)
			throws MissingObjectException, IncorrectObjectTypeException,
			IOException {
		RevWalk revWalk = new RevWalk(db);
		try {
			revWalk.setRetainBody(false);
			return RevWalkUtils.findBranchesReachableFrom(commit, revWalk, allRefs);
		} finally {
			revWalk.dispose();
		}
	}

	private String formatHeadRef(Ref ref) {
		final String name = ref.getName();
		if (name.startsWith(Constants.R_HEADS))
			return name.substring(Constants.R_HEADS.length());
		else if (name.startsWith(Constants.R_REMOTES))
			return name.substring(Constants.R_REMOTES.length());
		return name;
	}

	private String formatTagRef(Ref ref) {
		final String name = ref.getName();
		if (name.startsWith(Constants.R_TAGS))
			return name.substring(Constants.R_TAGS.length());
		return name;
	}

	private void makeGrayText(StringBuilder d, List<StyleRange> styles) {
		int p0 = 0;
		for (int i = 0; i < styles.size(); ++i) {
			StyleRange r = styles.get(i);
			if (p0 < r.start) {
				StyleRange nr = new StyleRange(p0, r.start - p0, darkGrey,
						null);
				styles.add(i, nr);
				p0 = r.start;
			} else {
				if (r.foreground == null)
					r.foreground = darkGrey;
				p0 = r.start + r.length;
			}
		}
		if (d.length() - 1 > p0) {
			StyleRange nr = new StyleRange(p0, d.length() - p0, darkGrey,
					null);
			styles.add(nr);
		}
	}


	private void buildDiffs(final StringBuilder d,
			final List<StyleRange> styles, IProgressMonitor monitor,
			boolean trace) throws OperationCanceledException,
			IOException {

		// the encoding for the currently processed file
		final String[] currentEncoding = new String[1];

		if (trace)
			GitTraceLocation.getTrace().traceEntry(
					GitTraceLocation.HISTORYVIEW.getLocation());
		if (commit.getParentCount() > 1) {
			d.append(UIText.CommitMessageViewer_CanNotRenderDiffMessage);
			return;
		}

		try {
			monitor.beginTask(UIText.CommitMessageViewer_BuildDiffListTaskName,
					currentDiffs.size());
			BufferedOutputStream bos = new SafeBufferedOutputStream(
					new ByteArrayOutputStream() {
						@Override
						public synchronized void write(byte[] b, int off,
								int len) {
							super.write(b, off, len);
							if (currentEncoding[0] == null)
								d.append(toString());
							else
								try {
									d.append(toString(currentEncoding[0]));
								} catch (UnsupportedEncodingException e) {
									d.append(toString());
								}
							reset();
						}

					});
			final DiffFormatter diffFmt = new MessageViewerFormatter(bos,
					styles, d, hunkheaderColor, linesAddedColor, linesRemovedColor);

			for (FileDiff currentDiff : currentDiffs) {
				if (monitor.isCanceled())
					throw new OperationCanceledException();
				if (currentDiff.getBlobs().length == 2) {
					String path = currentDiff.getNewPath();
					monitor.setTaskName(NLS.bind(
							UIText.CommitMessageViewer_BuildDiffTaskName, path));
					currentEncoding[0] = CompareCoreUtils.getResourceEncoding(db,
							path);
					d.append(formatPathLine(path)).append(LF);
					currentDiff.outputDiff(d, db, diffFmt, true);
					diffFmt.flush();
				}
				monitor.worked(1);
			}

		} finally {
			monitor.done();
			if (trace)
				GitTraceLocation.getTrace().traceExit(
						GitTraceLocation.HISTORYVIEW.getLocation());
		}
	}

	private String formatPathLine(String path) {
		int n = 80 - path.length() - 2;
		if (n < 0)
			return path;
		final StringBuilder d = new StringBuilder();
		int i = 0;
		for (; i < n / 2; i++)
			d.append("-"); //$NON-NLS-1$
		d.append(SPACE).append(path).append(SPACE);
		for (; i < n - 1; i++)
			d.append("-"); //$NON-NLS-1$
		return d.toString();
	}

	private String getTagsString() {
		StringBuilder sb = new StringBuilder();
		Map<String, Ref> tagsMap = db.getTags();
		for (Entry<String, Ref> tagEntry : tagsMap.entrySet()) {
			ObjectId target = tagEntry.getValue().getPeeledObjectId();
			if (target == null)
				target = tagEntry.getValue().getObjectId();
			if (target != null && target.equals(commit)) {
				if (sb.length() > 0)
					sb.append(", "); //$NON-NLS-1$
				sb.append(tagEntry.getKey());
			}
		}
		return sb.toString();
	}

	private static final class MessageViewerFormatter extends DiffFormatter {
		private final List<StyleRange> styles;

		private final StringBuilder d;

		private final Color hunkheaderColor;
		private final Color linesAddedColor;
		private final Color linesRemovedColor;

		private MessageViewerFormatter(OutputStream out,
				List<StyleRange> styles, StringBuilder d, Color hunkheaderColor, Color linesAddedColor, Color linesRemovedColor) {
			super(out);
			this.styles = styles;
			this.hunkheaderColor = hunkheaderColor;
			this.linesAddedColor = linesAddedColor;
			this.linesRemovedColor = linesRemovedColor;
			this.d = d;
		}

		@Override
		protected void writeHunkHeader(int aCur, int aEnd, int bCur, int bEnd)
				throws IOException {
			flush();
			int start = d.length();
			super.writeHunkHeader(aCur, aEnd, bCur, bEnd);
			flush();
			int end = d.length();
			styles.add(new StyleRange(start, end - start, hunkheaderColor,
					null));
		}

		@Override
		protected void writeAddedLine(RawText b, int bCur) throws IOException {
			flush();
			int start = d.length();
			super.writeAddedLine(b, bCur);
			flush();
			int end = d.length();
			styles.add(new StyleRange(start, end - start,
					linesAddedColor, null));
		}

		@Override
		protected void writeRemovedLine(RawText b, int bCur) throws IOException {
			flush();
			int start = d.length();
			super.writeRemovedLine(b, bCur);
			flush();
			int end = d.length();
			styles.add(new StyleRange(start, end - start,
					linesRemovedColor, null));
		}
	}

	/**
	 * Finds next door tagged revision. Searches forwards (in descendants) or
	 * backwards (in ancestors)
	 *
	 * @param searchDescendant
	 *            if <code>false</code>, will search for tagged revision in
	 *            ancestors
	 * @param monitor
	 * @return {@link Ref} or <code>null</code> if no tag found
	 * @throws IOException
	 * @throws OperationCanceledException
	 */
	private Ref getNextTag(boolean searchDescendant, IProgressMonitor monitor)
			throws IOException, OperationCanceledException {
		if (monitor.isCanceled())
			throw new OperationCanceledException();
		RevWalk revWalk = new RevWalk(db);
		revWalk.setRetainBody(false);
		Map<String, Ref> tagsMap = db.getTags();
		Ref tagRef = null;

		for (Ref ref : tagsMap.values()) {
			if (monitor.isCanceled())
				throw new OperationCanceledException();
			// both RevCommits must be allocated using same RevWalk instance,
			// otherwise isMergedInto returns wrong result!
			RevCommit current = revWalk.parseCommit(commit);
			// tags can point to any object, we only want tags pointing at
			// commits
			RevObject any = revWalk.peel(revWalk.parseAny(ref.getObjectId()));
			if (!(any instanceof RevCommit))
				continue;
			RevCommit newTag = (RevCommit) any;
			if (newTag.getId().equals(commit))
				continue;

			// check if newTag matches our criteria
			if (isMergedInto(revWalk, newTag, current, searchDescendant)) {
				if (monitor.isCanceled())
					throw new OperationCanceledException();
				if (tagRef != null) {
					RevCommit oldTag = revWalk
							.parseCommit(tagRef.getObjectId());

					// both oldTag and newTag satisfy search criteria, so taking
					// the closest one
					if (isMergedInto(revWalk, oldTag, newTag, searchDescendant))
						tagRef = ref;
				} else
					tagRef = ref;
			}
		}
		return tagRef;
	}

	/**
	 * @param rw
	 * @param base
	 * @param tip
	 * @param swap
	 *            if <code>true</code>, base and tip arguments are swapped
	 * @return <code>true</code> if there is a path directly from tip to base
	 *         (and thus base is fully merged into tip); <code>false</code>
	 *         otherwise.
	 * @throws IOException
	 */
	private boolean isMergedInto(final RevWalk rw, final RevCommit base,
			final RevCommit tip, boolean swap) throws IOException {
		return !swap ? rw.isMergedInto(base, tip) : rw.isMergedInto(tip, base);
	}

}
