/*
 * Copyright 2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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 com.amazonaws.services.sqs.util;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.amazonaws.AmazonClientException;
import com.amazonaws.services.sqs.AmazonSQS;
import com.amazonaws.services.sqs.model.ChangeMessageVisibilityBatchRequest;
import com.amazonaws.services.sqs.model.ChangeMessageVisibilityBatchRequestEntry;
import com.amazonaws.services.sqs.model.GetQueueAttributesRequest;
import com.amazonaws.services.sqs.model.Message;
import com.amazonaws.services.sqs.model.QueueAttributeName;
import com.amazonaws.services.sqs.model.ReceiveMessageRequest;
import com.amazonaws.services.sqs.model.ReceiveMessageResult;

/**
 * The ReceiveQueueBuffer class is responsible for dequeueing of messages from a single SQS queue.
 * <p>
 * Synchronization strategy: 
 * - Threads must hold the monitor of the "futures" list to modify the list 
 * - Threads must hold the monitor of the "finishedTasks" list to modify the list 
 * - If you need to lock both futures and finishedTasks, lock futures first and finishedTasks second
 */
public class ReceiveQueueBuffer {

    private static final Log LOG = LogFactory.getLog(ReceiveQueueBuffer.class);

    private static final Queue<Message> EMPTY_DEQUE = new ArrayDeque<>();

    private final ScheduledExecutorService waitTimer;

    private final AmazonSQS sqsClient;

    /**
     * This buffer's queue visibility timeout. Used to detect expired message that should not be
     * returned by the {@code receiveMessage} call.
     */
    private final long defaultVisibilityTimeoutNanos;

    /**
     * This buffer's queue default receive wait time. Used to set the timeout on futures so they complete
     * according to when the synchronous call to SQS would have.
     */
    private final long defaultWaitTimeNanos;

    /** shutdown buffer does not retrieve any more messages from sqs */
    volatile boolean shutDown = false;

    // TODO-RS: We could use a SynchronousQueue to manange handing messages
    // from sources to futures, which would simplify a lot of logic here.
    // We just have to be willing to create threads to block on Queue#poll
    // and Queue#offer.
    
    /** message delivery futures we gave out */
    private final Set<ReceiveMessageFuture> futures = new LinkedHashSet<>();

    /** finished batches are stored in this list. */
    protected LinkedList<ReceiveMessageBatchTask> finishedTasks = new LinkedList<>();

    public ReceiveQueueBuffer(AmazonSQS sqsClient, ScheduledExecutorService waitTimer, String queueUrl) {
        this.sqsClient = sqsClient;
        this.waitTimer = waitTimer;

        if (queueUrl.endsWith(".fifo")) {
            throw new IllegalArgumentException("FIFO queues are not yet supported: " + queueUrl);
        }

        GetQueueAttributesRequest getQueueAttributesRequest = new GetQueueAttributesRequest().withQueueUrl(queueUrl)
                .withAttributeNames(QueueAttributeName.ReceiveMessageWaitTimeSeconds.toString(),
                        QueueAttributeName.VisibilityTimeout.toString());
        // TODO-RS: UserAgent?
        Map<String, String> attributes = sqsClient.getQueueAttributes(getQueueAttributesRequest).getAttributes();
        long visibilityTimeoutSeconds = Long.parseLong(attributes.get("VisibilityTimeout"));
        defaultVisibilityTimeoutNanos = TimeUnit.SECONDS.toNanos(visibilityTimeoutSeconds);
        long waitTimeSeconds = Long.parseLong(attributes.get("ReceiveMessageWaitTimeSeconds"));
        defaultWaitTimeNanos = TimeUnit.SECONDS.toNanos(waitTimeSeconds);
    }

    public ReceiveQueueBuffer(ReceiveQueueBuffer other) {
        this.sqsClient = other.sqsClient;
        this.waitTimer = other.waitTimer;
        this.defaultWaitTimeNanos = other.defaultWaitTimeNanos;
        this.defaultVisibilityTimeoutNanos = other.defaultVisibilityTimeoutNanos;
    }

    /**
     * Prevents adding new futures and nacks all inflight messages.
     */
    public void shutdown() {
        shutDown = true;
        clear();
    }

