// Copyright (c) 2003-present, Jodd Team (http://jodd.org)
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

package jodd.htmlstapler;

import jodd.io.FileNameUtil;
import jodd.io.FileUtil;
import jodd.io.NetUtil;
import jodd.io.ZipUtil;
import jodd.io.findfile.FindFile;
import jodd.util.Base32;
import jodd.util.CharUtil;
import jodd.util.RandomString;
import jodd.util.StringBand;
import jodd.util.StringPool;
import jodd.util.StringUtil;
import jodd.util.SystemUtil;
import jodd.log.Logger;
import jodd.log.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * HTML resources bundles manager.
 */
public class HtmlStaplerBundlesManager {

	private static final Logger log = LoggerFactory.getLogger(HtmlStaplerBundlesManager.class);

	protected int bundleCount;		// counter for new bundles

	protected Map<String, String> actionBundles; 			// action -> bundleId/digest
	protected Map<String, String> mirrors;					// temp id -> bundleId

	protected final String webRoot;
	protected final String contextPath;
	protected final Strategy strategy;

	// parameters
	protected String localFilesEncoding = StringPool.UTF_8;
	protected String bundleFolder;
	protected String staplerPath = "jodd-bundle";
	protected String localAddressAndPort = "http://localhost:8080";
	protected boolean downloadLocal;
	protected boolean sortResources;
	protected boolean notFoundExceptionEnabled = true;
	protected int randomDigestChars = 0;

	private static String uniqueDigestKey;

	// ---------------------------------------------------------------- strategy

	public enum Strategy {

		/**
		 * For each action manager stores the bundle id.
		 * This gives top performances, as the links are collected only
		 * once per page and bundle id (digest) is calculated only once
		 * as well, only first time page is accessed. However, if there
		 * are dynamic REST-alike links, there might be a large or infinite
		 * number of action links in the application. This can be handled
		 * in the code by overriding method <code>resolveRealActionPath()</code>.
		 */
		ACTION_MANAGED,

		/**
		 * Pragmatic strategy that collects all links and builds  bundle id
		 * (digest) every time when page is processed. This gives slightly
		 * slower performances, but there is no additional memory consumption.
		 */
		RESOURCES_ONLY
	}

	// ---------------------------------------------------------------- init

	/**
	 * Creates new instance and initialize it.
	 */
	public HtmlStaplerBundlesManager(String contextPath, String webRoot, Strategy strategy) {
		this.contextPath = contextPath;
		this.webRoot = webRoot;
		this.strategy = strategy;
		this.bundleFolder = SystemUtil.tempDir();

		if (strategy == Strategy.ACTION_MANAGED) {
			actionBundles = new HashMap<>();
			mirrors = new HashMap<>();
		}
	}

	/**
	 * Starts bundle usage by creating new {@link BundleAction}.
	 */
	public BundleAction start(String servletPath, String bundleName) {
		return new BundleAction(this, servletPath, bundleName);
	}

	// ---------------------------------------------------------------- params

	/**
	 * Returns current {@link Strategy strategy}.
	 */
	public Strategy getStrategy() {
		return strategy;
	}

	/**
	 * Returns <code>true</code> if resources are sorted before
	 * bundle id (a digest) is created. When sorting is enabled,
	 * two pages will share the same bundle even if they list
	 * resources in different order.
	 */
	public boolean isSortResources() {
		return sortResources;
	}

	/**
	 * Sets the resources sorting before bundle id (i.e. a digest)
	 * is created.
	 */
	public void setSortResources(boolean sortResources) {
		this.sortResources = sortResources;
	}

	/**
	 * Returns current web root.
	 */
	public String getWebRoot() {
		return webRoot;
	}

	/**
	 * Returns bundles folder. By default, it is a system temp folder.
	 */
	public String getBundleFolder() {
		return bundleFolder;
	}

	/**
	 * Sets bundle folder.
	 */
	public void setBundleFolder(String bundleFolder) {
		this.bundleFolder = bundleFolder;
	}

	/**
	 * Returns stapler path. It is both the file system folder
	 * name and the web folder name.
	 */
	public String getStaplerPath() {
		return staplerPath;
	}

