package org.codefilarete.stalactite.engine.runtime;

import java.sql.ResultSet;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.codefilarete.stalactite.engine.SelectExecutor;
import org.codefilarete.stalactite.engine.configurer.builder.BuildLifeCycleListener;
import org.codefilarete.stalactite.engine.configurer.builder.PersisterBuilderContext;
import org.codefilarete.stalactite.engine.runtime.load.EntityInflater;
import org.codefilarete.stalactite.engine.runtime.load.EntityJoinTree;
import org.codefilarete.stalactite.engine.runtime.load.EntityTreeInflater;
import org.codefilarete.stalactite.engine.runtime.load.EntityTreeQueryBuilder;
import org.codefilarete.stalactite.engine.runtime.load.EntityTreeQueryBuilder.EntityTreeQuery;
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.builder.QuerySQLBuilderFactory.QuerySQLBuilder;
import org.codefilarete.stalactite.query.model.CriteriaChain;
import org.codefilarete.stalactite.query.model.GroupBy;
import org.codefilarete.stalactite.query.model.Having;
import org.codefilarete.stalactite.query.model.Limit;
import org.codefilarete.stalactite.query.model.OrderBy;
import org.codefilarete.stalactite.query.model.Query;
import org.codefilarete.stalactite.query.model.Select;
import org.codefilarete.stalactite.query.model.Selectable;
import org.codefilarete.stalactite.query.model.Where;
import org.codefilarete.stalactite.sql.ConnectionProvider;
import org.codefilarete.stalactite.sql.Dialect;
import org.codefilarete.stalactite.sql.ddl.structure.Column;
import org.codefilarete.stalactite.sql.ddl.structure.Table;
import org.codefilarete.stalactite.sql.result.Accumulator;
import org.codefilarete.stalactite.sql.result.ColumnedRow;
import org.codefilarete.stalactite.sql.result.ColumnedRowIterator;
import org.codefilarete.stalactite.sql.statement.PreparedSQL;
import org.codefilarete.stalactite.sql.statement.ReadOperation;
import org.codefilarete.stalactite.sql.statement.SQLExecutionException;
import org.codefilarete.stalactite.sql.statement.SQLOperation.SQLOperationListener;
import org.codefilarete.stalactite.sql.statement.SQLStatement;
import org.codefilarete.stalactite.sql.statement.StringParamedSQL;
import org.codefilarete.stalactite.sql.statement.binder.PreparedStatementWriter;
import org.codefilarete.stalactite.sql.statement.binder.ResultSetReader;
import org.codefilarete.tool.collection.Iterables;
import org.codefilarete.tool.collection.Maps;

import static org.codefilarete.tool.bean.Objects.preventNull;

/**
 * Class aimed at loading an entity graph which is selected by some criteria on some properties coming from a {@link CriteriaChain}.
 * 
 * Implementation is based on {@link EntityJoinTree} to build the query and the entity graph.
 * 
 * @author Guillaume Mary
 * @see EntityFinder#select(ConfiguredEntityCriteria, Map, OrderBy, Limit)
 */
public class RelationalEntityFinder<C, I, T extends Table<T>> implements EntityFinder<C, I> {
	
	private static final String PRIMARY_KEY_ALIAS = "rootId";
	
	private final EntityJoinTree<C, I> entityJoinTree;
	
	private final ConnectionProvider connectionProvider;

	private final Dialect dialect;
	private final EntityCriteriaSupport<C> criteriaSupport;
	private SelectExecutor<C, I> selectExecutor;
	
	private EntityTreeQuery<C> entityTreeQuery;
	
	private Query query;
	
	private SQLOperationListener<?> operationListener;
	
