package nxm.sys.net;

import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.PasswordAuthentication;
import java.net.URL;
import java.util.Comparator;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.net.ssl.HttpsURLConnection;

import nxm.sys.lib.Shell;

/** Set the default authenticator for {@link HttpURLConnection} and {@link HttpsURLConnection}
    that requires HTTP Basic/Digest authentication. This uses the {@link Authenticator#setDefault(Authenticator)}
    API to set this class as the default authenticator (for the current JVM instance) when a HTTP/HTTPS server
    asks for authentication (per RFC 2617 HTTP Authentication: Basic and Digest Access Authentication).

    Note that HttpURLConnection caches all successful authentication information in a static field, which prevents
    changing of the login credentials (username/password) once a URL already have been authenticated. In other
    words, the JVM needs to be restarted to use different username/password for a already authenticated URL.
    See Java BUG ID <a href="http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6626700">6626700</a>
    [Request for ability to turn off authentication caching in HttpURLConnection] for more info.

    See <a href="http://download.oracle.com/javase/6/docs/technotes/guides/net/http-auth.html">
    http://download.oracle.com/javase/6/docs/technotes/guides/net/http-auth.html</a> for more info on
    HTTP Authentication.

    Note: This class uses the Singleton pattern to ensure that only one instance exists.

    @author  Neon Ngo
    @since   NeXtMidas 2.9.0
 */
public class HttpAuthenticator extends Authenticator {
  /** singleton instance of this class */
  private static final HttpAuthenticator INSTANCE = new HttpAuthenticator();

  /** display debug messages? */
  private static boolean debug = false;

  /** is this authenticator enable/disable (that we know of) */
  private boolean enabled = false;

  /** credentials map: key is URL, value is username/password credentials
      for the longest matching key to the URL. Hence we use a TreeMap (sorted)
      with a customer Reverse string comparator.
   */
  private TreeMap<String,PasswordAuthentication> credentialsMap;

  /** registered callback class to do authentication if there is no matching
      credentials in the cache for the request HTTP authentication.
      @see HttpAuthenticator.CallbackIF
   */
  private CallbackIF callback = null;

  /** Hide (private) constructor */
  private HttpAuthenticator () {
    credentialsMap = new TreeMap<>(ReverseComparator.getInstance());
  }

  /** get HTTP/HTTPS authenticator instance
   *  @return the HTTP/HTTPS authenticator instance */
  public static HttpAuthenticator getInstance () {
    return INSTANCE;
  }

  /** enable/disable debug messages
   *  @param val true to enable debug messages */
  public static void setDebug (boolean val) {
    debug = val;
  }

  /** check is debug messages are enabled/disabled
   *  @return true if debug messages are enabled */
  public static boolean getDebug () {
    return debug;
  }

  /** Clear all login credentials from the cache.
      WARNING: see note in class description about URLs that have already been
      authenticated begin cached at the HttpURLConnection level.
    */
  public synchronized void clear () {
    credentialsMap.clear();
// 2010-09-29 NTN: Below commented out line uses knowledge of SUN implementation and proprietary API
//                 to clear their Authentication cache by replacing it with an empty one.
// sun.net.www.protocol.http.AuthCacheValue.setAuthCache(new sun.net.www.protocol.http.AuthCacheImpl());
  }

  /** Put entry into the credentials cache.
      Note: the longest matching URL String will have highest precedence.
      @param url URL string to apply the specified username/password (cannot be null).
      @param username User name credential for specified URL
      @param password Password credential for specified URL (cannot be null)
      @throws NullPointerException if url or password is null.
   */
  public synchronized void put (String url, String username, String password) {
    if (url == null)      throw new NullPointerException("URL cannot not be null.");
    if (password == null) throw new NullPointerException("Password cannot not be null.");
    PasswordAuthentication value = new PasswordAuthentication(username,password.toCharArray());
    credentialsMap.put(url, value);
  }

  /** Remove the login credentials for the specified URL from the cache.
      WARNING: see note in class description about URLs that have already been
      authenticated begin cached at the HttpURLConnection level.
      @param url the URL for which to remove login credentials
   */
  public synchronized void remove (String url) {
    credentialsMap.remove(url);
  }

  /** Register this authenticator as the default for the current JVM.
      Note: as of Java6, there is no way to get the current default Authenticator,
      so that any previously set default Authenticator cannot be restored.
      @param enable if true, this set this as the default authenticator;
                    if false, then clear/remove the default authenticator.
      @return if true, then this to allow chained calls, e.g.
        <code>HttpAuthenticator.getInstance().enable().put(url,user,pass);</code>
      @see Authenticator#setDefault(Authenticator)
   */
  public synchronized HttpAuthenticator enable (boolean enable) {
    enabled = enable;                 // save enabled state
    if (enable) {
      Authenticator.setDefault(this); // register as default Authenticator
      return this;
    } else {
      Authenticator.setDefault(null); // clear default Authenticator
      return null;
    }
  }

  /** Return the enabled/disabled state of this authenticator (i.e. if it have
      registered itself as the default for the JVM as far we this class can tell.
      NOTE: It is possible another class may hook itself as the default authenticator
      after we have done so and there is NO way we can tell (as of JDK 6).
      @return true if this authenticator have been enabled, otherwise false.
   */
  public boolean getEnabled () {
    return enabled;
  }

