/*
 * Hibernate, Relational Persistence for Idiomatic Java
 *
 * Copyright (c) 2010-2011, Red Hat, Inc. and/or its affiliates or third-party contributors as
 * indicated by the @author tags or express copyright attribution
 * statements applied by the authors.  All third-party contributions are
 * distributed under license by Red Hat, Inc.
 *
 * This copyrighted material is made available to anyone wishing to use, modify,
 * copy, or redistribute it subject to the terms and conditions of the GNU
 * Lesser General Public License, as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
 * for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this distribution; if not, write to:
 * Free Software Foundation, Inc.
 * 51 Franklin Street, Fifth Floor
 * Boston, MA  02110-1301  USA
 */
package org.hibernate.search.query.hibernate.impl;

import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.SoftReference;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Blob;
import java.sql.Clob;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;

import org.hibernate.search.util.logging.impl.Log;

import org.hibernate.HibernateException;
import org.hibernate.ScrollableResults;
import org.hibernate.engine.spi.SessionImplementor;
import org.hibernate.search.SearchException;
import org.hibernate.search.query.engine.spi.DocumentExtractor;
import org.hibernate.search.query.engine.spi.EntityInfo;
import org.hibernate.search.util.logging.impl.LoggerFactory;
import org.hibernate.type.Type;

/**
 * Implements scrollable and paginated resultsets.
 * Contrary to Query#iterate() or Query#list(), this implementation is
 * exposed to returned null objects (if the index is out of date).
 * <p/>
 * <p/>
 * The following methods that change the value of 'current' will check
 * and set its value to either 'afterLast' or 'beforeFirst' depending
 * on direction. This is to prevent rogue values from setting it outside
 * the boundaries of the results.
 * <ul>
 * <li>next()</li>
 * <li>previous()</li>
 * <li>scroll(i)</li>
 * <li>last()</li>
 * <li>first()</li>
 * </ul>
 *
 * @see org.hibernate.Query
 *
 * @author Emmanuel Bernard
 * @author John Griffin
 * @author Sanne Grinovero
 */
public class ScrollableResultsImpl implements ScrollableResults {

	private static final Log log = LoggerFactory.make();

	private final int first;
	private final int max;
	private final int fetchSize;
	private final Loader loader;
	private final DocumentExtractor documentExtractor;
	private final SessionImplementor session;

	/**
	 * Caches result rows and EntityInfo from
	 * <code>first</code> to <code>max</code>
	 */
	private final LoadedObject[] resultsContext;

	private int current;

	public ScrollableResultsImpl(int fetchSize, DocumentExtractor extractor,
			Loader loader, SessionImplementor sessionImplementor
	) {
		this.loader = loader;
		this.documentExtractor = extractor;
		this.fetchSize = fetchSize;
		this.session = sessionImplementor;
		this.first = extractor.getFirstIndex();
		this.max = extractor.getMaxIndex();
		int size = Math.max( max - first + 1, 0 );
		this.resultsContext = new LoadedObject[size];
		beforeFirst();
	}

	private LoadedObject ensureCurrentLoaded() {
		LoadedObject currentCacheRef = resultsContext[current - first];
		if ( currentCacheRef != null ) {
			return currentCacheRef;
		}
		// the loading window is optimized for scrolling in both directions:
		int windowStop = Math.min( max + 1 , current + fetchSize );
		int windowStart = Math.max( first, current - fetchSize + 1 );
		List<EntityInfo> entityInfosToLoad = new ArrayList<EntityInfo>( fetchSize );
		int sizeToLoad = 0;
		for (int x = windowStart; x < windowStop; x++) {
			int arrayIdx = x - first;
			LoadedObject lo = resultsContext[arrayIdx];
			if ( lo == null ) {
				lo = new LoadedObject();
				// makes hard references and extract EntityInfos:
				entityInfosToLoad.add( lo.getEntityInfo( x ) );
				resultsContext[arrayIdx] = lo;
				sizeToLoad++;
				if ( sizeToLoad >= fetchSize )
					break;
			}
		}
		//preload efficiently by batches:
		if ( sizeToLoad > 1 ) {
			loader.load( entityInfosToLoad.toArray( new EntityInfo[sizeToLoad] ) );
			//(no references stored at this point: they still need to be loaded one by one to inject null results)
		}
		return resultsContext[ current - first ];
	}

	/**
	 * {@inheritDoc}
	 */
	public boolean next() {
		//	Increases cursor pointer by one. If this places it >
		//	max + 1 (afterLast) then set it to afterLast and return
		//	false.
		if ( ++current > max ) {
			afterLast();
			return false;
		}
		return true;
	}

	public boolean previous() {
		//	Decreases cursor pointer by one. If this places it <
		//	first - 1 (beforeFirst) then set it to beforeFirst and
		//	return false.
		if ( --current < first ) {
			beforeFirst();
			return false;
		}
		return true;
	}

