package com.amazonaws.services.sqs;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import com.amazonaws.services.sqs.model.QueueNameExistsException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.amazonaws.services.sqs.model.ChangeMessageVisibilityBatchRequest;
import com.amazonaws.services.sqs.model.ChangeMessageVisibilityBatchResult;
import com.amazonaws.services.sqs.model.ChangeMessageVisibilityRequest;
import com.amazonaws.services.sqs.model.ChangeMessageVisibilityResult;
import com.amazonaws.services.sqs.model.CreateQueueRequest;
import com.amazonaws.services.sqs.model.CreateQueueResult;
import com.amazonaws.services.sqs.model.DeleteMessageBatchRequest;
import com.amazonaws.services.sqs.model.DeleteMessageBatchResult;
import com.amazonaws.services.sqs.model.DeleteMessageRequest;
import com.amazonaws.services.sqs.model.DeleteMessageResult;
import com.amazonaws.services.sqs.model.DeleteQueueRequest;
import com.amazonaws.services.sqs.model.DeleteQueueResult;
import com.amazonaws.services.sqs.model.GetQueueAttributesRequest;
import com.amazonaws.services.sqs.model.GetQueueAttributesResult;
import com.amazonaws.services.sqs.model.Message;
import com.amazonaws.services.sqs.model.QueueAttributeName;
import com.amazonaws.services.sqs.model.QueueDeletedRecentlyException;
import com.amazonaws.services.sqs.model.QueueDoesNotExistException;
import com.amazonaws.services.sqs.model.ReceiptHandleIsInvalidException;
import com.amazonaws.services.sqs.model.ReceiveMessageRequest;
import com.amazonaws.services.sqs.model.ReceiveMessageResult;
import com.amazonaws.services.sqs.model.SendMessageBatchRequest;
import com.amazonaws.services.sqs.model.SendMessageBatchResult;
import com.amazonaws.services.sqs.model.SendMessageRequest;
import com.amazonaws.services.sqs.model.SendMessageResult;
import com.amazonaws.services.sqs.model.SetQueueAttributesRequest;
import com.amazonaws.services.sqs.model.SetQueueAttributesResult;
import com.amazonaws.services.sqs.util.AbstractAmazonSQSClientWrapper;
import com.amazonaws.services.sqs.util.DaemonThreadFactory;
import com.amazonaws.services.sqs.util.ReceiveQueueBuffer;
import com.amazonaws.services.sqs.util.SQSQueueUtils;

/**
 * An AmazonSQS wrapper that adds automatically deletes unused queues after a configurable
 * period of inactivity.
 * <p>
 * This client monitors all queues created with the "IdleQueueRetentionPeriodSeconds" queue
 * attribute. Such queues must have names that begin with the prefix provided in the constructor,
 * as this client uses {@link #listQueues(ListQueuesRequest)} to sweep them.
 * <p>
 * This client uses a heartbeating mechanism based on queue tags. Making API calls to queues
 * through this client causes tags on those queues to be refreshed every 5 seconds (by default,
 * heartbeating mechanism is configurable). If the process
 * using a client shuts down uncleanly, other client instances using the same queue prefix will
 * detect that its queue(s) are idle and delete them.
 */
class AmazonSQSIdleQueueDeletingClient extends AbstractAmazonSQSClientWrapper {

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

    // Publicly visible constants
    public static final String IDLE_QUEUE_RETENTION_PERIOD = "IdleQueueRetentionPeriodSeconds";
    public static final long MINIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS = 1;
    public static final long MAXIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS = TimeUnit.MINUTES.toSeconds(5);
    public static final long HEARTBEAT_INTERVAL_SECONDS_DEFAULT = 5;
    public static final long HEARTBEAT_INTERVAL_SECONDS_MIN_VALUE = 1;

    static final String IDLE_QUEUE_RETENTION_PERIOD_TAG = "__IdleQueueRetentionPeriodSeconds";

    private static final String SWEEPING_QUEUE_DLQ_SUFFIX = "_DLQ";
    private static final long DLQ_MESSAGE_RETENTION_PERIOD = TimeUnit.DAYS.toSeconds(14);

    static final String LAST_HEARTBEAT_TIMESTAMP_TAG = "__AmazonSQSIdleQueueDeletingClient.LastHeartbeatTimestamp";