	public RelationalEntityFinder(EntityJoinTree<C, I> entityJoinTree,
								  SelectExecutor<C, I> selectExecutor,
								  ConnectionProvider connectionProvider,
								  Dialect dialect) {
		this.entityJoinTree = entityJoinTree;
		this.selectExecutor = selectExecutor;
		this.connectionProvider = connectionProvider;
		this.dialect = dialect;
		this.entityTreeQuery = new EntityTreeQueryBuilder<>(this.entityJoinTree, dialect.getColumnBinderRegistry()).buildSelectQuery();
		this.criteriaSupport = new EntityCriteriaSupport<>(this.entityJoinTree);
		
		PersisterBuilderContext.CURRENT.get().addBuildLifeCycleListener(new BuildLifeCycleListener() {
			@Override
			public void afterBuild() {
			}
			
			@Override
			public void afterAllBuild() {
				buildQuery();
			}
		});
	}
	
	public RelationalEntityFinder(AdvancedEntityPersister<C, I> mainPersister,
								  ConnectionProvider connectionProvider,
								  Dialect dialect,
								  boolean withImmediateQueryBuild) {
		this.entityJoinTree = mainPersister.getEntityJoinTree();
		this.connectionProvider = connectionProvider;
		this.dialect = dialect;
		this.entityTreeQuery = new EntityTreeQueryBuilder<>(this.entityJoinTree, dialect.getColumnBinderRegistry()).buildSelectQuery();
		this.criteriaSupport = new EntityCriteriaSupport<>(this.entityJoinTree, withImmediateQueryBuild);
	}
	
	private void buildQuery() {
		entityTreeQuery = new EntityTreeQueryBuilder<>(this.entityJoinTree, dialect.getColumnBinderRegistry()).buildSelectQuery();
		query = entityTreeQuery.getQuery();
	}
	
	@Override
	public void setOperationListener(SQLOperationListener<?> operationListener) {
		this.operationListener = operationListener;
	}
	
	@Override
	public EntityJoinTree<C, I> getEntityJoinTree() {
		return entityJoinTree;
	}
	
	@Override
	public EntityQueryCriteriaSupport<C, I> newCriteriaSupport() {
		return new EntityQueryCriteriaSupport<>(this, criteriaSupport.copy());
	}
	
	public Set<C> selectFromQueryBean(String sql, Map<String, Object> values) {
		// Computing parameter binders from values
		Map<String, PreparedStatementWriter<?>> parameterBinders = new HashMap<>();
		values.forEach((paramName, value) -> {
			PreparedStatementWriter<?> writer = dialect.getColumnBinderRegistry().getWriter(value.getClass());
			parameterBinders.put(paramName, writer);
		});
		
		return selectFromQueryBean(sql, values, parameterBinders);
	}
	
	public Set<C> selectFromQueryBean(String sql, Map<String, Object> values, Map<String, PreparedStatementWriter<?>> parameterBinders) {
		// we use EntityTreeQueryBuilder to get the inflater, please note that it also build the default Query
		EntityTreeInflater<C> inflater = entityTreeQuery.getInflater();
		
		// computing SQL readers from dialect binder registry
		Query query = entityTreeQuery.getQuery();
		Map<Selectable<?>, ResultSetReader<?>> selectParameterBinders = new HashMap<>();
		Map<Selectable<?>, String> aliases = new HashMap<>();
		query.getColumns().forEach(selectable -> {
			ResultSetReader<?> reader;
			String alias = preventNull(query.getAliases().get(selectable), selectable.getExpression());
			if (selectable instanceof Column) {
				reader = dialect.getColumnBinderRegistry().getReader((Column) selectable);
				selectParameterBinders.put(selectable, reader);
			} else {
				reader = dialect.getColumnBinderRegistry().getReader(selectable.getJavaType());
			}
			selectParameterBinders.put(selectable, reader);
			aliases.put(selectable, alias);
		});
		
		StringParamedSQL statement = new StringParamedSQL(sql, parameterBinders);
		statement.setValues(values);
		return new InternalExecutor(inflater, selectParameterBinders, aliases).execute(statement);
	}
	
