/**
 * 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;

import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Hashtable;

import com.mucommander.commons.file.compat.CompatURLStreamHandler;
import com.mucommander.commons.file.protocol.FileProtocols;
import com.mucommander.commons.file.protocol.local.LocalFile;
import com.mucommander.commons.file.protocol.search.SearchFile;
import com.mucommander.commons.file.protocol.search.SearchSchemeParser;
import com.mucommander.commons.file.util.PathUtils;
import com.mucommander.commons.runtime.OsFamily;
import com.mucommander.commons.util.StringUtils;

/**
 * This class represents a Uniform Resource Locator (URL). The general format of a URL is as follows:
 * <pre>
 * 	scheme://[login[:password]@]host[:port][/path][?query]
 * </pre>
 *
 * <h3>Instantiation</h3>
 * <p>
 * FileURL cannot be instantiated directly, instances can be created using {@link #getFileURL(String)}.
 * Unlike the <code>java.net.URL</code> and <code>java.net.URI</code> classes, FileURL instances are mutable --
 * all URL parts can be freely modified. FileURL instances can also be cloned using the standard {@link #clone()} method.
 * </p>
 *
 * <h3>Handlers and Scheme-specific attributes</h3>
 * <p>
 * In addition to standard URL features, FileURL gives access to scheme-specific attributes:
 * <dl>
 *  <dt>{@link #getStandardPort() standard port}</dt><dd>the standard port implied when no port is defined in the URL,
 * e.g. 21 for FTP</dd>
 *  <dt>{@link #getPathSeparator() path separator}</dt><dd>the character(s) that separates path fragments, e.g. '/' for
 * most schemes, '\' for local paths under certain OSes like Windows.</dd>
 *  <dt>{@link #getGuestCredentials() guest credentials}</dt><dd>credentials to authenticate as a guest, e.g. 'GUEST'
 * for SMB, 'anonymous' for FTP.</dd>
 *  <dt>{@link #getRealm() authentication realm}</dt><dd>the base URL throughout which a set of credentials can be used.
 * </dd>
 * </dl>
 * These attribute values are provided by the {@link SchemeHandler} registered with the scheme, if any.
 * </p>
 * <p>
 * In addition to providing those attributes, a SchemeHandler provides a {@link SchemeParser}
 * instance which takes care of the actual parsing of URLs of a particular scheme when {@link #getFileURL(String)} is
 * invoked. This allows for scheme-specific parsing, like for example for the query part which should only be parsed
 * and considered as a separate part for certain schemes such as HTTP.
 * </p>
 * <p>
 * This class registers a number of handlers for the schemes/protocols supported by the muCommander file API.
 * Additional handlers can be registered dynamically using {@link #registerHandler(String, SchemeHandler)}. Likewise,
 * existing handlers can be unregistered or replaced at runtime using <code>registerHandler</code> and
 * <code>unregisterHandler</code>.
 * </p>
 * <p>
 * A {@link #getDefaultHandler() default handler} is used for schemes that do not have a specific handler registered.
 * It provides default values for the above-mentioned attributes and provides a parser that parses those scheme URLs.
 * The default handler's parser is also used for parsing locations passed to {@link #getFileURL(String)} that do not
 * contain a scheme (i.e. without the leading <code>scheme://</code>). Those locations can be system-dependent,
 * local and absolute paths, or UNC paths. These paths are turned by the parser into an equivalent, fully-qualified URL. 
 * </p>
 *
 * <h3>Properties</h3>
 * <p>
 * This class provides methods to attach properties to a FileURL instance. These properties are not part of the URL
 * itself and are absent from its string representation. They allow protocol-specific properties like connection
 * settings to be passed along, to {@link AbstractFile} instances in particular.
 * </p>
 *
 * <h3>Limitations</h3>
 * <p>
 * This class has the several limitations that are worth noting:
 * <ul>
 *  <li>URL syntax is not strictly enforced: some invalid URLs (as per RFC) will be parsed without throwing an exception</li>
 *  <li>relative URLs are not supported</li>
 *  <li>no proper percent encoding/decoding </li>
 *  <li>no support for the fragment part</li>
 * </ul>
 * Some of these limitations will be addressed in upcoming revisions of this class.
 * </p>
 *
 * @see SchemeHandler
 * @see SchemeParser
 * @author Maxence Bernard
 */
public class FileURL implements Cloneable {

    // Todo: add support for the fragment part
    // Todo: add percent encoding/decoding

    /** Handler instance that provides the scheme-specific features of this FileURL */
    private SchemeHandler handler;

    /** Scheme part */
    private String scheme;
    /** Port part, -1 if this URL has none */
    private int port = -1;
    /** Host part, null if this URL has none */
    private String host;
    /** Path part */
    private String path;
    /** Filename, extracted from the path, null if the path has none */
    private String filename;
    /** Query part, null if this URL has none */
    private String query;

