/*
 * This file is part of Nuts Framework.
 * Copyright (C) 2009 Nuts Develop Team.
 *
 * Nuts Framework is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License any later version.
 *
 * Nuts Framework is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Nuts Framework. If not, see <http://www.gnu.org/licenses/>.
 */
package nuts.exts.struts2.interceptor;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import nuts.exts.struts2.dispatcher.multipart.MultiPartRequestWrapper;
import nuts.exts.xwork2.util.LocalizedTextUtils;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.vfs.FileObject;
import org.apache.commons.vfs.FileSystemException;
import org.apache.struts2.ServletActionContext;

import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.ActionInvocation;
import com.opensymphony.xwork2.ActionProxy;
import com.opensymphony.xwork2.ValidationAware;
import com.opensymphony.xwork2.inject.Inject;
import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
import com.opensymphony.xwork2.util.PatternMatcher;
import com.opensymphony.xwork2.util.TextParseUtil;

/**
 * <!-- START SNIPPET: description -->
 * <p/>
 * Interceptor that is based off of {@link MultiPartRequestWrapper}, which is
 * automatically applied for any request that includes a file. It adds the
 * following parameters, where [File Name] is the name given to the file
 * uploaded by the HTML form:
 * <p/>
 * <ul>
 * <p/>
 * <li>[File Name] : File - the actual File</li>
 * <p/>
 * <li>[File Name]ContentType : String - the content type of the file</li>
 * <p/>
 * <li>[File Name]FileName : String - the actual name of the file uploaded (not
 * the HTML name)</li>
 * <p/>
 * </ul>
 * <p/>
 * <p/>
 * You can get access to these files by merely providing setters in your action
 * that correspond to any of the three patterns above, such as setDocument(File
 * document), setDocumentContentType(String contentType), etc. <br/>
 * See the example code section.
 * <p/>
 * <p/>
 * This interceptor will add several field errors, assuming that the action
 * implements {@link ValidationAware}. These error messages are based on several
 * i18n values stored in struts-messages.properties, a default i18n file
 * processed for all i18n requests. You can override the text of these messages
 * by providing text for the following keys:
 * <p/>
 * <ul>
 * <p/>
 * <li>struts.messages.error.uploading - a general error that occurs when the
 * file could not be uploaded</li>
 * <p/>
 * <li>struts.messages.error.file.too.large - occurs when the uploaded file is
 * too large</li>
 * <p/>
 * <li>struts.messages.error.content.type.not.allowed - occurs when the uploaded
 * file does not match the expected content types specified</li>
 * <p/>
 * <li>struts.messages.error.file.extension.not.allowed - occurs when the
 * uploaded file does not match the expected file extensions specified</li>
 * <p/>
 * </ul>
 * <p/>
 * <!-- END SNIPPET: description -->
 * <p/>
 * <p/>
 * <u>Interceptor parameters:</u>
 * <p/>
 * <!-- START SNIPPET: parameters -->
 * <p/>
 * <ul>
 * <p/>
 * <li>maximumSize (optional) - the maximum size (in bytes) that the interceptor
 * will allow a file reference to be set on the action. Note, this is <b>not</b>
 * related to the various properties found in struts.properties. Default to
 * approximately 2MB.</li>
 * <p/>
 * <li>allowedTypes (optional) - a comma separated list of content types (ie:
 * text/html) that the interceptor will allow a file reference to be set on the
 * action. If none is specified allow all types to be uploaded.</li>
 * <p/>
 * <li>allowedExtensions (optional) - a comma separated list of file extensions
 * (ie: .html) that the interceptor will allow a file reference to be set on the
 * action. If none is specified allow all extensions to be uploaded.</li>
 * </ul>
 * <p/>
 * <p/>
 * <!-- END SNIPPET: parameters -->
 * <p/>
 * <p/>
 * <u>Extending the interceptor:</u>
 * <p/>
 * <p/>
 * <p/>
 * <!-- START SNIPPET: extending -->
 * <p/>
 * You can extend this interceptor and override the acceptFile method to provide
 * more control over which files are supported and which are not.
 * <p/>
 * <!-- END SNIPPET: extending -->
 * <p/>
 * <p/>
 * <u>Example code:</u>
 * <p/>
 * 
 * <pre>
 * <!-- START SNIPPET: example-configuration -->
 * &lt;action name="doUpload" class="com.example.UploadAction"&gt;
 *     &lt;interceptor-ref name="fileUpload"/&gt;
 *     &lt;interceptor-ref name="basicStack"/&gt;
 *     &lt;result name="success"&gt;good_result.jsp&lt;/result&gt;
 * &lt;/action&gt;
 * <!-- END SNIPPET: example-configuration -->
 * </pre>
 * <p/>
 * <!-- START SNIPPET: multipart-note -->
 * <p/>
 * You must set the encoding to <code>multipart/form-data</code> in the form
 * where the user selects the file to upload.
 * <p/>
 * <!-- END SNIPPET: multipart-note -->
 * <p/>
 * 
 * <pre>
 * <!-- START SNIPPET: example-form -->
 *   &lt;s:form action="doUpload" method="post" enctype="multipart/form-data"&gt;
 *       &lt;s:file name="upload" label="File"/&gt;
 *       &lt;s:submit/&gt;
 *   &lt;/s:form&gt;
 * <!-- END SNIPPET: example-form -->
 * </pre>
 * <p/>
 * And then in your action code you'll have access to the File object if you
 * provide setters according to the naming convention documented in the start.
 * <p/>
 * 
 * <pre>
 * <!-- START SNIPPET: example-action -->
 *    package com.example;
 * <p/>
 *    import java.io.File;
 *    import com.opensymphony.xwork2.ActionSupport;
 * <p/>
 *    public UploadAction extends ActionSupport {
 *       private File file;
 *       private String contentType;
 *       private String filename;
 * <p/>
 *       public void setUpload(File file) {
 *          this.file = file;
 *       }
 * <p/>
 *       public void setUploadContentType(String contentType) {
 *          this.contentType = contentType;
 *       }
 * <p/>
 *       public void setUploadFileName(String filename) {
 *          this.filename = filename;
 *       }
 * <p/>
 *       public String execute() {
 *          //...
 *          return SUCCESS;
 *       }
 *  }
 * <!-- END SNIPPET: example-action -->
 * </pre>
 */
