/*
 * Copyright 2011 gitblit.com.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gitblit.utils;

import static org.eclipse.jgit.lib.Constants.encode;
import static org.eclipse.jgit.lib.Constants.encodeASCII;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.wicket.Application;
import org.apache.wicket.Localizer;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.util.RawParseUtils;

import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.utils.DiffUtils.BinaryDiffHandler;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.wicket.GitBlitWebApp;

/**
 * Generates an html snippet of a diff in Gitblit's style, tracks changed paths, and calculates diff stats.
 *
 * @author James Moger
 * @author Tom <tw201207@gmail.com>
 *
 */
public class GitBlitDiffFormatter extends DiffFormatter {

	/** Regex pattern identifying trailing whitespace. */
	private static final Pattern trailingWhitespace = Pattern.compile("(\\s+?)\r?\n?$");

	/**
	 * gitblit.properties key for the per-file limit on the number of diff lines.
	 */
	private static final String DIFF_LIMIT_PER_FILE_KEY = "web.maxDiffLinesPerFile";

	/**
	 * gitblit.properties key for the global limit on the number of diff lines in a commitdiff.
	 */
	private static final String GLOBAL_DIFF_LIMIT_KEY = "web.maxDiffLines";

	/**
	 * Diffs with more lines are not shown in commitdiffs. (Similar to what GitHub does.) Can be reduced
	 * (but not increased) through gitblit.properties key {@link #DIFF_LIMIT_PER_FILE_KEY}.
	 */
	private static final int DIFF_LIMIT_PER_FILE = 4000;

	/**
	 * Global diff limit. Commitdiffs with more lines are truncated. Can be reduced (but not increased)
	 * through gitblit.properties key {@link #GLOBAL_DIFF_LIMIT_KEY}.
	 */
	private static final int GLOBAL_DIFF_LIMIT = 20000;

	private static final boolean CONVERT_TABS = true;

	private final DiffOutputStream os;

	private final DiffStat diffStat;

	private PathChangeModel currentPath;

	private int left, right;

	/**
	 * If a single file diff in a commitdiff produces more than this number of lines, we don't display
	 * the diff. First, it's too taxing on the browser: it'll spend an awful lot of time applying the
	 * CSS rules (despite my having optimized them). And second, no human can read a diff with thousands
	 * of lines and make sense of it.
	 * <p>
	 * Set to {@link #DIFF_LIMIT_PER_FILE} for commitdiffs, and to -1 (switches off the limit) for
	 * single-file diffs.
	 * </p>
	 */
	private final int maxDiffLinesPerFile;

	/**
	 * Global limit on the number of diff lines. Set to {@link #GLOBAL_DIFF_LIMIT} for commitdiffs, and
	 * to -1 (switched off the limit) for single-file diffs.
	 */
	private final int globalDiffLimit;

	/** Number of lines for the current file diff. Set to zero when a new DiffEntry is started. */
	private int nofLinesCurrent;
	/**
	 * Position in the stream when we try to write the first line. Used to rewind when we detect that
	 * the diff is too large.
	 */
	private int startCurrent;
	/** Flag set to true when we rewind. Reset to false when we start a new DiffEntry. */
	private boolean isOff;
	/** The current diff entry. */
	private DiffEntry entry;

	// Global limit stuff.

	/** Total number of lines written before the current diff entry. */
	private int totalNofLinesPrevious;
	/** Running total of the number of diff lines written. Updated until we exceed the global limit. */
	private int totalNofLinesCurrent;
	/** Stream position to reset to if we decided to truncate the commitdiff. */
	private int truncateTo;
	/** Whether we decided to truncate the commitdiff. */
	private boolean truncated;
	/** If {@link #truncated}, contains all entries skipped. */
	private final List<DiffEntry> skipped = new ArrayList<DiffEntry>();

	private int tabLength;

	/**
	 * A {@link ResettableByteArrayOutputStream} that intercept the "Binary files differ" message produced
	 * by the super implementation. Unfortunately the super implementation has far too many things private;
	 * otherwise we'd just have re-implemented {@link GitBlitDiffFormatter#format(DiffEntry) format(DiffEntry)}
	 * completely without ever calling the super implementation.
	 */
	private static class DiffOutputStream extends ResettableByteArrayOutputStream {

		private static final String BINARY_DIFFERENCE = "Binary files differ\n";

		private GitBlitDiffFormatter formatter;
		private BinaryDiffHandler binaryDiffHandler;