  /** Output some info about the status of this Authenticator the enabled state,
      the number of entries on the credentials cache (in memory database), if
      a callback have been registered.
      @return String containing some information about this authenticators state.
   */
  public String toString () {
    StringBuilder sb = new StringBuilder();
    sb.append("HttpAuthenticator: ");
    sb.append(enabled? "enabled " : "disabled ");
    sb.append("with "+credentialsMap.size()+" entries, callback=");
    sb.append(callback);
    return sb.toString();
  }

  /** This method is synchronized to prevent puts/removes/clears from interfering
      with the iteration through the credentials map. The precedence is to
      check the local credentials cache then the registered callback if there isn't
      a match (1a. exact URL match;  1b. longest starting String match to URL).
      Note: If a GUI callback is used and it waits for user input, all subsequent
      HTTP/HTTPS authentication will wait until it is complete.
   */
  @Override
  protected synchronized PasswordAuthentication getPasswordAuthentication () {
    String urlStr = getRequestingURL().toString();
    PasswordAuthentication result = credentialsMap.get(urlStr);
    if (result != null) {
      if (debug) debug("found exact URL match. key="+urlStr + " credentials= "+toDebugStr(result));
      return result;        // 1. found exact matching URL
    }
    else if (!credentialsMap.isEmpty()) {
      SortedMap<String,PasswordAuthentication> sortedMap = credentialsMap.tailMap(urlStr);
      for (String key : sortedMap.keySet()) {
        if (urlStr.startsWith(key)) {
          result = sortedMap.get(key);
          if (debug) debug("found prefix match, key="+key+" credentials= "+toDebugStr(result));
          return result;    // 2. found longest starting String matching this URL
        }
      } // end for loop
    }

    if (callback != null) { // 3. if have callback, use it to authenticate
      result = callback.authenticate(
          getRequestingHost(),
          getRequestingSite(),
          getRequestingPort(),
          getRequestingProtocol(),
          getRequestingPrompt(),
          getRequestingScheme(),
          getRequestingURL(),
          getRequestorType());
      if (debug) debug("Result from callback, url="+urlStr+" credentials= "+toDebugStr(result));
      return result;
    }
    return null; // return null for no matching credentials
  }

  private static final String toDebugStr (PasswordAuthentication credential) {
    return credential.getUserName() + " / *" + credential.getPassword().length;
  }
  private static final void debug (String msg) {
    Shell.writeln("HttpAuthenticator: " + msg);
  }

  /** Sets the authentication callback hander for HTTP/HTTP authentication request
      for URLs that we are not registered to handle.
      @param newCallback the callback to use
      @return the previously registered callback used, null for none.
   */
  public CallbackIF setCallback (CallbackIF newCallback) {
    CallbackIF prev = callback;
    callback = newCallback;
    return prev;
  }

  /** Callback interface to allow another class to hook in their own authentication scheme
      (i.e. to prompt user, query database, etc.)
      @author Neon Ngo
   */
  public static interface CallbackIF {
    /** Callback method to do HTTP/HTTPS authentication. All the necessary info
        is contained in the given Authenticator.
        Implementing class MUST DO NOT call <code>a.getPasswordAuthentication()</code>!
        @param host The hostname of the site requesting authentication.
        @param addr The InetAddress of the site requesting authorization, or null if not known.
        @param port the port for the requested connection
        @param protocol The protocol that's requesting the connection ({@link Authenticator#getRequestingProtocol()})
        @param prompt A prompt string for the user
        @param scheme The authentication scheme
        @param url The requesting URL that caused the authentication
        @param reqType The type (server or proxy) of the entity requesting authentication.
        @return login credentials or null (if unable to authenticate);
                see {@link Authenticator#getPasswordAuthentication()}
     */
    public PasswordAuthentication authenticate (
             String host,
             InetAddress addr,
             int port,
             String protocol,
             String prompt,
             String scheme,
             URL url,
             Authenticator.RequestorType reqType);
  } // end interface CallbackIF

  /** Reverse String order comparator (i.e. z to a).
      Note: null Strings are not supported.
      @author Neon Ngo
   */
  static final class ReverseComparator implements Comparator<String> {

    private static final ReverseComparator INSTANCE = new ReverseComparator();

    /** hide constructor, use {@link #getInstance()}*/
    private ReverseComparator() {
      super();
    }

    /** @since 3.5.0 */
    public static ReverseComparator getInstance () {
      return INSTANCE;
    }

    @Override
    public int compare (String s1, String s2) {
      return -s1.compareTo(s2); // reverse order (by using minus in front)
    }

    @Override
    public boolean equals (Object obj) {
      if (this == obj) {
        return true;
      }
      if (obj == null) {
        return false;
      }
      if (getClass() != obj.getClass()) {
        return false;
      }
      return true;
    }

    /** @since 3.5.0 */
    @Override
    public int hashCode () {
      return 71; // there is only ONE distinct ReverseComparator (singleton)
    }

  } // end class ReverseComparator
}
