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

import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

import org.codefilarete.reflection.MethodReferenceDispatcher;
import org.codefilarete.reflection.SerializableAccessor;
import org.codefilarete.reflection.SerializableMutator;
import org.codefilarete.stalactite.engine.EntityCriteria;
import org.codefilarete.stalactite.engine.EntityCriteria.CriteriaPath;
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.EntityPersister.ExecutableProjectionQuery;
import org.codefilarete.stalactite.engine.EntityPersister.SelectAdapter;
import org.codefilarete.stalactite.engine.ExecutableProjection;
import org.codefilarete.stalactite.engine.ExecutableProjection.ProjectionDataProvider;
import org.codefilarete.stalactite.engine.runtime.query.EntityCriteriaSupport;
import org.codefilarete.stalactite.engine.runtime.query.EntityQueryCriteriaSupport;
import org.codefilarete.stalactite.query.ConfiguredEntityCriteria;
import org.codefilarete.stalactite.query.EntityFinder;
import org.codefilarete.stalactite.query.Operators;
import org.codefilarete.stalactite.query.api.CriteriaChain;
import org.codefilarete.stalactite.query.api.Selectable;
import org.codefilarete.stalactite.query.model.Limit;
import org.codefilarete.stalactite.query.model.OrderBy;
import org.codefilarete.stalactite.query.model.Select;
import org.codefilarete.stalactite.sql.result.Accumulator;
import org.codefilarete.tool.function.SerializableTriFunction;
import org.danekja.java.util.function.serializable.SerializableBiFunction;

/**
 * <ul>
 * Class aimed at handling projection 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#selectProjection(Consumer, Map, Accumulator, ConfiguredEntityCriteria, boolean, OrderBy, Limit)}</li>
 * </ul>
 *
 * @param <C> entity type
 * @param <I> identifier type
 * @author Guillaume Mary
 */
public class ProjectionQueryCriteriaSupport<C, I> {
	
	
	/** Support for {@link EntityCriteria} query execution */
	private final EntityFinder<C, I> entityFinder;
	
	private final EntityCriteriaSupport<C> entityCriteriaSupport;
	
	private final ProjectionQueryPageSupport<C> queryPageSupport;
	
	private Consumer<SelectAdapter<C>> selectAdapter;
	
	public ProjectionQueryCriteriaSupport(EntityFinder<C, I> entityFinder, Consumer<SelectAdapter<C>> selectAdapter) {
		this(entityFinder, entityFinder.newCriteriaSupport().getEntityCriteriaSupport(), new ProjectionQueryPageSupport<>(), selectAdapter);
	}
	
	public ProjectionQueryCriteriaSupport(EntityFinder<C, I> entityFinder, EntityCriteriaSupport<C> entityCriteriaSupport, Consumer<SelectAdapter<C>> selectAdapter) {
		this(entityFinder, entityCriteriaSupport, new ProjectionQueryPageSupport<>(), selectAdapter);
	}
	
	public ProjectionQueryCriteriaSupport(EntityFinder<C, I> entityFinder,
										  EntityCriteriaSupport<C> entityCriteriaSupport,
										  ProjectionQueryPageSupport<C> queryPageSupport,
										  Consumer<SelectAdapter<C>> selectAdapter) {
		this.entityFinder = entityFinder;
		this.entityCriteriaSupport = entityCriteriaSupport;
		this.queryPageSupport = queryPageSupport;
		this.selectAdapter = selectAdapter;
	}
	
