/*
 * 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 2013-2015 ForgeRock AS
 */
package org.opends.server.protocols.http;

import static org.forgerock.opendj.adapter.server3x.Converters.from;
import static org.forgerock.opendj.adapter.server3x.Converters.getResponseResult;
import static org.forgerock.opendj.ldap.LdapException.newLdapException;
import static org.opends.messages.ProtocolMessages.WARN_CLIENT_DISCONNECT_IN_PROGRESS;
import static org.opends.server.loggers.CommonAudit.DEFAULT_TRANSACTION_ID;
import static org.opends.server.loggers.AccessLogger.logDisconnect;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

import org.forgerock.http.MutableUri;
import org.forgerock.http.header.MalformedHeaderException;
import org.forgerock.http.header.TransactionIdHeader;
import org.forgerock.http.protocol.Request;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchResultHandler;
import org.forgerock.opendj.ldap.responses.Result;
import org.forgerock.opendj.ldap.spi.LdapPromiseImpl;
import org.forgerock.services.context.AttributesContext;
import org.forgerock.services.context.ClientContext;
import org.forgerock.services.context.Context;
import org.opends.server.api.ClientConnection;
import org.opends.server.core.AddOperation;
import org.opends.server.core.BindOperation;
import org.opends.server.core.CompareOperation;
import org.opends.server.core.DeleteOperation;
import org.opends.server.core.DirectoryServer;
import org.opends.server.core.ExtendedOperation;
import org.opends.server.core.ModifyDNOperation;
import org.opends.server.core.ModifyOperation;
import org.opends.server.core.SearchOperation;
import org.opends.server.core.ServerContext;
import org.opends.server.loggers.HTTPAccessLogger;
import org.opends.server.loggers.HTTPRequestInfo;
import org.opends.server.protocols.ldap.AddResponseProtocolOp;
import org.opends.server.protocols.ldap.BindResponseProtocolOp;
import org.opends.server.protocols.ldap.CompareResponseProtocolOp;
import org.opends.server.protocols.ldap.DeleteResponseProtocolOp;
import org.opends.server.protocols.ldap.ExtendedResponseProtocolOp;
import org.opends.server.protocols.ldap.LDAPMessage;
import org.opends.server.protocols.ldap.ModifyDNResponseProtocolOp;
import org.opends.server.protocols.ldap.ModifyResponseProtocolOp;
import org.opends.server.protocols.ldap.ProtocolOp;
import org.opends.server.protocols.ldap.SearchResultDoneProtocolOp;
import org.opends.server.protocols.ldap.SearchResultEntryProtocolOp;
import org.opends.server.protocols.ldap.SearchResultReferenceProtocolOp;
import org.opends.server.types.CancelRequest;
import org.opends.server.types.CancelResult;
import org.opends.server.types.DN;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.DisconnectReason;
import org.opends.server.types.IntermediateResponse;
import org.opends.server.types.Operation;
import org.opends.server.types.OperationType;
import org.opends.server.types.SearchResultEntry;
import org.opends.server.types.SearchResultReference;

/**
 * This class defines an HTTP client connection, which is a type of client
 * connection that will be accepted by an instance of the HTTP connection
 * handler.
 */
final class HTTPClientConnection extends ClientConnection implements HTTPRequestInfo
{

  // TODO JNR Confirm with Matt that persistent searches are inapplicable to Rest2LDAP.
  // TODO JNR Should I override getIdleTime()?

  /**
   * Class grouping together an {@link Operation} and its associated
   * {@link LdapPromiseImpl} to ensure they are both atomically added
   * and removed from the {@link HTTPClientConnection#operationsInProgress} Map.
   */
  private static class OperationWithPromise
  {
    final Operation operation;
    final LdapPromiseImpl<Result> promise;

    public OperationWithPromise(Operation operation, LdapPromiseImpl<Result> promise)
    {
      this.operation = operation;
      this.promise = promise;
    }

    @Override
    public String toString()
    {
      return operation.toString();
    }
  }

  /** Search Operation with a promise. */
  private static final class SearchOperationWithPromise extends OperationWithPromise
  {

    final SearchResultHandler entryHandler;

    public SearchOperationWithPromise(
        Operation operation, LdapPromiseImpl<Result> promise, SearchResultHandler entryHandler)
    {
      super(operation, promise);
      this.entryHandler = entryHandler;
    }
  }

  /** The tracer object for the debug logger. */
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();

