/*
 * Copyright 2013-2022 the original author or authors.
 *
 * Licensed 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
 *
 *      https://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 io.awspring.cloud.sqs.integration;

import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.awspring.cloud.sqs.CompletableFutures;
import io.awspring.cloud.sqs.MessageHeaderUtils;
import io.awspring.cloud.sqs.annotation.SqsListener;
import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration;
import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory;
import io.awspring.cloud.sqs.listener.ContainerOptions;
import io.awspring.cloud.sqs.listener.FifoSqsComponentFactory;
import io.awspring.cloud.sqs.listener.ListenerMode;
import io.awspring.cloud.sqs.listener.MessageListener;
import io.awspring.cloud.sqs.listener.MessageListenerContainer;
import io.awspring.cloud.sqs.listener.SqsHeaders;
import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer;
import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering;
import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback;
import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler;
import io.awspring.cloud.sqs.listener.acknowledgement.handler.OnSuccessAcknowledgementHandler;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.util.Assert;
import org.springframework.util.StopWatch;
import software.amazon.awssdk.services.sqs.SqsAsyncClient;
import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchResponse;

/**
 * @author Tomaz Fernandes
 * @since 3.0
 */
@SpringBootTest
class SqsFifoIntegrationTests extends BaseSqsIntegrationTest {

	private static final Logger logger = LoggerFactory.getLogger(SqsFifoIntegrationTests.class);

	static final String FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME = "fifo_receives_messages_in_order.fifo";

	static final String FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME = "fifo_receives_messages_in_order_many_groups.fifo";

	static final String FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME = "fifo_stops_processing_on_error.fifo";

	static final String FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME = "fifo_stops_processing_on_ack_error.fifo";

	static final String FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME = "fifo_receives_batches_many_groups.fifo";

	static final String FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME = "fifo_manually_create_container_test_queue.fifo";

	static final String FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME = "fifo_manually_create_factory_test_queue.fifo";

	static final String FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME = "fifo_manually_create_batch_container_test_queue.fifo";

	static final String FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME = "fifo_manually_create_batch_factory_test_queue.fifo";

	private static final String TEST_SQS_ASYNC_CLIENT_BEAN_NAME = "testSqsAsyncClient";

	private static final String ERROR_ON_ACK_FACTORY = "errorOnAckFactory";

	@Autowired
	LatchContainer latchContainer;

	@Autowired
	@Qualifier(TEST_SQS_ASYNC_CLIENT_BEAN_NAME)
	SqsAsyncClient sqsAsyncClient;

	@Autowired
	ObjectMapper objectMapper;

	@Autowired(required = false)
	ReceivesMessageInOrderListener receivesMessageInOrderListener;

	@Autowired(required = false)
	ReceivesMessageInOrderManyGroupsListener receivesMessageInOrderManyGroupsListener;

	@Autowired(required = false)
	StopsOnErrorListener stopsOnErrorListener;

	@Autowired(required = false)
	ReceivesBatchesFromManyGroupsListener receivesBatchesFromManyGroupsListener;

	@Autowired
	LoadSimulator loadSimulator;

	@Autowired
	Settings settings;

	@Autowired
	MessagesContainer messagesContainer;

	@BeforeAll
	static void beforeTests() {
		SqsAsyncClient client = createAsyncClient();
		CompletableFuture.allOf(
				createFifoQueue(client, FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME, getVisibilityAttribute("20")),
				createFifoQueue(client, FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME),
				createFifoQueue(client, FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME, getVisibilityAttribute("2")),
				createFifoQueue(client, FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME,
						getVisibilityAttribute("2")),
				createFifoQueue(client, FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME),
				createFifoQueue(client, FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME),
				createFifoQueue(client, FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME),
				createFifoQueue(client, FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME),
				createFifoQueue(client, FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME)).join();
	}