	/**
	 * Implementation note: the load is done in 2 phases: one for root ids selection from criteria, a second for the whole graph load from found root ids.
	 */
	@Override
	public Set<C> select(ConfiguredEntityCriteria where,
						 Map<String, Object> valuesPerParam,
						 OrderBy orderBy,
						 Limit limit) {
		
		// When the condition contains some criteria on a collection, the ResultSet contains only data matching it,
		// then the graph is a partial view of the real entity. Therefore, when the condition contains some Collection criteria
		// we must load the graph in 2 phases: a first lookup for ids matching the result, and a second phase that loads the entity graph
		// according to the ids
		if (where.hasCollectionCriteria()) {
			Query queryClone = new Query(
					new Select(),
					entityTreeQuery.getQuery().getFromDelegate(),
					new Where<>(where.getCriteria()),
					new GroupBy(),
					new Having(),
					orderBy,
					limit);
			
			QuerySQLBuilder sqlQueryBuilder = dialect.getQuerySQLBuilderFactory().queryBuilder(queryClone);
			
			// First phase: selecting ids (made by clearing selected elements for performance issue)
			Column<T, I> pk = (Column<T, I>) Iterables.first(((Table) entityJoinTree.getRoot().getTable()).getPrimaryKey().getColumns());
			queryClone.select(pk, PRIMARY_KEY_ALIAS);
			Map<Column<?, ?>, String> aliases = Maps.asMap(pk, PRIMARY_KEY_ALIAS);
			Map<Column<?, ?>, ResultSetReader<?>> columnReaders = Maps.asMap(pk, dialect.getColumnBinderRegistry().getBinder(pk));
			Set<I> ids = readIds(sqlQueryBuilder.toPreparableSQL().toPreparedSQL(new HashMap<>()), columnReaders, aliases);
			
			if (ids.isEmpty()) {
				// No result found, we must stop here because request below doesn't support in(..) without values (SQL error from database)
				return Collections.emptySet();
			} else {
				// Second phase : selecting elements by found identifiers
				return selectExecutor.select(ids);
			}
		} else {
			// The condition doesn't have a criteria on a collection property (*-to-many): the load can be done with one query because the SQL criteria
			// doesn't make a subset of the entity graph
			// We clone the query to avoid polluting the instance one, else, from select(..) to select(..), we append the criteria at the end of it,
			// which makes the query usually returning no data (because of the condition mix)
			Query queryClone = new Query(
					new Select(entityTreeQuery.getQuery().getSelectDelegate()),
					entityTreeQuery.getQuery().getFromDelegate(),
					new Where<>(where.getCriteria()),
					new GroupBy(),
					new Having(),
					orderBy,
					limit);
			
			QuerySQLBuilder sqlQueryBuilder = dialect.getQuerySQLBuilderFactory().queryBuilder(queryClone);
			PreparedSQL preparedSQL = sqlQueryBuilder.toPreparableSQL().toPreparedSQL(valuesPerParam);
			return new InternalExecutor(entityTreeQuery).execute(preparedSQL);
		}
	}
	
	private Set<I> readIds(PreparedSQL preparedSQL, Map<Column<?, ?>, ResultSetReader<?>> columnReaders, Map<Column<?, ?>, String> aliases) {
		EntityInflater<C, I> entityInflater = entityJoinTree.getRoot().getEntityInflater();
		try (ReadOperation<Integer> closeableOperation = dialect.getReadOperationFactory().createInstance(preparedSQL, connectionProvider)) {
			ColumnedRowIterator rowIterator = new ColumnedRowIterator(closeableOperation.execute(), columnReaders, aliases);
			return Iterables.collect(() -> rowIterator, row -> entityInflater.giveIdentifier(row), HashSet::new);
		} catch (RuntimeException e) {
			throw new SQLExecutionException(preparedSQL.getSQL(), e);
		}
	}
	
