/*
 * This file is part of Nuts Framework.
 * Copyright(C) 2009-2012 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.core.io;

import nuts.core.lang.StringUtils;

import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.net.URL;
import java.net.URLConnection;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSelectInfo;
import org.apache.commons.vfs2.FileSelector;
import org.apache.commons.vfs2.FileType;

/**
 * I/O Utilities class.
 */
public class IOUtils extends org.apache.commons.io.IOUtils {

	protected static Log log = LogFactory.getLog(IOUtils.class);

	protected static final String TOP_PATH = "..";

	protected static final String CURRENT_PATH = ".";

	protected static final char EXTENSION_SEPARATOR = '.';

	protected static final Pattern ABS_PATH = Pattern.compile("^[/\\\\]|[a-zA-Z]:[/\\\\]");

	/**
	 * Apply the given relative path to the given path, assuming standard Java folder separation
	 * (i.e. "/" separators);
	 * 
	 * @param path the path to start from (usually a full file path)
	 * @param relativePath the relative path to apply (relative to the full file path above)
	 * @return the full file path that results from applying the relative path
	 */
	public static String applyRelativePath(String path, String relativePath) {
		if (StringUtils.isEmpty(relativePath)) {
			return path;
		}

		int separatorIndex = path.lastIndexOf(DIR_SEPARATOR_UNIX);
		if (separatorIndex != -1) {
			String newPath = path.substring(0, separatorIndex);
			if (relativePath.charAt(0) == DIR_SEPARATOR_UNIX) {
				newPath += DIR_SEPARATOR_UNIX;
			}
			return newPath + relativePath;
		}
		else {
			return relativePath;
		}
	}

	/**
	 * Normalize the path by suppressing sequences like "path/.." and inner simple dots.
	 * <p>
	 * The result is convenient for path comparison. For other uses, notice that Windows separators
	 * ("\") are replaced by simple slashes.
	 * 
	 * @param path the original path
	 * @return the normalized path
	 */
	public static String cleanPath(String path) {
		String pathToUse = StringUtils.replaceChars(path, DIR_SEPARATOR_WINDOWS, DIR_SEPARATOR_UNIX);

		// Strip prefix from path to analyze, to not treat it as part of the
		// first path element. This is necessary to correctly parse paths like
		// "file:core/../core/io/Resource.class", where the ".." should just
		// strip the first "core" directory while keeping the "file:" prefix.
		int prefixIndex = pathToUse.indexOf(":");
		String prefix = "";
		if (prefixIndex != -1) {
			prefix = pathToUse.substring(0, prefixIndex + 1);
			pathToUse = pathToUse.substring(prefixIndex + 1);
		}

		List<String> pathList = StringUtils.parseCsv(pathToUse, DIR_SEPARATOR_UNIX);
		List<String> pathElements = new LinkedList<String>();
		int tops = 0;

		for (int i = pathList.size() - 1; i >= 0; i--) {
			if (CURRENT_PATH.equals(pathList.get(i))) {
				// Points to current directory - drop it.
			}
			else if (TOP_PATH.equals(pathList.get(i))) {
				// Registering top path found.
				tops++;
			}
			else {
				if (tops > 0) {
					// Merging path element with corresponding to top path.
					tops--;
				}
				else {
					// Normal path element found.
					pathElements.add(0, pathList.get(i));
				}
			}
		}

		// Remaining top paths need to be retained.
		for (int i = 0; i < tops; i++) {
			pathElements.add(0, TOP_PATH);
		}

		return prefix + StringUtils.join(pathElements, DIR_SEPARATOR_UNIX);
	}

	/**
	 * Compare two paths after normalization of them.
	 * 
	 * @param path1 first path for comparison
	 * @param path2 second path for comparison
	 * @return whether the two paths are equivalent after normalization
	 */
	public static boolean pathEquals(String path1, String path2) {
		return cleanPath(path1).equals(cleanPath(path2));
	}

	/**
	 * Extract the filename from the given path, e.g. "mypath/myfile.txt" -> "myfile.txt".
	 * 
	 * @param path the file path
	 * @return the extracted filename
	 */
	public static String getFileName(String path) {
		if (path == null) {
			return null;
		}

		int sepIndex = path.lastIndexOf(DIR_SEPARATOR_UNIX);
		if (sepIndex < 0) {
			sepIndex = path.lastIndexOf(DIR_SEPARATOR_WINDOWS);
		}
		return (sepIndex >= 0 ? path.substring(sepIndex + 1) : path);
	}

