/*
 * Hibernate, Relational Persistence for Idiomatic Java
 *
 * License: GNU Lesser General Public License (LGPL), version 2.1 or later.
 * See the lgpl.txt file in the root directory or <http://www.gnu.org/licenses/lgpl-2.1.html>.
 */
package org.hibernate.loader.criteria;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;

import org.hibernate.Criteria;
import org.hibernate.HibernateException;
import org.hibernate.LockMode;
import org.hibernate.LockOptions;
import org.hibernate.MappingException;
import org.hibernate.QueryException;
import org.hibernate.criterion.CriteriaQuery;
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.EnhancedProjection;
import org.hibernate.criterion.ParameterInfoCollector;
import org.hibernate.criterion.Projection;
import org.hibernate.engine.query.spi.OrdinalParameterDescriptor;
import org.hibernate.engine.spi.QueryParameters;
import org.hibernate.engine.spi.RowSelection;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.TypedValue;
import org.hibernate.hql.internal.ast.util.SessionFactoryHelper;
import org.hibernate.internal.CriteriaImpl;
import org.hibernate.internal.util.StringHelper;
import org.hibernate.internal.util.collections.ArrayHelper;
import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.entity.Loadable;
import org.hibernate.persister.entity.PropertyMapping;
import org.hibernate.persister.entity.Queryable;
import org.hibernate.sql.JoinType;
import org.hibernate.type.AssociationType;
import org.hibernate.type.CollectionType;
import org.hibernate.type.StringRepresentableType;
import org.hibernate.type.Type;

/**
 * @author Gavin King
 */
public class CriteriaQueryTranslator implements CriteriaQuery {

	public static final String ROOT_SQL_ALIAS = Criteria.ROOT_ALIAS + '_';

	private CriteriaQuery outerQueryTranslator;

	private final CriteriaImpl rootCriteria;
	private final String rootEntityName;
	private final String rootSQLAlias;

	private final Map<Criteria, CriteriaInfoProvider> criteriaInfoMap = new LinkedHashMap<Criteria, CriteriaInfoProvider>();
	private final Map<String, CriteriaInfoProvider> nameCriteriaInfoMap = new LinkedHashMap<String, CriteriaInfoProvider>();
	private final Map<Criteria, String> criteriaSQLAliasMap = new HashMap<Criteria, String>();
	private final Map<String, Criteria> aliasCriteriaMap = new HashMap<String, Criteria>();
	private final Map<String, Criteria> associationPathCriteriaMap = new LinkedHashMap<String, Criteria>();
	private final Map<String, JoinType> associationPathJoinTypesMap = new LinkedHashMap<String,JoinType>();
	private final Map<String, Criterion> withClauseMap = new HashMap<String, Criterion>();
	private Set<String> associations;

	private final SessionFactoryImplementor sessionFactory;
	private final SessionFactoryHelper helper;

	public CriteriaQueryTranslator(
			final SessionFactoryImplementor factory,
			final CriteriaImpl criteria,
			final String rootEntityName,
			final String rootSQLAlias,
			CriteriaQuery outerQuery) throws HibernateException {
		this( factory, criteria, rootEntityName, rootSQLAlias );
		outerQueryTranslator = outerQuery;
	}

	public CriteriaQueryTranslator(
			final SessionFactoryImplementor factory,
			final CriteriaImpl criteria,
			final String rootEntityName,
			final String rootSQLAlias) throws HibernateException {
		this.rootCriteria = criteria;
		this.rootEntityName = rootEntityName;
		this.sessionFactory = factory;
		this.rootSQLAlias = rootSQLAlias;
		this.helper = new SessionFactoryHelper(factory);
		createAliasCriteriaMap();
		createAssociationPathCriteriaMap();
		createCriteriaEntityNameMap();
		createCriteriaSQLAliasMap();
	}

	public void setAssociations(Set<String> associations) {
		this.associations = associations;
	}

	@Override
	public String generateSQLAlias() {
		int aliasCount = 0;
		return StringHelper.generateAlias( Criteria.ROOT_ALIAS, aliasCount ) + '_';
	}

	public String getRootSQLALias() {
		return rootSQLAlias;
	}

