/*
 * 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 2007-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2011-2015 ForgeRock AS
 */
package org.opends.server.admin.client.cli;

import static com.forgerock.opendj.cli.ArgumentConstants.*;
import static com.forgerock.opendj.cli.CliMessages.*;
import static com.forgerock.opendj.cli.ReturnCode.*;
import static com.forgerock.opendj.cli.Utils.*;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.i18n.LocalizableMessageDescriptor.Arg1;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.config.server.ConfigException;
import org.opends.admin.ads.util.ApplicationTrustManager;
import org.opends.server.admin.AdministrationConnector;
import org.opends.server.admin.server.ServerManagementContext;
import org.opends.server.admin.std.server.AdministrationConnectorCfg;
import org.opends.server.admin.std.server.FileBasedTrustManagerProviderCfg;
import org.opends.server.admin.std.server.RootCfg;
import org.opends.server.admin.std.server.TrustManagerProviderCfg;
import org.opends.server.core.DirectoryServer;

import com.forgerock.opendj.cli.Argument;
import com.forgerock.opendj.cli.ArgumentException;
import com.forgerock.opendj.cli.BooleanArgument;
import com.forgerock.opendj.cli.CliConstants;
import com.forgerock.opendj.cli.CommonArguments;
import com.forgerock.opendj.cli.FileBasedArgument;
import com.forgerock.opendj.cli.IntegerArgument;
import com.forgerock.opendj.cli.StringArgument;

/**
 * This is a commodity class that can be used to check the arguments required to
 * establish a secure connection in the command line. It can be used to generate
 * an ApplicationTrustManager object based on the options provided by the user
 * in the command line.
 */
public final class SecureConnectionCliArgs
{
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();

  /** The 'hostName' global argument. */
  public StringArgument hostNameArg;
  /** The 'port' global argument. */
  public IntegerArgument portArg;
  /** The 'bindDN' global argument. */
  public StringArgument bindDnArg;
  /** The 'adminUID' global argument. */
  public StringArgument adminUidArg;
  /** The 'bindPasswordFile' global argument. */
  public FileBasedArgument bindPasswordFileArg;
  /** The 'bindPassword' global argument. */
  public StringArgument bindPasswordArg;
  /** The 'trustAllArg' global argument. */
  public BooleanArgument trustAllArg;
  /** The 'trustStore' global argument. */
  public StringArgument trustStorePathArg;
  /** The 'trustStorePassword' global argument. */
  public StringArgument trustStorePasswordArg;
  /** The 'trustStorePasswordFile' global argument. */
  public FileBasedArgument trustStorePasswordFileArg;
  /** The 'keyStore' global argument. */
  public StringArgument keyStorePathArg;
  /** The 'keyStorePassword' global argument. */
  public StringArgument keyStorePasswordArg;
  /** The 'keyStorePasswordFile' global argument. */
  public FileBasedArgument keyStorePasswordFileArg;
  /** The 'certNicknameArg' global argument. */
  public StringArgument certNicknameArg;
  /** The 'useSSLArg' global argument. */
  public BooleanArgument useSSLArg;
  /** The 'useStartTLSArg' global argument. */
  public BooleanArgument useStartTLSArg;
  /** Argument indicating a SASL option. */
  public StringArgument saslOptionArg;
  /** Argument to specify the connection timeout. */
  public IntegerArgument connectTimeoutArg;

  /** Private container for global arguments. */
  private Set<Argument> argList;

  /** The trust manager. */
  private ApplicationTrustManager trustManager;

  private boolean configurationInitialized;

  /** Defines if the CLI always use the SSL connection type. */
  private boolean alwaysSSL;

  /**
   * Creates a new instance of secure arguments.
   *
   * @param alwaysSSL
   *          If true, always use the SSL connection type. In this case, the
   *          arguments useSSL and startTLS are not present.
   */
  public SecureConnectionCliArgs(boolean alwaysSSL)
  {
    this.alwaysSSL = alwaysSSL;
  }

  /**
   * Indicates whether or not any of the arguments are present.
   *
   * @return boolean where true indicates that at least one of the arguments is
   *         present
   */
  public boolean argumentsPresent()
  {
    if (argList != null)
    {
      for (Argument arg : argList)
      {
        if (arg.isPresent())
        {
          return true;
        }
      }
    }
    return false;
  }

  /**
   * Get the admin UID which has to be used for the command.
   *
   * @return The admin UID specified by the command line argument, or the
   *         default value, if not specified.
   */
  public String getAdministratorUID()
  {
    if (adminUidArg.isPresent())
    {
      return adminUidArg.getValue();
    }
    return adminUidArg.getDefaultValue();
  }