@SuppressWarnings("serial")
public class FileUploadInterceptor extends AbstractInterceptor {

	protected static final Log log = LogFactory.getLog(FileUploadInterceptor.class);
	private static final String DEFAULT_MESSAGE = "no.message.found";

	protected boolean useActionMessageBundle;

	protected boolean cleanUp = false;
	protected Long maximumSize;
	protected Set<String> allowedTypesSet = Collections.emptySet();
	protected Set<String> allowedExtensionsSet = Collections.emptySet();

	private PatternMatcher matcher;

	@Inject
	public void setMatcher(PatternMatcher matcher) {
		this.matcher = matcher;
	}

	public void setUseActionMessageBundle(String value) {
		this.useActionMessageBundle = Boolean.valueOf(value);
	}

	/**
	 * Sets the allowed extensions
	 * 
	 * @param allowedExtensions A comma-delimited list of extensions
	 */
	public void setAllowedExtensions(String allowedExtensions) {
		allowedExtensionsSet = TextParseUtil.commaDelimitedStringToSet(allowedExtensions);
	}

	/**
	 * Sets the allowed mimetypes
	 * 
	 * @param allowedTypes A comma-delimited list of types
	 */
	public void setAllowedTypes(String allowedTypes) {
		allowedTypesSet = TextParseUtil.commaDelimitedStringToSet(allowedTypes);
	}

	/**
	 * @param cleanUp the cleanUp to set
	 */
	public void setCleanUp(boolean cleanUp) {
		this.cleanUp = cleanUp;
	}

	/**
	 * Sets the maximum size of an uploaded file
	 * 
	 * @param maximumSize The maximum size in bytes
	 */
	public void setMaximumSize(Long maximumSize) {
		this.maximumSize = maximumSize;
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see
	 * com.opensymphony.xwork2.interceptor.Interceptor#intercept(com.opensymphony
	 * .xwork2.ActionInvocation)
	 */