    /**
     * Submits the request for retrieval of messages from the queue and returns a future that will
     * be signalled when the request is satisfied. The future may already be signalled by the time
     * it is returned.
     * 
     * @return never null
     */
    public Future<ReceiveMessageResult> receiveMessageAsync(ReceiveMessageRequest rq) {
        if (shutDown) {
            throw new AmazonClientException("The buffer has been shut down.");
        }

        // issue the future...
        int numMessages = 10;
        if (rq.getMaxNumberOfMessages() != null) {
            numMessages = rq.getMaxNumberOfMessages();
        }
        long waitTimeNanos;
        if (rq.getWaitTimeSeconds() != null) {
            waitTimeNanos = TimeUnit.SECONDS.toNanos(rq.getWaitTimeSeconds());
        } else {
            waitTimeNanos = defaultWaitTimeNanos;
        }
        ReceiveMessageFuture toReturn = issueFuture(numMessages, waitTimeNanos);

        // attempt to satisfy it right away...
        satisfyFuturesFromBuffer();

        toReturn.startWaitTimer();

        return toReturn;
    }

    /**
     * Creates and returns a new future object. Sleeps if the list of already-issued but as yet
     * unsatisfied futures is over a throttle limit.
     * 
     * @return never null
     */
    private ReceiveMessageFuture issueFuture(int size, Long waitTimeNanos) {
        synchronized (futures) {
            ReceiveMessageFuture theFuture = new ReceiveMessageFuture(size, waitTimeNanos);
            futures.add(theFuture);
            return theFuture;
        }
    }

    /**
     * Attempts to satisfy some or all of the already-issued futures from the local buffer. If the
     * buffer is empty or there are no futures, this method won't do anything.
     */
    protected void satisfyFuturesFromBuffer() {
        synchronized (futures) {
            synchronized (finishedTasks) {
                pruneExpiredFutures();

                // attempt to satisfy futures until we run out of either futures or
                // finished tasks
                Iterator<ReceiveMessageFuture> futureIter = futures.iterator(); 
                while (futureIter.hasNext() && (!finishedTasks.isEmpty())) {
                    // Remove any expired tasks before attempting to fufill the future
                    pruneExpiredTasks();
                    // Fufill the future from a non expired task if there is one. There is still a
                    // slight chance that the first task could have expired between the time we
                    // pruned and the time we fufill the future
                    if (!finishedTasks.isEmpty()) {
                        if (fulfillFuture(futureIter.next())) {
                            futureIter.remove();
                        } else {
                            // We couldn't produce enough messages, so break the loop and return.
                            // We may not hit the while loop termination condition because we might
                            // have inflight FIFO messages that are blocking some of the messages.
                            return;
                        }
                    }
                }
            }
        }
    }

    /**
     * Fills the future with whatever results were received by the full batch currently at the head
     * of the completed batch queue. Those results may be retrieved messages, or an exception. This
     * method assumes that you are holding the finished tasks lock locks when invoking it. violate
     * this assumption at your own peril
     */
    private boolean fulfillFuture(ReceiveMessageFuture future) {
        for (Iterator<ReceiveMessageBatchTask> iter = finishedTasks.iterator(); iter.hasNext();) {
            ReceiveMessageBatchTask task = iter.next();
            Exception exception = task.getException();
            if (exception != null) {
                // Only fulfill a future with an exception if it hasn't collected any messages yet!
                // Otherwise messages will be lost.
                if (future.messages.isEmpty()) {
                    iter.remove();
                    future.completeExceptionally(exception);
                    return true;
                }
            } else {
                task.populateResult(future);
                if (task.isEmpty()) {
                    task.clear();
                    iter.remove();
                }
                if (future.isFull()) {
                    return true;
                }
            }
        }

        if (!future.messages.isEmpty() || future.isExpired()) {
            future.complete();
            return true;
        } else {
            return false;
        }
    }

    /**
     * Prune any expired tasks that do not have an exception associated with them. This method
     * assumes that you are holding the finishedTasks lock when invoking it
     */
    private void pruneExpiredTasks() {
        int numberExpiredTasksPruned = pruneHeadTasks(t -> t.isExpired() && t.getException() == null);

        // If we pruned any tasks because they are expired we also want to prune any empty tasks
        // afterwards so we have a chance to receive those expired messages again.
        if (numberExpiredTasksPruned > 0) {
            pruneHeadTasks(t -> t.isEmpty() && t.getException() == null);
        }
    }