	private Criteria getAliasedCriteria(String alias) {
		return aliasCriteriaMap.get( alias );
	}

	public boolean isJoin(String path) {
		return associationPathCriteriaMap.containsKey( path );
	}

	public JoinType getJoinType(String path) {
		JoinType result = associationPathJoinTypesMap.get( path );
		return ( result == null ? JoinType.INNER_JOIN : result );
	}

	public Criteria getCriteria(String path) {
		return associationPathCriteriaMap.get( path );
	}

	public Set<Serializable> getQuerySpaces() {
		Set<Serializable> result = new HashSet<>();
		for ( CriteriaInfoProvider info : criteriaInfoMap.values() ) {
			result.addAll( Arrays.asList( info.getSpaces() ) );
		}
		for ( final Map.Entry<String, Criteria> entry : associationPathCriteriaMap.entrySet() ) {
			String path = entry.getKey();
			CriteriaImpl.Subcriteria crit = (CriteriaImpl.Subcriteria) entry.getValue();
			int index = path.lastIndexOf( '.' );
			if ( index > 0 ) {
				path = path.substring( index + 1, path.length() );
			}
			CriteriaInfoProvider info = criteriaInfoMap.get( crit.getParent() );
			CollectionPersister persister = getFactory().getMetamodel().collectionPersisters().get( info.getName() + "." + path );
			if ( persister != null ) {
				result.addAll( Arrays.asList( persister.getCollectionSpaces() ) );
			}
		}
		return result;
	}

	private void createAliasCriteriaMap() {
		aliasCriteriaMap.put( rootCriteria.getAlias(), rootCriteria );
		Iterator<CriteriaImpl.Subcriteria> iter = rootCriteria.iterateSubcriteria();
		while ( iter.hasNext() ) {
			Criteria subcriteria = iter.next();
			if ( subcriteria.getAlias() != null ) {
				Object old = aliasCriteriaMap.put( subcriteria.getAlias(), subcriteria );
				if ( old != null ) {
					throw new QueryException( "duplicate alias: " + subcriteria.getAlias() );
				}
			}
		}
	}

	private void createAssociationPathCriteriaMap() {
		final Iterator<CriteriaImpl.Subcriteria> iter = rootCriteria.iterateSubcriteria();
		while ( iter.hasNext() ) {
			CriteriaImpl.Subcriteria crit = iter.next();
			String wholeAssociationPath = getWholeAssociationPath( crit );
			Object old = associationPathCriteriaMap.put( wholeAssociationPath, crit );
			if ( old != null ) {
				throw new QueryException( "duplicate association path: " + wholeAssociationPath );
			}
			JoinType joinType = crit.getJoinType();
			old = associationPathJoinTypesMap.put( wholeAssociationPath, joinType );
			if ( old != null ) {
				// TODO : not so sure this is needed...
				throw new QueryException( "duplicate association path: " + wholeAssociationPath );
			}
			if ( crit.getWithClause() != null ) {
				this.withClauseMap.put( wholeAssociationPath, crit.getWithClause() );
			}
		}
	}

	private String getWholeAssociationPath(CriteriaImpl.Subcriteria subcriteria) {
		String path = subcriteria.getPath();

		// some messy, complex stuff here, since createCriteria() can take an
		// aliased path, or a path rooted at the creating criteria instance
		Criteria parent = null;
		if ( path.indexOf( '.' ) > 0 ) {
			// if it is a compound path
			String testAlias = StringHelper.root( path );
			if ( !testAlias.equals( subcriteria.getAlias() ) ) {
				// and the qualifier is not the alias of this criteria
				// -> check to see if we belong to some criteria other
				//  than the one that created us
				parent = aliasCriteriaMap.get( testAlias );
			}
		}
		if ( parent == null ) {
			// otherwise assume the parent is the the criteria that created us
			parent = subcriteria.getParent();
		}
		else {
			path = StringHelper.unroot( path );
		}

		if ( parent.equals( rootCriteria ) ) {
			// if it's the root criteria, we are done
			return path;
		}
		else {
			// otherwise, recurse
			return getWholeAssociationPath( ( CriteriaImpl.Subcriteria ) parent ) + '.' + path;
		}
	}