    /** Properties, null if none have been set thus far */
    private Hashtable<String, String> properties;
    /** Credentials (login and password parts), null if this URL has none */
    private Credentials credentials;

    /** Caches the value returned by #hashCode() for as long as this instance is not modified */
    private int hashCode;

    /** Default handler for schemes that do not have a specific handler */
    private final static SchemeHandler DEFAULT_HANDLER = new DefaultSchemeHandler();

    /** Maps schemes (String) onto SchemeHandler instances */
    private final static Hashtable<String, SchemeHandler> handlers = new Hashtable<String, SchemeHandler>();

    /** String designating the localhost */
    public final static String LOCALHOST = "localhost";


    static {
        // Register custom handlers for known schemes

        registerHandler(FileProtocols.FILE, new DefaultSchemeHandler(new DefaultSchemeParser(new DefaultPathCanonizer(LocalFile.SEPARATOR, System.getProperty("user.home")), false), -1, System.getProperty("file.separator"), AuthenticationType.NO_AUTHENTICATION, null));
        registerHandler(SearchFile.SCHEMA, new DefaultSchemeHandler(new SearchSchemeParser()));
        registerHandler(FileProtocols.FTP, new DefaultSchemeHandler(new DefaultSchemeParser(), 21, "/", AuthenticationType.AUTHENTICATION_REQUIRED, new Credentials("anonymous", "anonymous_coward@mucommander.com")));
        registerHandler(FileProtocols.SFTP, new DefaultSchemeHandler(new DefaultSchemeParser(), 22, "/", AuthenticationType.AUTHENTICATION_REQUIRED, null));
        registerHandler(FileProtocols.HDFS, new DefaultSchemeHandler(new DefaultSchemeParser(true), 8020, "/", AuthenticationType.AUTHENTICATION_OPTIONAL, null));
        registerHandler(FileProtocols.HTTP, new DefaultSchemeHandler(new DefaultSchemeParser(true), 80, "/", AuthenticationType.AUTHENTICATION_OPTIONAL, null));
        registerHandler(FileProtocols.S3, new DefaultSchemeHandler(new DefaultSchemeParser(true), 443, "/", AuthenticationType.AUTHENTICATION_REQUIRED, null));
        registerHandler(FileProtocols.WEBDAV, new DefaultSchemeHandler(new DefaultSchemeParser(true), 80, "/", AuthenticationType.AUTHENTICATION_REQUIRED, null));
        registerHandler(FileProtocols.HTTPS, new DefaultSchemeHandler(new DefaultSchemeParser(true), 443, "/", AuthenticationType.AUTHENTICATION_OPTIONAL, null));
        registerHandler(FileProtocols.WEBDAVS, new DefaultSchemeHandler(new DefaultSchemeParser(true), 443, "/", AuthenticationType.AUTHENTICATION_REQUIRED, null));
        registerHandler(FileProtocols.NFS, new DefaultSchemeHandler(new DefaultSchemeParser(), 2049, "/", AuthenticationType.NO_AUTHENTICATION, null));
        registerHandler(FileProtocols.VSPHERE, new DefaultSchemeHandler(new DefaultSchemeParser(true), 443, "/", AuthenticationType.AUTHENTICATION_REQUIRED, null));

        registerHandler(FileProtocols.SMB, new DefaultSchemeHandler(new DefaultSchemeParser(), -1, "/", AuthenticationType.AUTHENTICATION_REQUIRED, new Credentials("GUEST", "")) {
            @Override
            public FileURL getRealm(FileURL location) {
                FileURL realm = new FileURL(this);

                String newPath = location.getPath();
                // Find first path token (share)
                int pos = newPath.indexOf('/', 1);
                newPath = newPath.substring(0, pos==-1?newPath.length():pos+1);

                realm.setPath(newPath);
                realm.setScheme(location.getScheme());
                realm.setHost(location.getHost());
                realm.setPort(location.getPort());

                // Copy properties (if any)
                realm.importProperties(location);

                return realm;
            }
        });
    }


    /**
     * Private constructor. Creates an empty FileURL that uses the given handler, all parts have to be manually set.
     *
     * @param handler the handler to have this FileURL use
     */
    public FileURL(SchemeHandler handler) {
        this.handler = handler;
    }

    /**
     * This method is called whenever this instance is modified to invalidate caches.
     */
    private void urlModified() {
        hashCode = 0;
    }