	/**
	 * 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 ProjectionQueryCriteriaSupport<C, I> copyFor(ProjectionQueryPageSupport<C> otherPageSupport) {
		return new ProjectionQueryCriteriaSupport<>(entityFinder, entityCriteriaSupport, queryPageSupport.merge(otherPageSupport), this.selectAdapter);
	}
	
	public ProjectionQueryCriteriaSupport<C, I> copyFor(Consumer<SelectAdapter<C>> selectAdapter) {
		return new ProjectionQueryCriteriaSupport<>(entityFinder, entityCriteriaSupport, queryPageSupport, selectAdapter);
	}
	
	public EntityCriteriaSupport<C> getEntityCriteriaSupport() {
		return entityCriteriaSupport;
	}
	
	public ProjectionQueryPageSupport<C> getQueryPageSupport() {
		return queryPageSupport;
	}
	
	public ExecutableProjectionQuery<C, ?> wrapIntoExecutable() {
		Map<String, Object> values = new HashMap<>();
		MethodReferenceDispatcher methodDispatcher = new MethodReferenceDispatcher();
		return methodDispatcher
				.redirect((SerializableBiFunction<ExecutableProjection, Accumulator<? super ProjectionDataProvider, Object, Object>, Object>) ExecutableProjection::execute,
						wrapProjectionLoad(values))
				.redirect((SerializableTriFunction<ExecutableProjectionQuery<?, ?>, String, Object, Object>) ExecutableProjectionQuery::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((SerializableAccessor<ExecutableProjection, ExecutableProjection>) ExecutableProjection::distinct, queryPageSupport::distinct)
				.redirect((SerializableMutator<ExecutableProjection, Consumer<Set<Selectable<?>>>>) ExecutableProjection::selectInspector,
						selectInspector -> {
					this.selectAdapter = this.selectAdapter.andThen(selectAdapter -> selectInspector.accept(selectAdapter.getColumns()));
				})
				.redirect(EntityCriteria.class, entityCriteriaSupport, true)
				.build((Class<ExecutableProjectionQuery<C, ?>>) (Class) ExecutableProjectionQuery.class);
	}
	
	private <R> Function<Accumulator<? super ProjectionDataProvider, Object, R>, R> wrapProjectionLoad(
			Map<String, Object> values) {
		return (Accumulator<? super ProjectionDataProvider, Object, R> projectionDataProvider) -> {
			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.api.OrderByChain.Order.ASC
								: org.codefilarete.stalactite.query.api.OrderByChain.Order.DESC);
			});
			
			// creating an Accumulator that wraps the given one (projectionDataProvider) to plug the ProjectionDataProvider
			// onto the Function<Selectable<Object>, ?> that is consumed by the entityFinder.selectProjection(..) method
			Accumulator<Function<Selectable<Object>, ?>, Object, R> accumulator = new Accumulator<Function<Selectable<Object>, ?>, Object, R>() {
				@Override
				public Supplier<Object> supplier() {
					return projectionDataProvider.supplier();
				}
				
				@Override
				public BiConsumer<Object, Function<Selectable<Object>, ?>> aggregator() {
					return (o, selectableFunction) -> projectionDataProvider.aggregator().accept(o, new ProjectionDataProvider() {
						@Override
						public <O> O getValue(Selectable<O> selectable) {
							return (O) selectableFunction.apply((Selectable<Object>) selectable);
						}
						
						@Override
						public <O> O getValue(CriteriaPath<?, O> selectable) {
							return (O) selectableFunction.apply((Selectable<Object>) entityCriteriaSupport.getAggregateColumnMapping().giveColumn(selectable.getAccessors()));
						}
					});
				}
				
				@Override
				public Function<Object, R> finisher() {
					return projectionDataProvider.finisher();
				}
			};
			// we make an object that applies the SelectAdapter to the Select, this avoid to expose the Select class to end user
			Consumer<Select> selectConsumer = select -> ProjectionQueryCriteriaSupport.this.selectAdapter.accept(new SelectAdapterSupport<>(select, entityCriteriaSupport.getAggregateColumnMapping()));
			return entityFinder.selectProjection(
					selectConsumer,
					values,
					accumulator,
					entityCriteriaSupport,
					queryPageSupport.isDistinct(),
					orderBy,
					queryPageSupport.getLimit());
		};
	}
	
}