  /**
   * Official servlet property giving access to the SSF (Security Strength
   * Factor) used to encrypt the current connection.
   */
  private static final String SERVLET_SSF_CONSTANT = "javax.servlet.request.key_size";

  /**
   * Indicates whether the Directory Server believes this connection to be valid
   * and available for communication.
   */
  private volatile boolean connectionValid = true;

  /**
   * Indicates whether this connection is about to be closed. This will be used
   * to prevent accepting new requests while a disconnect is in progress.
   */
  private boolean disconnectRequested;

  /**
   * Indicates whether the connection should keep statistics regarding the
   * operations that it is performing.
   */
  private final boolean keepStats;

  /**
   * The Map (messageID => {@link OperationWithPromise}) of all operations
   * currently in progress on this connection.
   */
  private final Map<Integer, OperationWithPromise> operationsInProgress = new ConcurrentHashMap<>();

  /**
   * The number of operations performed on this connection. Used to compare with
   * the resource limits of the network group.
   */
  private final AtomicLong operationsPerformed = new AtomicLong(0);

  /**
   * The lock used to provide threadsafe access to the map of operations in
   * progress. This is used when we want to prevent puts on this map while we
   * are removing all operations in progress.
   */
  private final Object opsInProgressLock = new Object();

  /** The connection ID assigned to this connection. */
  private final long connectionID;

  /** The reference to the connection handler that accepted this connection. */
  private final HTTPConnectionHandler connectionHandler;

  /** The statistics tracker associated with this client connection. */
  private final HTTPStatistics statTracker;
  private boolean useNanoTime;

  /** Total execution time for this request. */
  private final AtomicLong totalProcessingTime = new AtomicLong();

  /** The protocol in use for this client connection. */
  private final String protocol;

  /** The HTTP method/verb used for this request. */
  private final String method;
  /** The query issued by the client. */
  private final String query;
  /** The user agent used by the client. */
  private final String userAgent;

  /** The username that was used to authenticate. */
  private String authUser;
  /**
   * The HTTP status code returned to the client. Using 0 to say no status code
   * was set since it is not .
   */
  private final AtomicInteger statusCode = new AtomicInteger(0);

  /** The client (remote) address. */
  private final String clientAddress;

  /** The client (remote) host name. */
  private String clientHost;

  /** The client (remote) port. */
  private final int clientPort;

  /** The remote (client) address. */
  private final InetAddress remoteAddress;

  /** The server (local) address. */
  private final String serverAddress;

  /** The server (local) host name. */
  private String serverHost;

  /** The server (local) port. */
  private final int serverPort;

  /** The local (server) address. */
  private final InetAddress localAddress;

  /** Whether this connection is secure. */
  private boolean isSecure;

  /** Security-Strength Factor extracted from the request attribute. */
  private final int securityStrengthFactor;

  /** TransactionId for tracking of ForgeRock stack transactions. */
  private final String transactionId;

  /**
   * Constructs an instance of this class.
   * @param serverContext
   *            The server context.
   * @param connectionHandler
   *          the connection handler that accepted this connection
   * @param context
   *          represents the context of this client connection.
   */
  public HTTPClientConnection(ServerContext serverContext, HTTPConnectionHandler connectionHandler, Context context,
      Request request)
  {
    this.connectionHandler = connectionHandler;
    final ClientContext clientCtx = context.asContext(ClientContext.class);
    // Memorize all the fields we need from the request before Grizzly decides to recycle it
    this.clientAddress = clientCtx.getRemoteAddress();
    this.remoteAddress = toInetAddress(clientAddress);
    this.clientPort = clientCtx.getRemotePort();
    this.isSecure = clientCtx.isSecure();

    final MutableUri uri = request.getUri();
    this.serverAddress = uri.getHost();
    this.localAddress = toInetAddress(serverAddress);
    this.serverPort = uri.getPort();
    this.securityStrengthFactor = calcSSF(
            context.asContext(AttributesContext.class).getAttributes().get(SERVLET_SSF_CONSTANT));
    this.method = request.getMethod();
    this.query = uri.getQuery();
    this.protocol = request.getVersion();
    this.userAgent = clientCtx.getUserAgent();

    this.statTracker = this.connectionHandler.getStatTracker();

    this.keepStats = connectionHandler.keepStats();
    if (this.keepStats)
    {
      this.statTracker.updateConnect();
      this.useNanoTime = DirectoryServer.getUseNanoTime();
    }
    this.transactionId = getTransactionId(serverContext, request);
    this.connectionID = DirectoryServer.newConnectionAccepted(this);
  }