    /**
     * Creates and returns a new FileURL instance from the given location, throws a <code>MalformedURLException</code>
     * if the specified location is not a valid URL or path and cannot be resolved. The {@link SchemeParser parser}
     * of the {@link SchemeHandler handler} registered for the location's scheme is used to parse the given location.
     * If the scheme specified in the location does not have a specific handler, or if the location does not contain a
     * scheme (i.e. is local or UNC path, not a URL) then the default handler's parser is used.
     *
     * @param location the URL or path for which to get a <code>FileURL</code> instance
     * @throws MalformedURLException if the specified string isn't a valid URL, according to the scheme's parser used
     * @return a FileURL corresponding to the given location
     */
    public static FileURL getFileURL(String location) throws MalformedURLException {
        int schemeDelimPos = location.indexOf("://");
        SchemeHandler handler;

        if(schemeDelimPos==-1) {
            // No scheme: the location is a local or UNC path, not a URL
            handler = getDefaultHandler();
        }
        else {
            handler = getSchemeHandler(location.substring(0, schemeDelimPos));
        }

        FileURL fileURL = new FileURL(handler);
        try {
            handler.getParser().parse(location, fileURL);
        }
        catch(Exception e) {
            // Catch any unexpected exception thrown by the SchemeParser and turn it into a MalformedURLException
            // with a specific error message.
            if(e instanceof MalformedURLException)
                throw (MalformedURLException)e;

            throw new MalformedURLException("URL parser error");
        }

        return fileURL;
    }

    /**
     * Returns the handler registered the specified scheme if there is one, the default handler otherwise.
     *
     * @param scheme the scheme for which to return a handler
     * @return a handler for the specified scheme
     */
    private static SchemeHandler getSchemeHandler(String scheme) {
        SchemeHandler handler = getRegisteredHandler(scheme);
        if(handler==null)
            return getDefaultHandler();

        return handler;
    }

    /**
     * Returns the <code>SchemeHandler</code> instance that provides the scheme-specific features of this FileURL.
     *
     * @return the <code>SchemeHandler</code> instance that provides the scheme-specific features of this FileURL
     */
    public SchemeHandler getHandler() {
        return handler;
    }

    /**
     * Sets the <code>SchemeHandler</code> that provides the scheme-specific features of this FileURL.
     * <p>
     * <b>Important:</b> after calling this method, the scheme should also be changed to match the new handler --
     * changing the handler without changing the scheme to an appropriate one will result in inconsistent
     * scheme-specific attributes to be returned.
     * </p>
     *
     * @param handler the <code>SchemeHandler</code> instance that provides the scheme-specific features of this FileURL
     */
    public void setHandler(SchemeHandler handler) {
        this.handler = handler;
    }

    /**
     * Registers a handler for the specified scheme, replacing any handler previously registered
     * for the same scheme.
     *
     * @param scheme the scheme to associate the handler with (case-insensitive)
     * @param handler the new handler in charge of the scheme
     */
    public static void registerHandler(String scheme, SchemeHandler handler) {
        handlers.put(scheme.toLowerCase(), handler);
    }

    /**
     * Removes any handler associated with the specified scheme, leaving the default handler in charge of the scheme.
     * This method has no effect if there is no handler registered for the scheme.
     *
     * @param scheme the scheme to remove the handler for
     */
    public static void unregisterHandler(String scheme) {
        handlers.remove(scheme.toLowerCase());
    }

    /**
     * Returns the handler registered for the specified scheme, <code>null</code> if there isn't any.
     *
     * @param scheme the scheme for which to return the handler
     * @return the handler registered for the specified scheme
     */
    public static SchemeHandler getRegisteredHandler(String scheme) {
        return handlers.get(scheme.toLowerCase());
    }

    /**
     * Returns the default handler, which handles schemes which do not have a specific handler.
     * The returned instance is a {@link DefaultSchemeHandler} created with the no-arg constructor. 
     *
     * @return the default handler
     */
    public static SchemeHandler getDefaultHandler() {
        return DEFAULT_HANDLER;
    }

    /**
     * Extracts the filename from the given path and returns it, or <code>null</null> if the path does not contain
     * a filename.
     *
     * @param path the path from which to extract a filename
     * @param separator the path separator
     * @return the filename extracted from the given path, <code>null</code> if the path doesn't contain any
     */
    public static String getFilenameFromPath(String path, String separator) {
        if(path.equals("") || path.equals("/"))
            return null;

        // Remove any trailing separator
        path = PathUtils.removeTrailingSeparator(path, separator);

        if(!separator.equals("/"))
            path = PathUtils.removeLeadingSeparator(path, "/");

        // Extract filename
        int pos = path.lastIndexOf(separator);
        if(pos==-1)
            return null;

        return path.substring(pos+1);
    }


    /**
     * Returns the scheme part of this URL. The returned scheme may never be <code>null</code>.
     *
     * @return the scheme part of this <code>FileURL</code>.
     * @see #setScheme(String)
     */
    public String getScheme() {
        return scheme;
    }