	private void createCriteriaEntityNameMap() {
		// initialize the rootProvider first
		final CriteriaInfoProvider rootProvider = new EntityCriteriaInfoProvider(
				(Queryable) sessionFactory.getEntityPersister( rootEntityName )
		);
		criteriaInfoMap.put( rootCriteria, rootProvider);
		nameCriteriaInfoMap.put( rootProvider.getName(), rootProvider );

		for ( Map.Entry<String, Criteria> entry : associationPathCriteriaMap.entrySet() ) {
			final String key = entry.getKey();
			final Criteria value = entry.getValue();
			final CriteriaInfoProvider info = getPathInfo( key );
			criteriaInfoMap.put( value, info );
			nameCriteriaInfoMap.put( info.getName(), info );
		}
	}


	private CriteriaInfoProvider getPathInfo(String path) {
		StringTokenizer tokens = new StringTokenizer( path, "." );
		String componentPath = "";

		// start with the 'rootProvider'
		CriteriaInfoProvider provider = nameCriteriaInfoMap.get( rootEntityName );

		while ( tokens.hasMoreTokens() ) {
			componentPath += tokens.nextToken();
			final Type type = provider.getType( componentPath );
			if ( type.isAssociationType() ) {
				// CollectionTypes are always also AssociationTypes - but there's not always an associated entity...
				final AssociationType atype = (AssociationType) type;
				final CollectionType ctype = type.isCollectionType() ? (CollectionType)type : null;
				final Type elementType = (ctype != null) ? ctype.getElementType( sessionFactory ) : null;
				// is the association a collection of components or value-types? (i.e a collection of valued types?)
				if ( ctype != null  && elementType.isComponentType() ) {
					provider = new ComponentCollectionCriteriaInfoProvider( helper.getCollectionPersister(ctype.getRole()) );
				}
				else if ( ctype != null && !elementType.isEntityType() ) {
					provider = new ScalarCollectionCriteriaInfoProvider( helper, ctype.getRole() );
				}
				else {
					provider = new EntityCriteriaInfoProvider(
							(Queryable) sessionFactory.getEntityPersister( atype.getAssociatedEntityName( sessionFactory ) )
					);
				}

				componentPath = "";
			}
			else if ( type.isComponentType() ) {
				if (!tokens.hasMoreTokens()) {
					throw new QueryException(
							"Criteria objects cannot be created directly on components.  Create a criteria on " +
									"owning entity and use a dotted property to access component property: " + path
					);
				}
				else {
					componentPath += '.';
				}
			}
			else {
				throw new QueryException( "not an association: " + componentPath );
			}
		}

		return provider;
	}

	public int getSQLAliasCount() {
		return criteriaSQLAliasMap.size();
	}

	private void createCriteriaSQLAliasMap() {
		int i = 0;
		for ( Map.Entry<Criteria, CriteriaInfoProvider> entry : criteriaInfoMap.entrySet() ) {
			final Criteria crit = entry.getKey();
			final CriteriaInfoProvider value = entry.getValue();
			String alias = crit.getAlias();
			if ( alias == null ) {
				// the entity name
				alias = value.getName();
			}
			criteriaSQLAliasMap.put( crit, StringHelper.generateAlias( alias, i++ ) );
		}

		criteriaSQLAliasMap.put( rootCriteria, rootSQLAlias );
	}

	public CriteriaImpl getRootCriteria() {
		return rootCriteria;
	}