	/**
	 * Sets stapler path.
	 */
	public void setStaplerPath(String staplerPath) {
		this.staplerPath = staplerPath;
	}

	/**
	 * Returns local files encoding. By default its UTF8.
	 */
	public String getLocalFilesEncoding() {
		return localFilesEncoding;
	}

	/**
	 * Sets local files encoding.
	 */
	public void setLocalFilesEncoding(String localFilesEncoding) {
		this.localFilesEncoding = localFilesEncoding;
	}

	/**
	 * Returns local address and port for downloading
	 * local resources.
	 */
	public String getLocalAddressAndPort() {
		return localAddressAndPort;
	}

	/**
	 * Specifies local address and port for downloading
	 * local resources. By default its "http://localhost:8080".
	 */
	public void setLocalAddressAndPort(String localAddressAndPort) {
		this.localAddressAndPort = localAddressAndPort;
	}

	/**
	 * Returns <code>true</code> if local resource files are downloaded
	 * and not loaded from file system.
	 */
	public boolean isDownloadLocal() {
		return downloadLocal;
	}

	/**
	 * Sets if local resource files should be downloaded or loaded from file system.
	 */
	public void setDownloadLocal(boolean downloadLocal) {
		this.downloadLocal = downloadLocal;
	}

	/**
	 * Returns <code>true</code> if exception will be thrown when
	 * resource is not found.
	 */
	public boolean isNotFoundExceptionEnabled() {
		return notFoundExceptionEnabled;
	}

	/**
	 * Sets if exception should be thrown when some resource is not found.
	 * If not enabled, the error will be logged as a warning.
	 */
	public void setNotFoundExceptionEnabled(boolean notFoundExceptionEnabled) {
		this.notFoundExceptionEnabled = notFoundExceptionEnabled;
	}

	/**
	 * Returns the number of random digest chars.
	 */
	public int getRandomDigestChars() {
		return randomDigestChars;
	}

	/**
	 * Sets the number of random characters that will be appended to the
	 * {@link #createDigest(String) digest}. When it is set to 0, nothing
	 * will be appended to the digest. Otherwise, a random string will be
	 * generated (containing only letters and digits) and appended to the
	 * digest.
	 * <p>
	 * Random digest chars is a <b>unique</b> key per one VM!
	 * This key is initialized only once.
	 * This is useful to automatically expire any cache that browsers may have in
	 * JS and CSS files, so that changes in those files will be downloaded by the
	 * browser.
	 */
	public void setRandomDigestChars(int randomDigestChars) {
		this.randomDigestChars = randomDigestChars;

		if (randomDigestChars == 0) {
			uniqueDigestKey = null;
		}
		else {
			uniqueDigestKey = new RandomString().randomAlphaNumeric(randomDigestChars);
		}
	}

	// ---------------------------------------------------------------- lookup

	/**
	 * Creates bundle file in bundleFolder/staplerPath. Only file object
	 * is created, not the file content.
	 */
	protected File createBundleFile(String bundleId) {
		File folder = new File(bundleFolder, staplerPath);
		if (!folder.exists()) {
			folder.mkdirs();
		}
		return new File(folder, bundleId);
	}

	/**
	 * Lookups for bundle file.
	 */
	public File lookupBundleFile(String bundleId) {
		if ((mirrors != null) && (!mirrors.isEmpty())) {
			String realBundleId = mirrors.remove(bundleId);

			if (realBundleId != null) {
				bundleId = realBundleId;
			}
		}
		return createBundleFile(bundleId);
	}

	/**
	 * Locates gzipped version of bundle file. If gzip file
	 * does not exist, it will be created.
	 */
	public File lookupGzipBundleFile(File file) throws IOException {
		String path = file.getPath() + ZipUtil.GZIP_EXT;
		File gzipFile = new File(path);

		if (!gzipFile.exists()) {
			if (log.isDebugEnabled()) {
				log.debug("gzip bundle to " + path);
			}
			ZipUtil.gzip(file);
		}

		return gzipFile;
	}

	/**
	 * Lookups for a bundle id for a given action.
	 * Returns <code>null</code> if action still has no bundle.
	 * Returns an empty string if action has an empty bundle.
	 */
	public String lookupBundleId(String actionPath) {
		return actionBundles.get(actionPath);
	}