    /**
     * Sets the scheme part of this URL. An <code>IllegalArgumentException</code> will be thrown if the specified scheme
     * is <code>null</code> or an empty string.
     * <p>
     * <b>Important:</b> after calling this method, the handler should also be changed to match the new scheme --
     * changing the scheme without changing the handler to an appropriate one will result in inconsistent
     * scheme-specific attributes to be returned.
     * </p>
     *
     * @param scheme new scheme part of this URL.
     * @throws IllegalArgumentException if the specified is null or an empty string
     * @see #getScheme()
     */
    public void setScheme(String scheme) {
        if(scheme==null)
            throw new IllegalArgumentException();

        this.scheme = scheme;

        urlModified();
    }

    /**
     * Returns the host part of this URL, <code>null</code> if it doesn't contain any.
     *
     * @return the host part of this URL.
     * @see #setHost(String)
     */
    public String getHost() {
        return host;
    }

    /**
     * Sets the host part of this URL, <code>null</code> for no host.
     *
     * @param host new host part of this URL.
     * @see #getHost()
     */
    public void setHost(String host) {
        this.host = host;

        urlModified();
    }

    /**
     * Returns the port part of this URL, <code>-1</code> if none was specified in the URL.
     *
     * @return the port part of this URL, -1 if there isn't any.
     * @see #setPort(int)
     * @see #getDefaultHandler()
     */
    public int getPort() {
        return port;
    }
	
    /**
     * Sets the port part of this URL, <code>-1</code> for no specific port.
     *
     * @param port new port part of this URL.
     * @see #getPort()
     * @see #getDefaultHandler()
     */
    public void setPort(int port) {
        this.port = port;

        urlModified();
    }

    /**
     * Returns this scheme's standard port, <code>-1</code> if the scheme doesn't have any.
     * If this URL doesn't have a specific port part, the return value should be considered as being this URL's port.
     *
     * <p>Some file protocols may not have a notion of standard port or even no use for the port part at all, for
     * example those that are not TCP or UDP based such as the local 'file' scheme.</p>
     *
     * <p>This method is just a shorthand for <code>getHandler().getStandardPort()</code>.</p>
     *
     * @return the scheme's standard port
     * @see #getPort()
     */
    public int getStandardPort() {
        return handler.getStandardPort();
    }
    

    /**
     * Returns the login part of this URL, <code>null</code> if there isn't any.
     *
     * @return the login part of this URL, <code>null</code> if there isn't any
     * @see #getCredentials()
     */
    public String getLogin() {
        return credentials==null?null:credentials.getLogin();
    }

    /**
     * Returns the password part of this URL, <code>null</code> if there isn't any.
     *
     * @return the password part of this URL, <code>null</code> if there isn't any
     * @see #getCredentials()
     */
    public String getPassword() {
        return credentials==null?null:credentials.getPassword();
    }

    /**
     * Returns the type of authentication used by the scheme's file protocol. The returned value is one of the constants
     * defined in the {@link AuthenticationType} enum.
     *
     * <p>This method is just a shorthand for <code>getHandler().getAuthenticationType()</code>.</p>
     *
     * @return the type of authentication used by the scheme's file protocol
     */
    public AuthenticationType getAuthenticationType() {
        return handler.getAuthenticationType();
    }

    /**
     * Returns true if this URL contains credentials, i.e. a login and/or password part. If <code>true</code> is
     * returned, {@link #getCredentials()} will return a non-null value.
     *
     * @return <code>true</code> if this URL contains credentials, <code>false</code> otherwise.
     */
    public boolean containsCredentials() {
        return credentials!=null;
    }

    /**
     * Returns the credentials (login and password) contained by this URL, wrapped in an {@link Credentials} object.
     * Returns <code>null</code> if this URL doesn't have a login or password part.
     *
     * <p>The returned credentials may or may be of any use for the scheme's file protocol depending on the value
     * returned by {@link #getAuthenticationType()}.</p>
     *
     * @return the credentials contained by this URL, <code>null</code> if this URL doesn't have a login or password part.
     * @see #setCredentials(Credentials)
     * @see #getAuthenticationType()
     */
    public Credentials getCredentials() {
        return credentials;
    }

    /**
     * Sets the login and password parts of this URL. Any credentials contained by this FileURL will be replaced.
     *  <code>null</code> can be passed to discard existing credentials.
     *
     * <p>Credentials may or may not be of any use for the scheme's file protocol depending on the value
     * returned by {@link #getAuthenticationType()}.</p>
     *
     * @param credentials the new login and password parts, replacing any existing credentials. If null is passed,
     * existing credentials will be discarded.
     * @see #getCredentials()
     */
    public void setCredentials(Credentials credentials) {
        if(credentials==null || credentials.isEmpty())  // Empty credentials are equivalent to null credentials
            this.credentials = null;
        else
            this.credentials = credentials;

        urlModified();
    }

