/*
 * Copyright 2014 Avanza Bank AB
 *
 * 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
 *
 *      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 com.avanza.astrix.remoting.client;

import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;
import java.util.function.Consumer;

import com.avanza.astrix.core.AstrixPartitionedRouting;
import com.avanza.astrix.core.AstrixRemoteResult;
import com.avanza.astrix.core.RemoteResultReducer;
import com.avanza.astrix.core.remoting.RoutingKey;
import com.avanza.astrix.core.util.ReflectionUtil;
import rx.Observable;
import rx.functions.Func1;
/**
 * 
 * @author Elias Lindholm (elilin)
 *
 */
public class PartitionedRemoteServiceMethod implements RemoteServiceMethod {

	private final int partitionedArgumentIndex;
	private final String methodSignature;
	private final RemotingEngine remotingEngine;
	private final Type targetReturnType;
	private final Class<? extends RemoteResultReducer<?>> reducerType;
	private final ContainerType partitionedArgumentContainerType;
	private final PartitionedRouter router;
	private final Method proxiedMethod;

	public PartitionedRemoteServiceMethod(int partitionedArgumentIndex,
										  Method proxiedMethod,
										  String methodSignature,
										  RemotingEngine remotingEngine,
										  Type targetReturnType,
										  Method targetServiceMethod) {
		this.partitionedArgumentIndex = partitionedArgumentIndex;
		this.proxiedMethod = proxiedMethod;
		this.methodSignature = methodSignature;
		this.remotingEngine = remotingEngine;
		this.targetReturnType = targetReturnType;
		AstrixPartitionedRouting partitionedRouting = getPartitionedRoutingAnnotation(proxiedMethod, partitionedArgumentIndex);
		this.reducerType = getReducer(partitionedRouting, targetServiceMethod);
		this.partitionedArgumentContainerType = getPartitionedArgumentContainerType(proxiedMethod, partitionedRouting);
		this.router = createRouter(partitionedRouting);
	}

	private PartitionedRouter createRouter(AstrixPartitionedRouting partitionedRouting) {
		Class<?> elementType = this.partitionedArgumentContainerType.getElementType();
		if (!partitionedRouting.routingMethod().isEmpty()) {
			Method routingMethod;
			try {
				routingMethod = elementType.getMethod(partitionedRouting.routingMethod());
				return PartitionedRouter.routingMethod(routingMethod);
			} catch (NoSuchMethodException | SecurityException e) {
				throw new IllegalArgumentException("Failed to find routing method for partitioned routing:\n"
												 + "service: " + ReflectionUtil.fullMethodName(proxiedMethod) + "\n"
												 + "@AstrixPartitionedRouting.routingMethod: " + partitionedRouting.routingMethod(), e);
			}
		}
		return PartitionedRouter.identity();
	}

	private ContainerType getPartitionedArgumentContainerType(Method proxiedMethod, AstrixPartitionedRouting partitionBy) {
		Class<?> partitionedArgumentType = proxiedMethod.getParameterTypes()[partitionedArgumentIndex];
		if (partitionedArgumentType.isArray()) {
			return new ArrayContainerType(partitionedArgumentType.getComponentType());
		}
		Class<? extends Collection<?>> collectionFactory = (Class<? extends Collection<?>>) partitionBy.collectionFactory();
		if (!proxiedMethod.getParameterTypes()[partitionedArgumentIndex].isAssignableFrom(collectionFactory)) {
			throw new IllegalArgumentException(String.format("Collection class supplied by @AstrixPartitionedRouting is not "
											 + "compatible with argument type, argumentType=%s classType=%s", 
											 proxiedMethod.getParameterTypes()[partitionedArgumentIndex].getName(),
											 collectionFactory));
		}
		Type rawType = proxiedMethod.getGenericParameterTypes()[partitionedArgumentIndex];
		if (!(rawType instanceof ParameterizedType)) {
			throw new IllegalArgumentException("Illegal service method: " + ReflectionUtil.fullMethodName(proxiedMethod) + ".\nWhen defining a routingMethod for @AstrixPartitionedRouting the target Collection type must not be a raw type. \nwas: " + rawType);
		}
		ParameterizedType partitionedArgumentTypeParameters = (ParameterizedType) rawType;
		return new CollectionContainerType(collectionFactory, (Class<?>)partitionedArgumentTypeParameters.getActualTypeArguments()[0]);
	}

