/*
 * Copyright (c) 2002-2009 Gargoyle Software Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gargoylesoftware.htmlunit;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.NTCredentials;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScheme;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.auth.CredentialsNotAvailableException;
import org.apache.commons.httpclient.auth.CredentialsProvider;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Default HtmlUnit implementation of the <tt>CredentialsProvider</tt> interface. Provides
 * credentials for both web servers and proxies. Supports NTLM authentication, Digest
 * authentication, and Basic HTTP authentication.
 *
 * @version $Revision: 4789 $
 * @author Daniel Gredler
 * @author Vikram Shitole
 * @author Marc Guillemot
 * @author Ahmed Ashour
 */
public class DefaultCredentialsProvider implements CredentialsProvider, Serializable  {

    private static final long serialVersionUID = 1036331144926557053L;
    private static final Log LOG = LogFactory.getLog(DefaultCredentialsProvider.class);

    private final Map<AuthScopeProxy, Credentials> credentials_ = new HashMap<AuthScopeProxy, Credentials>();
    private final Map<AuthScopeProxy, Credentials> proxyCredentials_ = new HashMap<AuthScopeProxy, Credentials>();
    private final Set<Object> answerMarks_ = Collections.synchronizedSortedSet(new TreeSet<Object>());

    /**
     * Creates a new <tt>DefaultCredentialsProvider</tt> instance.
     */
    public DefaultCredentialsProvider() {
        // nothing
    }

    /**
     * Adds credentials for the specified username/password for any host/port/realm combination.
     * The credentials may be for any authentication scheme, including NTLM, digest and basic
     * HTTP authentication. If you are using sensitive username/password information, please do
     * NOT use this method. If you add credentials using this method, any server that requires
     * authentication will receive the specified username and password.
     * @param username the username for the new credentials
     * @param password the password for the new credentials
     */
    public void addCredentials(final String username, final String password) {
        addCredentials(username, password, AuthScope.ANY_HOST, AuthScope.ANY_PORT, AuthScope.ANY_REALM);
    }

    /**
     * Adds credentials for the specified username/password on the specified host/port for the
     * specified realm. The credentials may be for any authentication scheme, including NTLM,
     * digest and basic HTTP authentication.
     * @param username the username for the new credentials
     * @param password the password for the new credentials
     * @param host the host to which to the new credentials apply (<tt>null</tt> if applicable to any host)
     * @param port the port to which to the new credentials apply (negative if applicable to any port)
     * @param realm the realm to which to the new credentials apply (<tt>null</tt> if applicable to any realm)
     */
    public void addCredentials(final String username, final String password, final String host,
            final int port, final String realm) {
        final AuthScopeProxy scope = new AuthScopeProxy(host, port, realm, AuthScope.ANY_SCHEME);
        final Credentials c = new UsernamePasswordCredentialsExt(username, password);
        credentials_.put(scope, c);
        clearAnswered(); // don't need to be precise, will cause in worst case one extra request
    }

    /**
     * Adds proxy credentials for the specified username/password for any host/port/realm combination.
     * @param username the username for the new credentials
     * @param password the password for the new credentials
     */
    public void addProxyCredentials(final String username, final String password) {
        addProxyCredentials(username, password, AuthScope.ANY_HOST, AuthScope.ANY_PORT);
    }

    /**
     * Adds proxy credentials for the specified username/password on the specified host/port.
     * @param username the username for the new credentials
     * @param password the password for the new credentials
     * @param host the host to which to the new credentials apply (<tt>null</tt> if applicable to any host)
     * @param port the port to which to the new credentials apply (negative if applicable to any port)
     */
    public void addProxyCredentials(final String username, final String password, final String host, final int port) {
        final AuthScopeProxy scope = new AuthScopeProxy(host, port, AuthScope.ANY_REALM, AuthScope.ANY_SCHEME);
        final Credentials c = new UsernamePasswordCredentialsExt(username, password);
        proxyCredentials_.put(scope, c);
        clearAnswered(); // don't need to be precise, will cause in worst case one extra request
    }

    /**
     * Adds NTLM credentials for the specified username/password on the specified host/port.
     * @param username the username for the new credentials; should not include the domain to authenticate with;
     *        for example: <tt>"user"</tt> is correct whereas <tt>"DOMAIN\\user"</tt> is not
     * @param password the password for the new credentials
     * @param host the host to which to the new credentials apply (<tt>null</tt> if applicable to any host)
     * @param port the port to which to the new credentials apply (negative if applicable to any port)
     * @param clientHost the host the authentication request is originating from; essentially, the computer name for
     *        this machine.
     * @param clientDomain the domain to authenticate within
     */
    public void addNTLMCredentials(final String username, final String password, final String host,
            final int port, final String clientHost, final String clientDomain) {
        final AuthScopeProxy scope = new AuthScopeProxy(host, port, AuthScope.ANY_REALM, AuthScope.ANY_SCHEME);
        final Credentials c = new NTCredentialsExt(username, password, clientHost, clientDomain);
        credentials_.put(scope, c);
        clearAnswered(); // don't need to be precise, will cause in worst case one extra request
    }