    /**
     * Returns this scheme's guest credentials, <code>null</code> if the scheme doesn't have any.
     * <p>
     * Guest credentials offer a way to authenticate a URL as a 'guest' on file protocols that require a set of
     * credentials to establish a connection. The returned credentials are provided with no guarantee that the filesystem
     * will actually accept them and allow the request/connection. The notion of 'guest' credentials may or may not
     * have a meaning depending on the underlying file protocol.
     * </p>
     *
     * <p>This method is just a shorthand for <code>getHandler().getGuestCredentials()</code>.</p>
     *
     * @return the scheme's guest credentials, <code>null</code> if the scheme doesn't have any
     */
    public Credentials getGuestCredentials() {
        return handler.getGuestCredentials();
    }


    /**
     * Returns the path part of this URL. The returned value will never be <code>null</code> and always start with a
     * leading '/' character.
     *
     * @return the path part of this URL.
     * @see    #setPath(String)
     */
    public String getPath() {
        return path;
    }

    /**
     * Sets the path part of this URL. The specified path cannot be <code>null</code> and must start with a leading 
     * '/' character. If the specified path value is <code>null</code>, then the path will be set to "/".
     * If the path does not start with a leading separator, one will be added.
     *
     * @param path new path part of this URL
     * @see #getPath()
     */
    public void setPath(String path) {
        if(path==null || path.equals(""))
            path = "/";

        if(!path.startsWith("/"))
            path = "/"+path;

        this.path = path;
        // Extract new filename from path
        this.filename = getFilenameFromPath(path, getPathSeparator());

        urlModified();
    }

    /**
     * Returns this scheme's path separator, which serves as a delimiter for path fragments. For most schemes, this is
     * the forward slash character.
     *
     * <p>This method is just a shorthand for <code>getHandler().getPathSeparator()</code>.</p>
     *
     * @return this scheme's path separator
     */
    public String getPathSeparator() {
        return handler.getPathSeparator();
    }


    /**
     * Returns the parent of this URL according to its path, <code>null</code> if this URL has no parent (its path is "/").
     * <p>
     * The returned FileURL will have the same handler, scheme, host, port, credentials and properties as this one.
     * The query part of the returned parent URL will always be <code>null</code>, even if this URL had one.
     * </p>
     * <p>Note: this method returns a new FileURL instance every time it is called, and all mutable fields of this FileURL
     * are cloned. Therefore, the returned URL can be safely modified without any risk of side effects.</p>
     *
     * @return this URL's parent, <code>null</code> if it doesn't have one.
     */
    public FileURL getParent() {
        // If path equals '/', url has no parent
        if(!(path.equals("/") || path.equals(""))) {
            String separator = getPathSeparator();

            // Remove any trailing separator
            String parentPath = path.endsWith(separator)?path.substring(0, path.length()-separator.length()):path;

            // Resolve parent folder's path and reconstruct parent URL
            int lastSeparatorPos = parentPath.lastIndexOf(separator);
            if(lastSeparatorPos!=-1) {
                FileURL parentURL = new FileURL(handler);

                parentURL.scheme = scheme;
                parentURL.host = host;
                parentURL.port = port;
                parentURL.path = parentPath.substring(0, lastSeparatorPos+1);  // Keep trailing slash
                parentURL.filename = getFilenameFromPath(parentURL.path, separator);

                // Set same credentials for parent, (if any)
                // Note: Credentials are immutable.
                parentURL.credentials = credentials;

                // Copy properties to parent (if any)
                if(properties!=null)
                    parentURL.properties = new Hashtable<String, String>(properties);

                return parentURL;
            }
        }

        return null;    // URL has no parent
    }


    /**
     * Returns the authentication realm corresponding to this URL, i.e. the base location throughout which credentials
     * can be used. Any property contained by the specified FileURL will be carried over in the returned FileURL.
     * On the contrary, credentials will not be copied, the returned URL always has no credentials.
     *
     * <p>Note: this method returns a new FileURL instance every time it is called. Therefore the returned FileURL can
     * safely be modified without any risk of side effects.</p>

     * <p>This method is just a shorthand for <code>getHandler().getRealm(this)</code>.</p>
     *
     * @return this url's authentication realm
     */
    public FileURL getRealm() {
        return handler.getRealm(this);
    }


    /**
     * Returns the filename of this URL , <code>null</code> if doesn't have one (e.g. if the path is "/").
     * <p>
     * There is no <code>setFilename</code> as the filename is simply extrapolated from the path.
     * Use {@link #setPath(String)} to change the path and its filename.
     * </p>
     *
     * @return the filename of this URL, <code>null</code> if it doesn't have one.
     * @see    #setPath(String)
     */
    public String getFilename() {
        return filename;
    }