	private Class<? extends RemoteResultReducer<?>> getReducer(AstrixPartitionedRouting partitionBy, Method targetServiceMethod) {
		Class<? extends RemoteResultReducer<?>> reducerType = (Class<? extends RemoteResultReducer<?>>) partitionBy.reducer();
		RemotingProxyUtil.validateRemoteResultReducer(targetServiceMethod, reducerType);
		return reducerType;
	}

	private static AstrixPartitionedRouting getPartitionedRoutingAnnotation(Method proxiedMethod, int partitionedByArgumentIndex) {
		for (Annotation a : proxiedMethod.getParameterAnnotations()[partitionedByArgumentIndex]) {
			if (a instanceof AstrixPartitionedRouting) {
				return AstrixPartitionedRouting.class.cast(a);
			}
		}
		throw new IllegalStateException("Programming error, proxied method does not hold AstrixPartitionedBy annotation: " + proxiedMethod);
	}

	@Override
	public String getSignature() {
		return methodSignature;
	}

	@Override
	public Observable<?> invoke(AstrixServiceInvocationRequest invocationRequest, Object[] args) throws Exception {
		/*
		 * 1. Partition Requests
		 * 2. Marshall arguments
		 * 3. Execute requests
		 */
		ServiceInvocationPartitioner serviceInvocationPartitioner = new ServiceInvocationPartitioner();
		List<RoutedServiceInvocationRequest> partitionInvocationRequest = serviceInvocationPartitioner.partitionInvocationRequest(invocationRequest, args);
		Observable<List<AstrixServiceInvocationResponse>> serviceInvocationResponses = remotingEngine.submitRoutedRequests(partitionInvocationRequest);
		return reduce(serviceInvocationResponses);
	}

	private <T> Observable<T> reduce(Observable<List<AstrixServiceInvocationResponse>> responses) {
		if (targetReturnType.equals(Void.TYPE) || targetReturnType.equals(Void.class)) {
			return responses.map(responseList -> {
				readResults(responseList);
				return null;
			});
		}
		final RemoteResultReducer<T> reducer = newRemoteResultReducer();
		return responses.map(responseList -> {
			List<AstrixRemoteResult<T>> unmarshalledResponses = new ArrayList<>(responseList.size());
			for (AstrixServiceInvocationResponse response : responseList) {
				AstrixRemoteResult<T> result = remotingEngine.toRemoteResult(response, targetReturnType);
				unmarshalledResponses.add(result);
			}
			return reducer.reduce(unmarshalledResponses);
		});
		
	}

	private void readResults(List<AstrixServiceInvocationResponse> responseList) {
		responseList.forEach(res -> remotingEngine.toRemoteResult(res, targetReturnType).getResult());
	}

	@SuppressWarnings("unchecked")
	private <T> RemoteResultReducer<T> newRemoteResultReducer() {
		return (RemoteResultReducer<T>) ReflectionUtil.newInstance(this.reducerType);
	}

	private ContainerBuilder newCollectionInstance() {
		return partitionedArgumentContainerType.newInstance();
	}

	private class RoutedServiceInvocationRequestBuilder {
		private final ContainerBuilder routingKeys;
		private final RoutingKey targetPartitionRoutingKey;

		public RoutedServiceInvocationRequestBuilder(ContainerBuilder keys, int targetPartition) {
			this.routingKeys = keys;
			this.targetPartitionRoutingKey = RoutingKey.create(targetPartition);
		}

		public void addKey(Object requestedKey) {
			this.routingKeys.add(requestedKey);
		}
		
		private RoutedServiceInvocationRequest createInvocationRequest(
				AstrixServiceInvocationRequest invocationRequest,
				Object[] unpartitionedArguments) {
			AstrixServiceInvocationRequest partitionedRequest = new AstrixServiceInvocationRequest();
			partitionedRequest.setAllHeaders(invocationRequest.getHeaders());
			Object[] requestForPartition = Arrays.copyOf(unpartitionedArguments, unpartitionedArguments.length);
			requestForPartition[partitionedArgumentIndex] = this.routingKeys.buildTarget();
			partitionedRequest.setArguments(remotingEngine.marshall(requestForPartition));
			return new RoutedServiceInvocationRequest(partitionedRequest, targetPartitionRoutingKey);
		}
	}
	
	private class ServiceInvocationPartitioner {
		private final RoutedServiceInvocationRequestBuilder[] requests;
		