		public void setFormatter(GitBlitDiffFormatter formatter, BinaryDiffHandler handler) {
			this.formatter = formatter;
			this.binaryDiffHandler = handler;
		}

		@Override
		public void write(byte[] b, int offset, int length) {
			if (binaryDiffHandler != null
					&& RawParseUtils.decode(Arrays.copyOfRange(b, offset, offset + length)).contains(BINARY_DIFFERENCE))
			{
				String binaryDiff = binaryDiffHandler.renderBinaryDiff(formatter.entry);
				if (binaryDiff != null) {
					byte[] bb = ("<tr><td colspan='4' align='center'>" + binaryDiff + "</td></tr>").getBytes(StandardCharsets.UTF_8);
					super.write(bb, 0, bb.length);
					return;
				}
			}
			super.write(b, offset, length);
		}

	}

	public GitBlitDiffFormatter(String commitId, Repository repository, String path, BinaryDiffHandler handler, int tabLength) {
		super(new DiffOutputStream());
		this.os = (DiffOutputStream) getOutputStream();
		this.os.setFormatter(this, handler);
		this.diffStat = new DiffStat(commitId, repository);
		this.tabLength = tabLength;
		// If we have a full commitdiff, install maxima to avoid generating a super-long diff listing that
		// will only tax the browser too much.
		maxDiffLinesPerFile = path != null ? -1 : getLimit(DIFF_LIMIT_PER_FILE_KEY, 500, DIFF_LIMIT_PER_FILE);
		globalDiffLimit = path != null ? -1 : getLimit(GLOBAL_DIFF_LIMIT_KEY, 1000, GLOBAL_DIFF_LIMIT);
	}

	/**
	 * Determines a limit to use for HTML diff output.
	 *
	 * @param key
	 *            to use to read the value from the GitBlit settings, if available.
	 * @param minimum
	 *            minimum value to enforce
	 * @param maximum
	 *            maximum (and default) value to enforce
	 * @return the limit
	 */
	private int getLimit(String key, int minimum, int maximum) {
		if (Application.exists()) {
			Application application = Application.get();
			if (application instanceof GitBlitWebApp) {
				GitBlitWebApp webApp = (GitBlitWebApp) application;
				int configValue = webApp.settings().getInteger(key, maximum);
				if (configValue < minimum) {
					return minimum;
				} else if (configValue < maximum) {
					return configValue;
				}
			}
		}
		return maximum;
	}

	/**
	 * Returns a localized message string, if there is a localization; otherwise the given default value.
	 *
	 * @param key
	 *            message key for the message
	 * @param defaultValue
	 *            to use if no localization for the message can be found
	 * @return the possibly localized message
	 */
	private String getMsg(String key, String defaultValue) {
		if (Application.exists()) {
			Localizer localizer = Application.get().getResourceSettings().getLocalizer();
			if (localizer != null) {
				// Use getStringIgnoreSettings because we don't want exceptions here if the key is missing!
				return localizer.getStringIgnoreSettings(key, null, null, defaultValue);
			}
		}
		return defaultValue;
	}