    /**
     * Returns the query part of this URL if it has one, <code>null</code> otherwise.
     *
     * @return the query part of this URL if it has one, <code>null</code> otherwise
     * @see    #setQuery(String)
     */
    public String getQuery() {
        return query;
    }

    /**
     * Sets the query part of this URL, <code>null</code> for no query part.
     *
     * @param query new query part of this URL, <code>null</code> for no query part
     * @see #getQuery()
     */
    public void setQuery(String query) {
        this.query = query;

        urlModified();
    }

	
    /**
     * Returns the value corresponding to the given property name, <code>null</code> if the property has no value.
     *
     * @param name name of the property whose value is to be retrieved
     * @return the value associated with the specified property name, <code>null</code> if it has no value
     * @see #setProperty(String,String)
     */
    public String getProperty(String name) {
        return properties==null?null:properties.get(name);
    }
	
    /**
     * Sets the given property (name/value pair) in the FileURL instance. A <code>null</code> property value has the
     * effect of removing the property.
     *
     * @param name name of the property to set
     * @param value value of the property
     * @see #getProperty(String)
     */
    public void setProperty(String name, String value) {
        // Create the property hashtable only when a property is set for the first time
        if(properties==null)
            properties = new Hashtable<String, String>();

        if(value==null)
            properties.remove(name);
        else
            properties.put(name, value);

        urlModified();
    }


    /**
     * Returns an <code>Enumeration</code> of all property names this FileURL contains.
     *
     * @return an <code>Enumeration</code> of all property names this FileURL contains
     */
    public Enumeration<String> getPropertyNames() {
        return properties != null ? properties.keys() : Collections.emptyEnumeration();
    }

    /**
     * Copy the properties of the given FileURL into this FileURL.
     *
     * @param url FileURL instance whose properties should be imported into this one.
     */
    public void importProperties(FileURL url) {
        // Slight optimization to avoid creating an enumeration if the FileURL doesn't have any property
        if(url.properties==null)
            return;

        Enumeration<String> propertyKeys = url.getPropertyNames();
        String key;
        while(propertyKeys.hasMoreElements()) {
            key = propertyKeys.nextElement();
            setProperty(key, url.getProperty(key));
        }
    }

    /**
     * Returns a String representation of this FileURL, including the login and password parts (credentials) only if
     * specified, and masking the password as requested. 
     *
     * @param includeCredentials if <code>true</code>, the login and password parts (if any) will be included in the
     * returned URL.
     * @param maskPassword if <code>true</code> and the includeCredentials parameter is also true, the password's
     * characters (if any) will be replaced by '*' characters. This allows a URL containing credentials to be displayed
     * to the end user without revealing the actual password.
     * @return a string representation of this <code>FileURL</code>
     */
    public String toString(boolean includeCredentials, boolean maskPassword) {
        StringBuffer sb = new StringBuffer(scheme);
        sb.append("://");

        if(includeCredentials && credentials!=null) {
            try {
                sb.append(URLEncoder.encode(credentials.getLogin(), "UTF-8"));
            }
            catch(UnsupportedEncodingException e) {
                // This can't happen in practice, UTF-8 is necessarily supported
            }

            String password = credentials.getPassword();
            if(!"".equals(password)) {
                sb.append(':');
                if(maskPassword)
                    sb.append(credentials.getMaskedPassword());
                else {
                    try {
                        sb.append(URLEncoder.encode(password, "UTF-8"));
                    }
                    catch(UnsupportedEncodingException e) {
                        // This can't happen in practice, UTF-8 is necessarily supported
                    }
                }
            }
            sb.append('@');
        }

        if(host!=null)
            sb.append(host);

        // Set the port only if it has a value that is different from the standard port
        if(port!=-1 && port!=handler.getStandardPort()) {
            sb.append(':');
            sb.append(port);
        }

        if(host!=null || !path.equals("/"))	{ // Test to avoid URLs like 'smb:///'
            if(path.startsWith("/")) {
                sb.append(path);
            }
            else {
                // Add a leading '/' if path doesn't already start with one, needed for scheme paths that are not
                // forward slash-separated
                sb.append('/');
                sb.append(path);
            }
        }

        if(query!=null) {
            sb.append('?');
            sb.append(query);
        }

        return sb.toString();
    }

    /**
     * Returns a String representation of this FileURL, including the login and password parts (credentials) only if
     * requested.
     *
     * @param includeCredentials if <code>true</code>, the login and password parts (if any) will be included in the
     * returned URL.
     * @return a string representation of this <code>FileURL</code>.
     */
    public String toString(boolean includeCredentials) {
        return toString(includeCredentials, false);
    }