  /**
   * Tells whether this parser uses the Administrator UID (instead of the bind
   * DN) or not.
   *
   * @return {@code true} if this parser uses the Administrator UID and
   *         {@code false} otherwise.
   */
  public boolean useAdminUID()
  {
    return !adminUidArg.isHidden();
  }

  /**
   * Get the bindDN which has to be used for the command.
   *
   * @return The bindDN specified by the command line argument, or the default
   *         value, if not specified.
   */
  public String getBindDN()
  {
    if (bindDnArg.isPresent())
    {
      return bindDnArg.getValue();
    }
    return bindDnArg.getDefaultValue();
  }

  /**
   * Initialize Global option.
   *
   * @throws ArgumentException
   *           If there is a problem with any of the parameters used to create
   *           this argument.
   * @return a ArrayList with the options created.
   */
  public Set<Argument> createGlobalArguments() throws ArgumentException
  {
    argList = new LinkedHashSet<>();

    useSSLArg = CommonArguments.getUseSSL();
    if (!alwaysSSL)
    {
      argList.add(useSSLArg);
    }
    else
    {
      // simulate that the useSSL arg has been given in the CLI
      useSSLArg.setPresent(true);
    }

    useStartTLSArg = CommonArguments.getStartTLS();
    if (!alwaysSSL)
    {
      argList.add(useStartTLSArg);
    }

    String defaultHostName;
    try
    {
      defaultHostName = InetAddress.getLocalHost().getHostName();
    }
    catch (Exception e)
    {
      defaultHostName = "Unknown (" + e + ")";
    }
    hostNameArg = CommonArguments.getHostName(defaultHostName);
    argList.add(hostNameArg);

    portArg = CommonArguments.getPort(AdministrationConnector.DEFAULT_ADMINISTRATION_CONNECTOR_PORT,
                                      alwaysSSL ? INFO_DESCRIPTION_ADMIN_PORT.get() : INFO_DESCRIPTION_PORT.get());
    argList.add(portArg);

    bindDnArg = CommonArguments.getBindDN(CliConstants.DEFAULT_ROOT_USER_DN);
    argList.add(bindDnArg);

    // It is up to the classes that required admin UID to make this argument
    // visible and add it.
    adminUidArg = new StringArgument("adminUID", 'I', OPTION_LONG_ADMIN_UID, false, false, true,
                                     INFO_ADMINUID_PLACEHOLDER.get(), CliConstants.GLOBAL_ADMIN_UID,
                                     null, INFO_DESCRIPTION_ADMIN_UID.get());
    adminUidArg.setPropertyName(OPTION_LONG_ADMIN_UID);
    adminUidArg.setHidden(true);

    bindPasswordArg = CommonArguments.getBindPassword();
    argList.add(bindPasswordArg);

    bindPasswordFileArg = CommonArguments.getBindPasswordFile();
    argList.add(bindPasswordFileArg);

    saslOptionArg = CommonArguments.getSASL();
    argList.add(saslOptionArg);

    trustAllArg = CommonArguments.getTrustAll();
    argList.add(trustAllArg);

    trustStorePathArg = CommonArguments.getTrustStorePath();
    argList.add(trustStorePathArg);

    trustStorePasswordArg = CommonArguments.getTrustStorePassword();
    argList.add(trustStorePasswordArg);

    trustStorePasswordFileArg = CommonArguments.getTrustStorePasswordFile();
    argList.add(trustStorePasswordFileArg);

    keyStorePathArg = CommonArguments.getKeyStorePath();
    argList.add(keyStorePathArg);

    keyStorePasswordArg = CommonArguments.getKeyStorePassword();
    argList.add(keyStorePasswordArg);

    keyStorePasswordFileArg = CommonArguments.getKeyStorePasswordFile();
    argList.add(keyStorePasswordFileArg);

    certNicknameArg = CommonArguments.getCertNickName();
    argList.add(certNicknameArg);

    connectTimeoutArg = CommonArguments.getConnectTimeOut();
    connectTimeoutArg.setHidden(false);
    argList.add(connectTimeoutArg);

    return argList;
  }

  /**
   * Get the host name which has to be used for the command.
   *
   * @return The host name specified by the command line argument, or the
   *         default value, if not specified.
   */
  public String getHostName()
  {
    if (hostNameArg.isPresent())
    {
      return hostNameArg.getValue();
    }
    return hostNameArg.getDefaultValue();
  }

  /**
   * Get the port which has to be used for the command.
   *
   * @return The port specified by the command line argument, or the default
   *         value, if not specified.
   */
  public String getPort()
  {
    if (portArg.isPresent())
    {
      return portArg.getValue();
    }
    return portArg.getDefaultValue();
  }