	@Override
	public void format(DiffEntry ent) throws IOException {
		currentPath = diffStat.addPath(ent);
		nofLinesCurrent = 0;
		isOff = false;
		entry = ent;
		if (!truncated) {
			totalNofLinesPrevious = totalNofLinesCurrent;
			if (globalDiffLimit > 0 && totalNofLinesPrevious > globalDiffLimit) {
				truncated = true;
				isOff = true;
			}
			truncateTo = os.size();
		} else {
			isOff = true;
		}
		if (truncated) {
			skipped.add(ent);
		} else {
			// Produce a header here and now
			String path;
			String id;
			if (ChangeType.DELETE.equals(ent.getChangeType())) {
				path = ent.getOldPath();
				id = ent.getOldId().name();
			} else {
				path = ent.getNewPath();
				id = ent.getNewId().name();
			}
			StringBuilder sb = new StringBuilder(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"n{0}\"><i class=\"icon-file\"></i> ", id));
			sb.append(StringUtils.escapeForHtml(path, false)).append("</div></div>");
			sb.append("<div class=\"diff\"><table cellpadding='0'><tbody>\n");
			os.write(sb.toString().getBytes());
		}
		// Keep formatting, but if off, don't produce anything anymore. We just keep on counting.
		super.format(ent);
		if (!truncated) {
			// Close the table
			os.write("</tbody></table></div>\n".getBytes());
		}
	}

	@Override
	public void flush() throws IOException {
		if (truncated) {
			os.resetTo(truncateTo);
		}
		super.flush();
	}

	/**
	 * Rewind and issue a message that the diff is too large.
	 */
	private void reset() {
		if (!isOff) {
			os.resetTo(startCurrent);
			writeFullWidthLine(getMsg("gb.diffFileDiffTooLarge", "Diff too large"));
			totalNofLinesCurrent = totalNofLinesPrevious;
			isOff = true;
		}
	}

	/**
	 * Writes an initial table row containing information about added/removed/renamed/copied files. In case
	 * of a deletion, we also suppress generating the diff; it's not interesting. (All lines removed.)
	 */
	private void handleChange() {
		// XXX Would be nice if we could generate blob links for the cases handled here. Alas, we lack the repo
		// name, and cannot reliably determine it here. We could get the .git directory of a Repository, if we
		// passed in the repo, and then take the name of the parent directory, but that'd fail for repos nested
		// in GitBlit projects. And we don't know if the repo is inside a project or is a top-level repo.
		//
		// That's certainly solvable (just pass along more information), but would require a larger rewrite than
		// I'm prepared to do now.
		String message;
		switch (entry.getChangeType()) {
		case ADD:
			message = getMsg("gb.diffNewFile", "New file");
			break;
		case DELETE:
			message = getMsg("gb.diffDeletedFile", "File was deleted");
			isOff = true;
			break;
		case RENAME:
			message = MessageFormat.format(getMsg("gb.diffRenamedFile", "File was renamed from {0}"), entry.getOldPath());
			break;
		case COPY:
			message = MessageFormat.format(getMsg("gb.diffCopiedFile", "File was copied from {0}"), entry.getOldPath());
			break;
		default:
			return;
		}
		writeFullWidthLine(message);
	}

	/**
	 * Output a hunk header
	 *
	 * @param aStartLine
	 *            within first source
	 * @param aEndLine
	 *            within first source
	 * @param bStartLine
	 *            within second source
	 * @param bEndLine
	 *            within second source
	 * @throws IOException
	 */
	@Override
	protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine) throws IOException {
		if (nofLinesCurrent++ == 0) {
			handleChange();
			startCurrent = os.size();
		}
		if (!isOff) {
			totalNofLinesCurrent++;
			if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
				reset();
			} else {
				os.write("<tr><th class='diff-line' data-lineno='..'></th><th class='diff-line' data-lineno='..'></th><th class='diff-state'></th><td class='hunk_header'>"
						.getBytes());
				os.write('@');
				os.write('@');
				writeRange('-', aStartLine + 1, aEndLine - aStartLine);
				writeRange('+', bStartLine + 1, bEndLine - bStartLine);
				os.write(' ');
				os.write('@');
				os.write('@');
				os.write("</td></tr>\n".getBytes());
			}
		}
		left = aStartLine + 1;
		right = bStartLine + 1;
	}

	protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
		os.write(' ');
		os.write(prefix);
		switch (cnt) {
		case 0:
			// If the range is empty, its beginning number must be the
			// line just before the range, or 0 if the range is at the
			// start of the file stream. Here, begin is always 1 based,
			// so an empty file would produce "0,0".
			//
			os.write(encodeASCII(begin - 1));
			os.write(',');
			os.write('0');
			break;

		case 1:
			// If the range is exactly one line, produce only the number.
			//
			os.write(encodeASCII(begin));
			break;

		default:
			os.write(encodeASCII(begin));
			os.write(',');
			os.write(encodeASCII(cnt));
			break;
		}
	}

	/**
	 * Writes a line spanning the full width of the code view, including the gutter.
	 *
	 * @param text
	 *            to put on that line; will be HTML-escaped.
	 */
	private void writeFullWidthLine(String text) {
		try {
			os.write("<tr><td class='diff-cell' colspan='4'>".getBytes());
			os.write(StringUtils.escapeForHtml(text, false).getBytes());
			os.write("</td></tr>\n".getBytes());
		} catch (IOException ex) {
			// Cannot happen with a ByteArrayOutputStream
		}
	}

	@Override
	protected void writeLine(final char prefix, final RawText text, final int cur) throws IOException {
		if (nofLinesCurrent++ == 0) {
			handleChange();
			startCurrent = os.size();
		}
		// update entry diffstat
		currentPath.update(prefix);
		if (isOff) {
			return;
		}
		totalNofLinesCurrent++;
		if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
			reset();
		} else {
			// output diff
			os.write("<tr>".getBytes());
			switch (prefix) {
			case '+':
				os.write(("<th class='diff-line'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
				os.write("<th class='diff-state diff-state-add'></th>".getBytes());
				os.write("<td class='diff-cell add2'>".getBytes());
				break;
			case '-':
				os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line'></th>").getBytes());
				os.write("<th class='diff-state diff-state-sub'></th>".getBytes());
				os.write("<td class='diff-cell remove2'>".getBytes());
				break;
			default:
				os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
				os.write("<th class='diff-state'></th>".getBytes());
				os.write("<td class='diff-cell context2'>".getBytes());
				break;
			}
			os.write(encode(codeLineToHtml(prefix, text.getString(cur))));
			os.write("</td></tr>\n".getBytes());
		}
	}

	/**
	 * Convert the given code line to HTML.
	 *
	 * @param prefix
	 *            the diff prefix (+/-) indicating whether the line was added or removed.
	 * @param line
	 *            the line to format as HTML
	 * @return the HTML-formatted line, safe for inserting as is into HTML.
	 */
	private String codeLineToHtml(final char prefix, final String line) {
		if ((prefix == '+' || prefix == '-')) {
			// Highlight trailing whitespace on deleted/added lines.
			Matcher matcher = trailingWhitespace.matcher(line);
			if (matcher.find()) {
				StringBuilder result = new StringBuilder(StringUtils.escapeForHtml(line.substring(0, matcher.start()), CONVERT_TABS, tabLength));
				result.append("<span class='trailingws-").append(prefix == '+' ? "add" : "sub").append("'>");
				result.append(StringUtils.escapeForHtml(matcher.group(1), false));
				result.append("</span>");
				return result.toString();
			}
		}
		return StringUtils.escapeForHtml(line, CONVERT_TABS, tabLength);
	}

	/**
	 * Workaround function for complex private methods in DiffFormatter. This sets the html for the diff headers.
	 *
	 * @return
	 */
	public String getHtml() {
		String html = RawParseUtils.decode(os.toByteArray());
		String[] lines = html.split("\n");
		StringBuilder sb = new StringBuilder();
		for (String line : lines) {
			if (line.startsWith("index") || line.startsWith("similarity")
					|| line.startsWith("rename from ") || line.startsWith("rename to ")) {
				// skip index lines
			} else if (line.startsWith("new file") || line.startsWith("deleted file")) {
				// skip new file lines
			} else if (line.startsWith("\\ No newline")) {
				// skip no new line
			} else if (line.startsWith("---") || line.startsWith("+++")) {
				// skip --- +++ lines
			} else if (line.startsWith("diff")) {
				// skip diff lines
			} else {
				boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
				if (gitLinkDiff) {
					sb.append("<tr><th class='diff-line'></th><th class='diff-line'></th>");
					if (line.charAt(0) == '+') {
						sb.append("<th class='diff-state diff-state-add'></th><td class=\"diff-cell add2\">");
					} else {
						sb.append("<th class='diff-state diff-state-sub'></th><td class=\"diff-cell remove2\">");
					}
					line = StringUtils.escapeForHtml(line.substring(1), CONVERT_TABS, tabLength);
				}
				sb.append(line);
				if (gitLinkDiff) {
					sb.append("</td></tr>");
				}
				sb.append('\n');
			}
		}
		if (truncated) {
			sb.append(MessageFormat.format("<div class='header'><div class='diffHeader'>{0}</div></div>",
					StringUtils.escapeForHtml(getMsg("gb.diffTruncated", "Diff truncated after the above file"), false)));
			// List all files not shown. We can be sure we do have at least one path in skipped.
			sb.append("<div class='diff'><table cellpadding='0'><tbody><tr><td class='diff-cell' colspan='4'>");
			String deletedSuffix = StringUtils.escapeForHtml(getMsg("gb.diffDeletedFileSkipped", "(deleted)"), false);
			boolean first = true;
			for (DiffEntry entry : skipped) {
				if (!first) {
					sb.append('\n');
				}
				if (ChangeType.DELETE.equals(entry.getChangeType())) {
					sb.append("<span id=\"n" + entry.getOldId().name() + "\">" + StringUtils.escapeForHtml(entry.getOldPath(), false) + ' ' + deletedSuffix + "</span>");
				} else {
					sb.append("<span id=\"n" + entry.getNewId().name() + "\">" + StringUtils.escapeForHtml(entry.getNewPath(), false) + "</span>");
				}
				first = false;
			}
			skipped.clear();
			sb.append("</td></tr></tbody></table></div>");
		}
		return sb.toString();
	}

	public DiffStat getDiffStat() {
		return diffStat;
	}
}
