package org.codefilarete.stalactite.engine.runtime.query;

import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.codefilarete.reflection.AbstractReflector;
import org.codefilarete.reflection.Accessor;
import org.codefilarete.reflection.AccessorByMember;
import org.codefilarete.reflection.AccessorByMethodReference;
import org.codefilarete.reflection.AccessorChain;
import org.codefilarete.reflection.AccessorDefinition;
import org.codefilarete.reflection.Accessors;
import org.codefilarete.reflection.MethodReferenceDispatcher;
import org.codefilarete.reflection.MutatorByMethodReference;
import org.codefilarete.reflection.ReversibleMutator;
import org.codefilarete.reflection.ValueAccessPoint;
import org.codefilarete.stalactite.engine.EntityCriteria;
import org.codefilarete.stalactite.engine.EntityPersister.ExecutableEntityQuery;
import org.codefilarete.stalactite.engine.EntityCriteria.LimitAware;
import org.codefilarete.stalactite.engine.EntityCriteria.OrderByChain;
import org.codefilarete.stalactite.engine.EntityCriteria.OrderByChain.Order;
import org.codefilarete.stalactite.engine.ExecutableQuery;
import org.codefilarete.stalactite.engine.listener.PersisterListenerCollection;
import org.codefilarete.stalactite.engine.runtime.RelationalEntityPersister.ExecutableEntityQueryCriteria;
import org.codefilarete.stalactite.engine.runtime.query.EntityQueryCriteriaSupport.EntityQueryPageSupport.OrderByItem;
import org.codefilarete.stalactite.query.ConfiguredEntityCriteria;
import org.codefilarete.stalactite.query.EntityFinder;
import org.codefilarete.stalactite.query.RelationalEntityCriteria;
import org.codefilarete.stalactite.query.model.CriteriaChain;
import org.codefilarete.stalactite.query.model.Limit;
import org.codefilarete.stalactite.query.model.Operators;
import org.codefilarete.stalactite.query.model.OrderBy;
import org.codefilarete.stalactite.query.model.Selectable;
import org.codefilarete.stalactite.sql.result.Accumulator;
import org.codefilarete.tool.Nullable;
import org.codefilarete.tool.VisibleForTesting;
import org.codefilarete.tool.collection.Arrays;
import org.codefilarete.tool.collection.KeepOrderSet;
import org.codefilarete.tool.function.SerializableTriFunction;
import org.codefilarete.tool.function.ThrowingExecutable;
import org.danekja.java.util.function.serializable.SerializableBiConsumer;
import org.danekja.java.util.function.serializable.SerializableBiFunction;
import org.danekja.java.util.function.serializable.SerializableFunction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.codefilarete.tool.Nullable.nullable;

/**
 * <ul>
 * Class aimed at handling entity query configuration and execution triggering :
 * <li>query configuration will be done by redirecting {@link CriteriaChain} methods to an {@link EntityQueryCriteriaSupport}.</li>
 * <li>execution triggering calls {@link EntityFinder#select(ConfiguredEntityCriteria, Map, OrderBy, Limit)}
 * and wraps it into {@link PersisterListenerCollection#doWithSelectListener(Iterable, ThrowingExecutable)}</li>
 * </ul>
 * 
 * @param <C> entity type
 * @param <I> identifier type
 * @author Guillaume Mary
 */
public class EntityQueryCriteriaSupport<C, I> {
	
	public static final Logger LOGGER = LoggerFactory.getLogger(EntityQueryCriteriaSupport.class);
	
	/** Support for {@link EntityCriteria} query execution */
	private final EntityFinder<C, I> entityFinder;
	
	private final EntityCriteriaSupport<C> entityCriteriaSupport;
	
	private final EntityQueryPageSupport<C> queryPageSupport;
	
	public EntityQueryCriteriaSupport(EntityFinder<C, I> entityFinder,
									  EntityCriteriaSupport<C> entityCriteriaSupport) {
		this(entityFinder, entityCriteriaSupport, new EntityQueryPageSupport<>());
	}
	
	private EntityQueryCriteriaSupport(EntityFinder<C, I> entityFinder,
									   EntityCriteriaSupport<C> entityCriteriaSupport,
									   EntityQueryPageSupport<C> queryPageSupport) {
		this.entityFinder = entityFinder;
		this.entityCriteriaSupport = entityCriteriaSupport;
		this.queryPageSupport = queryPageSupport;
	}
	