    private class QueueMetadata {
        private final String name;
        private Map<String, String> attributes;
        private Long heartbeatTimestamp;
        private Future<?> heartbeater;
        private ReceiveQueueBuffer buffer;

        private QueueMetadata(String name, String queueUrl, Map<String, String> attributes) {
            this.name = name;
            this.attributes = attributes;
            this.buffer = new ReceiveQueueBuffer(AmazonSQSIdleQueueDeletingClient.this, executor, queueUrl);
        }
    }

    private static ScheduledExecutorService executor = Executors.newScheduledThreadPool(1,
            new DaemonThreadFactory("AmazonSQSIdleQueueDeletingClient"));

    private final String queueNamePrefix;
    private final long heartbeatIntervalSeconds;

    private final Map<String, QueueMetadata> queues = new ConcurrentHashMap<>();

    private IdleQueueSweeper idleQueueSweeper;
    private String deadLetterQueueUrl;

    public AmazonSQSIdleQueueDeletingClient(AmazonSQS sqs, String queueNamePrefix, Long heartbeatIntervalSeconds) {
        super(sqs);
        
        if (queueNamePrefix.isEmpty()) {
            throw new IllegalArgumentException("Queue name prefix must be non-empty");
        }

        this.queueNamePrefix = queueNamePrefix;

        if (heartbeatIntervalSeconds != null) {
            if (heartbeatIntervalSeconds < HEARTBEAT_INTERVAL_SECONDS_MIN_VALUE) {
                throw new IllegalArgumentException("Heartbeat Interval Seconds: " +
                        heartbeatIntervalSeconds +
                        " must be equal to or bigger than " +
                        HEARTBEAT_INTERVAL_SECONDS_MIN_VALUE);
            }
            this.heartbeatIntervalSeconds = heartbeatIntervalSeconds;
        } else {
            this.heartbeatIntervalSeconds = HEARTBEAT_INTERVAL_SECONDS_DEFAULT;
        }
    }

    public AmazonSQSIdleQueueDeletingClient(AmazonSQS sqs, String queueNamePrefix) {
        this(sqs, queueNamePrefix, null);
    }

    protected synchronized void startSweeper(AmazonSQSRequester requester, AmazonSQSResponder responder,
                                             long period, TimeUnit unit,
                                             Consumer<Exception> exceptionHandler) {
        if (this.idleQueueSweeper != null) {
            throw new IllegalStateException("Idle queue sweeper is already started!");
        }

        // Create the DLQ first so the primary queue can reference it
        // Note that SSE doesn't have to be enabled on this queue since the messages
        // will already be encrypted in the primary queue, and dead-lettering doesn't affect that.
        // The messages will still be receivable from the DLQ regardless.
        Map<String, String> dlqAttributes = new HashMap<>();
        dlqAttributes.put(QueueAttributeName.MessageRetentionPeriod.name(), Long.toString(DLQ_MESSAGE_RETENTION_PERIOD));
        deadLetterQueueUrl = createOrUpdateQueue(queueNamePrefix + SWEEPING_QUEUE_DLQ_SUFFIX, dlqAttributes);
        String deadLetterQueueArn = super.getQueueAttributes(deadLetterQueueUrl,
                Collections.singletonList(QueueAttributeName.QueueArn.name()))
                        .getAttributes().get(QueueAttributeName.QueueArn.name());

        Map<String, String> queueAttributes = new HashMap<>();
        // Server-side encryption is important here because we're putting
        // queue URLs into this queue.
        queueAttributes.put(QueueAttributeName.KmsMasterKeyId.toString(), "alias/aws/sqs");
        queueAttributes.put(QueueAttributeName.RedrivePolicy.toString(),
                "{\"maxReceiveCount\":\"5\", \"deadLetterTargetArn\":\"" + deadLetterQueueArn + "\"}");
        // TODO-RS: Configure a tight MessageRetentionPeriod! Put explicit thought
        // into other configuration as well.
        String sweepingQueueUrl = createOrUpdateQueue(queueNamePrefix, queueAttributes);

        this.idleQueueSweeper = new IdleQueueSweeper(requester, responder, sweepingQueueUrl, queueNamePrefix,
                period, unit, exceptionHandler);
    }

