/**
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */



package com.mucommander.commons.file.util;

import com.mucommander.commons.file.AbstractFile;
import com.mucommander.commons.file.FileFactory;
import com.mucommander.commons.file.FileURL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.MalformedURLException;

/**
 * This class contains static helper methods that operate on file paths.
 *
 * @author Maxence Bernard
 */
public class PathUtils {
    private static final Logger LOGGER = LoggerFactory.getLogger(PathUtils.class);

    /**
     * This class represents a destination entered by the user and resolved by {@link PathUtils#resolveDestination(String, com.mucommander.commons.file.AbstractFile)}
     * into an <code>AbstractFile</code> and a destination type.
     *
     * @see PathUtils#resolveDestination(String, com.mucommander.commons.file.AbstractFile)
     */
    public static class ResolvedDestination {

        /** The destination AbstractFile, may be a regular file or a folder */
        private AbstractFile file;

        /** The destination's folder, the file itself for {@link #EXISTING_FOLDER}, the destination file's parent for
         * other types */
        private AbstractFile folder;

        /** The destination type, see constant values */
        private int type;

        /** Designates a folder, either a directory or archive, that exists on the filesystem. */
        public final static int EXISTING_FOLDER = 0;
        /** Designates a regular file that exists on the filesystem. The file may be a browsable archive but that was
         * refered to as a regular file, i.e. without a trailing separator character in the path. */
        public final static int EXISTING_FILE = 1;
        /** Designates a new file that doesn't exist on the filesystem. The file's parent however does always exist. */
        public final static int NEW_FILE = 2;

        /**
         * Creates a new <code>ResolvedDestination</code> with the specified destination file and type.
         *
         * @param destinationFile the destination file
         * @param destinationType the destination type
         * @param destinationFolder the destination folder
         */
        private ResolvedDestination(AbstractFile destinationFile, int destinationType, AbstractFile destinationFolder) {
            this.file = destinationFile;
            this.type = destinationType;
            this.folder = destinationFolder;
        }

        /**
         * Returns the resolved destination file. The returned file may or may not physically exist on the filesystem.
         * If it exists, the returned file may be a folder (directory or browsable archive) or a regular file.
         *
         * @return the resolved destination file
         * @see #getDestinationType()
         */
        public AbstractFile getDestinationFile() {
            return file;
        }

        /**
         * Returns the resolved destination's folder. Depending on the {@link #getDestinationType() destination type},
         * the destination folder is:
         * <dl>
         *  <dt>for {@link #EXISTING_FOLDER}</dt><dd>the {@link #getDestinationFile() destination file} itself</dd>
         *  <dt>for {@link #EXISTING_FILE} or {@link #NEW_FILE}</dt><dd>the {@link #getDestinationFile() destination file}'s parent</dd>
         * </dl>
         *  The returned <code>AbstractFile</code> is always a folder that exists.
         *
         * @return the resolved destination file
         * @see #getDestinationType()
         */
        public AbstractFile getDestinationFolder() {
            return folder;
        }

        /**
         * Returns the type of destination that was resolved. The returned value will be one of the following constant
         * fields defined in this class:
         * <dl>
         *  <dt>{@link #EXISTING_FOLDER}</dt><dd>if the path denotes a folder, either a directory or a browsable
         * archive.</dd>
         *  <dt>{@link #EXISTING_FILE}</dt><dd>if the path denotes a regular file. The file may be a browsable archive,
         * see below.</dd>
         *  <dt>{@link #NEW_FILE}</dt><dd>if the path denotes a non-existing file whose parent exists.</dd>
         * </dl>
         * Paths to browsable archives are considered as denoting a folder only if they end with a trailing separator
         * character. If they don't, they're considered as denoting a regular file. For example,
         * <code>/existing_folder/existing_archive.zip/</code> refers to the archive as a folder where as
         * <code>/existing_folder/existing_archive.zip</code> refers to the archive as a regular file.
         *
         * @return the type of destination that was resolved
         */
        public int getDestinationType() {
            return type;
        }
    }