	public QueryParameters getQueryParameters() {
		final RowSelection selection = new RowSelection();
		selection.setFirstRow( rootCriteria.getFirstResult() );
		selection.setMaxRows( rootCriteria.getMaxResults() );
		selection.setTimeout( rootCriteria.getTimeout() );
		selection.setFetchSize( rootCriteria.getFetchSize() );

		final LockOptions lockOptions = new LockOptions();
		final Map<String, LockMode> lockModeMap = rootCriteria.getLockModes();
		for ( Map.Entry<String, LockMode> entry : lockModeMap.entrySet() ) {
			final String key = entry.getKey();
			final LockMode value = entry.getValue();
			final Criteria subcriteria = getAliasedCriteria( key );
			lockOptions.setAliasSpecificLockMode( getSQLAlias( subcriteria ), value );
		}

		final List<Object> values = new ArrayList<Object>();
		final List<Type> types = new ArrayList<Type>();

		final Iterator<CriteriaImpl.Subcriteria> subcriteriaIterator = rootCriteria.iterateSubcriteria();
		while ( subcriteriaIterator.hasNext() ) {
			final CriteriaImpl.Subcriteria subcriteria = subcriteriaIterator.next();
			final LockMode lm = subcriteria.getLockMode();
			if ( lm != null ) {
				lockOptions.setAliasSpecificLockMode( getSQLAlias( subcriteria ), lm );
			}
			if ( subcriteria.getWithClause() != null ) {
				final TypedValue[] tv = subcriteria.getWithClause().getTypedValues( subcriteria, this );
				for ( TypedValue aTv : tv ) {
					values.add( aTv.getValue() );
					types.add( aTv.getType() );
				}
			}
		}

		// Type and value gathering for the WHERE clause needs to come AFTER lock mode gathering,
		// because the lock mode gathering loop now contains join clauses which can contain
		// parameter bindings (as in the HQL WITH clause).
		final Iterator<CriteriaImpl.CriterionEntry> iter = rootCriteria.iterateExpressionEntries();
		while ( iter.hasNext() ) {
			final CriteriaImpl.CriterionEntry ce = iter.next();
			final TypedValue[] tv = ce.getCriterion().getTypedValues( ce.getCriteria(), this );
			for ( TypedValue aTv : tv ) {
				values.add( aTv.getValue() );
				types.add( aTv.getType() );
			}
		}

		final Object[] valueArray = values.toArray();
		final Type[] typeArray = ArrayHelper.toTypeArray( types );
		return new QueryParameters(
				typeArray,
				valueArray,
				lockOptions,
				selection,
				rootCriteria.isReadOnlyInitialized(),
				( rootCriteria.isReadOnlyInitialized() && rootCriteria.isReadOnly() ),
				rootCriteria.getCacheable(),
				rootCriteria.getCacheRegion(),
				rootCriteria.getComment(),
				rootCriteria.getQueryHints(),
				rootCriteria.isLookupByNaturalKey(),
				rootCriteria.getResultTransformer()
		);
	}

	public boolean hasProjection() {
		return rootCriteria.getProjection() != null;
	}

	public String getGroupBy() {
		if ( rootCriteria.getProjection().isGrouped() ) {
			return rootCriteria.getProjection()
					.toGroupSqlString( rootCriteria.getProjectionCriteria(), this );
		}
		else {
			return "";
		}
	}

	public String getSelect() {
		return rootCriteria.getProjection().toSqlString(
				rootCriteria.getProjectionCriteria(),
				0,
				this
		);
	}

	/* package-protected */
	Type getResultType(Criteria criteria) {
		return getFactory().getTypeResolver().getTypeFactory().manyToOne( getEntityName( criteria ) );
	}

	public Type[] getProjectedTypes() {
		return rootCriteria.getProjection().getTypes( rootCriteria, this );
	}

	public String[] getProjectedColumnAliases() {
		return rootCriteria.getProjection() instanceof EnhancedProjection ?
				( ( EnhancedProjection ) rootCriteria.getProjection() ).getColumnAliases( 0, rootCriteria, this ) :
				rootCriteria.getProjection().getColumnAliases( 0 );
	}

	public String[] getProjectedAliases() {
		return rootCriteria.getProjection().getAliases();
	}

	public String getWhereCondition() {
		StringBuilder condition = new StringBuilder( 30 );
		Iterator<CriteriaImpl.CriterionEntry> criterionIterator = rootCriteria.iterateExpressionEntries();
		while ( criterionIterator.hasNext() ) {
			CriteriaImpl.CriterionEntry entry = criterionIterator.next();
			String sqlString = entry.getCriterion().toSqlString( entry.getCriteria(), this );
			condition.append( sqlString );
			if ( criterionIterator.hasNext() ) {
				condition.append( " and " );
			}
		}
		return condition.toString();
	}

