/*
 * Copyright 2015-2017 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 org.springframework.cloud.stream.test.binder;

import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.LinkedBlockingDeque;

import org.springframework.cloud.stream.binder.Binder;
import org.springframework.cloud.stream.binder.Binding;
import org.springframework.cloud.stream.binder.ConsumerProperties;
import org.springframework.cloud.stream.binder.ProducerProperties;
import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory;
import org.springframework.cloud.stream.converter.MessageConverterUtils;
import org.springframework.cloud.stream.test.matcher.MessageQueueMatcher;
import org.springframework.integration.channel.AbstractMessageChannel;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.SubscribableChannel;
import org.springframework.messaging.converter.DefaultContentTypeResolver;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;

/**
 * A minimal binder that
 * <ul>
 * <li>does nothing about binding consumers, leaving the channel as-is, so that a test
 * author can interact with it directly,</li>
 * <li>registers a queue channel on the producer side, so that it is easy to assert what
 * is received.</li>
 * </ul>
 *
 * @author Eric Bottard
 * @author Gary Russell
 * @author Mark Fisher
 * @author Oleg Zhurakousky
 * @author Soby Chacko
 * @see MessageQueueMatcher
 */
public class TestSupportBinder
		implements Binder<MessageChannel, ConsumerProperties, ProducerProperties> {

	private final MessageCollectorImpl messageCollector = new MessageCollectorImpl();

	private final ConcurrentMap<String, MessageChannel> messageChannels = new ConcurrentHashMap<>();

	@Override
	public Binding<MessageChannel> bindConsumer(String name, String group,
			MessageChannel inboundBindTarget, ConsumerProperties properties) {
		return new TestBinding(inboundBindTarget, null);
	}

	/**
	 * Registers a single subscriber to the channel, that enqueues messages for later
	 * retrieval and assertion in tests.
	 */
	@Override
	public Binding<MessageChannel> bindProducer(String name,
			MessageChannel outboundBindTarget, ProducerProperties properties) {
		final BlockingQueue<Message<?>> queue = this.messageCollector
				.register(outboundBindTarget, properties.isUseNativeEncoding());
		((SubscribableChannel) outboundBindTarget).subscribe(new MessageHandler() {
			@Override
			public void handleMessage(Message<?> message) throws MessagingException {
				queue.add(message);
			}
		});
		this.messageChannels.put(name, outboundBindTarget);
		return new TestBinding(outboundBindTarget, this.messageCollector);
	}

	public MessageCollector messageCollector() {
		return this.messageCollector;
	}

	public MessageChannel getChannelForName(String name) {
		return this.messageChannels.get(name);
	}

	/**
	 * Maintains mappings between channels and queues.
	 *
	 * @author Eric Bottard
	 */
	private static class MessageCollectorImpl implements MessageCollector {

		private final Map<MessageChannel, BlockingQueue<Message<?>>> results = new HashMap<>();

		private BlockingQueue<Message<?>> register(MessageChannel channel,
				boolean useNativeEncoding) {
			// we need to add this interceptor to ensure MessageCollector's compatibility
			// with
			// previous versions of SCSt when native encoding is disabled.
			if (!useNativeEncoding) {
				((AbstractMessageChannel) channel)
						.addInterceptor(new InboundMessageConvertingInterceptor());
			}
			LinkedBlockingDeque<Message<?>> result = new LinkedBlockingDeque<>();
			Assert.isTrue(!this.results.containsKey(channel),
					"Channel [" + channel + "] was already bound");
			this.results.put(channel, result);
			return result;
		}

		private void unregister(MessageChannel channel) {
			Assert.notNull(this.results.remove(channel),
					"Trying to unregister a mapping for an unknown channel [" + channel
							+ "]");
		}

		@Override
		public BlockingQueue<Message<?>> forChannel(MessageChannel channel) {
			BlockingQueue<Message<?>> queue = this.results.get(channel);
			Assert.notNull(queue, "Channel [" + channel + "] was not bound by "
					+ TestSupportBinder.class);
			return queue;
		}

	}

	/**
	 * @author Marius Bogoevici
	 */
	private static final class TestBinding implements Binding<MessageChannel> {

		private final MessageChannel target;

		private final MessageCollectorImpl messageCollector;

		private TestBinding(MessageChannel target,
				MessageCollectorImpl messageCollector) {
			this.target = target;
			this.messageCollector = messageCollector;
		}

		@Override
		public void unbind() {
			if (this.messageCollector != null) {
				this.messageCollector.unregister(this.target);
			}
		}

	}

	/**
	 * This is really an interceptor to maintain MessageCollector's backward compatibility
	 * with the behavior established in 1.3 - BINDER_ORIGINAL_CONTENT_TYPE - Kryo and Java
	 * deserialization - byte[] to String conversion - etc.
	 */
	private final static class InboundMessageConvertingInterceptor
			implements ChannelInterceptor {

		private final DefaultContentTypeResolver contentTypeResolver = new DefaultContentTypeResolver();

		private final CompositeMessageConverterFactory converterFactory = new CompositeMessageConverterFactory();

		/*
		 * Candidate to go into some utils class
		 */
		private static boolean equalTypeAndSubType(MimeType m1, MimeType m2) {
			return m1 != null && m2 != null && m1.getType().equalsIgnoreCase(m2.getType())
					&& m1.getSubtype().equalsIgnoreCase(m2.getSubtype());
		}

		@Override
		public Message<?> preSend(Message<?> message, MessageChannel channel) {
			Class<?> targetClass = null;
			MessageConverter converter = null;
			MimeType contentType = MimeType.valueOf(this.contentTypeResolver
									.resolve(message.getHeaders()).toString());

			if (contentType != null) {
				if (equalTypeAndSubType(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT,
						contentType)
						|| equalTypeAndSubType(MessageConverterUtils.X_JAVA_OBJECT,
								contentType)) {
					// for Java and Kryo de-serialization we need to reset the content
					// type
					message = MessageBuilder.fromMessage(message)
							.setHeader(MessageHeaders.CONTENT_TYPE, contentType).build();
					converter = equalTypeAndSubType(
							MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT, contentType)
									? this.converterFactory
											.getMessageConverterForType(contentType)
									: this.converterFactory
											.getMessageConverterForAllRegistered();
					String targetClassName = contentType.getParameter("type");
					if (StringUtils.hasText(targetClassName)) {
						try {
							targetClass = Class.forName(targetClassName, false,
									Thread.currentThread().getContextClassLoader());
						}
						catch (Exception e) {
							throw new IllegalStateException(
									"Failed to determine class name for contentType: "
											+ message.getHeaders(),
									e);
						}
					}
				}

			}

			Object payload;
			if (converter != null) {
				Assert.isTrue(
						!(equalTypeAndSubType(MessageConverterUtils.X_JAVA_OBJECT,
								contentType) && targetClass == null),
						"Cannot deserialize into message since 'contentType` is not "
								+ "encoded with the actual target type."
								+ "Consider 'application/x-java-object; type=foo.bar.MyClass'");
				payload = converter.fromMessage(message, targetClass);
			}
			else {
				MimeType deserializeContentType = this.contentTypeResolver
						.resolve(message.getHeaders());
				if (deserializeContentType == null) {
					deserializeContentType = contentType;
				}
				payload = deserializeContentType == null ? message.getPayload() : this
						.deserializePayload(message.getPayload(), deserializeContentType);
			}
			message = MessageBuilder.withPayload(payload)
					.copyHeaders(message.getHeaders())
					.setHeader(MessageHeaders.CONTENT_TYPE, contentType)
					.build();
			return message;
		}

		private Object deserializePayload(Object payload, MimeType contentType) {
			if (payload instanceof byte[]
					&& ("text".equalsIgnoreCase(contentType.getType())
							|| equalTypeAndSubType(MimeTypeUtils.APPLICATION_JSON,
									contentType))) {
				payload = new String((byte[]) payload, StandardCharsets.UTF_8);
			}
			return payload;
		}

	}

}