    /**
     * Resolves a destination path entered by the user and returns a {@link ResolvedDestination} object that
     * that contains a {@link AbstractFile} instance corresponding to the path and a type that describes the kind of
     * destination that was resolved. <code>null</code> is returned if the path is not a valid destination (see below)
     * or could not be resolved, for example becuase of I/O or authentication error.
     * <p>
     * The given path may be either absolute or relative to the specified base folder. If the base folder argument is
     * <code>null</code> and the path is relative, <code>null</code> will always be returned.
     * The path may contain '.', '..' and '~' tokens which will be left for the corresponding
     * {@link com.mucommander.commons.file.SchemeParser} to canonize.
     * </p>
     * <p>
     * The path may refer to the following listed destination types. In all cases, the destination's parent folder must
     * exist, if it doesn't <code>null</code> will always be returned. For example, <code>/non_existing_folder/file</code>
     * is not a valid destination (provided that '/non_existing_folder' does not exist).
     * <dl>
     *  <dt>{@link ResolvedDestination#EXISTING_FOLDER}</dt><dd>if the path denotes a folder, either a directory or a
     * browsable archive.</dd>
     *  <dt>{@link ResolvedDestination#EXISTING_FILE}</dt><dd>if the path denotes a regular file. The file may be a browsable archive,
     * see below.</dd>
     *  <dt>{@link ResolvedDestination#NEW_FILE}</dt><dd>if the path denotes a non-existing file whose parent exists.</dd>
     * </dl>
     * Paths to browsable archives are considered as denoting a folder only if they end with a trailing separator
     * character. If they don't, they're considered as denoting a regular file. For example,
     * <code>/existing_folder/existing_archive.zip/</code> refers to the archive as a folder where as
     * <code>/existing_folder/existing_archive.zip</code> refers to the archive as a regular file.
     * </p>
     *
     * @param destPath the destination path to resolve
     * @param baseFolder the base folder used for relative paths, <code>null</code> to accept only absolute paths
     * @return the object that that contains a {@link AbstractFile} instance corresponding to the path and a type that
     * describes the kind of destination that was resolved
     */
    public static ResolvedDestination resolveDestination(String destPath, AbstractFile baseFolder) {
        FileURL destURL;

        // Try to resolve the path as a URL
        try {
            destURL = FileURL.getFileURL(destPath);
            // destPath is absolute
        }
        catch(MalformedURLException e) {
            // destPath is relative (or malformed)

            // Abort now if there is no base folder
            if(baseFolder==null)
                return null;

            String separator = baseFolder.getSeparator();

            // Start by cloning the base folder's URL, including credentials and properties
            FileURL baseFolderURL = baseFolder.getURL();
            destURL  = (FileURL)baseFolderURL.clone();
            String basePath = destURL.getPath();
            if(!destPath.equals(""))
                destURL.setPath(basePath + (basePath.endsWith(separator)?"":separator) + destPath);

            // At this point we have the proper URL, except that the path may contain '.', '..' or '~' tokens.
            // => parse the URL from scratch to have the SchemeParser canonize them.
            try {
                destURL = FileURL.getFileURL(destURL.toString(false));
                // Import credentials separately, so that login and passwords that contain URI-unsafe characters
                // such as '/' are properly parsed.
                destURL.setCredentials(baseFolderURL.getCredentials());
                destURL.importProperties(baseFolderURL);
            }
            catch(MalformedURLException e2) {
                return null;
            }
        }

        // No point in going any further if the URL cannot be resolved into a file
        AbstractFile destFile = FileFactory.getFile(destURL);
        if(destFile ==null) {
            LOGGER.info("could not resolve a file for {}", destURL);
            return null;
        }

        // Test if the destination file exists
        boolean destFileExists = destFile.exists();
        if(destFileExists) {
            // Note: path to archives must end with a trailing separator character to refer to the archive as a folder,
            //  if they don't, they'll refer to the archive as a file.
            if(destFile.isDirectory() || (destPath.endsWith(destFile.getSeparator()) && destFile.isBrowsable()))
                return new ResolvedDestination(destFile, ResolvedDestination.EXISTING_FOLDER, destFile);
        }

        // Test if the destination's parent exists, if not the path is not a valid destination
        AbstractFile destParent = destFile.getParent();
        if(destParent==null || !destParent.exists())
            return null;

        return new ResolvedDestination(destFile, destFileExists?ResolvedDestination.EXISTING_FILE:ResolvedDestination.NEW_FILE, destParent);
    }


    /**
     * Removes any leading separator character (slash or backslash) from the given path and returns the modified path.
     *
     * @param path the path to modify
     * @return the modified path, free of any leading separator
     */
    public static String removeLeadingSeparator(String path) {
        char firstChar;
        if(path.length()>0 && ((firstChar=path.charAt(0))=='/' || firstChar=='\\'))
            return path.substring(1, path.length());

        return path;
    }

    /**
     * Removes any leading separator character from the given path and returns the modified path.
     *
     * @param path the path to modify
     * @param separator the path separator, usually "/" or "\\"
     * @return the modified path, free of any leading separator
     */
    public static String removeLeadingSeparator(String path, String separator) {
        return path.startsWith(separator) ?
                path.substring(separator.length(), path.length())
                : path;
    }

    /**
     * Removes any trailing separator character (slash or backslash) from the given path and returns the modified path.
     *
     * @param path the path to modify
     * @return the modified path, free of any trailing separator
     */
    public static String removeTrailingSeparator(String path) {
        char lastChar;
        int len = path.length();
        if(len>0 && ((lastChar=path.charAt(len-1))=='/' || lastChar=='\\'))
            return path.substring(0, len-1);

        return path;
    }

    /**
     * Removes any trailing separator character (slash or backslash) from the given path and returns the modified path.
     *
     * @param path the path to modify
     * @param separator the path separator, usually "/" or "\\"
     * @return the modified path, free of any trailing separator
     */
    public static String removeTrailingSeparator(String path, String separator) {
        if(path.endsWith(separator))
            return path.substring(0, path.length()-separator.length());

        return path;
    }