		public ServiceInvocationPartitioner() {
			this.requests = new RoutedServiceInvocationRequestBuilder[remotingEngine.partitionCount()];
		}

		public List<RoutedServiceInvocationRequest> partitionInvocationRequest(AstrixServiceInvocationRequest invocationRequest, Object[] args) {
			partitionedArgumentContainerType.iterateContainer(getContainerInstance(args), new Consumer<Object>() {
				@Override
				public void accept(Object element) {
					addElement(element);
				}
			});
			List<RoutedServiceInvocationRequest> result = new LinkedList<>();
			for (RoutedServiceInvocationRequestBuilder routedInvocationReqeustBuilder : requests) {
				if (routedInvocationReqeustBuilder != null) {
					result.add(routedInvocationReqeustBuilder.createInvocationRequest(invocationRequest, args));
				}
			}
			return result;
		}
		
		private Object getContainerInstance(Object[] args) {
			return args[partitionedArgumentIndex];
		}

		public void addElement(Object element) {
			RoutingKey routingKey = router.getRoutingKey(element);
			int targetPartition = routingKey.hashCode() % requests.length;
			RoutedServiceInvocationRequestBuilder invocationRequestBuilderForPartition = this.requests[targetPartition];
			if (invocationRequestBuilderForPartition == null) {
				invocationRequestBuilderForPartition = new RoutedServiceInvocationRequestBuilder(newCollectionInstance(), targetPartition);
				this.requests[targetPartition] = invocationRequestBuilderForPartition;
				
			}
			invocationRequestBuilderForPartition.addKey(element);
		}

	}
	
	private interface ContainerType {
		ContainerBuilder newInstance();
		void iterateContainer(Object container, Consumer<Object> consumer);
		Class<?> getElementType();
	}
	
	private static class CollectionContainerType implements ContainerType {
		private final Class<?> elementType;
		private final Class<? extends Collection<?>> collectionFactory;
		
		public CollectionContainerType(Class<? extends Collection<?>> collectionFactory, Class<?> elementType) {
			this.collectionFactory = Objects.requireNonNull(collectionFactory);
			this.elementType = Objects.requireNonNull(elementType);
		}

		@Override
		public ContainerBuilder newInstance() {
			return new CollectionContainerBuilder((Collection<? super Object>) ReflectionUtil.newInstance(this.collectionFactory));
		}

		@Override
		public void iterateContainer(Object container, Consumer<Object> consumer) {
			for (Object element : (Collection<? extends Object>) container) {
				consumer.accept(element);
			}
		}
		
		@Override
		public Class<?> getElementType() {
			return elementType;
		}
	}
	
	private static class ArrayContainerType implements ContainerType {
		
		private final Class<?> elementType;
		
		public ArrayContainerType(Class<?> elementType) {
			this.elementType = elementType;
		}

		@Override
		public ContainerBuilder newInstance() {
			return new ArrayContainerBuilder(elementType);
		}

		@Override
		public void iterateContainer(Object container, Consumer<Object> consumer) {
			for (int i = 0; i < Array.getLength(container); i++) {
				consumer.accept(Array.get(container, i));
			}
		}
		
		@Override
		public Class<?> getElementType() {
			return this.elementType;
		}
	}
	
	private static class ArrayContainerBuilder extends ContainerBuilder {
		
		private final Class<?> elementType;
		private final List<Object> elements = new ArrayList<>();
		
		public ArrayContainerBuilder(Class<?> elementType) {
			this.elementType = elementType;
		}
		
		@Override
		void add(Object element) {
			elements.add(element);
		}
		
		@Override
		Object buildTarget() {
			Object array = Array.newInstance(elementType, elements.size());
			int nextIndex = 0;
			for (Object element : elements) {
				Array.set(array, nextIndex, element);
				nextIndex++;
			}
			return array;
		}
	}
	
	private static abstract class ContainerBuilder {
		
		abstract void add(Object element);
		
		abstract Object buildTarget();
	}
	
	private static class CollectionContainerBuilder extends ContainerBuilder {
		private Collection<? super Object> collection;

		public CollectionContainerBuilder(Collection<? super Object> collection) {
			this.collection = collection;
		}
		
		@Override
		void add(Object element) {
			this.collection.add(element);
		}
		
		@Override
		Object buildTarget() {
			return collection;
		}
	}
	
}