    private String createOrUpdateQueue(String name, Map<String, String> attributes) {
        try {
            return super.createQueue(new CreateQueueRequest()
                    .withQueueName(name)
                    .withAttributes(attributes)).getQueueUrl();
        } catch (QueueNameExistsException e) {
            String queueUrl = super.getQueueUrl(name).getQueueUrl();
            super.setQueueAttributes(new SetQueueAttributesRequest()
                    .withQueueUrl(queueUrl)
                    .withAttributes(attributes));
            return queueUrl;
        }
    }

    @Override
    public CreateQueueResult createQueue(CreateQueueRequest request) {
        Map<String, String> attributes = new HashMap<>(request.getAttributes());
        Optional<Long> retentionPeriod = getRetentionPeriod(attributes);
        if (!retentionPeriod.isPresent()) {
            return super.createQueue(request);
        }

        String queueName = request.getQueueName();
        if (!queueName.startsWith(queueNamePrefix)) {
            throw new IllegalArgumentException();
        }

        CreateQueueRequest superRequest = request.clone()
                .withQueueName(queueName)
                .withAttributes(attributes);

        CreateQueueResult result = super.createQueue(superRequest);
        String queueUrl = result.getQueueUrl();

        String retentionPeriodString = retentionPeriod.get().toString();
        amazonSqsToBeExtended.tagQueue(queueUrl,
                Collections.singletonMap(IDLE_QUEUE_RETENTION_PERIOD_TAG, retentionPeriodString));

        // TODO-RS: Filter more carefully to all attributes valid for createQueue 
        List<String> attributeNames = Arrays.asList(QueueAttributeName.ReceiveMessageWaitTimeSeconds.toString(),
                                                    QueueAttributeName.VisibilityTimeout.toString());
        Map<String, String> createdAttributes = amazonSqsToBeExtended.getQueueAttributes(queueUrl, attributeNames).getAttributes();
        createdAttributes.put(IDLE_QUEUE_RETENTION_PERIOD, retentionPeriodString);

        QueueMetadata metadata = new QueueMetadata(queueName, queueUrl, createdAttributes);
        queues.put(queueUrl, metadata);

        metadata.heartbeater = executor.scheduleAtFixedRate(() -> heartbeatToQueue(queueUrl), 
                0, heartbeatIntervalSeconds, TimeUnit.SECONDS);

        return result;
    }

    static Optional<Long> getRetentionPeriod(Map<String, String> queueAttributes) {
        return Optional.ofNullable(queueAttributes.remove(IDLE_QUEUE_RETENTION_PERIOD))
                       .map(Long::parseLong)
                       .map(AmazonSQSIdleQueueDeletingClient::checkQueueRetentionPeriodBounds);
    }
    
    static long checkQueueRetentionPeriodBounds(long retentionPeriod) {
        if (retentionPeriod < MINIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS ||
                retentionPeriod > MAXIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS) {
            throw new IllegalArgumentException("The " + IDLE_QUEUE_RETENTION_PERIOD + 
                    " attribute must be between " + MINIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS +
                    " and " + MAXIMUM_IDLE_QUEUE_RETENTION_PERIOD_SECONDS + " seconds");
        }
        return retentionPeriod;
    }
    
    @Override
    public GetQueueAttributesResult getQueueAttributes(GetQueueAttributesRequest request) {
        QueueMetadata metadata = queues.get(request.getQueueUrl());
        if (metadata != null) {
            Map<String, String> filteredAttributes = new HashMap<>(metadata.attributes);
            filteredAttributes.keySet().retainAll(request.getAttributeNames());
            return new GetQueueAttributesResult().withAttributes(filteredAttributes);
        }

        return super.getQueueAttributes(request);
    }

    @Override
    public SetQueueAttributesResult setQueueAttributes(SetQueueAttributesRequest request) {
        SetQueueAttributesResult result = super.setQueueAttributes(request);

        QueueMetadata queue = queues.get(request.getQueueUrl());
        if (queue != null) {
            queue.attributes.putAll(request.getAttributes());
        }

        return result;
    }

    @Override
    public DeleteQueueResult deleteQueue(DeleteQueueRequest request) {
        DeleteQueueResult result = super.deleteQueue(request);
        queueDeleted(request.getQueueUrl());
        return result;
    }