    /**
     * Prune all tasks at the beginning of the finishedTasks list that meet the given condition.
     * Once a task is found that does not meet the given condition the pruning stops. This method
     * assumes that you are holding the finishedTasks lock when invoking it.
     * 
     * @param pruneCondition
     *            Condition on whether a task is eligible to be pruned
     * @return Number of total tasks pruned from finishedTasks
     */
    private int pruneHeadTasks(Predicate<ReceiveMessageBatchTask> pruneCondition) {
        int numberPruned = 0;
        while (!finishedTasks.isEmpty()) {
            ReceiveMessageBatchTask task = finishedTasks.getFirst();
            if (pruneCondition.test(task)) {
                task.clear();
                finishedTasks.removeFirst();
                numberPruned++;
            } else {
                break;
            }
        }
        return numberPruned;
    }

    private void pruneExpiredFutures() {
        for (Iterator<ReceiveMessageFuture> iterator = futures.iterator(); iterator.hasNext();) {
            ReceiveMessageFuture future = iterator.next();
            if (future.isExpired()) {
                future.complete();
                iterator.remove();
            }
        }
    }

    public void deliverMessages(List<Message> messages, String sourceQueueUrl, Integer visibilityTimeoutNanosOverride) {
        submit(Runnable::run, () -> messages, sourceQueueUrl, visibilityTimeoutNanosOverride);
    }

    public void deliverException(Exception exception) {
        submit(Runnable::run, () -> {throw exception;}, null, 0);
    }

    public void submit(Executor executor, Callable<List<Message>> callable, String queueUrl, Integer visibilityTimeoutSecondsOverride) {
        long visibilityTimeoutNanos;
        if (visibilityTimeoutSecondsOverride == null) {
            visibilityTimeoutNanos = defaultVisibilityTimeoutNanos;
        } else {
            visibilityTimeoutNanos = TimeUnit.SECONDS.toNanos(visibilityTimeoutSecondsOverride);
        }
        ReceiveMessageBatchTask task = new ReceiveMessageBatchTask(callable, queueUrl, visibilityTimeoutNanos);
        executor.execute(task);
    }

    /**
     * This method is called by the batches after they have finished retrieving the messages.
     */
    void reportBatchFinished(ReceiveMessageBatchTask batch) {
        if (shutDown) {
            batch.clear();
            return;
        }

        synchronized (finishedTasks) {
            finishedTasks.addLast(batch);
        }
        satisfyFuturesFromBuffer();
    }

    /**
     * Clears and nacks any pre-fetched messages in this buffer.
     */
    public void clear() {
        boolean done = false;
        while (!done) {
            ReceiveMessageBatchTask currentBatch;
            synchronized (finishedTasks) {
                currentBatch = finishedTasks.poll();
            }

            if (currentBatch != null) {
                currentBatch.clear();
            } else {
                // ran out of batches to clear
                done = true;
            }
        }
    }

    protected class ReceiveMessageFuture extends CompletableFuture<ReceiveMessageResult> {
        /* how many messages did the request ask for */
        private final int requestedSize;

        private final List<Message> messages;

        private final Long waitTimeDeadlineNano;
        private Future<?> timeoutFuture;

        ReceiveMessageFuture(int paramSize, Long waitTimeNanos) {
            requestedSize = paramSize;
            messages = new ArrayList<>(requestedSize);

            if (waitTimeNanos != null) {
                this.waitTimeDeadlineNano = System.nanoTime() + waitTimeNanos;
            } else {
                this.waitTimeDeadlineNano = null;
            }

            whenComplete((result, exception) -> cancelTimeout());
        }

        public synchronized void startWaitTimer() {
            if (waitTimeDeadlineNano == null || isDone() || timeoutFuture != null) {
                return;
            }

            long remaining = waitTimeDeadlineNano - System.nanoTime();
            if (remaining < 0) {
                timeout();
            } else {
                timeoutFuture = waitTimer.schedule(this::timeout, remaining, TimeUnit.NANOSECONDS);
            }
        }

        public boolean isExpired() {
            return waitTimeDeadlineNano != null && System.nanoTime() > waitTimeDeadlineNano;
        }

        public synchronized void addMessage(Message message) {
            if (isDone()) {
                throw new IllegalStateException("Future is already completed");
            }
            if (isFull()) {
                throw new IllegalStateException("Future already has enough messages");
            }
            messages.add(message);
            if (isFull()) {
                complete();
            }
        }

        public boolean isFull() {
            return messages.size() >= requestedSize;
        }