  /**
   * Indication if provided global options are validate.
   *
   * @param buf
   *          the LocalizableMessageBuilder to write the error messages.
   * @return return code.
   */
  public int validateGlobalOptions(LocalizableMessageBuilder buf)
  {
    List<LocalizableMessage> errors = new ArrayList<>();

    addIfArgsAreConflicting(errors, bindPasswordArg, bindPasswordFileArg);

    // Couldn't have at the same time trustAll and trustStore related args
    addIfArgsAreConflicting(errors, trustAllArg, trustStorePathArg);
    addIfArgsAreConflicting(errors, trustAllArg, trustStorePasswordArg);
    addIfArgsAreConflicting(errors, trustAllArg, trustStorePasswordFileArg);

    addIfArgsAreConflicting(errors, trustStorePasswordArg, trustStorePasswordFileArg);

    checkIfPathArgumentIsReadable(errors, trustStorePathArg, ERR_CANNOT_READ_TRUSTSTORE);
    checkIfPathArgumentIsReadable(errors, keyStorePathArg, ERR_CANNOT_READ_KEYSTORE);

    addIfArgsAreConflicting(errors, useStartTLSArg, useSSLArg);

    if (!errors.isEmpty())
    {
      for (LocalizableMessage error : errors)
      {
        if (buf.length() > 0)
        {
          buf.append(LINE_SEPARATOR);
        }
        buf.append(error);
      }
      return CONFLICTING_ARGS.get();
    }

    return SUCCESS.get();
  }

  private void addIfArgsAreConflicting(List<LocalizableMessage> errors, Argument arg1, Argument arg2)
  {
    if (arg1.isPresent() && arg2.isPresent())
    {
      errors.add(ERR_TOOL_CONFLICTING_ARGS.get(arg1.getLongIdentifier(), arg2.getLongIdentifier()));
    }
  }

  private void checkIfPathArgumentIsReadable(List<LocalizableMessage> errors, StringArgument pathArg, Arg1<Object> msg)
  {
    if (pathArg.isPresent() && !canRead(pathArg.getValue()))
    {
      errors.add(msg.get(pathArg.getValue()));
    }
  }

  /**
   * Indicate if the SSL mode is required.
   *
   * @return True if SSL mode is required
   */
  public boolean useSSL()
  {
    return useSSLArg.isPresent() || alwaysSSL();
  }

  /**
   * Indicate if the startTLS mode is required.
   *
   * @return True if startTLS mode is required
   */
  public boolean useStartTLS()
  {
    return useStartTLSArg.isPresent();
  }

  /**
   * Indicate if the SSL mode is always used.
   *
   * @return True if SSL mode is always used.
   */
  public boolean alwaysSSL()
  {
    return alwaysSSL;
  }