  private String getTransactionId(ServerContext serverContext, Request request)
  {
    if (serverContext.getCommonAudit().shouldTrustTransactionIds())
    {
      try
      {
        TransactionIdHeader txHeader = request.getHeaders().get(TransactionIdHeader.class);
        return txHeader == null ? DEFAULT_TRANSACTION_ID :  txHeader.getTransactionId().getValue();
      }
      catch (MalformedHeaderException e)
      {
        // ignore it
      }
    }
    return DEFAULT_TRANSACTION_ID;
  }

  @Override
  public String getAuthUser()
  {
    return this.authUser;
  }

  @Override
  public long getConnectionID()
  {
    return connectionID;
  }

  @Override
  public HTTPConnectionHandler getConnectionHandler()
  {
    return connectionHandler;
  }

  @Override
  public long getTotalProcessingTime()
  {
    return totalProcessingTime.get();
  }

  @Override
  public String getProtocol()
  {
    return protocol;
  }

  @Override
  public String getClientAddress()
  {
    return clientAddress;
  }

  @Override
  public String getClientHost()
  {
    return clientHost;
  }

  @Override
  public int getClientPort()
  {
    return clientPort;
  }

  @Override
  public String getServerAddress()
  {
    return serverAddress;
  }

  @Override
  public String getServerHost()
  {
    return serverHost;
  }

  @Override
  public int getServerPort()
  {
    return serverPort;
  }

  @Override
  public InetAddress getRemoteAddress()
  {
    return remoteAddress;
  }

  @Override
  public InetAddress getLocalAddress()
  {
    return localAddress;
  }

  @Override
  public String getTransactionId()
  {
    return transactionId;
  }

  @Override
  public boolean isSecure()
  {
    return isSecure;
  }

  @Override
  public void sendResponse(Operation operation)
  {
    final long time = getProcessingTime(operation);
    this.totalProcessingTime.addAndGet(time);

    if (keepStats)
    {
      this.statTracker.updateRequestMonitoringData(getMethod(), time);
      this.statTracker.updateOperationMonitoringData(operation.getOperationType(), time);
    }

    OperationWithPromise op = this.operationsInProgress.get(operation.getMessageID());
    if (op != null)
    {
      try
      {
        op.promise.handleResult(getResponseResult(operation));

        if (keepStats)
        {
          this.statTracker.updateMessageWritten(
              new LDAPMessage(operation.getMessageID(), toResponseProtocolOp(operation)));
        }
      }
      catch (LdapException e)
      {
        op.promise.handleException(e);
      }
    }
  }

  private long getProcessingTime(Operation operation)
  {
    if (useNanoTime)
    {
      return operation.getProcessingNanoTime();
    }
    return operation.getProcessingTime();
  }

  private ProtocolOp toResponseProtocolOp(Operation operation)
  {
    final int resultCode = operation.getResultCode().intValue();
    if (operation instanceof AddOperation)
    {
      return new AddResponseProtocolOp(resultCode);
    }
    else if (operation instanceof BindOperation)
    {
      return new BindResponseProtocolOp(resultCode);
    }
    else if (operation instanceof CompareOperation)
    {
      return new CompareResponseProtocolOp(resultCode);
    }
    else if (operation instanceof DeleteOperation)
    {
      return new DeleteResponseProtocolOp(resultCode);
    }
    else if (operation instanceof ExtendedOperation)
    {
      return new ExtendedResponseProtocolOp(resultCode);
    }
    else if (operation instanceof ModifyDNOperation)
    {
      return new ModifyDNResponseProtocolOp(resultCode);
    }
    else if (operation instanceof ModifyOperation)
    {
      return new ModifyResponseProtocolOp(resultCode);
    }
    else if (operation instanceof SearchOperation)
    {
      return new SearchResultDoneProtocolOp(resultCode);
    }
    throw new RuntimeException("Not implemented for operation " + operation);
  }

  @Override
  public void sendSearchEntry(SearchOperation operation, SearchResultEntry searchEntry) throws DirectoryException
  {
    SearchOperationWithPromise op =
        (SearchOperationWithPromise) this.operationsInProgress.get(operation.getMessageID());
    if (op != null)
    {
      op.entryHandler.handleEntry(from(searchEntry));
      if (keepStats)
      {
        this.statTracker.updateMessageWritten(
            new LDAPMessage(operation.getMessageID(), new SearchResultEntryProtocolOp(searchEntry)));
      }
    }
  }