	/**
	 * Makes a copy of this instance merged with given one
	 * Made to handle Spring Data's different ways of sorting (should have been put closer to its usage, but was too complex)
	 * 
	 * @param otherPageSupport some other paging options
	 * @return a merge of this instance with given page options
	 */
	public EntityQueryCriteriaSupport<C, I> copyFor(EntityQueryPageSupport<C> otherPageSupport) {
		return new EntityQueryCriteriaSupport<>(entityFinder, entityCriteriaSupport, queryPageSupport.merge(otherPageSupport));
	}
	
	public EntityCriteriaSupport<C> getEntityCriteriaSupport() {
		return entityCriteriaSupport;
	}
	
	public EntityQueryPageSupport<C> getQueryPageSupport() {
		return queryPageSupport;
	}
	
	public ExecutableEntityQueryCriteria<C, ?> wrapIntoExecutable() {
		Map<String, Object> values = new HashMap<>();
		MethodReferenceDispatcher methodDispatcher = new MethodReferenceDispatcher();
		return methodDispatcher
				.redirect((SerializableBiFunction<ExecutableQuery<C>, Accumulator<C, ? extends Collection<C>, Object>, Object>) ExecutableQuery::execute,
						wrapGraphLoad(values))
				.redirect((SerializableTriFunction<ExecutableEntityQuery<?, ?>, String, Object, Object>) ExecutableEntityQuery::set,
						// Don't use "values::put" because its signature returns previous value, which means it is a Function
						// and dispatch to redirect(..) that takes a Function as argument, which, at runtime,
						// will create some ClassCastException due to incompatible type between ExecutableEntityQuery
						// and values contained in the Map (because ExecutableEntityQuery::set returns ExecutableEntityQuery)
						(s, object) -> { values.put(s, object); }
				)
				.redirect(OrderByChain.class, queryPageSupport, true)
				.redirect(LimitAware.class, queryPageSupport, true)
				.redirect(RelationalEntityCriteria.class, entityCriteriaSupport, true)
				// making an exception for 2 of the methods that can't return the proxy
				.redirect((SerializableFunction<ConfiguredEntityCriteria, CriteriaChain>) ConfiguredEntityCriteria::getCriteria, entityCriteriaSupport::getCriteria)
				.redirect((SerializableFunction<ConfiguredEntityCriteria, Boolean>) ConfiguredEntityCriteria::hasCollectionCriteria, entityCriteriaSupport::hasCollectionCriteria)
				.build((Class<ConfiguredExecutableEntityQueryCriteria<C>>) (Class) ConfiguredExecutableEntityQueryCriteria.class);
	}
	
	/**
	 * A mashup to redirect all {@link ExecutableEntityQueryCriteria} methods being redirected to {@link EntityCriteriaSupport} while redirecting
	 * {@link ConfiguredEntityCriteria} methods to some specific methods of {@link EntityCriteriaSupport}.
	 * Made as such to avoid to expose internal / implementation methods "getCriteria" and "hasCollectionCriteria" to the
	 * configuration API ({@link ExecutableEntityQueryCriteria})
	 *
	 * @param <C>
	 * @author Guillaume Mary
	 */
	private interface ConfiguredExecutableEntityQueryCriteria<C> extends ConfiguredEntityCriteria, ExecutableEntityQueryCriteria<C, ConfiguredExecutableEntityQueryCriteria<C>> {
		
	}
	