        public synchronized void timeout() {
            if (!isDone()) {
                complete();
            }
        }

        public synchronized void complete() {
            if (!isDone()) {
                ReceiveMessageResult result = new ReceiveMessageResult();
                result.setMessages(messages);
                complete(result);
            }
        }

        private synchronized void cancelTimeout() {
            if (timeoutFuture != null) {
                timeoutFuture.cancel(false);
            }
        }
    }

    /**
     * Task to receive messages from SQS.
     * <p>
     * The batch task is constructed {@code !open} until the {@code ReceiveMessage} completes. At
     * that point, the batch opens and its messages (if any) become available to read.
     */
    protected class ReceiveMessageBatchTask extends FutureTask<List<Message>> {
        private Exception exception = null;
        protected Queue<Message> messages;
        private final String sourceQueueUrl;
        private final long visibilityTimeoutNanos;
        private long visibilityDeadlineNano;
        private Future<?> expiryFuture;

        /**
         * Constructs a receive task waiting the specified time before calling SQS.
         * 
         * @param waitTimeMs
         *            the time to wait before calling SQS
         */
        ReceiveMessageBatchTask(Callable<List<Message>> callable, String sourceQueueUrl, long visibilityTimeoutNanos) {
            super(callable);
            this.sourceQueueUrl = sourceQueueUrl;
            this.visibilityTimeoutNanos = visibilityTimeoutNanos;
            messages = EMPTY_DEQUE;
        }

        synchronized boolean isEmpty() {
            if (!isDone()) {
                throw new IllegalStateException();
            }
            return messages.isEmpty();
        }

        /**
         * @return the exception that was thrown during execution, or null if there was no exception
         */
        synchronized Exception getException() {
            if (!isDone()) {
                throw new IllegalStateException();
            }
            return exception;
        }


        synchronized void populateResult(ReceiveMessageFuture future) {
            if (!isDone()) {
                throw new IllegalStateException("batch is not open");
            }

            // our messages expired.
            if (isExpired()) {
                clear();
                return;
            }

            if (messages.isEmpty()) {
                return;
            }

            for (Iterator<Message> iter = messages.iterator(); iter.hasNext() && !future.isFull();) {
                Message message = iter.next();
                iter.remove();
                future.addMessage(message);
            }
        }

        public synchronized void startExpiryTimer() {
            if (isExpired() || expiryFuture != null) {
                return;
            }

            long remaining = visibilityDeadlineNano - System.nanoTime();
            if (remaining < 0) {
                clear();
            } else {
                expiryFuture = waitTimer.schedule(this::clear, remaining, TimeUnit.NANOSECONDS);
            }
        }

        boolean isExpired() {
            return System.nanoTime() > visibilityDeadlineNano;
        }

        /**
         * Nacks and clears all messages remaining in the batch.
         */
        synchronized void clear() {
            if (!isDone()) {
                throw new IllegalStateException("batch is not open");
            }

            if (expiryFuture != null) {
                expiryFuture.cancel(false);
            }

            if (!isExpired()) {
                nackMessages(messages);
            }
            messages.clear();
        }

        protected void nackMessages(Collection<Message> messages) {
            if (messages.isEmpty()) {
                return;
            }

            ChangeMessageVisibilityBatchRequest batchRequest = new ChangeMessageVisibilityBatchRequest().withQueueUrl(sourceQueueUrl);
            // TODO-RS: UserAgent?

            List<ChangeMessageVisibilityBatchRequestEntry> entries = 
                    new ArrayList<ChangeMessageVisibilityBatchRequestEntry>(messages.size());

            int i = 0;
            for (Message m : messages) {

                entries.add(new ChangeMessageVisibilityBatchRequestEntry().withId(Integer.toString(i))
                        .withReceiptHandle(m.getReceiptHandle()).withVisibilityTimeout(0));
                ++i;
            }

            try {
                batchRequest.setEntries(entries);
                sqsClient.changeMessageVisibilityBatch(batchRequest);
            } catch (AmazonClientException e) {
                // Log and ignore.
                LOG.warn("ReceiveMessageBatchTask: changeMessageVisibility failed " + e);
            }
        }

        @Override
        protected void set(List<Message> v) {
            messages = new ArrayDeque<Message>(v);
            visibilityDeadlineNano = System.nanoTime() + visibilityTimeoutNanos;
            super.set(v);
        }

        @Override
        protected void done() {
            reportBatchFinished(this);
        }
    }
}