/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
 * or http://forgerock.org/license/CDDLv1.0.html.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at legal-notices/CDDLv1_0.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2008-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2012-2015 ForgeRock AS
 */

package org.opends.admin.ads.util;

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Set;

import javax.naming.CommunicationException;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.StartTlsRequest;
import javax.naming.ldap.StartTlsResponse;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.KeyManager;
import javax.net.ssl.TrustManager;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.opends.server.replication.plugin.EntryHistorical;
import org.opends.server.schema.SchemaConstants;

import com.forgerock.opendj.cli.Utils;

/**
 * Class providing some utilities to create LDAP connections using JNDI and
 * to manage entries retrieved using JNDI.
 *
 */
public class ConnectionUtils
{
  private static final String STARTTLS_PROPERTY =
    "org.opends.connectionutils.isstarttls";

  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();

  /**
   * Private constructor: this class cannot be instantiated.
   */
  private ConnectionUtils()
  {
  }

  /**
   * Creates a clear LDAP connection and returns the corresponding LdapContext.
   * This methods uses the specified parameters to create a JNDI environment
   * hashtable and creates an InitialLdapContext instance.
   *
   * @param ldapURL
   *          the target LDAP URL
   * @param dn
   *          passed as Context.SECURITY_PRINCIPAL if not null
   * @param pwd
   *          passed as Context.SECURITY_CREDENTIALS if not null
   * @param timeout
   *          passed as com.sun.jndi.ldap.connect.timeout if > 0
   * @param env
   *          null or additional environment properties
   *
   * @throws NamingException
   *           the exception thrown when instantiating InitialLdapContext
   *
   * @return the created InitialLdapContext.
   * @see javax.naming.Context
   * @see javax.naming.ldap.InitialLdapContext
   */
  public static InitialLdapContext createLdapContext(String ldapURL, String dn,
      String pwd, int timeout, Hashtable<String, String> env)
      throws NamingException
  {
    env = copy(env);
    env.put(Context.INITIAL_CONTEXT_FACTORY,
        "com.sun.jndi.ldap.LdapCtxFactory");
    env.put("java.naming.ldap.attributes.binary",
        EntryHistorical.HISTORICAL_ATTRIBUTE_NAME);
    env.put(Context.PROVIDER_URL, ldapURL);
    if (timeout >= 1)
    {
      env.put("com.sun.jndi.ldap.connect.timeout", String.valueOf(timeout));
    }
    if (dn != null)
    {
      env.put(Context.SECURITY_PRINCIPAL, dn);
    }
    if (pwd != null)
    {
      env.put(Context.SECURITY_CREDENTIALS, pwd);
    }

    /* Contains the DirContext and the Exception if any */
    final Object[] pair = new Object[]
      { null, null };
    final Hashtable<String, String> fEnv = env;
    Thread t = new Thread(new Runnable()
    {
      @Override
      public void run()
      {
        try
        {
          pair[0] = new InitialLdapContext(fEnv, null);

        } catch (NamingException ne)
        {
          pair[1] = ne;

        } catch (Throwable t)
        {
          t.printStackTrace();
          pair[1] = t;
        }
      }
    });
    t.setDaemon(true);
    return getInitialLdapContext(t, pair, timeout);
  }