  @Override
  public boolean sendSearchReference(SearchOperation operation, SearchResultReference searchReference)
      throws DirectoryException
  {
    SearchOperationWithPromise op =
        (SearchOperationWithPromise) this.operationsInProgress.get(operation.getMessageID());
    if (op != null)
    {
      op.entryHandler.handleReference(from(searchReference));
      if (keepStats)
      {
        this.statTracker.updateMessageWritten(
            new LDAPMessage(operation.getMessageID(), new SearchResultReferenceProtocolOp(searchReference)));
      }
    }

    return connectionValid;
  }

  @Override
  protected boolean sendIntermediateResponseMessage(IntermediateResponse intermediateResponse)
  {
    // if (keepStats)
    // {
    // this.statTracker.updateMessageWritten(new LDAPMessage(
    // intermediateResponse.getOperation().getMessageID(),
    // new IntermediateResponseProtocolOp(intermediateResponse.getOID())));
    // }
    throw new RuntimeException("Not implemented");
  }

  @Override
  public void setAuthUser(String authUser)
  {
    this.authUser = authUser;
  }

  /**
   * {@inheritDoc}
   *
   * @param sendNotification
   *          not used with HTTP.
   */
  @Override
  public void disconnect(DisconnectReason disconnectReason, boolean sendNotification, LocalizableMessage message)
  {
    // Set a flag indicating that the connection is being terminated so
    // that no new requests will be accepted.
    // Also cancel all operations in progress.
    synchronized (opsInProgressLock)
    {
      // If we are already in the middle of a disconnect, then don't do anything.
      if (disconnectRequested)
      {
        return;
      }

      disconnectRequested = true;
    }

    if (keepStats)
    {
      statTracker.updateDisconnect();
    }

    if (connectionID >= 0)
    {
      DirectoryServer.connectionClosed(this);
    }

    // Indicate that this connection is no longer valid.
    connectionValid = false;

    if (message != null)
    {
      LocalizableMessageBuilder msgBuilder = new LocalizableMessageBuilder();
      msgBuilder.append(disconnectReason.getClosureMessage());
      msgBuilder.append(": ");
      msgBuilder.append(message);
      cancelAllOperations(new CancelRequest(true, msgBuilder.toMessage()));
    }
    else
    {
      cancelAllOperations(new CancelRequest(true, disconnectReason.getClosureMessage()));
    }
    finalizeConnectionInternal();


    this.connectionHandler.removeClientConnection(this);
    logDisconnect(this, disconnectReason, message);
  }

  @Override
  public String getMethod()
  {
    return this.method;
  }

  @Override
  public String getQuery()
  {
    return this.query;
  }

  @Override
  public int getStatusCode()
  {
    return this.statusCode.get();
  }

  @Override
  public String getUserAgent()
  {
    return this.userAgent;
  }

  @Override
  public Collection<Operation> getOperationsInProgress()
  {
    Collection<OperationWithPromise> values = operationsInProgress.values();
    Collection<Operation> results = new ArrayList<>(values.size());
    for (OperationWithPromise op : values)
    {
      results.add(op.operation);
    }
    return results;
  }

  @Override
  public Operation getOperationInProgress(int messageID)
  {
    OperationWithPromise op = operationsInProgress.get(messageID);
    if (op != null)
    {
      return op.operation;
    }
    return null;
  }

  /**
   * Adds the passed in search operation to the in progress list along with the
   * associated promise and the {@code SearchResultHandler}.
   *
   * @param operation
   *          the operation to add to the in progress list
   * @param promise
   *          the promise associated to the operation
   * @param searchResultHandler
   *          the search result handler associated to the promise result
   * @throws DirectoryException
   *           If an error occurs
   */
  void addOperationInProgress(Operation operation, LdapPromiseImpl<Result> promise,
      SearchResultHandler searchResultHandler) throws DirectoryException
  {
    if (searchResultHandler != null)
    {
      addOperationWithPromise(new SearchOperationWithPromise(operation, promise, searchResultHandler));
    }
    else
    {
      addOperationWithPromise(new OperationWithPromise(operation, promise));
    }
  }