	// ---------------------------------------------------------------- register

	/**
	 * Registers new, temporary bundle id for given action path.
	 * This id is used on first bundle usage, later it will be replaces with
	 * real bundle id.
	 */
	public String registerNewBundleId() {
		bundleCount++;
		return String.valueOf(bundleCount);
	}

	/**
	 * Registers new bundle that consist of provided list of source paths.
	 * Returns the real bundle id, as provided one is just a temporary bundle id.
	 */
	public synchronized String registerBundle(String contextPath, String actionPath, String tempBundleId, String bundleContentType, List<String> sources) {

		if (tempBundleId == null || sources.isEmpty()) {
			if (strategy == Strategy.ACTION_MANAGED) {
				// page does not include any resource source file
				actionBundles.put(actionPath, StringPool.EMPTY);
			}
			return null;
		}

		// create unique digest from the collected sources
		String[] sourcesArray = sources.toArray(new String[sources.size()]);
		for (int i = 0, sourcesArrayLength = sourcesArray.length; i < sourcesArrayLength; i++) {
			sourcesArray[i] = sourcesArray[i].trim().toLowerCase();
		}
		if (sortResources) {
			Arrays.sort(sourcesArray);
		}

		StringBand sb = new StringBand(sourcesArray.length);
		for (String src : sourcesArray) {
			sb.append(src);
		}
		String sourcesString = sb.toString();

		String bundleId = createDigest(sourcesString);
		bundleId += '.' + bundleContentType;

		// bundle appears for the first time, create the bundle
		if (strategy == Strategy.ACTION_MANAGED) {
			actionBundles.put(actionPath, bundleId);
			mirrors.put(tempBundleId, bundleId);
		}
		try {
			createBundle(contextPath, actionPath, bundleId, sources);
		} catch (IOException ioex) {
			throw new HtmlStaplerException("Can't create bundle", ioex);
		}
		return bundleId;
	}

	/**
	 * Creates digest i.e. bundle id from given string.
	 * Returned digest must be filename safe, for all platforms.
	 */
	protected String createDigest(String source) {
		MessageDigest shaDigester;
		try {
			shaDigester = MessageDigest.getInstance("SHA-256");
		} catch (NoSuchAlgorithmException nsaex) {
			throw new HtmlStaplerException(nsaex);
		}

		byte[] bytes = shaDigester.digest(CharUtil.toSimpleByteArray(source));
		String digest = Base32.encode(bytes);

		if (uniqueDigestKey != null) {
			digest += uniqueDigestKey;
		}

		return digest;
	}

	/**
	 * Creates bundle file by loading resource files content. If bundle file already
	 * exist it will not be recreated!
	 */
	protected void createBundle(String contextPath, String actionPath, String bundleId, List<String>sources) throws IOException {
		File bundleFile = createBundleFile(bundleId);
		if (bundleFile.exists()) {
			return;
		}

		StringBand sb = new StringBand(sources.size() * 2);
		for (String src : sources) {
			if (sb.length() != 0) {
				sb.append(StringPool.NEWLINE);
			}
			String content;
			if (isExternalResource(src)) {
				try {
					content = NetUtil.downloadString(src, localFilesEncoding);
				} catch (IOException ioex) {
					if (notFoundExceptionEnabled) {
						throw ioex;
					}
					if (log.isWarnEnabled()) {
						log.warn("Download failed: " + src + "; " + ioex.getMessage());
					}
					content = null;
				}
			} else {
				if (!downloadLocal) {
					// load local resource from file system
					String localFile = webRoot;

					if (src.startsWith(contextPath + '/')) {
						src = src.substring(contextPath.length());
					}

					if (src.startsWith(StringPool.SLASH)) {
						// absolute path
						localFile += src;
					} else {
						// relative path
						localFile += '/' + FileNameUtil.getPathNoEndSeparator(actionPath) + '/' + src;
					}

					// trim link parameters, if any
					int qmndx = localFile.indexOf('?');
					if (qmndx != -1) {
						localFile = localFile.substring(0, qmndx);
					}

					try {
						content = FileUtil.readString(localFile);
					} catch (IOException ioex) {
						if (notFoundExceptionEnabled) {
							throw ioex;
						}
						if (log.isWarnEnabled()) {
							log.warn(ioex.getMessage());
						}
						content = null;
					}
				} else {
					// download local resource
					String localUrl = localAddressAndPort;

					if (src.startsWith(StringPool.SLASH)) {
						localUrl += contextPath + src;
					} else {
						localUrl += contextPath + FileNameUtil.getPath(actionPath) + '/' + src;
					}

					try {
						content = NetUtil.downloadString(localUrl, localFilesEncoding);
					} catch (IOException ioex) {
						if (notFoundExceptionEnabled) {
							throw ioex;
						}
						if (log.isWarnEnabled()) {
							log.warn("Download failed: " + localUrl + "; " + ioex.getMessage());
						}
						content = null;
					}
				}

				if (content != null) {
					if (isCssResource(src)) {
						content = fixCssRelativeUrls(content, src);
					}
				}
			}

			if (content != null) {
				content = onResourceContent(content);
				sb.append(content);
			}
		}

		FileUtil.writeString(bundleFile, sb.toString());

		if (log.isInfoEnabled()) {
			log.info("Bundle created: " + bundleId);
		}
	}