  /**
   * Creates an LDAPS connection and returns the corresponding LdapContext.
   * This method uses the TrusteSocketFactory class so that the specified
   * trust manager gets called during the SSL handshake. If trust manager is
   * null, certificates are not verified during SSL handshake.
   *
   * @param ldapsURL      the target *LDAPS* URL.
   * @param dn            passed as Context.SECURITY_PRINCIPAL if not null.
   * @param pwd           passed as Context.SECURITY_CREDENTIALS if not null.
   * @param timeout       passed as com.sun.jndi.ldap.connect.timeout if > 0.
   * @param env           null or additional environment properties.
   * @param trustManager  null or the trust manager to be invoked during SSL
   * negotiation.
   * @param keyManager    null or the key manager to be invoked during SSL
   * negotiation.
   * @return the established connection with the given parameters.
   *
   * @throws NamingException the exception thrown when instantiating
   * InitialLdapContext.
   *
   * @see javax.naming.Context
   * @see javax.naming.ldap.InitialLdapContext
   * @see TrustedSocketFactory
   */
  public static InitialLdapContext createLdapsContext(String ldapsURL,
      String dn, String pwd, int timeout, Hashtable<String, String> env,
      TrustManager trustManager, KeyManager keyManager) throws NamingException {
    env = copy(env);
    env.put(Context.INITIAL_CONTEXT_FACTORY,
        "com.sun.jndi.ldap.LdapCtxFactory");
    env.put("java.naming.ldap.attributes.binary",
        EntryHistorical.HISTORICAL_ATTRIBUTE_NAME);
    env.put(Context.PROVIDER_URL, ldapsURL);
    env.put("java.naming.ldap.factory.socket",
        org.opends.admin.ads.util.TrustedSocketFactory.class.getName());

    if (dn != null)
    {
      env.put(Context.SECURITY_PRINCIPAL, dn);
    }

    if (pwd != null)
    {
      env.put(Context.SECURITY_CREDENTIALS, pwd);
    }

    if (trustManager == null)
    {
      trustManager = new BlindTrustManager();
    }

    /* Contains the DirContext and the Exception if any */
    final Object[] pair = new Object[] {null, null};
    final Hashtable<String, String> fEnv = env;
    final TrustManager fTrustManager = trustManager;
    final KeyManager   fKeyManager   = keyManager;

    Thread t = new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          TrustedSocketFactory.setCurrentThreadTrustManager(fTrustManager,
              fKeyManager);
          pair[0] = new InitialLdapContext(fEnv, null);
        } catch (NamingException | RuntimeException ne) {
          pair[1] = ne;
        }
      }
    });
    t.setDaemon(true);
    return getInitialLdapContext(t, pair, timeout);
  }

  /**
   * Clones the provided InitialLdapContext and returns a connection using
   * the same parameters.
   * @param ctx the connection to be cloned.
   * @param timeout the timeout to establish the connection in milliseconds.
   * Use {@code 0} to express no timeout.
   * @param trustManager the trust manager to be used to connect.
   * @param keyManager the key manager to be used to connect.
   * @return the new InitialLdapContext connected to the server.
   * @throws NamingException if there was an error creating the new connection.
   */
  public static InitialLdapContext cloneInitialLdapContext(
      final InitialLdapContext ctx, int timeout, TrustManager trustManager,
      KeyManager keyManager) throws NamingException
  {
    Hashtable<?, ?> env = ctx.getEnvironment();
    Control[] ctls = ctx.getConnectControls();
    Control[] newCtls = null;
    if (ctls != null)
    {
      newCtls = new Control[ctls.length];
      System.arraycopy(ctls, 0, newCtls, 0, ctls.length);
    }
    /* Contains the DirContext and the Exception if any */
    final Object[] pair = new Object[] {null, null};
    final Hashtable<?, ?> fEnv = env;
    final TrustManager fTrustManager = trustManager;
    final KeyManager   fKeyManager   = keyManager;
    final Control[] fNewCtls = newCtls;

    Thread t = new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          if (isSSL(ctx) || isStartTLS(ctx))
          {
            TrustedSocketFactory.setCurrentThreadTrustManager(fTrustManager,
                fKeyManager);
          }
          pair[0] = new InitialLdapContext(fEnv, fNewCtls);
        } catch (NamingException | RuntimeException ne) {
          pair[1] = ne;
        }
      }
    });
    return getInitialLdapContext(t, pair, timeout);
  }

  /**
   * Creates an LDAP+StartTLS connection and returns the corresponding
   * LdapContext.
   * This method first creates an LdapContext with anonymous bind. Then it
   * requests a StartTlsRequest extended operation. The StartTlsResponse is
   * setup with the specified hostname verifier. Negotiation is done using a
   * TrustSocketFactory so that the specified TrustManager gets called during
   * the SSL handshake.
   * If trust manager is null, certificates are not checked during SSL
   * handshake.
   *
   * @param ldapURL       the target *LDAP* URL.
   * @param dn            passed as Context.SECURITY_PRINCIPAL if not null.
   * @param pwd           passed as Context.SECURITY_CREDENTIALS if not null.
   * @param timeout       passed as com.sun.jndi.ldap.connect.timeout if > 0.
   * @param env           null or additional environment properties.
   * @param trustManager  null or the trust manager to be invoked during SSL
   * negotiation.
   * @param keyManager    null or the key manager to be invoked during SSL
   * negotiation.
   * @param verifier      null or the hostname verifier to be setup in the
   * StartTlsResponse.
   * @return the established connection with the given parameters.
   *
   * @throws NamingException the exception thrown when instantiating
   * InitialLdapContext.
   *
   * @see javax.naming.Context
   * @see javax.naming.ldap.InitialLdapContext
   * @see javax.naming.ldap.StartTlsRequest
   * @see javax.naming.ldap.StartTlsResponse
   * @see TrustedSocketFactory
   */

  public static InitialLdapContext createStartTLSContext(String ldapURL,
      String dn, String pwd, int timeout, Hashtable<String, String> env,
      TrustManager trustManager, KeyManager keyManager,
      HostnameVerifier verifier)
  throws NamingException
  {
    if (trustManager == null)
    {
      trustManager = new BlindTrustManager();
    }
    if (verifier == null) {
      verifier = new BlindHostnameVerifier();
    }

    env = copy(env);
    env.put(Context.INITIAL_CONTEXT_FACTORY,
        "com.sun.jndi.ldap.LdapCtxFactory");
    env.put("java.naming.ldap.attributes.binary",
        EntryHistorical.HISTORICAL_ATTRIBUTE_NAME);
    env.put(Context.PROVIDER_URL, ldapURL);
    env.put(Context.SECURITY_AUTHENTICATION , "none");

    /* Contains the DirContext and the Exception if any */
    final Object[] pair = new Object[] {null, null};
    final Hashtable<?, ?> fEnv = env;
    final String fDn = dn;
    final String fPwd = pwd;
    final TrustManager fTrustManager = trustManager;
    final KeyManager fKeyManager     = keyManager;
    final HostnameVerifier fVerifier = verifier;

    Thread t = new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          StartTlsResponse tls;

          InitialLdapContext result = new InitialLdapContext(fEnv, null);

          tls = (StartTlsResponse) result.extendedOperation(
              new StartTlsRequest());
          tls.setHostnameVerifier(fVerifier);
          try
          {
            tls.negotiate(new TrustedSocketFactory(fTrustManager,fKeyManager));
          }
          catch(IOException x) {
            NamingException xx;
            xx = new CommunicationException(
                "Failed to negotiate Start TLS operation");
            xx.initCause(x);
            result.close();
            throw xx;
          }

          result.addToEnvironment(STARTTLS_PROPERTY, "true");
          if (fDn != null)
          {
            result.addToEnvironment(Context.SECURITY_AUTHENTICATION , "simple");
            result.addToEnvironment(Context.SECURITY_PRINCIPAL, fDn);
            if (fPwd != null)
            {
              result.addToEnvironment(Context.SECURITY_CREDENTIALS, fPwd);
            }
            result.reconnect(null);
          }
          pair[0] = result;
        } catch (NamingException | RuntimeException ne)
        {
          pair[1] = ne;
        }
      }
    });
    t.setDaemon(true);
    return getInitialLdapContext(t, pair, timeout);
  }

  private static Hashtable<String, String> copy(Hashtable<String, String> env) {
    return env != null ? new Hashtable<>(env) : new Hashtable<String, String>();
  }

  /**
   * Returns the LDAP URL used in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return the LDAP URL used in the provided InitialLdapContext.
   */
  public static String getLdapUrl(InitialLdapContext ctx)
  {
    String s = null;
    try
    {
      s = (String)ctx.getEnvironment().get(Context.PROVIDER_URL);
    }
    catch (NamingException ne)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Naming exception getting environment of "+ctx,
          ne));
    }
    return s;
  }

  /**
   * Returns the host name used in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return the host name used in the provided InitialLdapContext.
   */
  public static String getHostName(InitialLdapContext ctx)
  {
    String s = null;
    try
    {
      URI ldapURL = new URI(getLdapUrl(ctx));
      s = ldapURL.getHost();
    }
    catch (Throwable t)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Error getting host: "+t, t));
    }
    return s;
  }

  /**
   * Returns the port number used in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return the port number used in the provided InitialLdapContext.
   */
  public static int getPort(InitialLdapContext ctx)
  {
    int port = -1;
    try
    {
      URI ldapURL = new URI(getLdapUrl(ctx));
      port = ldapURL.getPort();
    }
    catch (Throwable t)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Error getting port: "+t, t));
    }
    return port;
  }

  /**
   * Returns the host port representation of the server to which this
   * context is connected.
   * @param ctx the context to analyze.
   * @return the host port representation of the server to which this
   * context is connected.
   */
  public static String getHostPort(InitialLdapContext ctx)
  {
    return getHostName(ctx)+":"+getPort(ctx);
  }

  /**
   * Returns the bind DN used in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return the bind DN used in the provided InitialLdapContext.
   */
  public static String getBindDN(InitialLdapContext ctx)
  {
    String bindDN = null;
    try
    {
      bindDN = (String)ctx.getEnvironment().get(Context.SECURITY_PRINCIPAL);
    }
    catch (NamingException ne)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Naming exception getting environment of "+ctx,
          ne));
    }
    return bindDN;
  }

  /**
   * Returns the password used in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return the password used in the provided InitialLdapContext.
   */
  public static String getBindPassword(InitialLdapContext ctx)
  {
    String bindPwd = null;
    try
    {
      bindPwd = (String)ctx.getEnvironment().get(Context.SECURITY_CREDENTIALS);
    }
    catch (NamingException ne)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Naming exception getting environment of "+ctx,
          ne));
    }
    return bindPwd;
  }

  /**
   * Tells whether we are using SSL in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return <CODE>true</CODE> if we are using SSL and <CODE>false</CODE>
   * otherwise.
   */
  public static boolean isSSL(InitialLdapContext ctx)
  {
    boolean isSSL = false;
    try
    {
      isSSL = getLdapUrl(ctx).toLowerCase().startsWith("ldaps");
    }
    catch (Throwable t)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Error getting if is SSL "+t, t));
    }
    return isSSL;
  }

  /**
   * Tells whether we are using StartTLS in the provided InitialLdapContext.
   * @param ctx the context to analyze.
   * @return <CODE>true</CODE> if we are using StartTLS and <CODE>false</CODE>
   * otherwise.
   */
  public static boolean isStartTLS(InitialLdapContext ctx)
  {
    boolean isStartTLS = false;
    try
    {
      isStartTLS = "true".equalsIgnoreCase((String)ctx.getEnvironment().get(
            STARTTLS_PROPERTY));
    }
    catch (NamingException ne)
    {
      // This is really strange.  Seems like a bug somewhere.
      logger.warn(LocalizableMessage.raw("Naming exception getting environment of "+ctx,
          ne));
    }
    return isStartTLS;
  }

  /**
   * Method used to know if we can connect as administrator in a server with a
   * given password and dn.
   * @param ldapUrl the LDAP URL of the server.
   * @param dn the dn to be used.
   * @param pwd the password to be used.
   * @param timeout the timeout to establish the connection in milliseconds.
   * Use {@code 0} to express no timeout.
   * @return <CODE>true</CODE> if we can connect and read the configuration and
   * <CODE>false</CODE> otherwise.
   */
  public static boolean canConnectAsAdministrativeUser(String ldapUrl,
      String dn, String pwd, int timeout)
  {
    boolean canConnectAsAdministrativeUser = false;
    try
    {
      InitialLdapContext ctx;
      if (ldapUrl.toLowerCase().startsWith("ldap:"))
      {
        ctx = createLdapContext(ldapUrl, dn, pwd, timeout,
            null);
      }
      else
      {
        ctx = createLdapsContext(ldapUrl, dn, pwd, timeout,
            null, null, null);
      }

      canConnectAsAdministrativeUser = connectedAsAdministrativeUser(ctx);
    } catch (NamingException ne)
    {
      // Nothing to do.
    } catch (Throwable t)
    {
      throw new IllegalStateException("Unexpected throwable.", t);
    }
    return canConnectAsAdministrativeUser;
  }

  /**
   * Method used to know if we are connected as administrator in a server with a
   * given InitialLdapContext.
   * @param ctx the context.
   * @return <CODE>true</CODE> if we are connected and read the configuration
   * and <CODE>false</CODE> otherwise.
   */
  public static boolean connectedAsAdministrativeUser(InitialLdapContext ctx)
  {
    boolean connectedAsAdministrativeUser = false;
    try
    {
      /*
       * Search for the config to check that it is the directory manager.
       */
      SearchControls searchControls = new SearchControls();
      searchControls.setSearchScope(
          SearchControls. OBJECT_SCOPE);
      searchControls.setReturningAttributes(
          new String[] { SchemaConstants.NO_ATTRIBUTES });
      NamingEnumeration<SearchResult> sr =
       ctx.search("cn=config", "objectclass=*", searchControls);
      try
      {
        while (sr.hasMore())
        {
          sr.next();
        }
      }
      finally
      {
        try
        {
          sr.close();
        }
        catch(Exception ex)
        {
          logger.warn(LocalizableMessage.raw(
              "Unexpected error closing enumeration on cn=Config entry", ex));
        }
      }
      connectedAsAdministrativeUser = true;
    } catch (NamingException ne)
    {
      // Nothing to do.
    } catch (Throwable t)
    {
      throw new IllegalStateException("Unexpected throwable.", t);
    }
    return connectedAsAdministrativeUser;
  }

  /**
   * This is just a commodity method used to try to get an InitialLdapContext.
   * @param t the Thread to be used to create the InitialLdapContext.
   * @param pair an Object[] array that contains the InitialLdapContext and the
   * Throwable if any occurred.
   * @param timeout the timeout in milliseconds.  If we do not get to create the
   * connection before the timeout a CommunicationException will be thrown.
   * @return the created InitialLdapContext
   * @throws NamingException if something goes wrong during the creation.
   */
  private static InitialLdapContext getInitialLdapContext(Thread t,
      Object[] pair, int timeout) throws NamingException
  {
    try
    {
      if (timeout > 0)
      {
        t.start();
        t.join(timeout);
      } else
      {
        t.run();
      }

    } catch (InterruptedException x)
    {
      // This might happen for problems in sockets
      // so it does not necessarily imply a bug
    }

    boolean throwException = false;

    if (timeout > 0 && t.isAlive())
    {
      t.interrupt();
      try
      {
        t.join(2000);
      } catch (InterruptedException x)
      {
        // This might happen for problems in sockets
        // so it does not necessarily imply a bug
      }
      throwException = true;
    }

    if (pair[0] == null && pair[1] == null)
    {
      throwException = true;
    }

    if (throwException)
    {
      NamingException xx;
      ConnectException x = new ConnectException("Connection timed out");
      xx = new CommunicationException("Connection timed out");
      xx.initCause(x);
      throw xx;
    }

    if (pair[1] != null)
    {
      if (pair[1] instanceof NamingException)
      {
        throw (NamingException) pair[1];

      } else if (pair[1] instanceof RuntimeException)
      {
        throw (RuntimeException) pair[1];

      } else if (pair[1] instanceof Throwable)
      {
        throw new IllegalStateException("Unexpected throwable occurred",
            (Throwable) pair[1]);
      }
    }
    return (InitialLdapContext) pair[0];
  }

  /**
   * Returns the LDAP URL for the provided parameters.
   * @param host the host name.
   * @param port the LDAP port.
   * @param useSSL whether to use SSL or not.
   * @return the LDAP URL for the provided parameters.
   */
  public static String getLDAPUrl(String host, int port, boolean useSSL)
  {
    host = Utils.getHostNameForLdapUrl(host);
    return (useSSL ? "ldaps://" : "ldap://") + host + ":" + port;
  }

  /**
   * Returns the String representation of the first value of an attribute in a
   * LDAP entry.
   * @param entry the entry.
   * @param attrName the attribute name.
   * @return the String representation of the first value of an attribute in a
   * LDAP entry.
   * @throws NamingException if there is an error processing the entry.
   */
  public static String getFirstValue(SearchResult entry, String attrName)
  throws NamingException
  {
    String v = null;
    Attributes attrs = entry.getAttributes();
    if (attrs != null)
    {
      Attribute attr = attrs.get(attrName);
      if (attr != null && attr.size() > 0)
      {
        Object o = attr.get();
        if (o instanceof String)
        {
          v = (String)o;
        }
        else
        {
          v = String.valueOf(o);
        }
      }
    }
    return v;
  }

  /**
   * Returns a Set with the String representation of the values of an attribute
   * in a LDAP entry.  The returned Set will never be null.
   * @param entry the entry.
   * @param attrName the attribute name.
   * @return a Set with the String representation of the values of an attribute
   * in a LDAP entry.
   * @throws NamingException if there is an error processing the entry.
   */
  public static Set<String> getValues(SearchResult entry, String attrName)
  throws NamingException
  {
    Set<String> values = new HashSet<>();
    Attributes attrs = entry.getAttributes();
    if (attrs != null)
    {
      Attribute attr = attrs.get(attrName);
      if (attr != null)
      {
        for (int i=0; i<attr.size(); i++)
        {
          values.add((String)attr.get(i));
        }
      }
    }
    return values;
  }
}
