/*
 * Copyright 2014 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.servlet;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.MessageFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.tika.Tika;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.MutableObjectId;
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.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.gitblit.Constants;
import com.gitblit.Keys;
import com.gitblit.manager.IRepositoryManager;
import com.gitblit.manager.IRuntimeManager;
import com.gitblit.models.PathModel;
import com.gitblit.utils.ByteFormat;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.MarkdownUtils;
import com.gitblit.utils.StringUtils;
import com.google.inject.Inject;
import com.google.inject.Singleton;

/**
 * Serves the content of a branch.
 *
 * @author James Moger
 *
 */
@Singleton
public class RawServlet extends HttpServlet {

	private static final long serialVersionUID = 1L;

	private transient Logger logger = LoggerFactory.getLogger(RawServlet.class);

	private final IRuntimeManager runtimeManager;

	private final IRepositoryManager repositoryManager;

	@Inject
	public RawServlet(
			IRuntimeManager runtimeManager,
			IRepositoryManager repositoryManager) {

		this.runtimeManager = runtimeManager;
		this.repositoryManager = repositoryManager;
	}

	/**
	 * Returns an url to this servlet for the specified parameters.
	 *
	 * @param baseURL
	 * @param repository
	 * @param branch
	 * @param path
	 * @return an url
	 */
	public static String asLink(String baseURL, String repository, String branch, String path) {
		if (baseURL.length() > 0 && baseURL.charAt(baseURL.length() - 1) == '/') {
			baseURL = baseURL.substring(0, baseURL.length() - 1);
		}

		char fsc = '!';
		char c = GitblitContext.getManager(IRuntimeManager.class).getSettings().getChar(Keys.web.forwardSlashCharacter, '/');
		if (c != '/') {
			fsc = c;
		}
		if (branch != null) {
			branch = Repository.shortenRefName(branch).replace('/', fsc);
		}

		String encodedPath = path == null ? "" : path.replace('/', fsc);
		return baseURL + Constants.RAW_PATH + repository + "/" + (branch == null ? "" : (branch + "/" + encodedPath));
	}

	protected String getBranch(String repository, HttpServletRequest request) {
		String pi = request.getPathInfo();
		String branch = pi.substring(pi.indexOf(repository) + repository.length() + 1);
		int fs = branch.indexOf('/');
		if (fs > -1) {
			branch = branch.substring(0, fs);
		}
		char c = runtimeManager.getSettings().getChar(Keys.web.forwardSlashCharacter, '/');
		return branch.replace('!', '/').replace(c, '/');
	}

	protected String getPath(String repository, String branch, HttpServletRequest request) {
		String base = repository + "/" + branch;
		String pi = request.getPathInfo().substring(1);
		if (pi.equals(base)) {
			return "";
		}
		String path = pi.substring(pi.indexOf(base) + base.length() + 1);
		if (path.endsWith("/")) {
			path = path.substring(0, path.length() - 1);
		}
		char c = runtimeManager.getSettings().getChar(Keys.web.forwardSlashCharacter, '/');
		return path.replace('!', '/').replace(c, '/');
	}

	protected boolean renderIndex() {
		return false;
	}