	public String getOrderBy() {
		StringBuilder orderBy = new StringBuilder( 30 );
		Iterator<CriteriaImpl.OrderEntry> criterionIterator = rootCriteria.iterateOrderings();
		while ( criterionIterator.hasNext() ) {
			CriteriaImpl.OrderEntry oe = criterionIterator.next();
			orderBy.append( oe.getOrder().toSqlString( oe.getCriteria(), this ) );
			if ( criterionIterator.hasNext() ) {
				orderBy.append( ", " );
			}
		}
		return orderBy.toString();
	}

	@Override
	public SessionFactoryImplementor getFactory() {
		return sessionFactory;
	}

	@Override
	public String getSQLAlias(Criteria criteria) {
		return criteriaSQLAliasMap.get( criteria );
	}

	@Override
	public String getEntityName(Criteria criteria) {
		final CriteriaInfoProvider infoProvider = criteriaInfoMap.get( criteria );
		return infoProvider != null ? infoProvider.getName() : null;
	}

	@Override
	public String getColumn(Criteria criteria, String propertyName) {
		String[] cols = getColumns( propertyName, criteria );
		if ( cols.length != 1 ) {
			throw new QueryException( "property does not map to a single column: " + propertyName );
		}
		return cols[0];
	}

	/**
	 * Get the names of the columns constrained
	 * by this criterion.
	 */
	@Override
	public String[] getColumnsUsingProjection(
			Criteria subcriteria,
			String propertyName) throws HibernateException {

		//first look for a reference to a projection alias
		final Projection projection = rootCriteria.getProjection();
		String[] projectionColumns = null;
		if ( projection != null ) {
			projectionColumns = ( projection instanceof EnhancedProjection ?
					( ( EnhancedProjection ) projection ).getColumnAliases( propertyName, 0, rootCriteria, this ) :
					projection.getColumnAliases( propertyName, 0 )
			);
		}
		if ( projectionColumns == null ) {
			//it does not refer to an alias of a projection,
			//look for a property
			try {
				return getColumns( propertyName, subcriteria );
			}
			catch ( HibernateException he ) {
				//not found in inner query , try the outer query
				if ( outerQueryTranslator != null ) {
					return outerQueryTranslator.getColumnsUsingProjection( subcriteria, propertyName );
				}
				else {
					throw he;
				}
			}
		}
		else {
			//it refers to an alias of a projection
			return projectionColumns;
		}
	}

	@Override
	public String[] getIdentifierColumns(Criteria criteria) {
		String[] idcols =
				( ( Loadable ) getPropertyMapping( getEntityName( criteria ) ) ).getIdentifierColumnNames();
		return StringHelper.qualify( getSQLAlias( criteria ), idcols );
	}

	@Override
	public Type getIdentifierType(Criteria criteria) {
		return ( ( Loadable ) getPropertyMapping( getEntityName( criteria ) ) ).getIdentifierType();
	}

	@Override
	public TypedValue getTypedIdentifierValue(Criteria criteria, Object value) {
		final Loadable loadable = ( Loadable ) getPropertyMapping( getEntityName( criteria ) );
		return new TypedValue( loadable.getIdentifierType(), value );
	}

	@Override
	public String[] getColumns(
			String propertyName,
			Criteria subcriteria) throws HibernateException {
		return getPropertyMapping( getEntityName( subcriteria, propertyName ) )
				.toColumns(
						getSQLAlias( subcriteria, propertyName ),
						getPropertyName( propertyName )
				);
	}

	/**
	 * Get the names of the columns mapped by a property path; if the
	 * property path is not found in subcriteria, try the "outer" query.
	 * Projection aliases are ignored.
	 */
	@Override
	public String[] findColumns(String propertyName, Criteria subcriteria )
	throws HibernateException {
		try {
			return getColumns( propertyName, subcriteria );
		}
		catch ( HibernateException he ) {
			//not found in inner query, try the outer query
			if ( outerQueryTranslator != null ) {
				return outerQueryTranslator.findColumns( propertyName, subcriteria );
			}
			else {
				throw he;
			}
		}
	}