    /**
     * Creates and returns a <code>java.net.URL</code> referring to the same location as this <code>FileURL</code>.
     * The <code>java.net.URL</code> is created from the string representation of this <code>FileURL</code>.
     * Thus, any credentials this <code>FileURL</code> contains are preserved, but properties are lost.
     *
     * <p>The returned <code>URL</code> uses an {@link AbstractFile} to access the associated resource.
     * An {@link AbstractFile} instance is created by the underlying <code>URLConnection</code> when the URL is
     * connected.</p>  
     *
     * <p>It is important to note that this method is provided for interoperability purposes, for the sole purpose of
     * connecting to APIs that require a <code>java.net.URL</code>.</p>
     *
     * @return a <code>java.net.URL</code> referring to the same location as this <code>FileURL</code>
     * @throws MalformedURLException if the java.net.URL could not parse the location of this FileURL
     */
    public URL getJavaNetURL() throws MalformedURLException {
        return new URL(null, toString(true), new CompatURLStreamHandler());
    }

    /**
     * Returns <code>true</code> if the scheme part of this URL and the given URL are equal.
     * The comparison is case-sensitive.
     *
     * @param url the URL to test for scheme equality
     * @return <code>true</code> if the scheme part of this URL and the given URL are equal
     */
    public boolean schemeEquals(FileURL url) {
        return this.scheme.equalsIgnoreCase(url.scheme);
    }

    /**
     * Returns <code>true</code> if the host part of this URL and the given URL are equal.
     * The comparison is case-insensitive.
     *
     * @param url the URL to test for host equality
     * @return <code>true</code> if the host part of this URL and the given URL are equal
     */
    public boolean hostEquals(FileURL url) {
        // Note: StringUtils#equals is null-safe 
        return StringUtils.equals(this.host, url.host, false);
    }

    /**
     * Returns <code>true</code> if the port of this URL and the given URL's are equal. Ports are said to be equal if
     * the values returned by {@link #getPort()} are equal, or if both URLs have the same standard port
     * (as returned by {@link #getStandardPort()} and one of the port value is <code>-1</code> (undefined) and the other
     * is the standard port.
     *
     * @param url the URL to test for port equality
     * @return <code>true</code> if the port of this URL and the given one are equal
     */
    public boolean portEquals(FileURL url) {
        int port1 = this.port;
        int port2 = url.port;
        int standardPort = getStandardPort();

        return port1==port2 ||
            (standardPort==url.getStandardPort() && ((port1==-1 && port2==standardPort || (port2==-1 && port1==standardPort))));
    }

