/*
 * 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.work.batch;

import java.io.IOException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.drill.common.exceptions.DrillRuntimeException;
import org.apache.drill.exec.ExecConstants;
import org.apache.drill.exec.ops.FragmentContext;
import org.apache.drill.exec.record.RawFragmentBatch;

public abstract class BaseRawBatchBuffer<T> implements RawBatchBuffer {
  private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(BaseRawBatchBuffer.class);

  private enum BufferState {
    INIT,
    STREAMS_FINISHED,
    KILLED
  }

  protected interface BufferQueue<T> {
    void addOomBatch(RawFragmentBatch batch);
    RawFragmentBatch poll() throws IOException, InterruptedException;
    RawFragmentBatch take() throws IOException, InterruptedException;
    RawFragmentBatch poll(long timeout, TimeUnit timeUnit) throws InterruptedException, IOException;
    boolean checkForOutOfMemory();
    int size();
    boolean isEmpty();
    void add(T obj);
  }

  protected BufferQueue<T> bufferQueue;
  private volatile BufferState state = BufferState.INIT;
  protected final int bufferSizePerSocket;
  protected final AtomicBoolean outOfMemory = new AtomicBoolean(false);
  private int streamCounter;
  private final int fragmentCount;
  protected final FragmentContext context;

  public BaseRawBatchBuffer(final FragmentContext context, final int fragmentCount) {
    bufferSizePerSocket = context.getConfig().getInt(ExecConstants.INCOMING_BUFFER_SIZE);

    this.fragmentCount = fragmentCount;
    this.streamCounter = fragmentCount;
    this.context = context;
  }

  /**
   * Return the fragment count from construction time.
   *
   * @return the fragment count
   */
  protected int getFragmentCount() {
    return fragmentCount;
  }

  @Override
  public synchronized void enqueue(final RawFragmentBatch batch) throws IOException {

    // if this fragment is already canceled or failed, we shouldn't need any or more stuff. We do the null check to
    // ensure that tests run.
    if (context != null && !context.getExecutorState().shouldContinue()) {
      this.kill(context);
    }

    if (isTerminated()) {
      if (state == BufferState.KILLED) {
        // do not even enqueue just release and send ack back
        batch.release();
        batch.sendOk();
        return;
      } else {
        throw new IOException("Attempted to enqueue batch after finished");
      }
    }
    enqueueInner(batch);
  }

  /**
   * implementation specific method to enqueue batch
   *
   * @param batch
   * @throws IOException
   */
  protected abstract void enqueueInner(final RawFragmentBatch batch) throws IOException;

//  ## Add assertion that all acks have been sent. TODO
  @Override
  public void close() {
    if (!isTerminated() && context.getExecutorState().shouldContinue()) {
      final String msg = String.format("Cleanup before finished. %d out of %d streams have finished", completedStreams(), fragmentCount);
      throw  new IllegalStateException(msg);
    }

    if (!bufferQueue.isEmpty()) {
      if (context.getExecutorState().shouldContinue()) {
        context.getExecutorState().fail(new IllegalStateException("Batches still in queue during cleanup"));
        logger.error("{} Batches in queue.", bufferQueue.size());
      }
      clearBufferWithBody();
    }
  }

  @Override
  public synchronized void kill(final FragmentContext context) {
    state = BufferState.KILLED;
    clearBufferWithBody();
  }

  /**
   * Helper method to clear buffer with request bodies release also flushes ack queue - in case there are still
   * responses pending
   */
  private void clearBufferWithBody() {
    RawFragmentBatch batch;
    while (!bufferQueue.isEmpty()) {
      batch = null;
      try {
        batch = bufferQueue.poll();
        assertAckSent(batch);
      } catch (IOException e) {
        context.getExecutorState().fail(e);
        continue;
      } catch (InterruptedException e) {
        context.getExecutorState().fail(e);
        // keep the state that the thread is interrupted
        Thread.currentThread().interrupt();
        continue;
      } finally {
        if (batch != null && batch.getBody() != null) {
          batch.getBody().release();
        }
      }
    }
  }

  private void allStreamsFinished() {
    if (state != BufferState.KILLED) {
      state = BufferState.STREAMS_FINISHED;
    }

    if (!bufferQueue.isEmpty()) {
      throw new IllegalStateException("buffer not empty when finished");
    }
  }

  @Override
  public RawFragmentBatch getNext() throws IOException {

    if (outOfMemory.get()) {
      if (bufferQueue.size() < 10) {
        outOfMemory.set(false);
      }
    }

    RawFragmentBatch b;
    try {
      b = bufferQueue.poll();

      // if we didn't get a batch, block on waiting for queue.
      if (b == null && (!isTerminated() || !bufferQueue.isEmpty())) {
        // We shouldn't block infinitely here. There can be a condition such that due to a failure FragmentExecutor
        // state is changed to FAILED and queue is empty. Because of this the minor fragment main thread will block
        // here waiting for next batch to arrive. Meanwhile when next batch arrived and was enqueued it sees
        // FragmentExecutor failure state and doesn't enqueue the batch and cleans up the buffer queue. Hence this
        // thread will stuck forever. So we pool for 5 seconds until we get a batch or FragmentExecutor state is in
        // error condition.
        while (b == null) {
          b = bufferQueue.poll(5, TimeUnit.SECONDS);
          if (!context.getExecutorState().shouldContinue()) {
            kill(context);
            if (b != null) {
              assertAckSent(b);
              if (b.getBody() != null) {
                b.getBody().release();
              }
              b = null;
            }
          } // else b will be assigned a valid batch
        }
      }
    } catch (final InterruptedException e) {

      // We expect that the interrupt means the fragment is canceled or failed, so we should kill this buffer
      if (!context.getExecutorState().shouldContinue()) {
        kill(context);
      } else {
        throw new DrillRuntimeException("Interrupted but context.shouldContinue() is true", e);
      }
      // Preserve evidence that the interruption occurred so that code higher up on the call stack can learn of the
      // interruption and respond to it if it wants to.
      Thread.currentThread().interrupt();

      return null;
    }

    if (context.getAllocator().isOverLimit()) {
      outOfMemory.set(true);
    }

    if (b != null) {

      upkeep(b);

      if (b.getHeader().getIsLastBatch()) {
        logger.debug("Got last batch from {}:{}", b.getHeader().getSendingMajorFragmentId(), b.getHeader()
            .getSendingMinorFragmentId());
        final int remainingStreams = decrementStreamCounter();
        if (remainingStreams == 0) {
          logger.debug("Streams finished");
          allStreamsFinished();
        }
      }
    } else {
      if (!bufferQueue.isEmpty()) {
        throw new IllegalStateException("Returning null when there are batches left in queue");
      }
      if (!isTerminated()) {
        throw new IllegalStateException("Returning null when not finished");
      }
    }

    assertAckSent(b);
    return b;

  }

  private void assertAckSent(RawFragmentBatch batch) {
    assert batch == null || batch.isAckSent() : "Ack not sent for batch";
  }

  private int decrementStreamCounter() {
    streamCounter--;
    return streamCounter;
  }

  private int completedStreams() {
    return fragmentCount - streamCounter;
  }

  /**
   * Handle miscellaneous tasks after batch retrieval
   */
  protected abstract void upkeep(RawFragmentBatch batch);

  protected boolean isTerminated() {
    return (state == BufferState.KILLED || state == BufferState.STREAMS_FINISHED);
  }
}