	@Override
	public Type getTypeUsingProjection(Criteria subcriteria, String propertyName)
			throws HibernateException {

		//first look for a reference to a projection alias
		final Projection projection = rootCriteria.getProjection();
		Type[] projectionTypes = projection == null ?
				null :
				projection.getTypes( propertyName, subcriteria, this );

		if ( projectionTypes == null ) {
			try {
				//it does not refer to an alias of a projection,
				//look for a property
				return getType( subcriteria, propertyName );
			}
			catch ( HibernateException he ) {
				//not found in inner query , try the outer query
				if ( outerQueryTranslator != null ) {
					return outerQueryTranslator.getType( subcriteria, propertyName );
				}
				else {
					throw he;
				}
			}
		}
		else {
			if ( projectionTypes.length != 1 ) {
				//should never happen, i think
				throw new QueryException( "not a single-length projection: " + propertyName );
			}
			return projectionTypes[0];
		}
	}

	@Override
	public Type getType(Criteria subcriteria, String propertyName)
			throws HibernateException {
		return getPropertyMapping( getEntityName( subcriteria, propertyName ) )
				.toType( getPropertyName( propertyName ) );
	}

	/**
	 * Get the typed value for the given property value.
	 */
	@Override
	public TypedValue getTypedValue(Criteria subcriteria, String propertyName, Object value) throws HibernateException {
		// Detect discriminator values...
		if ( value instanceof Class ) {
			final Class entityClass = (Class) value;
			final Queryable q = SessionFactoryHelper.findQueryableUsingImports( sessionFactory, entityClass.getName() );
			if ( q != null ) {
				final Type type = q.getDiscriminatorType();
				String stringValue = q.getDiscriminatorSQLValue();
				if ( stringValue != null
						&& stringValue.length() > 2
						&& stringValue.startsWith( "'" )
						&& stringValue.endsWith( "'" ) ) {
					// remove the single quotes
					stringValue = stringValue.substring( 1, stringValue.length() - 1 );
				}

				// Convert the string value into the proper type.
				if ( type instanceof StringRepresentableType ) {
					final StringRepresentableType nullableType = (StringRepresentableType) type;
					value = nullableType.fromStringValue( stringValue );
				}
				else {
					throw new QueryException( "Unsupported discriminator type " + type );
				}
				return new TypedValue( type, value );
			}
		}
		// Otherwise, this is an ordinary value.
		return new TypedValue( getTypeUsingProjection( subcriteria, propertyName ), value );
	}

	private PropertyMapping getPropertyMapping(String entityName) throws MappingException {
		final CriteriaInfoProvider info = nameCriteriaInfoMap.get( entityName );
		if ( info == null ) {
			throw new HibernateException( "Unknown entity: " + entityName );
		}
		return info.getPropertyMapping();
	}

	//TODO: use these in methods above
	@Override
	public String getEntityName(Criteria subcriteria, String propertyName) {
		if ( propertyName.indexOf( '.' ) > 0 ) {
			final String root = StringHelper.root( propertyName );
			final Criteria crit = getAliasedCriteria( root );
			if ( crit != null ) {
				return getEntityName( crit );
			}
		}
		return getEntityName( subcriteria );
	}

	@Override
	public String getSQLAlias(Criteria criteria, String propertyName) {
		if ( propertyName.indexOf( '.' ) > 0 ) {
			final String root = StringHelper.root( propertyName );
			final Criteria subcriteria = getAliasedCriteria( root );
			if ( subcriteria != null ) {
				return getSQLAlias( subcriteria );
			}
		}
		return getSQLAlias( criteria );
	}

	@Override
	public String getPropertyName(String propertyName) {
		if ( propertyName.indexOf( '.' ) > 0 ) {
			final String root = StringHelper.root( propertyName );
			final Criteria criteria = getAliasedCriteria( root );
			if ( criteria != null ) {
				return propertyName.substring( root.length() + 1 );
			}
		}
		return propertyName;
	}

	public String getWithClause(String path) {
		final Criterion criterion = withClauseMap.get( path );
		return criterion == null ? null : criterion.toSqlString( getCriteria( path ), this );
	}

	public boolean hasRestriction(String path) {
		final CriteriaImpl.Subcriteria subcriteria = (CriteriaImpl.Subcriteria) getCriteria( path );
		return subcriteria != null && subcriteria.hasRestriction();
	}
}