	@Override
	public <R, O> R selectProjection(Consumer<Select> selectAdapter,
									 Map<String, Object> values,
									 Accumulator<? super Function<Selectable<O>, O>, Object, R> accumulator,
									 ConfiguredEntityCriteria where,
									 boolean distinct,
									 OrderBy orderBy,
									 Limit limit) {
		Query queryClone = new Query(new Select(), query.getFromDelegate(), new Where<>(where.getCriteria()), new GroupBy(), new Having(), orderBy, limit);
		queryClone.getSelectDelegate().setDistinct(distinct);
		QuerySQLBuilder sqlQueryBuilder = dialect.getQuerySQLBuilderFactory().queryBuilder(queryClone);
		
		// First phase : selecting ids (made by clearing selected elements for performance issue)
		selectAdapter.accept(queryClone.getSelectDelegate());
		Map<Selectable<?>, ResultSetReader<?>> columnReaders = Iterables.map(queryClone.getColumns(), Function.identity(), selectable -> dialect.getColumnBinderRegistry().getBinder(selectable.getJavaType()));
		
		PreparedSQL preparedSQL = sqlQueryBuilder.toPreparableSQL().toPreparedSQL(values);
		Map<Selectable<?>, String> aliases = queryClone.getAliases();
		
		// Propagating aliases from the original query to clone if the user didn't mention the alias
		// This avoids the later thrown exception "Column doesn't exist : null" because the column reading is based on the alias (which doesn't exist)
		Map<Selectable<?>, String> defaultAliases = query.getAliases();
		aliases.entrySet().forEach(alias -> {
			if (alias.getValue() == null) {
				alias.setValue(defaultAliases.get(alias.getKey()));
			}
		});
		return readProjection(preparedSQL, columnReaders, aliases, accumulator);
	}
	
	private <R, O> R readProjection(PreparedSQL preparedSQL,
									Map<Selectable<?>, ResultSetReader<?>> columnReaders,
									Map<Selectable<?>, String> aliases,
									Accumulator<? super Function<Selectable<O>, O>, Object, R> accumulator) {
		try (ReadOperation<Integer> closeableOperation = dialect.getReadOperationFactory().createInstance(preparedSQL, connectionProvider)) {
			ColumnedRowIterator rowIterator = new ColumnedRowIterator(closeableOperation.execute(), columnReaders, aliases);
			return accumulator.collect(Iterables.stream(rowIterator).map(row -> (Function<Selectable<O>, O>) row::get).collect(Collectors.toList()));
		} catch (RuntimeException e) {
			throw new SQLExecutionException(preparedSQL.getSQL(), e);
		}
	}
	
	/**
	 * Small class to avoid passing {@link EntityTreeQuery} as argument to all methods
	 */
	private class InternalExecutor {
		
		private final EntityTreeInflater<C> inflater;
		private final Map<Selectable<?>, ResultSetReader<?>> selectParameterBinders;
		private final Map<Selectable<?>, String> columnAliases;
		
		private InternalExecutor(EntityTreeQuery<C> entityTreeQuery) {
			this(entityTreeQuery.getInflater(), entityTreeQuery.getSelectParameterBinders(), entityTreeQuery.getColumnAliases());
		}
		
		public InternalExecutor(EntityTreeInflater<C> inflater,
								Map<Selectable<?>, ? extends ResultSetReader<?>> selectParameterBinders,
								Map<Selectable<?>, String> columnAliases) {
			this.inflater = inflater;
			this.selectParameterBinders = (Map<Selectable<?>, ResultSetReader<?>>) selectParameterBinders;
			this.columnAliases = columnAliases;
		}
		
		protected <ParamType> Set<C> execute(SQLStatement<ParamType> query) {
			try (ReadOperation<ParamType> readOperation = dialect.getReadOperationFactory().createInstance(query, connectionProvider)) {
				readOperation.setListener((SQLOperationListener<ParamType>) operationListener);
				// Note that setValues must be done after operationListener set
				readOperation.setValues(query.getValues());
				return transform(readOperation);
			} catch (RuntimeException e) {
				throw new SQLExecutionException(query.getSQL(), e);
			}
		}
		
		protected Set<C> transform(ReadOperation<?> closeableOperation) {
			ResultSet resultSet = closeableOperation.execute();
			// NB: we give the same ParametersBinders of those given at ColumnParameterizedSelect since the row iterator is expected to read column from it
			ColumnedRowIterator rowIterator = new ColumnedRowIterator(resultSet, selectParameterBinders, columnAliases);
			return transform(rowIterator);
		}
		
		protected Set<C> transform(Iterator<? extends ColumnedRow> rowIterator) {
			return inflater.transform(() -> (Iterator<ColumnedRow>) rowIterator, 50);
		}
	}
}