  private void addOperationWithPromise(OperationWithPromise opPromise) throws DirectoryException
  {
    synchronized (opsInProgressLock)
    {
      // If we're already in the process of disconnecting the client, then reject the operation.
      if (disconnectRequested)
      {
        LocalizableMessage message = WARN_CLIENT_DISCONNECT_IN_PROGRESS.get();
        throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, message);
      }

      operationsInProgress.put(opPromise.operation.getMessageID(), opPromise);
    }
  }

  @Override
  public boolean removeOperationInProgress(int messageID)
  {
    final OperationWithPromise previousValue = operationsInProgress.remove(messageID);
    if (previousValue != null)
    {
      operationsPerformed.incrementAndGet();

      final Operation operation = previousValue.operation;
      if (operation.getOperationType() == OperationType.ABANDON
          && keepStats
          && operation.getResultCode() == ResultCode.CANCELLED)
      {
        statTracker.updateAbandonedOperation();
      }
    }
    return previousValue != null;
  }

  @Override
  public CancelResult cancelOperation(int messageID, CancelRequest cancelRequest)
  {
    OperationWithPromise op = operationsInProgress.remove(messageID);
    if (op != null)
    {
      op.promise.handleException(newLdapException(ResultCode.CANCELLED));
      return op.operation.cancel(cancelRequest);
    }
    return new CancelResult(ResultCode.NO_SUCH_OPERATION, null);
  }

  private int calcSSF(Object ssf)
  {
    if (ssf instanceof Number)
    {
      return ((Number) ssf).intValue();
    }
    else if (ssf instanceof String)
    {
      try
      {
        return Integer.parseInt((String) ssf);
      }
      catch (IllegalArgumentException ignored)
      {
        // We cannot do much about it. Just log it.
        logger.traceException(ignored);
      }
    }
    return 0;
  }

  @Override
  public void cancelAllOperations(CancelRequest cancelRequest)
  {
    synchronized (opsInProgressLock)
    {
      try
      {
        for (OperationWithPromise op : operationsInProgress.values())
        {
          try
          {
            op.promise.handleException(newLdapException(ResultCode.CANCELLED));
            op.operation.abort(cancelRequest);

            if (keepStats)
            {
              statTracker.updateAbandonedOperation();
            }
          }
          catch (Exception e)
          { // Make sure all operations are cancelled, no matter what
            logger.traceException(e);
          }
        }

        operationsInProgress.clear();
      }
      catch (Exception e)
      { // TODO JNR should I keep this catch?
        logger.traceException(e);
      }
    }
  }

  @Override
  public void cancelAllOperationsExcept(CancelRequest cancelRequest,
      int messageID)
  {
    synchronized (opsInProgressLock)
    {
      OperationWithPromise toKeep = operationsInProgress.remove(messageID);
      try
      {
        cancelAllOperations(cancelRequest);
      }
      finally
      { // Ensure we always put back this operation
        operationsInProgress.put(messageID, toKeep);
      }
    }
  }

  @Override
  public long getNumberOfOperations()
  {
    return this.operationsPerformed.get();
  }

  @Override
  public String getMonitorSummary()
  {
    StringBuilder buffer = new StringBuilder();
    buffer.append("connID=\"");
    buffer.append(getConnectionID());
    buffer.append("\" connectTime=\"");
    buffer.append(getConnectTimeString());
    buffer.append("\" source=\"");
    buffer.append(getClientAddress());
    buffer.append(":");
    buffer.append(getClientPort());
    buffer.append("\" destination=\"");
    buffer.append(getServerAddress());
    buffer.append(":");
    buffer.append(connectionHandler.getListenPort());
    buffer.append("\" authDN=\"");
    DN authDN = getAuthenticationInfo().getAuthenticationDN();
    if (authDN != null)
    {
      authDN.toString(buffer);
    }
    return buffer.toString();
  }

  private InetAddress toInetAddress(String address)
  {
    try
    {
      return InetAddress.getByName(address);
    }
    catch (UnknownHostException e)
    {
      throw new RuntimeException("Should never happen", e);
    }
  }

  @Override
  public void toString(StringBuilder buffer)
  {
    buffer.append("HTTP client connection from ");
    buffer.append(getClientAddress()).append(":").append(getClientPort());
    buffer.append(" to ");
    buffer.append(getServerAddress()).append(":").append(getServerPort());
  }

  /**
   * Returns the statTracker for this connection handler.
   *
   * @return the statTracker for this connection handler
   */
  public HTTPStatistics getStatTracker()
  {
    return statTracker;
  }

  @Override
  public int getSSF()
  {
    return securityStrengthFactor;
  }

  @Override
  public boolean isConnectionValid()
  {
    return connectionValid;
  }

  @Override
  public boolean isInnerConnection()
  {
    return true;
  }

  @Override
  public void log(int statusCode)
  {
    if (this.statusCode.compareAndSet(0, statusCode))
    { // This request was not logged before
      HTTPAccessLogger.logRequestInfo(this);
    }
  }
}