	public String intercept(ActionInvocation invocation) throws Exception {
		ActionContext ac = invocation.getInvocationContext();

		HttpServletRequest request = (HttpServletRequest)ac.get(ServletActionContext.HTTP_REQUEST);

		if (!(request instanceof MultiPartRequestWrapper)) {
			if (log.isDebugEnabled()) {
				ActionProxy proxy = invocation.getProxy();
				log.debug(getTextMessage("struts.messages.bypass.request",
						new Object[] { proxy.getNamespace(),
								proxy.getActionName() }, ac.getLocale()));
			}

			return invocation.invoke();
		}

		ValidationAware validation = null;

		Object action = invocation.getAction();

		if (action instanceof ValidationAware) {
			validation = (ValidationAware)action;
		}

		MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper)request;

		if (multiWrapper.hasErrors()) {
			for (String error : multiWrapper.getErrors()) {
				if (validation != null) {
					validation.addActionError(error);
				}

				log.warn(error);
			}
		}

		// bind allowed Files
		Enumeration fileParameterNames = multiWrapper.getFileParameterNames();
		while (fileParameterNames != null
				&& fileParameterNames.hasMoreElements()) {
			// get the value of this input tag
			String inputName = (String)fileParameterNames.nextElement();

			// get the content type
			String[] contentType = multiWrapper.getContentTypes(inputName);

			if (isNonEmpty(contentType)) {
				// get the name of the file from the input tag
				String[] fileName = multiWrapper.getFileNames(inputName);

				if (isNonEmpty(fileName)) {
					// get a File object for the uploaded File
					FileObject[] files = multiWrapper.getFiles(inputName);
					if (files != null && files.length > 0) {
						List<FileObject> acceptedFiles = new ArrayList<FileObject>(
								files.length);
						List<String> acceptedContentTypes = new ArrayList<String>(
								files.length);
						List<String> acceptedFileNames = new ArrayList<String>(
								files.length);
						String fileFieldName = inputName + ".file";
						String contentTypeName = inputName + ".contentType";
						String fileNameName = inputName + ".fileName";

						for (int index = 0; index < files.length; index++) {
							if (acceptFile(action, files[index],
									fileName[index], contentType[index],
									inputName, validation, ac.getLocale())) {
								acceptedFiles.add(files[index]);
								acceptedContentTypes.add(contentType[index]);
								acceptedFileNames.add(fileName[index]);
							}
						}

						if (!acceptedFiles.isEmpty()) {
							Map<String, Object> params = ac.getParameters();

							params.put(
									fileFieldName,
									acceptedFiles.toArray(new FileObject[acceptedFiles.size()]));
							params.put(
									contentTypeName,
									acceptedContentTypes.toArray(new String[acceptedContentTypes.size()]));
							params.put(
									fileNameName,
									acceptedFileNames.toArray(new String[acceptedFileNames.size()]));
						}
					}
				}
				else {
					log.warn(getTextMessage(action,
							"struts.messages.invalid.file",
							new Object[] { inputName }, ac.getLocale()));
				}
			}
			else {
				log.warn(getTextMessage(action,
						"struts.messages.invalid.content.type",
						new Object[] { inputName }, ac.getLocale()));
			}
		}

		// invoke action
		String result = invocation.invoke();

		// cleanup
		if (cleanUp) {
			cleanUp(multiWrapper, invocation);
		}
		