    /**
     * Adds NTLM proxy credentials for the specified username/password on the specified host/port.
     * @param username the username for the new credentials; should not include the domain to authenticate with;
     *        for example: <tt>"user"</tt> is correct whereas <tt>"DOMAIN\\user"</tt> is not.
     * @param password the password for the new credentials
     * @param host the host to which to the new credentials apply (<tt>null</tt> if applicable to any host)
     * @param port the port to which to the new credentials apply (negative if applicable to any port)
     * @param clientHost the host the authentication request is originating from; essentially, the computer name for
     *        this machine
     * @param clientDomain the domain to authenticate within
     */
    public void addNTLMProxyCredentials(final String username, final String password, final String host,
            final int port, final String clientHost, final String clientDomain) {
        final AuthScopeProxy scope = new AuthScopeProxy(host, port, AuthScope.ANY_REALM, AuthScope.ANY_SCHEME);
        final Credentials c = new NTCredentialsExt(username, password, clientHost, clientDomain);
        proxyCredentials_.put(scope, c);
        clearAnswered(); // don't need to be precise, will cause in worst case one extra request
    }

    /**
     * Returns the credentials associated with the specified scheme, host and port.
     * @param scheme the authentication scheme being used (basic, digest, NTLM, etc)
     * @param host the host we are authenticating for
     * @param port the port we are authenticating for
     * @param proxy Whether or not we are authenticating using a proxy
     * @return the credentials corresponding to the specified scheme, host and port or <code>null</code>
     * if already asked for it to avoid infinite loop
     * @throws CredentialsNotAvailableException if the specified credentials cannot be provided due to an error
     * @see CredentialsProvider#getCredentials(AuthScheme, String, int, boolean)
     */
    public Credentials getCredentials(final AuthScheme scheme, final String host, final int port, final boolean proxy)
        throws CredentialsNotAvailableException {

        // It's the responsibility of the CredentialProvider to answer only once with a
        // given Credentials to avoid infinite loop if it is incorrect:
        // see http://issues.apache.org/bugzilla/show_bug.cgi?id=8140
        if (alreadyAnswered(scheme, host, port, proxy)) {
            LOG.debug("Already answered for " + buildKey(scheme, host, port, proxy) + ", returning null");
            return null;
        }

        final Map<AuthScopeProxy, Credentials> credentials;
        if (proxy) {
            credentials = proxyCredentials_;
        }
        else {
            credentials = credentials_;
        }

        for (final Map.Entry<AuthScopeProxy, Credentials> entry : credentials.entrySet()) {
            final AuthScope scope = entry.getKey().getAuthScope();
            final Credentials c = entry.getValue();
            if (matchScheme(scope, scheme) && matchHost(scope, host)
                && matchPort(scope, port) && matchRealm(scope, scheme)) {
                markAsAnswered(scheme, host, port, proxy);
                LOG.debug("Returning " + c + " for " + buildKey(scheme, host, port, proxy));
                return c;
            }
        }

        LOG.debug("No credential found for " + buildKey(scheme, host, port, proxy));
        return null;
    }

    /**
     * @param scheme the request scheme for which Credentials are asked
     * @param scope the configured authorization scope
     * @return <code>true</code> if the scope's realm matches the one of the scheme
     */
    protected boolean matchRealm(final AuthScope scope, final AuthScheme scheme) {
        return scope.getRealm() == AuthScope.ANY_REALM || scope.getRealm().equals(scheme.getRealm());
    }

    /**
     * @param port the request port for which Credentials are asked
     * @param scope the configured authorization scope
     * @return <code>true</code> if the scope's port matches the provided one
     */
    protected boolean matchPort(final AuthScope scope, final int port) {
        return scope.getPort() == AuthScope.ANY_PORT || scope.getPort() == port;
    }

    /**
     * @param host the request host for which Credentials are asked
     * @param scope the configured authorization scope
     * @return <code>true</code> if the scope's host matches the provided one
     */
    protected boolean matchHost(final AuthScope scope, final String host) {
        return scope.getHost() == AuthScope.ANY_HOST || scope.getHost().equals(host);
    }

    /**
     * @param scheme the request scheme for which Credentials are asked
     * @param scope the configured authorization scope
     * @return <code>true</code> if the scope's scheme matches the provided one
     */
    protected boolean matchScheme(final AuthScope scope, final AuthScheme scheme) {
        return scope.getScheme() == AuthScope.ANY_SCHEME || scope.getScheme().equals(scheme.getSchemeName());
    }