    private void queueDeleted(String queueUrl) {
        QueueMetadata metadata = queues.remove(queueUrl);
        if (metadata != null && metadata.heartbeater != null) {
            metadata.heartbeater.cancel(true);
            metadata.buffer.shutdown();
        }

        String alternateQueueUrl = alternateQueueName(queueUrl);
        QueueMetadata alternateMetadata = queues.remove(alternateQueueUrl);
        if (alternateMetadata != null) {
            super.deleteQueue(alternateQueueUrl);
            alternateMetadata.heartbeater.cancel(true);
            alternateMetadata.buffer.shutdown();
        }
    }

    private void heartbeatToQueue(String queueUrl) {
        // TODO-RS: Clock drift? Shouldn't realistically be a problem as long as the idleness threshold is long enough.
        long currentTimestamp = System.currentTimeMillis();
        try {
            amazonSqsToBeExtended.tagQueue(queueUrl, 
                    Collections.singletonMap(LAST_HEARTBEAT_TIMESTAMP_TAG, String.valueOf(currentTimestamp)));
        } catch (QueueDoesNotExistException e) {
            recreateQueue(queueUrl);
            // TODO-RS: Retry right away
        }
        queues.get(queueUrl).heartbeatTimestamp = currentTimestamp;
    }

    private void heartbeatToQueueIfNecessary(String queueUrl) {
        QueueMetadata queueMetadata = queues.get(queueUrl);
        if (queueMetadata != null) {
            Long lastHeartbeat = queueMetadata.heartbeatTimestamp;
            if (lastHeartbeat == null || (System.currentTimeMillis() - lastHeartbeat) > 2 * heartbeatIntervalSeconds) {
                return;
            }
            heartbeatToQueue(queueUrl);
        }
    }

    static Long getLongTag(Map<String, String> queueTags, String key) {
        String tag = queueTags.get(key);
        return tag == null ? null : Long.parseLong(tag);
    }

    private String recreateQueue(String queueUrl) {
        // TODO-RS: CW metrics
        QueueMetadata queue = queues.get(queueUrl);
        if (queue != null) {
            LOG.warn("Queue " + queueUrl + " was deleted while it was still in use! Attempting to recreate...");
            try {
                createQueue(new CreateQueueRequest().withQueueName(queue.name)
                        .withAttributes(queue.attributes));
                LOG.info("Queue " + queueUrl + " successfully recreated.");
                return queueUrl;
            } catch (QueueDeletedRecentlyException e) {
                // Ignore, will retry later
                LOG.warn("Queue " + queueUrl + " was recently deleted, cannot create it yet.");
            }
        }

        String alternateQueueUrl = alternateQueueName(queueUrl);
        QueueMetadata metadata = queues.get(alternateQueueUrl);
        if (metadata == null && queue != null) {
            LOG.info("Attempting to create failover queue: " + alternateQueueUrl);
            try {
                createQueue(new CreateQueueRequest().withQueueName(alternateQueueName(queue.name))
                        .withAttributes(queue.attributes));
                LOG.info("Failover queue " + alternateQueueUrl + " successfully created.");
            } catch (QueueDeletedRecentlyException e) {
                // Ignore, will retry later
                LOG.warn("Failover queue " + alternateQueueUrl + " was recently deleted, cannot create it yet.");
            }	
        }
        return alternateQueueUrl;
    }

    static String alternateQueueName(String prefix) {
        return prefix + "-Failover";
    }

    @Override
    public SendMessageResult sendMessage(SendMessageRequest request) {
        try {
            heartbeatToQueueIfNecessary(request.getQueueUrl());
            return super.sendMessage(request);
        } catch (QueueDoesNotExistException e) {
            request.setQueueUrl(recreateQueue(request.getQueueUrl()));
            return super.sendMessage(request);
        }
    }

    @Override
    public SendMessageBatchResult sendMessageBatch(SendMessageBatchRequest request) {
        try {
            heartbeatToQueueIfNecessary(request.getQueueUrl());
            return super.sendMessageBatch(request);
        } catch (QueueDoesNotExistException e) {
            request.setQueueUrl(recreateQueue(request.getQueueUrl()));
            return super.sendMessageBatch(request);
        }
    }