	public boolean scroll(int i) {
		//  Since we have to take into account that we can scroll any
		//  amount positive or negative, we perform the same tests that
		//  we performed in next() and previous().
		current = current + i;
		if ( current > max ) {
			afterLast();
			return false;
		}
		else if ( current < first ) {
			beforeFirst();
			return false;
		}
		else {
			return true;
		}
	}

	public boolean last() {
		current = max;
		if ( current < first ) {
			beforeFirst();
			return false;
		}
		return max >= first;
	}

	public boolean first() {
		current = first;
		if ( current > max ) {
			afterLast();
			return false;
		}
		return max >= first;
	}

	public void beforeFirst() {
		current = first - 1;
	}

	public void afterLast() {
		current = max + 1;
		//TODO help gc by clearing all structures when using forwardonly scrollmode.
	}

	public boolean isFirst() {
		return current == first;
	}

	public boolean isLast() {
		return current == max;
	}

	public void close() {
		try {
			documentExtractor.close();
		}
		catch (SearchException e) {
			log.unableToCloseSearcherInScrollableResult( e );
		}
	}

	public Object[] get() throws HibernateException {
		// don't throw an exception here just
		// return 'null' this is similar to the
		// RowSet spec in JDBC. It returns false
		// (or 0 I can't remember) but we can't
		// do that since we have to make up for
		// an Object[]. J.G
		if ( current < first || current > max ) return null;
		LoadedObject cacheEntry = ensureCurrentLoaded();
		return cacheEntry.getManagedResult( current );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Object get(int i) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Type getType(int i) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Integer getInteger(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Long getLong(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Float getFloat(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Boolean getBoolean(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Double getDouble(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Short getShort(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Byte getByte(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Character getCharacter(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public byte[] getBinary(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public String getText(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Blob getBlob(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Clob getClob(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public String getString(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public BigDecimal getBigDecimal(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public BigInteger getBigInteger(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Date getDate(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Locale getLocale(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public Calendar getCalendar(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	/**
	 * This method is not supported on Lucene based queries
	 * @throws UnsupportedOperationException always thrown
	 */
	public TimeZone getTimeZone(int col) {
		throw new UnsupportedOperationException( "Lucene does not work on columns" );
	}

	public int getRowNumber() {
		if ( max < first ) return -1;
		return current - first;
	}

	public boolean setRowNumber(int rowNumber) {
		if ( rowNumber >= 0 ) {
			current = first + rowNumber;
		}
		else {
			current = max + rowNumber + 1; //max row start at -1
		}
		return current >= first && current <= max;
	}

	private final class LoadedObject {

		private Reference<Object[]> entity; //never==null but Reference.get can return null
		private Reference<EntityInfo> einfo; //never==null but Reference.get can return null

		/**
		 * Gets the objects from cache if it is available and attached to session,
		 * or reload them and update the cache entry.
		 * @param x absolute position in fulltext result.
		 * @return the managed objects
		 */
		private Object[] getManagedResult(int x) {
			EntityInfo entityInfo = getEntityInfo( x );
			Object[] objects = entity == null ? null : entity.get();
			if ( objects != null && areAllEntitiesManaged( objects, entityInfo ) ) {
				return objects;
			}
			else {
				Object loaded = loader.load( entityInfo );
				if ( ! loaded.getClass().isArray() ) loaded = new Object[] { loaded };
				objects = (Object[]) loaded;
				this.entity = new SoftReference<Object[]>( objects );
				return objects;
			}
		}

		/**
		 * Extract an entityInfo, either from cache or from the index.
		 * @param x the position in the index.
		 * @return
		 */
		private EntityInfo getEntityInfo(int x) {
			EntityInfo entityInfo = einfo == null ? null : einfo.get();
			if ( entityInfo == null ) {
				try {
					entityInfo = documentExtractor.extract( x );
				}
				catch (IOException e) {
					throw new SearchException( "Unable to read Lucene topDocs[" + x + "]", e );
				}
				einfo = new SoftReference<EntityInfo>( entityInfo );
			}
			return entityInfo;
		}

	}

	private boolean areAllEntitiesManaged(Object[] objects,	EntityInfo entityInfo) {
		//check if all entities are session-managed and skip the check on projected values
		org.hibernate.Session hibSession = (org.hibernate.Session) session;
		if ( entityInfo.getProjection() != null ) {
			// using projection: test only for entities
			for ( int idx : entityInfo.getIndexesOfThis() ) {
				Object o = objects[idx];
				//TODO improve: is it useful to check for proxies and have them reassociated to persistence context?
				if ( ! hibSession.contains( o ) )
					return false;
			}
			return true;
		}
		else {
			return hibSession.contains( objects[0] );
		}
	}

}
