/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.drill.exec.rpc.user;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.DrillBuf;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;

import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.drill.common.exceptions.UserException;
import org.apache.drill.common.exceptions.UserRemoteException;
import org.apache.drill.exec.proto.UserBitShared.QueryData;
import org.apache.drill.exec.proto.UserBitShared.QueryId;
import org.apache.drill.exec.proto.UserBitShared.QueryResult;
import org.apache.drill.exec.proto.UserBitShared.QueryResult.QueryState;
import org.apache.drill.exec.proto.helper.QueryIdHelper;
import org.apache.drill.exec.rpc.BaseRpcOutcomeListener;
import org.apache.drill.exec.rpc.user.UserClient.UserToBitConnection;
import org.apache.drill.exec.rpc.ConnectionThrottle;
import org.apache.drill.exec.rpc.RpcBus;
import org.apache.drill.exec.rpc.RpcConnectionHandler;
import org.apache.drill.exec.rpc.RpcException;
import org.apache.drill.exec.rpc.RpcOutcomeListener;

import org.apache.drill.shaded.guava.com.google.common.collect.Maps;
import org.apache.drill.shaded.guava.com.google.common.collect.Queues;

/**
 * Encapsulates the future management of query submissions.  This entails a
 * potential race condition.  Normal ordering is:
 * <ul>
 *   <li>1.  Submit query to be executed. </li>
 *   <li>2.  Receive QueryHandle for buffer management. </li>
 *   <li>3.  Start receiving results batches for query. </li>
 * </ul>
 * However, 3 could potentially occur before 2.   Because of that, we need to
 * handle this case and then do a switcheroo.
 */
public class QueryResultHandler {
  private static final org.slf4j.Logger logger =
      org.slf4j.LoggerFactory.getLogger(QueryResultHandler.class);

  /**
   * Current listener for results, for each active query.
   * <p>
   *   Concurrency:  Access by SubmissionLister for query-ID message vs.
   *   access by batchArrived is not otherwise synchronized.
   * </p>
   */
  private final ConcurrentMap<QueryId, UserResultsListener> queryIdToResultsListenersMap =
      Maps.newConcurrentMap();

  public RpcOutcomeListener<QueryId> getWrappedListener(UserResultsListener resultsListener) {
    return new SubmissionListener(resultsListener);
  }

  public RpcConnectionHandler<UserToBitConnection> getWrappedConnectionHandler(
      final RpcConnectionHandler<UserToBitConnection> handler) {
    return new ChannelClosedHandler(handler);
  }

  /**
   * Maps internal low-level API protocol to {@link UserResultsListener}-level API protocol.
   * handles data result messages
   */
  public void resultArrived( ByteBuf pBody ) throws RpcException {
    final QueryResult queryResult = RpcBus.get( pBody, QueryResult.PARSER );

    final QueryId queryId = queryResult.getQueryId();
    final QueryState queryState = queryResult.getQueryState();

    if (logger.isDebugEnabled()) {
      logger.debug("resultArrived: queryState: {}, queryId = {}", queryState,
          QueryIdHelper.getQueryId(queryId));
    }

    assert queryResult.hasQueryState() : "received query result without QueryState";

    final boolean isFailureResult = QueryState.FAILED == queryState;
    // CANCELED queries are handled the same way as COMPLETED
    final boolean isTerminalResult;
    switch ( queryState ) {
      case FAILED:
      case CANCELED:
      case COMPLETED:
        isTerminalResult = true;
        break;
      default:
        logger.error( "Unexpected/unhandled QueryState " + queryState
          + " (for query " + queryId +  ")" );
        isTerminalResult = false;
        break;
    }

    assert isFailureResult || queryResult.getErrorCount() == 0
      : "Error count for the query batch is non-zero but QueryState != FAILED";

    UserResultsListener resultsListener = newUserResultsListener(queryId);

    try {
      if (isFailureResult) {
        // Failure case--pass on via submissionFailed(...).

        resultsListener.submissionFailed(new UserRemoteException(queryResult.getError(0)));
        // Note: Listener is removed in finally below.
      } else if (isTerminalResult) {
        // A successful completion/canceled case--pass on via resultArrived

        try {
          resultsListener.queryCompleted(queryState);
        } catch ( Exception e ) {
          resultsListener.submissionFailed(UserException.systemError(e).build(logger));
        }
      } else {
        logger.warn("queryState {} was ignored", queryState);
      }
    } finally {
      if ( isTerminalResult ) {
        // TODO:  What exactly are we checking for?  How should we really check
        // for it?
        if ( (! ( resultsListener instanceof BufferingResultsListener )
          || ((BufferingResultsListener) resultsListener).output != null ) ) {
          queryIdToResultsListenersMap.remove( queryId, resultsListener );
        }
      }
    }
  }