		return result;
	}

	protected void cleanUp(MultiPartRequestWrapper multiWrapper, ActionInvocation invocation) throws IOException {
		ActionContext ac = invocation.getInvocationContext();
		Object action = invocation.getAction();
		
		Enumeration fileParameterNames = multiWrapper.getFileParameterNames();
		while (fileParameterNames != null
				&& fileParameterNames.hasMoreElements()) {
			String inputValue = (String)fileParameterNames.nextElement();
			FileObject[] files = multiWrapper.getFiles(inputValue);

			for (FileObject currentFile : files) {
				if (log.isInfoEnabled()) {
					log.info(getTextMessage(action,
							"struts.messages.removing.file", new Object[] {
									inputValue, currentFile }, ac.getLocale()));
				}

				if (currentFile != null) {
					if (currentFile.delete() == false) {
						log.warn("Resource Leaking:  Could not remove uploaded file '"
								+ currentFile.getURL() + "'.");
					}
				}
			}
		}
	}
	
	/**
	 * Override for added functionality. Checks if the proposed file is
	 * acceptable based on contentType and size.
	 * 
	 * @param action - uploading action for message retrieval.
	 * @param file - proposed upload file.
	 * @param contentType - contentType of the file.
	 * @param inputName - inputName of the file.
	 * @param validation - Non-null ValidationAware if the action implements
	 *            ValidationAware, allowing for better logging.
	 * @param locale
	 * @return true if the proposed file is acceptable by contentType and size.
	 * @throws FileSystemException file exception
	 */
	protected boolean acceptFile(Object action, FileObject file, String filename,
			String contentType, String inputName, ValidationAware validation,
			Locale locale) throws FileSystemException {
		boolean fileIsAcceptable = false;

		// If it's null the upload failed
		if (file == null) {
			String errMsg = getTextMessage(action,
					"struts.messages.error.uploading",
					new Object[] { inputName }, locale);
			if (validation != null) {
				validation.addFieldError(inputName, errMsg);
			}

			log.warn(errMsg);
		}
		else if (maximumSize != null && maximumSize < file.getContent().getSize()) {
			String errMsg = getTextMessage(action,
					"struts.messages.error.file.too.large", new Object[] {
							inputName, filename, file.getName(),
							"" + file.getContent().getSize() }, locale);
			if (validation != null) {
				validation.addFieldError(inputName, errMsg);
			}

			log.warn(errMsg);
		}
		else if ((!allowedTypesSet.isEmpty())
				&& (!containsItem(allowedTypesSet, contentType))) {
			String errMsg = getTextMessage(action,
					"struts.messages.error.content.type.not.allowed",
					new Object[] { inputName, filename, file.getName(),
							contentType }, locale);
			if (validation != null) {
				validation.addFieldError(inputName, errMsg);
			}

			log.warn(errMsg);
		}
		else if ((!allowedExtensionsSet.isEmpty())
				&& (!hasAllowedExtension(allowedExtensionsSet, filename))) {
			String errMsg = getTextMessage(action,
					"struts.messages.error.file.extension.not.allowed",
					new Object[] { inputName, filename, file.getName(),
							contentType }, locale);
			if (validation != null) {
				validation.addFieldError(inputName, errMsg);
			}

			log.warn(errMsg);
		}
		else {
			fileIsAcceptable = true;
		}

		return fileIsAcceptable;
	}

	/**
	 * @param extensionCollection - Collection of extensions (all lowercase).
	 * @param filename - filename to check.
	 * @return true if the filename has an allowed extension, false otherwise.
	 */
	private boolean hasAllowedExtension(Collection<String> extensionCollection,
			String filename) {
		if (filename == null) {
			return false;
		}

		String lowercaseFilename = filename.toLowerCase();
		for (String extension : extensionCollection) {
			if (lowercaseFilename.endsWith(extension)) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param itemCollection - Collection of string items (all lowercase).
	 * @param item - Item to search for.
	 * @return true if itemCollection contains the item, false otherwise.
	 */
	private boolean containsItem(Collection<String> itemCollection, String item) {
		for (String pattern : itemCollection)
			if (matchesWildcard(pattern, item))
				return true;
		return false;
	}

	private boolean matchesWildcard(String pattern, String text) {
		Object o = matcher.compilePattern(pattern);
		return matcher.match(new HashMap<String, String>(), text, o);
	}

	private boolean isNonEmpty(Object[] objArray) {
		boolean result = false;
		for (int index = 0; index < objArray.length && !result; index++) {
			if (objArray[index] != null) {
				result = true;
			}
		}
		return result;
	}

	private String getTextMessage(String messageKey, Object[] args,
			Locale locale) {
		return getTextMessage(null, messageKey, args, locale);
	}

	private String getTextMessage(Object action, String messageKey,
			Object[] args, Locale locale) {
		if (args == null || args.length == 0) {
			if (action != null && useActionMessageBundle) {
				return LocalizedTextUtils.findText(action.getClass(),
						messageKey, locale);
			}
			return LocalizedTextUtils.findText(this.getClass(), messageKey,
					locale);
		}
		else {
			if (action != null && useActionMessageBundle) {
				return LocalizedTextUtils.findText(action.getClass(),
						messageKey, locale, DEFAULT_MESSAGE, args);
			}
			return LocalizedTextUtils.findText(this.getClass(), messageKey,
					locale, DEFAULT_MESSAGE, args);
		}
	}
}