	private static Map<QueueAttributeName, String> getVisibilityAttribute(String value) {
		return Collections.singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, value);
	}

	private static class Settings implements SmartInitializingSingleton {

		@Autowired
		LoadSimulator loadSimulator;

		public boolean receiveMessages = true;

		public boolean sendMessages = true;

		private final int messagesPerTest = 5;

		private final int messagesPerMessageGroup = 10;

		private final int latchTimeoutSeconds = messagesPerTest * 10;

		@Override
		public void afterSingletonsInstantiated() {
			loadSimulator.setLoadEnabled(false);
			loadSimulator.setBound(1000);
			loadSimulator.setRandom(true);
		}
	}

	@Test
	void receivesMessagesInOrder() throws Exception {
		latchContainer.receivesMessageLatch = new CountDownLatch(this.settings.messagesPerTest);
		String messageGroupId = UUID.randomUUID().toString();
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		String queueUrl = fetchQueueUrl(FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME);
		sendMessageTo(queueUrl, values, messageGroupId);
		assertThat(latchContainer.receivesMessageLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)).isTrue();
		assertThat(receivesMessageInOrderListener.receivedMessages).containsExactlyElementsOf(values);
	}

	@Test
	void receivesMessagesInOrderFromManyMessageGroups() throws Exception {
		String queueUrl = fetchQueueUrl(FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME);
		int messagesPerTest = Math.max(this.settings.messagesPerTest, 30);
		int numberOfMessageGroups = messagesPerTest / Math.max(this.settings.messagesPerMessageGroup, 10);
		int messagesPerMessageGroup = Math.max(messagesPerTest / numberOfMessageGroups, 1);
		latchContainer.receivesMessageManyGroupsLatch = new CountDownLatch(messagesPerTest);
		latchContainer.manyGroupsAcks = new CountDownLatch(messagesPerTest);
		List<String> values = IntStream.range(0, messagesPerMessageGroup).mapToObj(String::valueOf).collect(toList());
		List<String> messageGroups = IntStream.range(0, numberOfMessageGroups)
				.mapToObj(index -> UUID.randomUUID().toString()).collect(toList());
		StopWatch watch = new StopWatch();
		watch.start();
		LoadSimulator loadSimulator = new LoadSimulator().setLoadEnabled(true).setRandom(true).setBound(20);
		IntStream.range(0, messageGroups.size()).forEach(index -> {
			if (this.settings.sendMessages) {
				sendMessageTo(queueUrl, values, messageGroups.get(index));
			}
			if (index % 10 == 0) {
				loadSimulator.runLoad();
			}
		});
		assertThat(latchContainer.receivesMessageManyGroupsLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		assertThat(latchContainer.manyGroupsAcks.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)).isTrue();
		watch.stop();
		messageGroups.forEach(group -> {
			assertThat(receivesMessageInOrderManyGroupsListener.receivedMessages.get(group))
					.containsExactlyElementsOf(values);
			assertThat(messagesContainer.acknowledgesFromManyGroups.get(group)).containsExactlyElementsOf(values);
		});
		double totalTimeSeconds = watch.getTotalTimeSeconds();
		logger.debug("{}s for processing {} messages in {} message groups. Messages / seconds: {}", totalTimeSeconds,
				messagesPerTest, numberOfMessageGroups, messagesPerTest / totalTimeSeconds);
	}

	@Test
	void stopsProcessingAfterException() throws Exception {
		latchContainer.stopsProcessingOnErrorLatch1 = new CountDownLatch(4);
		latchContainer.stopsProcessingOnErrorLatch2 = new CountDownLatch(this.settings.messagesPerTest + 1);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		String messageGroupId = UUID.randomUUID().toString();
		String queueUrl = fetchQueueUrl(FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME);
		sendMessageTo(queueUrl, values, messageGroupId);
		assertThat(latchContainer.stopsProcessingOnErrorLatch1.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		logger.debug("receivedMessagesBeforeException: {}", stopsOnErrorListener.receivedMessagesBeforeException);
		assertThat(stopsOnErrorListener.receivedMessagesBeforeException)
				.containsExactlyElementsOf(values.stream().limit(4).collect(toList()));
		assertThat(latchContainer.stopsProcessingOnErrorLatch2.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		logger.debug("receivedMessagesBeforeException: {}", stopsOnErrorListener.receivedMessagesBeforeException);
		logger.debug("receivedMessagesAfterException: {}", stopsOnErrorListener.receivedMessagesAfterException);
		assertThat(stopsOnErrorListener.receivedMessagesBeforeException)
				.containsExactlyElementsOf(values.stream().limit(4).collect(toList()));
		assertThat(stopsOnErrorListener.receivedMessagesAfterException)
				.containsExactlyElementsOf(values.subList(3, this.settings.messagesPerTest));
	}

	@Test
	void stopsProcessingAfterAckException() throws Exception {
		latchContainer.stopsProcessingOnAckErrorLatch1 = new CountDownLatch(4);
		latchContainer.stopsProcessingOnAckErrorLatch2 = new CountDownLatch(this.settings.messagesPerTest - 3);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		String messageGroupId = UUID.randomUUID().toString();
		String queueUrl = fetchQueueUrl(FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME);
		sendMessageTo(queueUrl, values, messageGroupId);
		assertThat(latchContainer.stopsProcessingOnAckErrorLatch1.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		logger.debug("Messages consumed before error: {}", messagesContainer.stopsProcessingOnAckErrorBeforeThrown);
		assertThat(messagesContainer.stopsProcessingOnAckErrorBeforeThrown)
				.containsExactlyElementsOf(values.stream().limit(4).collect(toList()));
		assertThat(latchContainer.stopsProcessingOnAckErrorLatch2.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		logger.debug("Messages consumed before error second latch: {}",
				messagesContainer.stopsProcessingOnAckErrorBeforeThrown);
		assertThat(messagesContainer.stopsProcessingOnAckErrorBeforeThrown)
				.containsExactlyElementsOf(values.stream().limit(4).collect(toList()));
		logger.debug("Messages consumed after error: {}", messagesContainer.stopsProcessingOnAckErrorAfterThrown);
		assertThat(messagesContainer.stopsProcessingOnAckErrorAfterThrown)
				.containsExactlyElementsOf(values.subList(3, this.settings.messagesPerTest));
	}

	@Test
	void receivesBatchesManyGroups() throws Exception {
		latchContainer.receivesBatchManyGroupsLatch = new CountDownLatch(this.settings.messagesPerTest * 3);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		String messageGroupId1 = UUID.randomUUID().toString();
		String messageGroupId2 = UUID.randomUUID().toString();
		String messageGroupId3 = UUID.randomUUID().toString();
		String queueUrl = fetchQueueUrl(FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME);
		sendMessageTo(queueUrl, values, messageGroupId1);
		sendMessageTo(queueUrl, values, messageGroupId2);
		sendMessageTo(queueUrl, values, messageGroupId3);
		assertThat(latchContainer.receivesBatchManyGroupsLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId1))
				.containsExactlyElementsOf(values);
		assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId2))
				.containsExactlyElementsOf(values);
		assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId3))
				.containsExactlyElementsOf(values);
	}

	@Test
	void manuallyCreatesContainer() throws Exception {
		String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		sendMessageTo(queueUrl, values, UUID.randomUUID().toString());
		assertThat(latchContainer.manuallyCreatedContainerLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		assertThat(messagesContainer.manuallyCreatedContainerMessages).containsExactlyElementsOf(values);
	}

	@Test
	void manuallyCreatesBatchContainer() throws Exception {
		String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		sendMessageTo(queueUrl, values, UUID.randomUUID().toString());
		assertThat(
				latchContainer.manuallyCreatedBatchContainerLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
						.isTrue();
		assertThat(messagesContainer.manuallyCreatedBatchContainerMessages).containsExactlyElementsOf(values);
	}

	@Test
	void manuallyCreatesFactory() throws Exception {
		String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		sendMessageTo(queueUrl, values, UUID.randomUUID().toString());
		assertThat(latchContainer.manuallyCreatedFactoryLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
				.isTrue();
		assertThat(messagesContainer.manuallyCreatedFactoryMessages).containsExactlyElementsOf(values);
	}

	@Test
	void manuallyCreatesBatchFactory() throws Exception {
		String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME);
		List<String> values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf)
				.collect(toList());
		sendMessageTo(queueUrl, values, UUID.randomUUID().toString());
		assertThat(
				latchContainer.manuallyCreatedBatchFactoryLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS))
						.isTrue();
		assertThat(messagesContainer.manuallyCreatedBatchFactoryMessages).containsExactlyElementsOf(values);
	}

	static class ReceivesMessageInOrderListener {

		List<String> receivedMessages = Collections.synchronizedList(new ArrayList<>());

		@Autowired
		LatchContainer latchContainer;

		@Autowired
		LoadSimulator loadSimulator;

		@SqsListener(queueNames = FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME)
		void listen(Message<String> message) {
			logger.debug("Received message with id {} and payload {} from ReceivesMessageInOrderListener",
					MessageHeaderUtils.getId(message), message.getPayload());
			loadSimulator.runLoad();
			receivedMessages.add(message.getPayload());
			latchContainer.receivesMessageLatch.countDown();
		}
	}

	static class ReceivesMessageInOrderManyGroupsListener {

		private final AtomicInteger totalMessagesReceived = new AtomicInteger();

		private final Map<String, List<String>> receivedMessages = new ConcurrentHashMap<>();

		@Autowired
		LatchContainer latchContainer;

		@Autowired
		LoadSimulator loadSimulator;

		@SqsListener(queueNames = FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME, id = "receives-in-order-many-groups")
		void listen(Message<String> message,
				@Header(SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER) String groupId) {
			logger.trace("Received message {} in listener method from groupId {}", message.getPayload(), groupId);
			loadSimulator.runLoad();
			List<String> messageList = receivedMessages.computeIfAbsent(groupId, newGroupId -> new ArrayList<>());
			if (messageList.contains(message.getPayload())) {
				logger.warn("Message {} with id {} already present for group id {}", message.getPayload(),
						MessageHeaderUtils.getId(message), groupId);
			}
			messageList.add(message.getPayload());
			latchContainer.receivesMessageManyGroupsLatch.countDown();
			int received = totalMessagesReceived.incrementAndGet();
			if (received % 1000 == 0) {
				logger.debug("{} messages received from {} message groups", received, receivedMessages.size());
			}
			// logger.debug("Message {} processed.", message);
		}
	}

	static class StopsOnErrorListener {

		List<String> receivedMessagesBeforeException = Collections.synchronizedList(new ArrayList<>());

		List<String> receivedMessagesAfterException = Collections.synchronizedList(new ArrayList<>());

		AtomicBoolean hasThrown = new AtomicBoolean(false);

		@Autowired
		LatchContainer latchContainer;

		@Autowired
		LoadSimulator loadSimulator;

		@SqsListener(queueNames = FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME, messageVisibilitySeconds = "3", id = "stops-processing-on-error")
		void listen(String message) {
			logger.debug("Received message in listener method: " + message);
			loadSimulator.runLoad(500);
			if (!hasThrown.get()) {
				this.receivedMessagesBeforeException.add(message);
			}
			else {
				this.receivedMessagesAfterException.add(message);
			}
			latchContainer.stopsProcessingOnErrorLatch1.countDown();
			latchContainer.stopsProcessingOnErrorLatch2.countDown();
			if ("3".equals(message) && this.hasThrown.compareAndSet(false, true)) {
				throw new RuntimeException("Expected exception from stops-processing-on-error");
			}
		}
	}

	static class StopsOnAckErrorListener {

		@Autowired
		LatchContainer latchContainer;

		@SqsListener(queueNames = FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME, factory = ERROR_ON_ACK_FACTORY, messageVisibilitySeconds = "2", id = "stops-on-ack-error")
		void listen(String message) {
			logger.debug("Received message in listener method: " + message);
		}
	}

	static class ReceivesBatchesFromManyGroupsListener {

		Map<String, List<String>> receivedMessages = new ConcurrentHashMap<>();

		@Autowired
		LatchContainer latchContainer;

		@SqsListener(queueNames = FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME, messageVisibilitySeconds = "20")
		void listen(List<Message<String>> messages) {
			String firstMessage = messages.iterator().next().getPayload();// Make sure we got the right type
			Assert.isTrue(MessageHeaderUtils
					.getHeader(messages, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, String.class)
					.stream().distinct().count() == 1, "More than one message group returned in the same batch");
			String messageGroupId = messages.iterator().next().getHeaders()
					.get(SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, String.class);
			List<String> values = messages.stream().map(Message::getPayload).collect(toList());
			logger.trace("Started processing messages {} for group id {}", values, messageGroupId);
			receivedMessages.computeIfAbsent(messageGroupId, groupId -> Collections.synchronizedList(new ArrayList<>()))
					.addAll(values);
			messages.forEach(msg -> latchContainer.receivesBatchManyGroupsLatch.countDown());
			logger.trace("Finished processing messages {} for group id {}", values, messageGroupId);
		}
	}

	private void sendMessageTo(String queueUrl, List<String> messageBodies, String messageGroupId) {
		try {
			if (useLocalStackClient) {
				sendManyTo(queueUrl, messageBodies, messageGroupId).join();
			}
			else {
				sendManyTo(queueUrl, messageBodies, messageGroupId);
			}
		}
		catch (Exception e) {
			logger.error("Error sending messages to queue {}", queueUrl, e);
			throw (RuntimeException) e;
		}
	}

	private CompletableFuture<Void> sendManyTo(String queueUrl, List<String> messageBodies, String messageGroupId) {
		return IntStream.range(0, (int) Math.ceil(messageBodies.size() / 10.))
				.mapToObj(index -> messageBodies.subList(index * 10, Math.min((index + 1) * 10, messageBodies.size())))
				.reduce(CompletableFuture.completedFuture(null), (previousFuture, messages) -> previousFuture
						.thenCompose(theVoid -> doSendMessageTo(queueUrl, messages, messageGroupId).thenRun(() -> {
						})), (a, b) -> a);
	}

	AtomicInteger messagesSent = new AtomicInteger();

	private CompletableFuture<SendMessageBatchResponse> doSendMessageTo(String queueUrl, List<String> messageBodies,
			String messageGroupId) {
		return sqsAsyncClient.sendMessageBatch(req -> req
				.entries(messageBodies.stream().map(body -> createEntry(body, messageGroupId)).collect(toList()))
				.queueUrl(queueUrl).build()).whenComplete((v, t) -> {
					if (t != null) {
						logger.error("Error sending messages", t);
					}
					else {
						int sent = messagesSent.addAndGet(messageBodies.size());
						if (sent % 1000 == 0) {
							logger.debug("Sent {} messages to queue {}", sent, queueUrl);
						}
					}
				});
	}

	private SendMessageBatchRequestEntry createEntry(String body, String messageGroupId) {
		return SendMessageBatchRequestEntry.builder().messageBody(body).id(UUID.randomUUID().toString())
				.messageGroupId(messageGroupId).messageDeduplicationId(UUID.randomUUID().toString()).build();
	}

	private String fetchQueueUrl(String receivesMessageQueueName) throws InterruptedException, ExecutionException {
		return this.sqsAsyncClient.getQueueUrl(req -> req.queueName(receivesMessageQueueName)).get().queueUrl();
	}

	static class LatchContainer {

		@Autowired
		Settings settings;

		final CountDownLatch manuallyCreatedContainerLatch = new CountDownLatch(this.settings.messagesPerTest);
		final CountDownLatch manuallyCreatedFactoryLatch = new CountDownLatch(this.settings.messagesPerTest);
		final CountDownLatch manuallyCreatedBatchContainerLatch = new CountDownLatch(this.settings.messagesPerTest);
		final CountDownLatch manuallyCreatedBatchFactoryLatch = new CountDownLatch(this.settings.messagesPerTest);

		// Lazily initialized
		CountDownLatch receivesMessageLatch = new CountDownLatch(1);
		CountDownLatch receivesMessageManyGroupsLatch = new CountDownLatch(1);
		CountDownLatch manyGroupsAcks = new CountDownLatch(1);
		CountDownLatch stopsProcessingOnErrorLatch1 = new CountDownLatch(3);
		CountDownLatch stopsProcessingOnErrorLatch2 = new CountDownLatch(1);
		CountDownLatch stopsProcessingOnAckErrorLatch1 = new CountDownLatch(1);
		CountDownLatch stopsProcessingOnAckErrorLatch2 = new CountDownLatch(1);
		CountDownLatch receivesBatchManyGroupsLatch = new CountDownLatch(1);

	}

	static class MessagesContainer {

		Map<String, List<String>> acknowledgesFromManyGroups = Collections.synchronizedMap(new HashMap<>());
		List<String> manuallyCreatedContainerMessages = Collections.synchronizedList(new ArrayList<>());
		List<String> manuallyCreatedBatchContainerMessages = Collections.synchronizedList(new ArrayList<>());
		List<String> manuallyCreatedFactoryMessages = Collections.synchronizedList(new ArrayList<>());
		List<String> manuallyCreatedBatchFactoryMessages = Collections.synchronizedList(new ArrayList<>());
		List<String> stopsProcessingOnAckErrorBeforeThrown = Collections.synchronizedList(new ArrayList<>());
		List<String> stopsProcessingOnAckErrorAfterThrown = Collections.synchronizedList(new ArrayList<>());

	}

	@Import(SqsBootstrapConfiguration.class)
	@Configuration
	static class SQSConfiguration {

		MessagesContainer messagesContainer = new MessagesContainer();

		@Bean
		public MessagesContainer messagesContainer() {
			return this.messagesContainer;
		}

		// @formatter:off
		@Bean
		public SqsMessageListenerContainerFactory<String> defaultSqsListenerContainerFactory() {
			SqsMessageListenerContainerFactory<String> factory = new SqsMessageListenerContainerFactory<>();
			factory.configure(options -> options
				.maxInflightMessagesPerQueue(10)
				.acknowledgementThreshold(10)
				.acknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP)
				.acknowledgementInterval(Duration.ofSeconds(1)));
			factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createHighThroughputAsyncClient);
			factory.setAcknowledgementResultCallback(new AcknowledgementResultCallback<String>() {

				final AtomicInteger ackedMessages = new AtomicInteger();

				@Override
				public void onSuccess(Collection<Message<String>> messages) {
					if (FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME.equals(MessageHeaderUtils.getHeaderAsString(messages.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER))) {
						messages.stream()
							.collect(groupingBy(msg -> MessageHeaderUtils.getHeaderAsString(msg, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER)))
							.forEach((key, value) -> messagesContainer.acknowledgesFromManyGroups.computeIfAbsent(key,
								newGroup -> Collections.synchronizedList(new ArrayList<>())).addAll(value.stream().map(Message::getPayload).collect(toList())));
						messages.forEach(msg -> {
							int acked = ackedMessages.incrementAndGet();
							if (acked % 1000 == 0) {
								logger.debug("Acknowledged {} messages", acked);
							}
							latchContainer.manyGroupsAcks.countDown();
						});
					}
				}
			});
			return factory;
		}

		@Bean(ERROR_ON_ACK_FACTORY)
		public SqsMessageListenerContainerFactory<String> errorOnAckSqsListenerContainerFactory() {
			SqsMessageListenerContainerFactory<String> factory = new SqsMessageListenerContainerFactory<>();
			factory.configure(options -> options
				.maxInflightMessagesPerQueue(10)
				.acknowledgementThreshold(10)
				.acknowledgementInterval(Duration.ofMillis(200))
				.permitAcquireTimeout(Duration.ofSeconds(1))
				.pollTimeout(Duration.ofSeconds(3)));
			factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient);
			factory.setContainerComponentFactories(Collections.singletonList(new FifoSqsComponentFactory<String>() {
				@Override
				public AcknowledgementHandler<String> createAcknowledgementHandler(ContainerOptions options) {
					return new OnSuccessAcknowledgementHandler<String>() {

						final AtomicBoolean hasThrown = new AtomicBoolean(false);

						@Override
						public CompletableFuture<Void> onSuccess(Message<String> message,
																 AcknowledgementCallback<String> callback) {
							if (message.getPayload().equals("3") && hasThrown.compareAndSet(false, true)) {
								messagesContainer.stopsProcessingOnAckErrorBeforeThrown.add(message.getPayload());
								latchContainer.stopsProcessingOnAckErrorLatch1.countDown();
								return CompletableFutures.failedFuture(new RuntimeException("Expected acking error"));
							}
							return super.onSuccess(message, callback).whenComplete((v, t) -> handleResult(message));
						}

						private void handleResult(Message<String> message) {
							if (!hasThrown.get()) {
								messagesContainer.stopsProcessingOnAckErrorBeforeThrown.add(message.getPayload());
								latchContainer.stopsProcessingOnAckErrorLatch1.countDown();
							}
							else {
								messagesContainer.stopsProcessingOnAckErrorAfterThrown.add(message.getPayload());
								latchContainer.stopsProcessingOnAckErrorLatch2.countDown();
							}
						}
					};
				}
			}));
			return factory;
		}

		@Bean
		public MessageListenerContainer<String> manuallyCreatedContainer() {
			SqsMessageListenerContainer<String> container = new SqsMessageListenerContainer<>(createAsyncClient());
			container.configure(options -> options
				.permitAcquireTimeout(Duration.ofSeconds(1))
				.pollTimeout(Duration.ofSeconds(1)));
			container.setQueueNames(FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME);
			container.setId("fifo-manually-created-container");
			container.setMessageListener(msg -> {
				messagesContainer.manuallyCreatedContainerMessages.add(msg.getPayload());
				latchContainer.manuallyCreatedContainerLatch.countDown();
			});
			return container;
		}

		@Bean
		public MessageListenerContainer<String> manuallyCreatedBatchContainer() {
			SqsMessageListenerContainer<String> container = new SqsMessageListenerContainer<>(createAsyncClient());
			container.configure(options -> options
				.permitAcquireTimeout(Duration.ofSeconds(1))
				.pollTimeout(Duration.ofSeconds(1))
				.listenerMode(ListenerMode.BATCH));
			container.setQueueNames(FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME);
			container.setMessageListener(new MessageListener<String>() {
				@Override
				public void onMessage(Message<String> message) {
					throw new UnsupportedOperationException();
				}

				@Override
				public void onMessage(Collection<Message<String>> messages) {
					messagesContainer.manuallyCreatedBatchContainerMessages
						.addAll(messages.stream().map(Message::getPayload).collect(toList()));
					messages.forEach(msg -> latchContainer.manuallyCreatedBatchContainerLatch.countDown());
				}
			});
			return container;
		}

		@Bean
		public SqsMessageListenerContainer<String> manuallyCreatedFactory() {
			SqsMessageListenerContainerFactory<String> factory = new SqsMessageListenerContainerFactory<>();
			factory.configure(options ->
				options.maxInflightMessagesPerQueue(10)
					.pollTimeout(Duration.ofSeconds(1))
					.maxMessagesPerPoll(10)
					.permitAcquireTimeout(Duration.ofSeconds(1)));
			factory.setSqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient());
			factory.setMessageListener(msg -> {
				logger.debug("Processed message {}", msg.getPayload());
				messagesContainer.manuallyCreatedFactoryMessages.add(msg.getPayload());
				latchContainer.manuallyCreatedFactoryLatch.countDown();
			});
			return factory.createContainer(FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME);
		}

		@Bean
		public MessageListenerContainer<String> manuallyCreatedBatchFactory() {
			SqsMessageListenerContainerFactory<String> factory = new SqsMessageListenerContainerFactory<>();
			factory.configure(options -> options
				.maxInflightMessagesPerQueue(10)
				.pollTimeout(Duration.ofSeconds(1))
				.maxMessagesPerPoll(10)
				.permitAcquireTimeout(Duration.ofSeconds(1))
				.listenerMode(ListenerMode.BATCH));
			factory.setSqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient());
			factory.setMessageListener(new MessageListener<String>() {
				@Override
				public void onMessage(Message<String> message) {
					throw new UnsupportedOperationException();
				}

				@Override
				public void onMessage(Collection<Message<String>> messages) {
					messagesContainer.manuallyCreatedBatchFactoryMessages
						.addAll(messages.stream().map(Message::getPayload).collect(toList()));
					messages.forEach(msg -> latchContainer.manuallyCreatedBatchFactoryLatch.countDown());
				}
			});
			return factory.createContainer(FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME);
		}
		// @formatter:on

		@Bean
		ReceivesMessageInOrderListener receivesMessageInOrderListener() {
			return new ReceivesMessageInOrderListener();
		}

		@Bean
		ReceivesMessageInOrderManyGroupsListener receivesMessageInOrderManyGroupsListener() {
			if (settings.receiveMessages) {
				return new ReceivesMessageInOrderManyGroupsListener();
			}
			return null;
		}

		@Bean
		StopsOnErrorListener stopsOnErrorListener() {
			return new StopsOnErrorListener();
		}

		@Bean
		StopsOnAckErrorListener stopsOnAckErrorListener() {
			return new StopsOnAckErrorListener();
		}

		@Bean
		ReceivesBatchesFromManyGroupsListener receiveBatchesFromManyGroupsListener() {
			return new ReceivesBatchesFromManyGroupsListener();
		}

		LatchContainer latchContainer = new LatchContainer();

		@Bean
		LatchContainer latchContainer() {
			return this.latchContainer;
		}

		@Bean
		LoadSimulator loadSimulator() {
			return new LoadSimulator();
		}

		Settings settings = new Settings();

		@Bean
		Settings settings() {
			return settings;
		}

		@Bean
		ObjectMapper objectMapper() {
			return new ObjectMapper();
		}

		@Bean(name = TEST_SQS_ASYNC_CLIENT_BEAN_NAME)
		SqsAsyncClient sqsAsyncClientProducer() {
			return BaseSqsIntegrationTest.createHighThroughputAsyncClient();
		}

	}

}