  /**
   * Maps internal low-level API protocol to {@link UserResultsListener}-level API protocol.
   * handles query data messages
   */
  public void batchArrived( ConnectionThrottle throttle,
                            ByteBuf pBody, ByteBuf dBody ) throws RpcException {
    final QueryData queryData = RpcBus.get( pBody, QueryData.PARSER );
    // Current batch coming in.
    final DrillBuf drillBuf = (DrillBuf) dBody;
    final QueryDataBatch batch = new QueryDataBatch( queryData, drillBuf );

    final QueryId queryId = queryData.getQueryId();

    if (logger.isDebugEnabled()) {
      logger.debug("batchArrived: queryId = {}", QueryIdHelper.getQueryId(queryId));
    }
    logger.trace( "batchArrived: batch = {}", batch );

    final UserResultsListener resultsListener = newUserResultsListener(queryId);

    // A data case--pass on via dataArrived
    try {
      resultsListener.dataArrived(batch, throttle);
      // That releases batch if successful.
    } catch ( Exception e ) {
      batch.release();
      resultsListener.submissionFailed(UserException.systemError(e).build(logger));
    }
  }

  /**
   * Return {@link UserResultsListener} associated with queryId. Will create a new {@link BufferingResultsListener}
   * if no listener found.
   * @param queryId queryId we are getting the listener for
   * @return {@link UserResultsListener} associated with queryId
   */
  private UserResultsListener newUserResultsListener(QueryId queryId) {
    UserResultsListener resultsListener = queryIdToResultsListenersMap.get( queryId );
    logger.trace( "For QueryId [{}], retrieved results listener {}", queryId, resultsListener );
    if ( null == resultsListener ) {
      // WHO?? didn't get query ID response and set submission listener yet,
      // so install a buffering listener for now

      BufferingResultsListener bl = new BufferingResultsListener();
      resultsListener = queryIdToResultsListenersMap.putIfAbsent( queryId, bl );
      // If we had a successful insertion, use that reference.  Otherwise, just
      // throw away the new buffering listener.
      if ( null == resultsListener ) {
        resultsListener = bl;
      }
    }
    return resultsListener;
  }

  private static class BufferingResultsListener implements UserResultsListener {

    private ConcurrentLinkedQueue<QueryDataBatch> results = Queues.newConcurrentLinkedQueue();
    private volatile UserException ex;
    private volatile QueryState queryState;
    private volatile UserResultsListener output;
    private volatile ConnectionThrottle throttle;

    public boolean transferTo(UserResultsListener l) {
      synchronized (this) {
        output = l;
        for (QueryDataBatch r : results) {
          l.dataArrived(r, throttle);
        }
        if (ex != null) {
          l.submissionFailed(ex);
          return true;
        } else if (queryState != null) {
          l.queryCompleted(queryState);
          return true;
        }

        return false;
      }
    }

    @Override
    public void queryCompleted(QueryState state) {
      assert queryState == null;
      this.queryState = state;
      synchronized (this) {
        if (output != null) {
          output.queryCompleted(state);
        }
      }
    }

    @Override
    public void dataArrived(QueryDataBatch result, ConnectionThrottle throttle) {
      this.throttle = throttle;

      synchronized (this) {
        if (output == null) {
          this.results.add(result);
        } else {
          output.dataArrived(result, throttle);
        }
      }
    }

    @Override
    public void submissionFailed(UserException ex) {
      assert queryState == null;
      // there is one case when submissionFailed() is called even though the query didn't fail on the server side
      // it happens when UserResultsListener.batchArrived() throws an exception that will be passed to
      // submissionFailed() by QueryResultHandler.dataArrived()
      queryState = QueryState.FAILED;
      synchronized (this) {
        if (output == null) {
          this.ex = ex;
        } else{
          output.submissionFailed(ex);
        }
      }
    }

    @Override
    public void queryIdArrived(QueryId queryId) {
    }
  }