	public <R> Function<Accumulator<C, ? extends Collection<C>, R>, R> wrapGraphLoad(Map<String, Object> values) {
		if (queryPageSupport.getLimit() != null && entityCriteriaSupport.hasCollectionProperty()) {
			throw new UnsupportedOperationException("Can't limit query when entity graph contains Collection relations");
		}
		if (entityCriteriaSupport.hasCollectionCriteria() && !queryPageSupport.getOrderBy().isEmpty()) {
			// a collection property in criteria will trigger a 2-phases load (ids, then entities)
			// which is no compatible with an SQL "order by" clause, therefore, we sort the result in memory
			// and we don't ask for SQL "order by" because it's useless
			// Note that we must wrap this creation in an if statement due to that entities don't implement Comparable, we avoid a
			// ClassCastException of the addAll(..) operation
			return (Accumulator<C, ? extends Collection<C>, R> accumulatorParam) -> {
				LOGGER.debug("Sorting loaded entities in memory");
				Set<C> loadedEntities = entityFinder.select(
						entityCriteriaSupport,
						values,
						new OrderBy(),
						queryPageSupport.getLimit());
				TreeSet<C> sortedResult = new TreeSet<>(buildComparator(queryPageSupport.getOrderBy()));
				sortedResult.addAll(loadedEntities);
				return accumulatorParam.collect(sortedResult);
			};
		} else {
			// single query
			return (Accumulator<C, ? extends Collection<C>, R> accumulatorParam) -> {
				OrderBy orderBy = new OrderBy();
				queryPageSupport.getOrderBy().forEach(duo -> {
					Selectable column = entityCriteriaSupport.getAggregateColumnMapping().giveColumn(duo.getProperty());
					orderBy.add(
							duo.isIgnoreCase()
									? Operators.lowerCase(column)
									: column,
							duo.getDirection() == Order.ASC
									? org.codefilarete.stalactite.query.model.OrderByChain.Order.ASC
									: org.codefilarete.stalactite.query.model.OrderByChain.Order.DESC);
				});
				Set<C> select = entityFinder.select(
						entityCriteriaSupport,
						values,
						orderBy,
						queryPageSupport.getLimit()
				);
				return accumulatorParam.collect(select);
			};
		}
	}
	