	/**
	 * Retrieves the specified resource from the specified branch of the
	 * repository.
	 *
	 * @param request
	 * @param response
	 * @throws javax.servlet.ServletException
	 * @throws java.io.IOException
	 */
	private void processRequest(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		String path = request.getPathInfo();
		if (path.toLowerCase().endsWith(".git")) {
			// forward to url with trailing /
			// this is important for relative pages links
			response.sendRedirect(request.getServletPath() + path + "/");
			return;
		}
		if (path.charAt(0) == '/') {
			// strip leading /
			path = path.substring(1);
		}

		// determine repository and resource from url
		String repository = path;
		Repository r = null;
		int terminator = repository.length();
		do {
			repository = repository.substring(0, terminator);
			r = repositoryManager.getRepository(repository, false);
			terminator = repository.lastIndexOf('/');
		} while (r == null && terminator > -1 );

		ServletContext context = request.getSession().getServletContext();

		try {
			if (r == null) {
				// repository not found!
				String mkd = MessageFormat.format(
						"# Error\nSorry, no valid **repository** specified in this url: {0}!",
						path);
				error(response, mkd);
				return;
			}

			// identify the branch
			String branch = getBranch(repository, request);
			if (StringUtils.isEmpty(branch)) {
				branch = r.getBranch();
				if (branch == null) {
					// no branches found!  empty?
					String mkd = MessageFormat.format(
							"# Error\nSorry, no valid **branch** specified in this url: {0}!",
							path);
					error(response, mkd);
				} else {
					// redirect to default branch
					String base = request.getRequestURI();
					String url = base + branch + "/";
					response.sendRedirect(url);
				}
				return;
			}

			// identify the requested path
			String requestedPath = getPath(repository, branch, request);

			// identify the commit
			RevCommit commit = JGitUtils.getCommit(r, branch);
			if (commit == null) {
				// branch not found!
				String mkd = MessageFormat.format(
						"# Error\nSorry, the repository {0} does not have a **{1}** branch!",
						repository, branch);
				error(response, mkd);
				return;
			}

			Map<String, String> quickContentTypes = new HashMap<>();
			quickContentTypes.put("html", "text/html");
			quickContentTypes.put("htm", "text/html");
			quickContentTypes.put("xml", "application/xml");
			quickContentTypes.put("json", "application/json");

			List<PathModel> pathEntries = JGitUtils.getFilesInPath(r, requestedPath, commit);
			if (pathEntries.isEmpty()) {
				// requested a specific resource
				String file = StringUtils.getLastPathElement(requestedPath);
				try {

					String ext = StringUtils.getFileExtension(file).toLowerCase();
					// We can't parse out an extension for classic "dotfiles", so make a general assumption that
					// they're text files to allow presenting them in browser instead of only for download.
					//
					// However, that only holds for files with no other extension included, for files that happen
					// to start with a dot but also include an extension, process the extension normally.
					// This logic covers .gitattributes, .gitignore, .zshrc, etc., but does not cover .mongorc.js, .zshrc.bak
					boolean isExtensionlessDotfile = file.charAt(0) == '.' && (file.length() == 1 || file.indexOf('.',  1) < 0);
					String contentType = isExtensionlessDotfile ? "text/plain" : quickContentTypes.get(ext);

					if (contentType == null) {
						List<String> exts = runtimeManager.getSettings().getStrings(Keys.web.prettyPrintExtensions);
						if (exts.contains(ext)) {
							// extension is a registered text type for pretty printing
							contentType = "text/plain";
						} else {
							// query Tika for the content type
							Tika tika = new Tika();
							contentType = tika.detect(file);
						}
					}

					if (contentType == null) {
						// ask the container for the content type
						contentType = context.getMimeType(requestedPath);

						if (contentType == null) {
							// still unknown content type, assume binary
							contentType = "application/octet-stream";
						}
					}

					if (isTextType(contentType) || isTextDataType(contentType)) {

						// load, interpret, and serve text content as UTF-8
						String [] encodings = runtimeManager.getSettings().getStrings(Keys.web.blobEncodings).toArray(new String[0]);
						String content = JGitUtils.getStringContent(r, commit.getTree(), requestedPath, encodings);
						if (content == null) {
							logger.error("RawServlet Failed to load {} {} {}", repository, commit.getName(), path);
							notFound(response, requestedPath, branch);
							return;
						}

						byte [] bytes = content.getBytes(Constants.ENCODING);
						setContentType(response, contentType);
						response.setContentLength(bytes.length);
						ByteArrayInputStream is = new ByteArrayInputStream(bytes);
						sendContent(response, JGitUtils.getCommitDate(commit), is);

					} else {
						// stream binary content directly from the repository
						if (!streamFromRepo(request, response, r, commit, requestedPath)) {
							logger.error("RawServlet Failed to load {} {} {}", repository, commit.getName(), path);
							notFound(response, requestedPath, branch);
						}
					}
					return;
				} catch (Exception e) {
					logger.error(null, e);
				}
			} else {
				// path request
				if (!request.getPathInfo().endsWith("/")) {
					// redirect to trailing '/' url
					response.sendRedirect(request.getServletPath() + request.getPathInfo() + "/");
					return;
				}

				if (renderIndex()) {
					// locate and render an index file
					Map<String, String> names = new TreeMap<String, String>();
					for (PathModel entry : pathEntries) {
						names.put(entry.name.toLowerCase(), entry.name);
					}

					List<String> extensions = new ArrayList<String>();
					extensions.add("html");
					extensions.add("htm");

					String content = null;
					for (String ext : extensions) {
						String key = "index." + ext;

						if (names.containsKey(key)) {
							String fileName = names.get(key);
							String fullPath = fileName;
							if (!requestedPath.isEmpty()) {
								fullPath = requestedPath + "/" + fileName;
							}

							String [] encodings = runtimeManager.getSettings().getStrings(Keys.web.blobEncodings).toArray(new String[0]);
							String stringContent = JGitUtils.getStringContent(r, commit.getTree(), fullPath, encodings);
							if (stringContent == null) {
								continue;
							}
							content = stringContent;
							requestedPath = fullPath;
							break;
						}
					}

					response.setContentType("text/html; charset=" + Constants.ENCODING);
					byte [] bytes = content.getBytes(Constants.ENCODING);
					response.setContentLength(bytes.length);

					ByteArrayInputStream is = new ByteArrayInputStream(bytes);
					sendContent(response, JGitUtils.getCommitDate(commit), is);
					return;
				}
			}

			// no content, document list or 404 page
			if (pathEntries.isEmpty()) {
				// default 404 page
				notFound(response, requestedPath, branch);
				return;
			} else {
				//
				// directory list
				//
				response.setContentType("text/html");
				response.getWriter().append("<style>table th, table td { min-width: 150px; text-align: left; }</style>");
				response.getWriter().append("<table>");
				response.getWriter().append("<thead><tr><th>path</th><th>mode</th><th>size</th></tr>");
				response.getWriter().append("</thead>");
				response.getWriter().append("<tbody>");
				String pattern = "<tr><td><a href=\"{0}/{1}\">{1}</a></td><td>{2}</td><td>{3}</td></tr>";
				final ByteFormat byteFormat = new ByteFormat();
				if (!pathEntries.isEmpty()) {
					if (pathEntries.get(0).path.indexOf('/') > -1) {
						// we are in a subdirectory, add parent directory link
						String pp = URLEncoder.encode(requestedPath, Constants.ENCODING);
						pathEntries.add(0, new PathModel("..", pp + "/..", null, 0, FileMode.TREE.getBits(), null, null));
					}
				}

				String basePath = request.getServletPath() + request.getPathInfo();
				if (basePath.charAt(basePath.length() - 1) == '/') {
					// strip trailing slash
					basePath = basePath.substring(0, basePath.length() - 1);
				}
				for (PathModel entry : pathEntries) {
					String pp = URLEncoder.encode(entry.name, Constants.ENCODING);
					response.getWriter().append(MessageFormat.format(pattern, basePath, pp,
							JGitUtils.getPermissionsFromMode(entry.mode),
							entry.isFile() ? byteFormat.format(entry.size) : ""));
				}
				response.getWriter().append("</tbody>");
				response.getWriter().append("</table>");
			}
		} catch (Throwable t) {
			logger.error("Failed to write page to client", t);
		} finally {
			r.close();
		}
	}