  /**
   * Handle TrustStore.
   *
   * @return The trustStore manager to be used for the command.
   */
  public ApplicationTrustManager getTrustManager()
  {
    if (trustManager == null)
    {
      KeyStore truststore = null;
      if (trustAllArg.isPresent())
      {
        // Running a null TrustManager  will force createLdapsContext and
        // createStartTLSContext to use a bindTrustManager.
        return null;
      }
      else if (trustStorePathArg.isPresent())
      {
        try (final FileInputStream fos = new FileInputStream(trustStorePathArg.getValue()))
        {
          String trustStorePasswordStringValue = null;
          if (trustStorePasswordArg.isPresent())
          {
            trustStorePasswordStringValue = trustStorePasswordArg.getValue();
          }
          else if (trustStorePasswordFileArg.isPresent())
          {
            trustStorePasswordStringValue = trustStorePasswordFileArg.getValue();
          }

          if (trustStorePasswordStringValue != null)
          {
            trustStorePasswordStringValue = System.getProperty("javax.net.ssl.trustStorePassword");
          }

          char[] trustStorePasswordValue = null;
          if (trustStorePasswordStringValue != null)
          {
            trustStorePasswordValue = trustStorePasswordStringValue.toCharArray();
          }

          truststore = KeyStore.getInstance(KeyStore.getDefaultType());
          truststore.load(fos, trustStorePasswordValue);
        }
        catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException e)
        {
          // Nothing to do: if this occurs we will systematically refuse the
          // certificates.  Maybe we should avoid this and be strict, but we
          // are in a best effort mode.
          logger.warn(LocalizableMessage.raw("Error with the truststore"), e);
        }
      }
      trustManager = new ApplicationTrustManager(truststore);
    }
    return trustManager;
  }

  /**
   * Returns {@code true} if we can read on the provided path and {@code false}
   * otherwise.
   *
   * @param path
   *          the path.
   * @return {@code true} if we can read on the provided path and {@code false}
   *         otherwise.
   */
  private boolean canRead(String path)
  {
    final File file = new File(path);
    return file.exists() && file.canRead();
  }

  /**
   * Returns the absolute path of the trust store file that appears on the
   * config. Returns {@code null} if the trust store is not defined or it does
   * not exist.
   *
   * @return the absolute path of the trust store file that appears on the
   *         config.
   * @throws ConfigException
   *           if there is an error reading the configuration.
   */
  public String getTruststoreFileFromConfig() throws ConfigException
  {
    String truststoreFileAbsolute = null;
    TrustManagerProviderCfg trustManagerCfg = null;
    AdministrationConnectorCfg administrationConnectorCfg = null;

    boolean couldInitializeConfig = configurationInitialized;
    // Initialization for admin framework
    if (!configurationInitialized)
    {
      couldInitializeConfig = initializeConfiguration();
    }
    if (couldInitializeConfig)
    {
      // Get the Directory Server configuration handler and use it.
      RootCfg root = ServerManagementContext.getInstance().getRootConfiguration();
      administrationConnectorCfg = root.getAdministrationConnector();

      String trustManagerStr = administrationConnectorCfg.getTrustManagerProvider();
      trustManagerCfg = root.getTrustManagerProvider(trustManagerStr);
      if (trustManagerCfg instanceof FileBasedTrustManagerProviderCfg)
      {
        FileBasedTrustManagerProviderCfg fileBasedTrustManagerCfg = (FileBasedTrustManagerProviderCfg) trustManagerCfg;
        String truststoreFile = fileBasedTrustManagerCfg.getTrustStoreFile();
        // Check the file
        if (truststoreFile.startsWith(File.separator))
        {
          truststoreFileAbsolute = truststoreFile;
        }
        else
        {
          truststoreFileAbsolute = DirectoryServer.getInstanceRoot() + File.separator + truststoreFile;
        }
        File f = new File(truststoreFileAbsolute);
        if (!f.exists() || !f.canRead() || f.isDirectory())
        {
          truststoreFileAbsolute = null;
        }
        else
        {
          // Try to get the canonical path.
          try
          {
            truststoreFileAbsolute = f.getCanonicalPath();
          }
          catch (Throwable t)
          {
            // We can ignore this error.
          }
        }
      }
    }
    return truststoreFileAbsolute;
  }

  /**
   * Returns the admin port from the configuration.
   *
   * @return the admin port from the configuration.
   * @throws ConfigException
   *           if an error occurs reading the configuration.
   */
  public int getAdminPortFromConfig() throws ConfigException
  {
    // Initialization for admin framework
    boolean couldInitializeConfiguration = configurationInitialized;
    if (!configurationInitialized)
    {
      couldInitializeConfiguration = initializeConfiguration();
    }
    if (couldInitializeConfiguration)
    {
      RootCfg root = ServerManagementContext.getInstance().getRootConfiguration();
      return root.getAdministrationConnector().getListenPort();
    }
    else
    {
      return AdministrationConnector.DEFAULT_ADMINISTRATION_CONNECTOR_PORT;
    }
  }

  private boolean initializeConfiguration()
  {
    // check if the initialization is required
    try
    {
      ServerManagementContext.getInstance().getRootConfiguration().getAdministrationConnector();
    }
    catch (java.lang.Throwable th)
    {
      try
      {
        DirectoryServer.bootstrapClient();
        DirectoryServer.initializeJMX();
        DirectoryServer.getInstance().initializeConfiguration();
      }
      catch (Exception ex)
      {
        // do nothing
        return false;
      }
    }
    configurationInitialized = true;
    return true;
  }

  /**
   * Returns the port to be used according to the configuration and the
   * arguments provided by the user. This method should be called after the
   * arguments have been parsed.
   *
   * @return the port to be used according to the configuration and the
   *         arguments provided by the user.
   */
  public int getPortFromConfig()
  {
    int portNumber;
    if (alwaysSSL())
    {
      portNumber = AdministrationConnector.DEFAULT_ADMINISTRATION_CONNECTOR_PORT;
      // Try to get the port from the config file
      try
      {
        portNumber = getAdminPortFromConfig();
      }
      catch (ConfigException ex)
      {
        // Nothing to do
      }
    }
    else
    {
      portNumber = CliConstants.DEFAULT_SSL_PORT;
    }
    return portNumber;
  }

  /**
   * Updates the default values of the port and the trust store with what is
   * read in the configuration.
   *
   * @throws ConfigException
   *           if there is an error reading the configuration.
   */
  public void initArgumentsWithConfiguration() throws ConfigException
  {
    portArg.setDefaultValue(String.valueOf(getPortFromConfig()));

    String truststoreFileAbsolute = getTruststoreFileFromConfig();
    if (truststoreFileAbsolute != null)
    {
      trustStorePathArg.setDefaultValue(truststoreFileAbsolute);
    }
  }
}