    /**
     * Returns <code>true</code> if both specified paths are equal. The path comparison is case-sensitive but trailing
     * separator-insensitive: if the sole difference between two paths is a trailing path separator, they will be
     * considered as equal. For example, <code>/path</code> and <code>/path/</code> are considered equal, assuming the
     * path separator is '/'.
     * <p>
     * If any of the two specified paths is <code>null</code>, then the other one must also be <code>null</code> for
     * this method to return <code>true</code>. The given <code>separator</code> must never be <code>null</code> or
     * a {@link NullPointerException} will be thrown.
     * </p>
     *
     * @param path1 first path to test
     * @param path2 second path to test
     * @param separator path separator for both paths
     * @throws NullPointerException if the given separator is <code>null</code>
     * @return <code>true</code> if both paths are equal
     */
    public static boolean pathEquals(String path1, String path2, String separator) {
        if(path1==null)
            return path2==null;

        if(path2==null)
            return path1==null;
        
        if(path1.equals(path2))
            return true;

        int len1 = path1.length();
        int len2 = path2.length();
        int separatorLen = separator.length();

        // If the difference between the 2 strings is just a trailing path separator, we consider the paths as equal
        if(Math.abs(len1-len2)==separatorLen && (len1>len2 ? path1.startsWith(path2) : path2.startsWith(path1))) {
            String diff = len1>len2 ? path1.substring(len1-separatorLen) : path2.substring(len2-separatorLen);
            return separator.equals(diff);
        }

        return false;
    }

    /**
     * Returns a hashcode for the given path. The returned hashcode is consistent with
     * {@link #pathEquals(String, String, String)} in that hashcodes are trailing separator-invariant:
     * <code>path1.equals(path2)</code> implies <code>path1Hashcode==path2Hashcode.hashCode()</code>, even if path1
     * ends with a separator and path2 does not or vice-versa.
     * 
     * @param path the path for which to return a hashcode
     * @param separator separator of the given path
     * @return a trailing separator-insensitive hashcode
     */
    public static int getPathHashCode(String path, String separator) {
        // #equals(Object) is trailing separator insensitive, so the hashCode must be trailing separator invariant
        return path.endsWith(separator)
                ?path.substring(0, path.length()-separator.length()).hashCode()
                :path.hashCode();
    }


    /**
     * Removes the specified number of fragments from the beginning of the given path and returns the modified path,
     * free of a leading separator. Returns an empty string (<code>""</code>) if the path does not contain less or
     * exactly that many fragments.
     *
     * <p>
     * For instance, calling this method with
     * <ul>
     *   <li><code>("/home/maxence/, "/", 0)</code> will return "home/maxence/"</li>
     *   <li><code>("/home/maxence/, "/", 1)</code> will return "maxence/"</li>
     *   <li><code>("/home/maxence/, "/", 2)</code> will return ""</li>
     *   <li><code>("/home/maxence/, "/", 3)</code> will return ""</li>
     * </ul>
     * </p>
     *
     * @param path the path to modify
     * @param separator the path separator, usually "/" or "\\"
     * @param nbFragments number of path fragments to remove from the path
     * @return the modified path, free of any leading separator
     */
    public static String removeLeadingFragments(String path, String separator, int nbFragments) {
        path = removeLeadingSeparator(path, separator);

        if(nbFragments==0)
            return path;

        int pos=-1;
        for(int i=0; i<nbFragments && (pos=path.indexOf(separator, pos+1))!=-1; i++);

        if(pos==-1 || pos==path.length()-1)
            return "";

        return path.substring(pos+1, path.length());
    }


    /**
     * Returns the depth of the specified path, based on the number of path separators it contains, excluding those
     * occurring at the beginning and at the end. The minimum depth of a path is 0.<br/>
     * Here are a few examples when the path separator is <code>"/"</code>:
     * <dl>
     *   <dt>/</dt><dd>0</dd>
     *   <dt>/home</dt><dd>1</dd>
     *   <dt>/home/maxence</dt><dd>2</dd>
     * </dl>
     *
     * <p>
     * It is worth noting that this method relies strictly on the occurences of path separators and nothing else.
     * Therefore, Windows-like paths that start with a drive letter will always have a minimum depth
     * of 1.<br/>
     * Here are a few examples when the path separator is <code>"\\"</code>:
     * <dl>
     *   <dt>C:\\</dt><dd>1</dd>
     *   <dt>C:\\home</dt><dd>2</dd>
     *   <dt>C:\\home\\maxence</dt><dd>1</dd>
     * </dl>
     * </p>
     *
     * @param path the path for which to calculate the depth
     * @param separator the path separator, usually "/" or "\\"
     * @return the depth of the given path
     */
    public static int getDepth(String path, String separator) {
        if(path.equals("") || path.equals(separator))
            return 0;

        int depth = 1;
        int pos = path.startsWith(separator)?1:0;

        while ((pos=path.indexOf(separator, pos+1))!=-1)
            depth++;

        if(path.endsWith(separator))
            depth--;

        return depth;
    }
}