  private class SubmissionListener extends BaseRpcOutcomeListener<QueryId> {

    private final UserResultsListener resultsListener;
    private final AtomicBoolean isTerminal = new AtomicBoolean(false);

    public SubmissionListener(UserResultsListener resultsListener) {
      this.resultsListener = resultsListener;
    }

    @Override
    public void failed(RpcException ex) {
      if (!isTerminal.compareAndSet(false, true)) {
        logger.warn("Received multiple responses to run query request.");
        return;
      }

      // Although query submission failed, results might have arrived for this query.
      // However, the results could not be transferred to this resultListener because
      // there is no query id mapped to this resultListener. Look out for the warning
      // message from ChannelClosedHandler in the client logs.
      // TODO(DRILL-4586)
      resultsListener.submissionFailed(UserException.systemError(ex)
          .addContext("Query submission to Drillbit failed.")
          .build(logger));
    }

    @Override
    public void success(QueryId queryId, ByteBuf buf) {
      if (!isTerminal.compareAndSet(false, true)) {
        logger.warn("Received multiple responses to run query request.");
        return;
      }

      resultsListener.queryIdArrived(queryId);
      if (logger.isDebugEnabled()) {
        logger.debug("Received QueryId {} successfully. Adding results listener {}.",
          QueryIdHelper.getQueryId(queryId), resultsListener);
      }
      UserResultsListener oldListener =
          queryIdToResultsListenersMap.putIfAbsent(queryId, resultsListener);

      // We need to deal with the situation where we already received results by
      // the time we got the query id back.  In that case, we'll need to
      // transfer the buffering listener over, grabbing a lock against reception
      // of additional results during the transition.
      if (oldListener != null) {
        logger.debug("Unable to place user results listener, buffering listener was already in place.");
        if (oldListener instanceof BufferingResultsListener) {
          boolean all = ((BufferingResultsListener) oldListener).transferTo(this.resultsListener);
          // simply remove the buffering listener if we already have the last response.
          if (all) {
            queryIdToResultsListenersMap.remove(queryId);
          } else {
            boolean replaced = queryIdToResultsListenersMap.replace(queryId, oldListener, resultsListener);
            if (!replaced) {
              throw new IllegalStateException(); // TODO: Say what the problem is!
            }
          }
        } else {
          throw new IllegalStateException("Trying to replace a non-buffering User Results listener.");
        }
      }
    }

    @Override
    public void interrupted(final InterruptedException ex) {
      if (!isTerminal.compareAndSet(false, true)) {
        logger.warn("Received multiple responses to run query request.");
        return;
      }

      // TODO(DRILL-4586)
      resultsListener.submissionFailed(UserException.systemError(ex)
          .addContext("The client had been asked to wait as the Drillbit is potentially being over-utilized." +
              " But the client was interrupted while waiting.")
          .build(logger));
    }
  }

  /**
   * When a {@link UserToBitConnection connection} to a server is successfully created, this handler adds a
   * listener to that connection that listens to connection closure. If the connection is closed, all active
   * {@link UserResultsListener result listeners} are failed.
   */
  private class ChannelClosedHandler implements RpcConnectionHandler<UserToBitConnection> {

    private final RpcConnectionHandler<UserToBitConnection> parentHandler;

    public ChannelClosedHandler(final RpcConnectionHandler<UserToBitConnection> parentHandler) {
      this.parentHandler = parentHandler;
    }

    @Override
    public void connectionSucceeded(final UserToBitConnection connection) {
      connection.getChannel().closeFuture().addListener(
          new GenericFutureListener<Future<? super Void>>() {
            @Override
            public void operationComplete(Future<? super Void> future)
                throws Exception {
              for (final UserResultsListener listener : queryIdToResultsListenersMap.values()) {
                listener.submissionFailed(UserException.connectionError()
                    .message("Connection %s closed unexpectedly. Drillbit down?",
                        connection.getName())
                    .build(logger));
                if (listener instanceof BufferingResultsListener) {
                  // the appropriate listener will be failed by SubmissionListener#failed
                  logger.warn("Buffering listener failed before results were transferred to the actual listener.");
                }
              }
            }
          });
      parentHandler.connectionSucceeded(connection);
    }

    @Override
    public void connectionFailed(FailureType type, Throwable t) {
      parentHandler.connectionFailed(type, t);
    }
  }
}