	protected boolean isTextType(String contentType) {
		if (contentType.startsWith("text/")
				|| "application/json".equals(contentType)
				|| "application/xml".equals(contentType)) {
			return true;
		}
		return false;
	}

	protected boolean isTextDataType(String contentType) {
		if ("image/svg+xml".equals(contentType)) {
			return true;
		}
		return false;
	}

	/**
	 * Override all text types to be plain text.
	 *
	 * @param response
	 * @param contentType
	 */
	protected void setContentType(HttpServletResponse response, String contentType) {
		if (isTextType(contentType)) {
			response.setContentType("text/plain");
		} else {
			response.setContentType(contentType);
		}
	}

	protected boolean streamFromRepo(HttpServletRequest request, HttpServletResponse response, Repository repository,
			RevCommit commit, String requestedPath) throws IOException {

		boolean served = false;
		RevWalk rw = new RevWalk(repository);
		TreeWalk tw = new TreeWalk(repository);
		try {
			tw.reset();
			tw.addTree(commit.getTree());
			PathFilter f = PathFilter.create(requestedPath);
			tw.setFilter(f);
			tw.setRecursive(true);
			MutableObjectId id = new MutableObjectId();
			ObjectReader reader = tw.getObjectReader();
			while (tw.next()) {
				FileMode mode = tw.getFileMode(0);
				if (mode == FileMode.GITLINK || mode == FileMode.TREE) {
					continue;
				}
				tw.getObjectId(id, 0);

				String filename = StringUtils.getLastPathElement(requestedPath);
				try {
			    	String userAgent = request.getHeader("User-Agent");
					if (userAgent != null && userAgent.indexOf("MSIE 5.5") > -1) {
					      response.setHeader("Content-Disposition", "filename=\""
					    		  +  URLEncoder.encode(filename, Constants.ENCODING) + "\"");
					} else if (userAgent != null && userAgent.indexOf("MSIE") > -1) {
					      response.setHeader("Content-Disposition", "attachment; filename=\""
					    		  +  URLEncoder.encode(filename, Constants.ENCODING) + "\"");
					} else {
							response.setHeader("Content-Disposition", "attachment; filename=\""
							      + new String(filename.getBytes(Constants.ENCODING), "latin1") + "\"");
					}
				}
				catch (UnsupportedEncodingException e) {
					response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
				}

				long len = reader.getObjectSize(id, org.eclipse.jgit.lib.Constants.OBJ_BLOB);
				setContentType(response, "application/octet-stream");
				response.setIntHeader("Content-Length", (int) len);
				ObjectLoader ldr = repository.open(id);
				ldr.copyTo(response.getOutputStream());
				served = true;
			}
		} finally {
			tw.close();
			rw.dispose();
		}

		response.flushBuffer();
		return served;
	}

	protected void sendContent(HttpServletResponse response, Date date, InputStream is) throws ServletException, IOException {

		try {
			byte[] tmp = new byte[8192];
			int len = 0;
			while ((len = is.read(tmp)) > -1) {
				response.getOutputStream().write(tmp, 0, len);
			}
		} finally {
			is.close();
		}
		response.flushBuffer();
	}

	protected void notFound(HttpServletResponse response, String requestedPath, String branch)
			throws ParseException, ServletException, IOException {
		String str = MessageFormat.format(
				"# Error\nSorry, the requested resource **{0}** was not found in **{1}**.",
				requestedPath, branch);
		response.setStatus(HttpServletResponse.SC_NOT_FOUND);
		error(response, str);
	}

	private void error(HttpServletResponse response, String mkd) throws ServletException,
			IOException, ParseException {
		String content = MarkdownUtils.transformMarkdown(mkd);
		response.setContentType("text/html; charset=" + Constants.ENCODING);
		response.getWriter().write(content);
	}

	@Override
	protected void doPost(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		processRequest(request, response);
	}

	@Override
	protected void doGet(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		processRequest(request, response);
	}
}