	/**
	 * Extract the filename from the given path, e.g. "mypath/myfile.txt" -> "myfile.txt".
	 * 
	 * @param file the file
	 * @return the extracted filename
	 */
	public static String getFileName(File file) {
		if (file == null) {
			return null;
		}
		return file.getName();
	}

	/**
	 * Extract the base filename from the given path, e.g. "mypath/myfile.txt" -> "myfile".
	 * 
	 * @param path the file path
	 * @return the extracted base filename
	 */
	public static String getFileNameBase(String path) {
		if (path == null) {
			return null;
		}
		String fn = getFileName(path);
		return stripFileNameExtension(fn);
	}

	/**
	 * Extract the base filename from the given path, e.g. "mypath/myfile.txt" -> "myfile".
	 * 
	 * @param file the file object
	 * @return the extracted base filename
	 */
	public static String getFileNameBase(File file) {
		if (file == null) {
			return null;
		}
		return getFileNameBase(file.getName());
	}

	/**
	 * Extract the filename extension from the given path, e.g. "mypath/myfile.txt" -> "txt".
	 * 
	 * @param path the file path
	 * @return the extracted filename extension
	 */
	public static String getFileNameExtension(String path) {
		if (path == null) {
			return null;
		}
		int sepIndex = path.lastIndexOf(EXTENSION_SEPARATOR);
		return (sepIndex != -1 ? path.substring(sepIndex + 1) : "");
	}

	/**
	 * Extract the filename extension from the given path, e.g. "mypath/myfile.txt" -> "txt".
	 * 
	 * @param file the file object
	 * @return the extracted filename extension
	 */
	public static String getFileNameExtension(File file) {
		if (file == null) {
			return null;
		}
		return getFileNameExtension(file.getName());
	}

	/**
	 * Strip the filename extension from the given path, e.g. "mypath/myfile.txt" ->
	 * "mypath/myfile".
	 * 
	 * @param path the file path
	 * @return the path with stripped filename extension, or <code>null</code> if none
	 */
	public static String stripFileNameExtension(String path) {
		if (path == null) {
			return null;
		}
		int sepIndex = path.lastIndexOf(EXTENSION_SEPARATOR);
		return (sepIndex != -1 ? path.substring(0, sepIndex) : path);
	}

	/**
	 * Strip the filename extension from the given path, e.g. "mypath/myfile.txt" ->
	 * "mypath/myfile".
	 * 
	 * @param file the file object
	 * @return the path with stripped filename extension, or <code>null</code> if none
	 */
	public static String stripFileNameExtension(File file) {
		if (file == null) {
			return null;
		}
		return stripFileNameExtension(file.getPath());
	}

	/**
	 * Removes a leading path from a second path.
	 * 
	 * @param lead The leading path, must not be null, must be absolute.
	 * @param path The path to remove from, must not be null, must be absolute.
	 * @return path's normalized absolute if it doesn't start with leading; path's path with
	 *         leading's path removed otherwise.
	 */
	public static String removeLeadingPath(String lead, String path) {
		if (lead.equals(path)) {
			return "";
		}
		if (path.startsWith(lead)) {
			path = path.substring(lead.length());
			if (path.length() > 0) {
				if (path.charAt(0) == DIR_SEPARATOR_UNIX || path.charAt(0) == DIR_SEPARATOR_WINDOWS) {
					// remove first '//'
					path = path.substring(1);
				}
			}
		}
		return path;
	}

	/**
	 * Removes a leading path from a second path.
	 * 
	 * @param lead The leading path, must not be null, must be absolute.
	 * @param path The path to remove from, must not be null, must be absolute.
	 * @return path's normalized absolute if it doesn't start with leading; path's path with
	 *         leading's path removed otherwise.
	 */
	public static String removeLeadingPath(File lead, File path) {
		return removeLeadingPath(lead.getAbsolutePath(), path.getAbsolutePath());
	}

	/**
	 * Removes a leading path from a second path.
	 * 
	 * @param lead The leading path, must not be null, must be absolute.
	 * @param path The path to remove from, must not be null, must be absolute.
	 * @return path's normalized absolute if it doesn't start with leading; path's path with
	 *         leading's path removed otherwise.
	 */
	public static String removeLeadingPath(FileObject lead, FileObject path) {
		return removeLeadingPath(lead.getName().getPath(), path.getName().getPath());
	}