	/**
	 * Returns <code>true</code> if resource link has to be downloaded.
	 * By default, if resource link starts with "http://" or with "https://"
	 * it will be considered as external resource.
	 */
	protected boolean isExternalResource(String link) {
		return link.startsWith("http://") || (link.startsWith("https://"));
	}

	/**
	 * Invoked before resource content is stored in the bundle.
	 * May be us used for additional resource processing, such as
	 * compressing, cleaning etc. By default it just returns unmodified
	 * content.
	 */
	protected String onResourceContent(String content) {
		return content;
	}

	// ---------------------------------------------------------------- url rewriting

	/**
	 * Resolves real action path for given one.
	 * When some URLs are dynamically created, many different links points
	 * to the same page. Use this to prevent memory leaking.
	 */
	protected String resolveRealActionPath(String actionPath) {
		return actionPath;
	}

	// ---------------------------------------------------------------- reset

	/**
	 * Clears all settings and removes all created bundle files from file system.
	 */
	public synchronized void reset() {
		if (strategy == Strategy.ACTION_MANAGED) {
			actionBundles.clear();
			mirrors.clear();
		}

		FindFile ff = new FindFile();
		ff.includeDirs(false);
		ff.searchPath(new File(bundleFolder, staplerPath));

		File f;
		int count = 0;
		while ((f = ff.nextFile()) != null) {
			f.delete();
			count++;
		}
		if (log.isInfoEnabled()) {
			log.info("reset: " + count + " bundle files deleted.");
		}
	}

	// ---------------------------------------------------------------- css related

	/**
	 * Returns <code>true</code> if resource is CSS, so the
	 * CSS urls can be fixed.
	 */
	protected boolean isCssResource(String src) {
		return src.endsWith(".css");
	}

	private static final Pattern CSS_URL_PATTERN = Pattern.compile("url\\s*\\(\\s*([^\\)\\s]*)\\s*\\)", Pattern.CASE_INSENSITIVE);

	/**
	 * Returns the content with all relative URLs fixed.
	 */
	protected String fixCssRelativeUrls(String content, String src) {

		String path = FileNameUtil.getPath(src);

		Matcher matcher = CSS_URL_PATTERN.matcher(content);

		StringBuilder sb = new StringBuilder(content.length());

		int start = 0;

		while (matcher.find()) {
			sb.append(content.substring(start, matcher.start()));

			String url = fixRelativeUrl(matcher.group(1), path);

			sb.append(url);

			start = matcher.end();
		}

		sb.append(content.substring(start));

		return sb.toString();
	}

	/**
	 * For a given URL (optionally quoted), produces CSS URL
	 * where relative paths are fixed and prefixed with offsetPath.
	 */
	protected String fixRelativeUrl(String url, String offsetPath) {

		url = StringUtil.removeChars(url, "'\"");   // remove quotes

		StringBuilder res = new StringBuilder();
		res.append("url('");

		if (!url.startsWith(StringPool.SLASH)) {
			res
				.append("../")
				.append(offsetPath);
		}

		res.append(url).append("')");

		return res.toString();
	}

}