    @Override
    public ReceiveMessageResult receiveMessage(ReceiveMessageRequest request) {
        // Here we have to also fetch from the backup queue if we created it.
        String queueUrl = request.getQueueUrl();
        String alternateQueueUrl = alternateQueueName(queueUrl);
        QueueMetadata alternateMetadata = queues.get(alternateQueueUrl);
        if (alternateMetadata != null) {
            ReceiveQueueBuffer buffer = alternateMetadata.buffer;
            ReceiveMessageRequest alternateRequest = request.clone().withQueueUrl(alternateQueueUrl);
            buffer.submit(executor, () -> receiveIgnoringNonExistantQueue(request),
                    queueUrl, request.getVisibilityTimeout());
            buffer.submit(executor, () -> receiveIgnoringNonExistantQueue(alternateRequest),
                    queueUrl, request.getVisibilityTimeout());
            Future<ReceiveMessageResult> receiveFuture = buffer.receiveMessageAsync(request);
            return SQSQueueUtils.waitForFuture(receiveFuture);
        } else {
            try {
                heartbeatToQueueIfNecessary(queueUrl);
                return super.receiveMessage(request);
            } catch (QueueDoesNotExistException e) {
                request.setQueueUrl(recreateQueue(queueUrl));
                return super.receiveMessage(request);
            }
        }
    }

    private List<Message> receiveIgnoringNonExistantQueue(ReceiveMessageRequest request) {
        try {
            heartbeatToQueueIfNecessary(request.getQueueUrl());
            return amazonSqsToBeExtended.receiveMessage(request).getMessages();
        } catch (QueueDoesNotExistException e) {
            return Collections.emptyList();
        }
    }

    @Override
    public ChangeMessageVisibilityResult changeMessageVisibility(ChangeMessageVisibilityRequest request) {
        // If the queue is deleted, there's no way to change the message visibility.
        try {
            return super.changeMessageVisibility(request);
        } catch (QueueDoesNotExistException|ReceiptHandleIsInvalidException e) {
            // Try on the alternate queue
            return super.changeMessageVisibility(
                    request.clone().withQueueUrl(alternateQueueName(request.getQueueUrl())));
        }
    }

    @Override
    public ChangeMessageVisibilityBatchResult changeMessageVisibilityBatch(ChangeMessageVisibilityBatchRequest request) {
        // If the queue is deleted, there's no way to change the message visibility.
        try {
            return super.changeMessageVisibilityBatch(request);
        } catch (QueueDoesNotExistException|ReceiptHandleIsInvalidException e) {
            // Try on the alternate queue
            ChangeMessageVisibilityBatchRequest alternateRequest = request.clone().withQueueUrl(alternateQueueName(request.getQueueUrl()));
            return super.changeMessageVisibilityBatch(alternateRequest);
        }
    }

    @Override
    public DeleteMessageResult deleteMessage(DeleteMessageRequest request) {
        String queueUrl = request.getQueueUrl();
        try {
            heartbeatToQueueIfNecessary(queueUrl);
            return super.deleteMessage(request);
        } catch (QueueDoesNotExistException|ReceiptHandleIsInvalidException e) {
            try {
                return super.deleteMessage(
                        request.clone().withQueueUrl(alternateQueueName(request.getQueueUrl())));
            } catch (QueueDoesNotExistException e2) {
                // Silently fail - the message is definitely deleted after all!
                return new DeleteMessageResult();
            }
        }
    }

    @Override
    public DeleteMessageBatchResult deleteMessageBatch(DeleteMessageBatchRequest request) {
        String queueUrl = request.getQueueUrl();
        try {
            heartbeatToQueueIfNecessary(queueUrl);
            return super.deleteMessageBatch(request);
        } catch (QueueDoesNotExistException e) {
            try {
                return super.deleteMessageBatch(
                        request.clone().withQueueUrl(alternateQueueName(request.getQueueUrl())));
            } catch (QueueDoesNotExistException e2) {
                // Silently fail - the message is definitely deleted after all!
                return new DeleteMessageBatchResult();
            }
        }
    }

    @Override
    public void shutdown() {
        if (idleQueueSweeper != null) {
            idleQueueSweeper.shutdown();
        }
        queues.values().forEach(metadata -> metadata.buffer.shutdown());
    }
    
    public void teardown() {
        shutdown();
        if (idleQueueSweeper != null) {
            amazonSqsToBeExtended.deleteQueue(idleQueueSweeper.getQueueUrl());
        }
        if (deadLetterQueueUrl != null) {
            amazonSqsToBeExtended.deleteQueue(deadLetterQueueUrl);
        }
    }
}