	@VisibleForTesting
	static <C> Comparator<C> buildComparator(KeepOrderSet<OrderByItem> orderBy) {
		Nullable<Comparator> result = nullable((Comparator) null);
		orderBy.forEach(orderByPawn -> {
			AccessorChain<Object, Comparable> propertyAccessor = orderByPawn.propertyAsAccessorChain();
			Comparator comparator;
			if (orderByPawn.isIgnoreCase()) {
				AccessorChain<Object, String> stringAccessor = (AccessorChain<Object, String>) (AccessorChain) propertyAccessor;
				comparator = Comparator.comparing(stringAccessor::get, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER));
			} else {
				comparator = Comparator.comparing(propertyAccessor::get, Comparator.nullsLast(Comparator.naturalOrder()));
			}
			if (orderByPawn.getDirection() == Order.DESC) {
				comparator = comparator.reversed();
			}
			if (result.isPresent()) {
				Comparator finalComparator = comparator;
				result.map(c -> c.thenComparing(finalComparator));
			} else {
				result.set(comparator);
			}
		});
		return result.get();
	}
	
	/**
	 * Gives the {@link Accessor} underneath given {@link ValueAccessPoint}, either being itself or its mirror if it's a {@link ReversibleMutator}
	 * @param valueAccessPoint a property accessor from which we need an {@link Accessor}
	 * @param <C> declaring class type
	 * @param <T> property type
	 * @return given {@link ValueAccessPoint} as an {@link Accessor}
	 */
	private static <C, T> Accessor<C, T> toAccessor(ValueAccessPoint<C> valueAccessPoint) {
		if (valueAccessPoint instanceof Accessor) {
			return (Accessor<C, T>) valueAccessPoint;
		} else if (valueAccessPoint instanceof ReversibleMutator) {
			return ((ReversibleMutator<C, T>) valueAccessPoint).toAccessor();
		} else {
			AccessorDefinition accessorDefinition = AccessorDefinition.giveDefinition(valueAccessPoint);
			AccessorByMember accessor = Accessors.accessor(accessorDefinition.getDeclaringClass(), accessorDefinition.getName(), accessorDefinition.getMemberType());
			return (Accessor<C, T>) accessor;
		}
	}
	
	
	/**
	 * Simple class that stores paging options of the query
	 * @author Guillaume Mary
	 */
	public static class EntityQueryPageSupport<C>
			implements OrderByChain<C, EntityQueryPageSupport<C>>, LimitAware<EntityQueryPageSupport<C>> {
		
		private Limit limit;
		private final KeepOrderSet<OrderByItem> orderBy = new KeepOrderSet<>();
		
		private Limit getLimit() {
			return limit;
		}
		
		private KeepOrderSet<OrderByItem> getOrderBy() {
			return orderBy;
		}
		
		@Override
		public EntityQueryPageSupport<C> limit(int count) {
			limit = new Limit(count);
			return this;
		}
		
		@Override
		public EntityQueryPageSupport<C> limit(int count, Integer offset) {
			limit = new Limit(count, offset);
			return this;
		}
		
		@Override
		public EntityQueryPageSupport<C> orderBy(SerializableFunction<C, ?> getter, Order order) {
			AccessorByMethodReference<C, ?> methodReference = new AccessorByMethodReference<>(getter);
			orderBy.add(new OrderByItem(Arrays.asList(methodReference), order, false));
			assertAccessorIsNotIterable(methodReference, methodReference.getPropertyType());
			return this;
		}
		
		@Override
		public EntityQueryPageSupport<C> orderBy(SerializableBiConsumer<C, ?> setter, Order order) {
			MutatorByMethodReference<C, ?> methodReference = new MutatorByMethodReference<>(setter);
			orderBy.add(new OrderByItem(Arrays.asList(methodReference), order, false));
			assertAccessorIsNotIterable(methodReference, methodReference.getPropertyType());
			return this;
		}
		
		@Override
		public EntityQueryPageSupport<C> orderBy(AccessorChain<C, ?> getter, Order order) {
			return orderBy(getter, order, false);
		}
		
		@Override
		public EntityQueryPageSupport<C> orderBy(AccessorChain<C, ?> getter, Order order, boolean ignoreCase) {
			orderBy.add(new OrderByItem(getter.getAccessors(), order, ignoreCase));
			getter.getAccessors().forEach(accessor -> assertAccessorIsNotIterable(accessor, AccessorDefinition.giveDefinition(accessor).getMemberType()));
			return this;
		}
		
		private void assertAccessorIsNotIterable(ValueAccessPoint valueAccessPoint, Class memberType) {
			if (Iterable.class.isAssignableFrom(memberType)) {
				throw new IllegalArgumentException("OrderBy clause on a Collection property is unsupported due to eventual inconsistency"
						+ " with Collection nature : "
						+ (valueAccessPoint instanceof AbstractReflector
							? ((AbstractReflector<?>) valueAccessPoint).getDescription()
							: AccessorDefinition.giveDefinition(valueAccessPoint)).toString());
			}
		}
		
		/**
		 * Creates a copy of this instance by merging its options with another.
		 * Made to handle Spring Data's different ways of sorting (should have been put closer to its usage, but was too complex) 
		 * 
		 * @param other some other paging options
		 * @return a merge of this instance with given one
		 */
		private EntityQueryPageSupport<C> merge(EntityQueryPageSupport<C> other) {
			EntityQueryPageSupport<C> duplicate = new EntityQueryPageSupport<>();
			// applying this instance's limit and orderBy options
			if (this.getLimit() != null) {
				duplicate.limit(this.getLimit().getCount(), this.getLimit().getOffset());
			}
			duplicate.orderBy.addAll(this.orderBy);
			// adding other instance's limit and orderBy options (may overwrite info, but that's user responsibility, we can't do anything smart here)
			if (other.getLimit() != null) {
				duplicate.limit(other.getLimit().getCount(), other.getLimit().getOffset());
			}
			duplicate.orderBy.addAll(other.orderBy);
			return duplicate;
		}
		
		public static class OrderByItem {
			
			private final List<? extends ValueAccessPoint<?>> property;
			private final Order direction;
			private final boolean ignoreCase;
			
			public OrderByItem(List<? extends ValueAccessPoint<?>> property, Order direction, boolean ignoreCase) {
				this.property = property;
				this.direction = direction;
				this.ignoreCase = ignoreCase;
			}
			
			public List<? extends ValueAccessPoint<?>> getProperty() {
				return property;
			}
			
			public Order getDirection() {
				return direction;
			}
			
			public boolean isIgnoreCase() {
				return ignoreCase;
			}
			
			public AccessorChain<Object, Comparable> propertyAsAccessorChain() {
				AccessorChain<Object, Comparable> result;
				if (property.size() == 1) {
					ValueAccessPoint<?> valueAccessPoint = property.get(0);
					if (valueAccessPoint instanceof Accessor) {
						result = new AccessorChain<>((Accessor<?, Comparable>) valueAccessPoint);
					} else {
						AccessorDefinition accessorDefinition = AccessorDefinition.giveDefinition(valueAccessPoint);
						result = new AccessorChain<>(Accessors.accessor(accessorDefinition.getDeclaringClass(), accessorDefinition.getName(), accessorDefinition.getMemberType()));
					}
				} else {
					result = new AccessorChain<>(property.stream().map(EntityQueryCriteriaSupport::toAccessor).collect(Collectors.toList()));
				}
				result.setNullValueHandler(AccessorChain.RETURN_NULL);
				return result;
			}
		}
	}
}