    /**
     * Returns <tt>true</tt> if this provider has already provided an answer for the
     * specified (scheme, host, port, proxy) combination.
     * @param scheme the scheme
     * @param host the server name
     * @param port the server port
     * @param proxy is proxy
     * @return true if the provider has already provided an answer for this
     */
    protected boolean alreadyAnswered(final AuthScheme scheme, final String host, final int port, final boolean proxy) {
        return answerMarks_.contains(buildKey(scheme, host, port, proxy));
    }

    /**
     * Marks the specified (scheme, host, port, proxy) combination as having already been processed.
     * @param scheme the scheme
     * @param host the server name
     * @param port the server port
     * @param proxy is proxy
     */
    protected void markAsAnswered(final AuthScheme scheme, final String host, final int port, final boolean proxy) {
        answerMarks_.add(buildKey(scheme, host, port, proxy));
    }

    /**
     * Clears the cache of answered (scheme, host, port, proxy) combinations.
     */
    protected void clearAnswered() {
        answerMarks_.clear();
        LOG.debug("Flushed marked answers");
    }

    /**
     * Builds a key with the specified data.
     * @param scheme the scheme
     * @param host the server name
     * @param port the server port
     * @param proxy is proxy
     * @return the new key
     */
    protected Object buildKey(final AuthScheme scheme, final String host, final int port, final boolean proxy) {
        return scheme.getSchemeName() + " " + scheme.getRealm() + " " + host + ":" + port + " " + proxy;
    }

    /**
     * Clears the cache of answered credentials requests upon deserialization.
     * @param stream the object stream containing the instance being deserialized
     * @throws IOException if an IO error occurs
     * @throws ClassNotFoundException if the class of a serialized object cannot be found
     */
    private void readObject(final ObjectInputStream stream) throws IOException, ClassNotFoundException {
        stream.defaultReadObject();
        answerMarks_.clear();
    }

    /**
     * We have to wrap {@link AuthScope} instances in a serializable proxy so that the
     * {@link DefaultCredentialsProvider} class can be serialized correctly.
     */
    private static class AuthScopeProxy implements Serializable {
        private static final long serialVersionUID = 1464373861677912537L;
        private AuthScope authScope_;
        public AuthScopeProxy(final String host, final int port, final String realm, final String scheme) {
            authScope_ = new AuthScope(host, port, realm, scheme);
        }
        public AuthScope getAuthScope() {
            return authScope_;
        }
        private void writeObject(final ObjectOutputStream stream) throws IOException {
            stream.writeObject(authScope_.getHost());
            stream.writeInt(authScope_.getPort());
            stream.writeObject(authScope_.getRealm());
            stream.writeObject(authScope_.getScheme());
        }
        private void readObject(final ObjectInputStream stream) throws IOException, ClassNotFoundException {
            final String host = (String) stream.readObject();
            final int port = stream.readInt();
            final String realm = (String) stream.readObject();
            final String scheme = (String) stream.readObject();
            authScope_ = new AuthScope(host, port, realm, scheme);
        }
    }

    /**
     * We have to extend {@link UsernamePasswordCredentials} so that the
     * {@link DefaultCredentialsProvider} class can be serialized correctly.
     */
    private static class UsernamePasswordCredentialsExt
        extends UsernamePasswordCredentials implements Serializable {
        private static final long serialVersionUID = 6578356387067132849L;
        public UsernamePasswordCredentialsExt(final String username, final String password) {
            super(username, password);
        }
        private void writeObject(final ObjectOutputStream stream) throws IOException {
            stream.writeObject(this.getUserName());
            stream.writeObject(this.getPassword());
        }
        @SuppressWarnings("deprecation")
        private void readObject(final ObjectInputStream stream) throws IOException, ClassNotFoundException {
            final String username = (String) stream.readObject();
            final String password = (String) stream.readObject();
            this.setUserName(username);
            this.setPassword(password);
        }
    }

    /**
     * We have to extend {@link NTCredentials} so that the
     * {@link DefaultCredentialsProvider} class can be serialized correctly.
     */
    private static class NTCredentialsExt extends NTCredentials implements Serializable {
        private static final long serialVersionUID = 1390068355010421017L;
        public NTCredentialsExt(final String username, final String password, final String host, final String domain) {
            super(username, password, host, domain);
        }
        private void writeObject(final ObjectOutputStream stream) throws IOException {
            stream.writeObject(this.getUserName());
            stream.writeObject(this.getPassword());
            stream.writeObject(this.getHost());
            stream.writeObject(this.getDomain());
        }
        @SuppressWarnings("deprecation")
        private void readObject(final ObjectInputStream stream) throws IOException, ClassNotFoundException {
            final String username = (String) stream.readObject();
            final String password = (String) stream.readObject();
            final String host = (String) stream.readObject();
            final String domain = (String) stream.readObject();
            this.setUserName(username);
            this.setPassword(password);
            this.setHost(host);
            this.setDomain(domain);
        }
    }

}