	/**
	 * Tests whether or not a given path matches a given pattern.
	 * 
	 * @param path The path to match, as a String.
	 * @param pattern The pattern to match against.
	 * @return <code>true</code> if the pattern matches against the string, or <code>false</code>
	 *         otherwise.
	 */
	public static boolean pathMatch(String path, String pattern) {
		return pathMatch(path, pattern, true);
	}

	/**
	 * Tests whether or not a given path matches a given pattern.
	 * 
	 * @param path The path to match, as a String.
	 * @param pattern The pattern to match against.
	 * @param isCaseSensitive Whether or not matching should be performed case sensitively.
	 * @return <code>true</code> if the pattern matches against the string, or <code>false</code>
	 *         otherwise.
	 */
	public static boolean pathMatch(String path, String pattern, boolean isCaseSensitive) {
		if (path == null || path == null) {
			return false;
		}

		String[] strDirs = StringUtils.split(path.replace('\\', '/'), '/');
		String[] patDirs = StringUtils.split(pattern.replace('\\', '/'), '/');

		int patIdxStart = 0;
		int patIdxEnd = patDirs.length - 1;
		int strIdxStart = 0;
		int strIdxEnd = strDirs.length - 1;

		// up to first '**'
		while (patIdxStart <= patIdxEnd && strIdxStart <= strIdxEnd) {
			String patDir = patDirs[patIdxStart];
			if (patDir.equals("**")) {
				break;
			}
			if (!StringUtils.wildcardMatch(strDirs[strIdxStart], patDir, isCaseSensitive)) {
				return false;
			}
			patIdxStart++;
			strIdxStart++;
		}
		if (strIdxStart > strIdxEnd) {
			// String is exhausted
			for (int i = patIdxStart; i <= patIdxEnd; i++) {
				if (!patDirs[i].equals("**")) {
					return false;
				}
			}
			return true;
		}
		else {
			if (patIdxStart > patIdxEnd) {
				// String not exhausted, but pattern is. Failure.
				return false;
			}
		}

		// up to last '**'
		while (patIdxStart <= patIdxEnd && strIdxStart <= strIdxEnd) {
			String patDir = patDirs[patIdxEnd];
			if (patDir.equals("**")) {
				break;
			}
			if (!StringUtils.wildcardMatch(strDirs[strIdxEnd], patDir, isCaseSensitive)) {
				return false;
			}
			patIdxEnd--;
			strIdxEnd--;
		}
		if (strIdxStart > strIdxEnd) {
			// String is exhausted
			for (int i = patIdxStart; i <= patIdxEnd; i++) {
				if (!patDirs[i].equals("**")) {
					return false;
				}
			}
			return true;
		}

		while (patIdxStart != patIdxEnd && strIdxStart <= strIdxEnd) {
			int patIdxTmp = -1;
			for (int i = patIdxStart + 1; i <= patIdxEnd; i++) {
				if (patDirs[i].equals("**")) {
					patIdxTmp = i;
					break;
				}
			}
			if (patIdxTmp == patIdxStart + 1) {
				// '**/**' situation, so skip one
				patIdxStart++;
				continue;
			}
			// Find the pattern between padIdxStart & padIdxTmp in str between
			// strIdxStart & strIdxEnd
			int patLength = (patIdxTmp - patIdxStart - 1);
			int strLength = (strIdxEnd - strIdxStart + 1);
			int foundIdx = -1;
			strLoop: for (int i = 0; i <= strLength - patLength; i++) {
				for (int j = 0; j < patLength; j++) {
					String subPat = patDirs[patIdxStart + j + 1];
					String subStr = strDirs[strIdxStart + i + j];
					if (!StringUtils.wildcardMatch(subStr, subPat, isCaseSensitive)) {
						continue strLoop;
					}
				}

				foundIdx = strIdxStart + i;
				break;
			}

			if (foundIdx == -1) {
				return false;
			}

			patIdxStart = patIdxTmp;
			strIdxStart = foundIdx + patLength;
		}

		for (int i = patIdxStart; i <= patIdxEnd; i++) {
			if (!patDirs[i].equals("**")) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Gets the MIME type for the specified file name.
	 * 
	 * @param filename the specified file name
	 * @return a String indicating the MIME type for the specified file name.
	 */
	public static String getContentTypeFor(String filename) {
		return URLConnection.getFileNameMap().getContentTypeFor(filename);
	}

	/**
	 * close object without throw exception
	 * 
	 * @param o closeable object
	 */
	public static void closeQuietly(Closeable o) {
		try {
			if (o != null) {
				o.close();
			}
		}
		catch (Exception e) {
			;
		}
	}

	/**
	 * Copy the contents of the given input File to the given output File.
	 * 
	 * @param in the file to copy from
	 * @param out the file to copy to
	 * @return the number of bytes copied
	 * @throws IOException in case of I/O errors
	 */
	public static int copy(File in, File out) throws IOException {
		InputStream is = null;
		OutputStream os = null;
		
		try {
			is = new FileInputStream(in);
			os = new FileOutputStream(out);
			return copy(is, os);
		}
		finally {
			closeQuietly(is);
			closeQuietly(os);
		}
	}

	/**
	 * Copy the contents of the given input File to the given output File.
	 * 
	 * @param in the file to copy from
	 * @param out the file to copy to
	 * @return the number of bytes copied
	 * @throws IOException in case of I/O errors
	 */
	public static int copy(FileObject in, FileObject out) throws IOException {
		InputStream is = null;
		OutputStream os = null;
		
		try {
			is = in.getContent().getInputStream();
			os = out.getContent().getOutputStream();
			return copy(is, os);
		}
		finally {
			closeQuietly(is);
			closeQuietly(os);
		}
	}

	/**
	 * Copy the contents of the given input File to the given output File.
	 * 
	 * @param in the file to copy from
	 * @param os the output stream to copy to
	 * @return the number of bytes copied
	 * @throws IOException in case of I/O errors
	 */
	public static int copy(File in, OutputStream os) throws IOException {
		InputStream is = null;
		
		try {
			is = new FileInputStream(in);
			return copy(is, os);
		}
		finally {
			closeQuietly(is);
		}
	}

	/**
	 * Copy the contents of the given input File to the given output File.
	 * 
	 * @param in the file to copy from
	 * @param os the output stream to copy to
	 * @return the number of bytes copied
	 * @throws IOException in case of I/O errors
	 */
	public static int copy(FileObject in, OutputStream os) throws IOException {
		InputStream is = null;
		
		try {
			is = in.getContent().getInputStream();
			return copy(is, os);
		}
		finally {
			closeQuietly(is);
		}
	}

	/**
	 * Returns a copy of the specified stream. If the specified stream supports marking, it will be
	 * reset after the copy.
	 *
	 * @param sourceStream the stream to copy
	 * @return a copy of the stream
	 */
	public static InputStream copy(InputStream sourceStream) throws IOException {
		if (sourceStream.markSupported())
			sourceStream.mark(sourceStream.available());
		byte[] sourceData = toByteArray(sourceStream);
		if (sourceStream.markSupported())
			sourceStream.reset();
		return new ByteArrayInputStream(sourceData);
	}

	/**
	 * Returns a copy of the specified reader. If the specified reader supports marking, it will be
	 * reset after the copy.
	 *
	 * @param sourceReader the stream to reader
	 * @return a copy of the reader
	 */
	public static Reader copy(Reader sourceReader) throws IOException {
		if (sourceReader.markSupported())
			sourceReader.mark(Integer.MAX_VALUE);
		String sourceData = toString(sourceReader);
		if (sourceReader.markSupported())
			sourceReader.reset();
		return new StringReader(sourceData);
	}

	/**
	 * Write the contents of the given byte array to the given output File.
	 * 
	 * @param in the byte array to copy from
	 * @param out the file to copy to
	 * @throws IOException in case of I/O errors
	 */
	public static void write(byte[] in, File out) throws IOException {
		OutputStream os = null;
		
		try {
			os = new FileOutputStream(out);
			write(in, os);
		}
		finally {
			closeQuietly(os);
		}
	}

	/**
	 * Write the contents of the given byte array to the given output File.
	 * 
	 * @param in the byte array to copy from
	 * @param out the file to copy to
	 * @throws IOException in case of I/O errors
	 */
	public static void write(byte[] in, FileObject out) throws IOException {
		OutputStream os = null;
		
		try {
			os = out.getContent().getOutputStream();
			write(in, os);
		}
		finally {
			closeQuietly(os);
		}
	}

	/**
	 * read url content to byte array
	 * 
	 * @param url url
	 * @return byte array
	 * @throws FileNotFoundException if file not found
	 * @throws IOException in case of I/O errors
	 */
	public static byte[] toByteArray(URL url) throws IOException {
		InputStream is = url.openStream();
		try {
			byte[] b = toByteArray(is);
			return b;
		}
		finally {
			closeQuietly(is);
		}
	}

	/**
	 * read file content to byte array
	 * 
	 * @param file file
	 * @return byte array
	 * @throws FileNotFoundException if file not found
	 * @throws IOException in case of I/O errors
	 */
	public static byte[] toByteArray(File file) throws FileNotFoundException, IOException {
		FileInputStream fis = new FileInputStream(file);
		try {
			byte[] b = toByteArray(fis);
			return b;
		}
		finally {
			closeQuietly(fis);
		}
	}

	/**
	 * read file content to byte array
	 * 
	 * @param file file
	 * @return byte array
	 * @throws FileNotFoundException if file not found
	 * @throws IOException in case of I/O errors
	 */
	public static byte[] toByteArray(FileObject file) throws FileNotFoundException, IOException {
		InputStream is = null;
		try {
			is = file.getContent().getInputStream();
			byte[] b = toByteArray(is);
			return b;
		}
		finally {
			closeQuietly(is);
		}
	}
	
	/**
	 * delete the file and sub files
	 * @param file file
	 * @return deleted file and folder count
	 * @throws IOException if an IO error occurs
	 */
	public static int deltree(File file) throws IOException {
		int cnt = 0;
		
		if (file.exists()) {
			if (file.isDirectory()) {
				File[] cfs = file.listFiles();
				for (File cf : cfs) {
					cnt += deltree(cf);
				}
			}
			if (log.isDebugEnabled()) {
				log.debug("delete " + file.getCanonicalPath());
			}
			if (!file.delete()) {
				throw new IOException("Can not delete file: " + file.getCanonicalPath());
			}
			cnt++;
		}
		return cnt;
	}

	/**
	 * delete the file and sub files
	 * @param file file
	 * @return deleted file and folder count
	 * @throws IOException if an IO error occurs
	 */
	public static int deltree(FileObject file) throws IOException {
		return file.delete(new FileSelector() {
				public boolean includeFile(FileSelectInfo fileInfo) throws Exception {
					return true;
				}

				public boolean traverseDescendents(FileSelectInfo fileInfo) throws Exception {
					return true;
				}
			});
	}
	
	public static boolean isAbsolutePath(String path) {
		Matcher m = ABS_PATH.matcher(path);
		return m.find();
	}

	/**
	 * zip folder
	 * @param src source file or folder
	 * @param zip output zip file
	 * @throws IOException if an IO error occurs
	 */
	public static void zip(final FileObject src, final FileObject zip) throws IOException {
		zip(src, zip, null);
	}
	
	/**
	 * zip folder
	 * @param src source file or folder
	 * @param zip output zip file
	 * @param depth depth
	 * @throws IOException if an IO error occurs
	 */
	public static void zip(final FileObject src, final FileObject zip, final int depth) throws IOException {
		zip(src, zip, new FileSelector() {
			public boolean includeFile(FileSelectInfo fileInfo) throws Exception {
				return true;
			}

			public boolean traverseDescendents(FileSelectInfo fileInfo) throws Exception {
				return fileInfo.getDepth() < depth;
			}
		});
	}
	
	/**
	 * zip folder
	 * @param src source file or folder
	 * @param zip output zip file
	 * @param selector file selector
	 * @throws IOException if an IO error occurs
	 */
	public static void zip(final FileObject src, final FileObject zip, final FileSelector selector) throws IOException {
		OutputStream fos = zip.getContent().getOutputStream();
		final ZipOutputStream zos = new ZipOutputStream(fos);
		try {
			src.findFiles(new FileSelector() {
				public boolean includeFile(FileSelectInfo fileInfo) throws Exception {
					FileObject fo = fileInfo.getFile();
					if (fo.getType().equals(FileType.FILE) && !fo.equals(zip)) {
						if (selector == null || selector.includeFile(fileInfo)) {
							zos.putNextEntry(new ZipEntry(removeLeadingPath(src, fo)));
							InputStream fis = fo.getContent().getInputStream();
							try {
								copy(fis, zos);
								zos.closeEntry();
							}
							finally {
								closeQuietly(fis);
							}
						}
					}
					return false;
				}
	
				public boolean traverseDescendents(FileSelectInfo fileInfo) throws Exception {
					return (selector == null || selector.traverseDescendents(fileInfo));
				}
			});
		}
		finally {
			closeQuietly(zos);
			closeQuietly(fos);
		}
	}
}