    /**
     * Returns <code>true</code> if the path of this URL and the given URL are equal. The comparison is case-sensitive.
     * If the sole difference between two paths is a trailing path separator (and both URLs have the same 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>It is noteworthy that this method uses <code>java.lang.String#equals(Object)</code> to compare URL paths,
     * which in some rare cases may return <code>false</code> for non-ascii/Unicode paths that have the same written
     * representation but are not equal according to <code>java.lang.String#equals(Object)</code>. Handling such cases
     * would require a locale-aware String comparison which is not an option here.</p>
     *
     * @param url the URL to test for path equality
     * @return <code>true</code> if the path of this URL and the given URL are equal
     */
    public boolean pathEquals(FileURL url) {
    	boolean isCaseSensitiveOS = !(OsFamily.getCurrent().equals(OsFamily.WINDOWS) || OsFamily.getCurrent().equals(OsFamily.OS_2));
    	
        String path1 = isCaseSensitiveOS ? this.getPath() : this.getPath().toLowerCase();
        String path2 = isCaseSensitiveOS ? url.getPath() : url.getPath().toLowerCase();

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

        String separator = getPathSeparator();

        if(separator.equals(url.getPathSeparator())) {
            int separatorLen = separator.length();
            int len1 = path1.length();
            int len2 = path2.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 <code>true</code> if the query part of this URL and the given URL are equal.
     * The comparison is case-sensitive.
     *
     * @param url the URL to test for query equality
     * @return <code>true</code> if the query part of this URL and the given URL are equal
     */
    public boolean queryEquals(FileURL url) {
        return StringUtils.equals(this.query, url.query, true);
    }

    /**
     * Returns <code>true</code> if the credentials (login and password) of this URL and the given URL are equal.
     * The comparison is case-sensitive.
     *
     * @param url the URL to test for credentials equality
     * @return <code>true</code> if the credentials of this URL and the given URL are equal
     */
    public boolean credentialsEquals(FileURL url) {
        Credentials creds1 = this.credentials;
        Credentials creds2 = url.credentials;

        return (creds1==null && creds2==null)
            || (creds1!=null && creds1.equals(creds2, true))
            || (creds2!=null && creds2.equals(creds1, true));
    }

    /**
     * Returns <code>true</code> if the properties contained by this URL and the given URL are equal.
     * The comparison of each property is case-sensitive.
     *
     * @param url the URL to test for properties equality
     * @return <code>true</code> if the properties contained by this URL and the given URL are equal
     */
    public boolean propertiesEquals(FileURL url) {
        return (this.properties==null && url.properties==null)
           ||  (this.properties!=null && this.properties.equals(url.properties))
           ||  (url.properties!=null && url.properties.equals(this.properties));
    }


    ////////////////////////
    // Overridden methods //
    ////////////////////////

    /**
     * Returns a String representation of this FileURL, without including the login and password parts it may have.
     */
    public String toString() {
        return toString(false);
    }


    /**
     * Returns a clone of this FileURL. The returned instance can safely be modified without any impact on this FileURL
     * or any previously cloned URL.
     */
    @Override
    public Object clone() {
        // Create a new FileURL return it, instead of using Object.clone() which is probably way slower;
        // most FileURL fields are immutable and as such reused in cloned instance
        FileURL clonedURL = new FileURL(handler);

        // Immutable fields
        clonedURL.scheme = scheme;
        clonedURL.host = host;
        clonedURL.port = port;
        clonedURL.path = path;
        clonedURL.filename = filename;
        clonedURL.query = query;
        clonedURL.credentials = credentials;  // Note: Credentials are immutable.

        // Mutable fields
        if(properties!=null)    // Copy properties (if any)
            clonedURL.properties = new Hashtable<String, String>(properties);

        // Caches
        clonedURL.hashCode = hashCode;

        return clonedURL;
    }

    /**
     * This method is equivalent to calling {@link #equals(Object, boolean, boolean)} with credentials and properties
     * comparisons enabled.
     *
     * @param o object to compare against this FileURL instance.
     * @return true if both FileURL instances are equal.
     */
    public boolean equals(Object o) {
        return equals(o, true, true);
    }

    /**
     * Tests the specified FileURL for equality with this FileURL. <code>false</code> is systematically returned if the
     * specified object is not a FileURL instance or is <code>null</code>.
     * <p>
     * Two <code>FileURL</code> instances are said to be equal if:
     * <ul>
     *  <li>schemes are equal (case-insensitive)</li>
     *  <li>hosts are equal (case-insensitive)</li>
     *  <li>ports are equal. The default port is taken into account when comparing ports: a non specified port part (-1)
     * is equivalent to the scheme's standard port. For instance, <code>http://mucommander.com:80/</code>
     * and <code>http://mucommander.com/</code> are considered equal.</li>
     *  <li>paths are equal (case-sensitive). There can be a trailing separator difference in the two paths, they will
     * still be considered as equal. For example, <code>/path</code> and <code>/path/</code> are considered equal
     * (assuming the path separator is '/').</li>
     *  <li>queries are equal (case-sensitive)</li>
     * </ul>
     * </p>
     * <p>
     * Credentials (login and password parts) are compared only if requested. The comparison for both the login and
     * password is case-sensitive.</br>
     * Likewise, properties are compared only if requested: the comparison of all properties is case-sensitive.
     * </p>
     *
     * @param o object to compare against this FileURL instance
     * @param compareCredentials if <code>true</code>, the login and password parts of both FileURL need to be
     * equal (case-sensitive) for the FileURL instances to be equal
     * @param compareProperties if <code>true</code>, all properties need to be equal (case-sensitive) in both
     * FileURL for them to be equal
     * @return true if both FileURL instances are equal
     */
    public boolean equals(Object o, boolean compareCredentials, boolean compareProperties) {
        if(o==null || !(o instanceof FileURL))
            return false;

        FileURL url = (FileURL)o;

        return pathEquals(url)      // Compare the path first as it is the most likely to be different
            && schemeEquals(url)
            && hostEquals(url)
            && portEquals(url)
            && queryEquals(url)
            && (!compareCredentials || credentialsEquals(url))
            && (!compareProperties || propertiesEquals(url));
    }

    /**
     * This method is overridden to return a hash code that takes into account the behavior of {@link FileURL#equals(Object)},
     * so that <code>url1.equals(url2)</code> implies <code>url1.hashCode()==url2.hashCode()</code>.
     */
    public int hashCode() {
        if(hashCode==0) {
            String separator = handler.getPathSeparator();

            // #equals(Object) is trailing separator insensitive, so the hashCode must be trailing separator invariant
            int h = PathUtils.getPathHashCode(path, separator);

            h = 31* h + scheme.toLowerCase().hashCode();
            h = 31* h + (port==-1?handler.getStandardPort():port);

            if(host!=null)
                h = 31* h + host.toLowerCase().hashCode();

            if(query!=null)
                h = 31* h + query.hashCode();

            if(credentials!=null)
                h = 31* h + credentials.hashCode();

            if(properties!=null)
                h = 31* h + properties.hashCode();

            // Cache the value until for as long as this instance is not modified
            hashCode = h;
        }

        return hashCode;
    }
}
