package org.jboss.cache.search;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.*;
import org.hibernate.HibernateException;
import org.hibernate.search.FullTextFilter;
import org.hibernate.search.SearchException;
import org.hibernate.search.engine.DocumentBuilder;
import org.hibernate.search.engine.DocumentExtractor;
import org.hibernate.search.engine.FilterDef;
import org.hibernate.search.engine.SearchFactoryImplementor;
import org.hibernate.search.filter.ChainedFilter;
import org.hibernate.search.filter.FilterKey;
import org.hibernate.search.impl.SearchFactoryImpl;
import org.hibernate.search.query.FullTextFilterImpl;
import org.hibernate.search.reader.ReaderProvider;
import static org.hibernate.search.reader.ReaderProviderHelper.getIndexReaders;
import org.hibernate.search.store.DirectoryProvider;
import org.hibernate.transform.ResultTransformer;
import org.jboss.cache.Cache;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Implementation class of the CacheQuery interface.
 * <p/>
 *
 * @author Navin Surtani  - navin@surtani.org
 */
public class CacheQueryImpl implements CacheQuery
{
//   private Cache cache; - Removed on 11/07/2008, cache is assigned but never used. Hence removed.
   private Class[] classes;
   private Sort sort;
   private Filter filter;
   private Map<String, FullTextFilterImpl> filterDefinitions;
   private SearchFactoryImplementor searchFactory;
   private Integer firstResult;
   private Integer resultSize;
   private Integer maxResults;
   private static final Log log = LogFactory.getLog(CacheQueryImpl.class);
   private Set<Class> classesAndSubclasses;
   private boolean needClassFilterClause;
   private Query luceneQuery;
   private int fetchSize;
   private String[] indexProjection;
   private ResultTransformer resultTransformer;
   CacheEntityLoader entityLoader;


   public CacheQueryImpl(Query luceneQuery, SearchFactoryImplementor searchFactory, Cache cache)
   {
      this.luceneQuery = luceneQuery;
//      this.cache = cache;
      entityLoader = new CacheEntityLoader(cache);
      this.searchFactory = searchFactory;
   }

   public CacheQueryImpl(Query luceneQuery, SearchFactoryImplementor searchFactory, Cache cache, Class... classes)
   {
      this(luceneQuery, searchFactory, cache);
      this.classes = classes;
   }

   /**
    * Takes in a lucene filter and sets it to the filter field in the class.
    *
    * @param f - lucene filter
    */

   public void setFilter(Filter f)
   {
      filter = f;
   }



   /**
    * @return The result size of the query.
    */
   public int getResultSize()
   {
      if (resultSize == null)
      {
//         get result size without object initialization
         IndexSearcher searcher = buildSearcher(searchFactory);
         if (searcher == null)
         {
            resultSize = 0;
         }
         else
         {
            Hits hits;
            try
            {
               hits = getHits(searcher);
               resultSize = hits.length();
            }
            catch (IOException e)
            {
               throw new HibernateException("Unable to query Lucene index", e);
            }
            finally
            {
               //searcher cannot be null
               try
               {
                  closeSearcher(searcher, searchFactory.getReaderProvider());
                  //searchFactoryImplementor.getReaderProvider().closeReader( searcher.getIndexReader() );
               }
               catch (SearchException e)
               {
                  log.warn("Unable to properly close searcher during lucene query: " + e);
               }
            }
         }
      }
      return this.resultSize;
   }

   public void setSort(Sort s)
   {
      sort = s;
   }


   private void closeSearcher(Searcher searcher, ReaderProvider readerProvider)
   {
      Set<IndexReader> indexReaders = getIndexReaders(searcher);

      for (IndexReader indexReader : indexReaders)
      {
         readerProvider.closeReader(indexReader);
      }
   }

   /**
    * Enable a given filter by its name.
    *
    * @param name of filter.
    * @return a FullTextFilter object.
    */
   public FullTextFilter enableFullTextFilter(String name)
   {
      if (filterDefinitions == null)
      {
         filterDefinitions = new HashMap<String, FullTextFilterImpl>();
      }
      FullTextFilterImpl filterDefinition = filterDefinitions.get(name);
      if (filterDefinition != null) return filterDefinition;

      filterDefinition = new FullTextFilterImpl();
      filterDefinition.setName(name);
      FilterDef filterDef = searchFactory.getFilterDefinition(name);
      if (filterDef == null)
      {
         throw new SearchException("Unkown @FullTextFilter: " + name);
      }
      filterDefinitions.put(name, filterDefinition);
      return filterDefinition;
   }

   /**
    * Disable a given filter by its name.
    *
    * @param name of filter.
    */
   public void disableFullTextFilter(String name)
   {
      filterDefinitions.remove(name);
   }

   /**
    * Sets the the result of the given integer value to the first result.
    *
    * @param firstResult index to be set.
    * @throws IllegalArgumentException if the index given is less than zero.
    */
   public void setFirstResult(int firstResult)
   {
      if (firstResult < 0)
      {
         throw new IllegalArgumentException("'first' pagination parameter less than 0");
      }
      this.firstResult = firstResult;

      //TODO How do we deal with this if the parameter is too high.
   }


   public QueryResultIterator iterator() throws HibernateException
   {
      List<CacheEntityId> ids = null;
      IndexSearcher searcher = buildSearcher(searchFactory);
      if (searcher == null)
      {
         throw new NullPointerException("IndexSearcher instance is null.");
      }

      try
      {
         Hits hits = getHits(searcher);
         int first = first();
         int max = max(first, hits);
         int size = max - first + 1 < 0 ? 0 : max - first + 1;
         ids = new ArrayList<CacheEntityId>(size);

         DocumentExtractor extractor = new DocumentExtractor(luceneQuery, searcher, searchFactory, indexProjection);
         for (int index = first; index <= max; index++)
         {
            String documentId = (String) extractor.extract(hits, index).id;
            CacheEntityId id = new CacheEntityId(documentId);
            ids.add(id);
         }

      }
      catch (IOException e)
      {
         throw new HibernateException("Unable to query Lucene index", e);

      }

      finally
      {

         closeSearcher(searcher, searchFactory.getReaderProvider());

      }

      return new QueryResultIteratorImpl(ids, entityLoader);
   }

   /**
    * Returns a the results from the query as a List object.
    *
    * @return List of results.
    * @throws HibernateException
    */
   public List<Object> list() throws HibernateException
   {
      IndexSearcher searcher = buildSearcher(searchFactory);

      if (searcher == null) return new ArrayList(0);

      Hits hits;

      try
      {
         hits = getHits(searcher);
         if (log.isTraceEnabled()) log.trace("Number of hits are " + hits.length());
         int first = first();
         int max = max(first, hits);

         int size = max - first + 1 < 0 ? 0 : max - first + 1;

         List<CacheEntityId> ids = new ArrayList<CacheEntityId>(size);
         DocumentExtractor extractor = new DocumentExtractor(luceneQuery, searcher, searchFactory, indexProjection);

         for (int index = first; index <= max; index++)
         {
            String documentId = (String) extractor.extract(hits, index).id;
            CacheEntityId id = new CacheEntityId(documentId);
            ids.add(id);
         }

         List<Object> list = entityLoader.load(ids);
         if (resultTransformer == null)
         {
            return list;
         }
         else
         {
            return resultTransformer.transformList(list);

         }

      }
      catch (IOException e)
      {
         throw new HibernateException("Unable to query Lucene index", e);

      }
      finally
      {
         closeSearcher(searcher, searchFactory.getReaderProvider());

      }

   }


   private int max(int first, Hits hits)
   {
      return maxResults == null ?
              hits.length() - 1 :
              maxResults + first < hits.length() ?
                      first + maxResults - 1 :
                      hits.length() - 1;
   }

   private int first()
   {
      return firstResult != null ?
              firstResult :
              0;
   }

   public void setMaxResults(int maxResults)
   {
      if (maxResults < 0)
      {
         throw new IllegalArgumentException("'max' pagination parameter less than 0");
      }
      this.maxResults = maxResults;
   }

   public void setFetchSize(int fetchSize)
   {
      if (fetchSize <= 0)
      {
         throw new IllegalArgumentException("'fetch size' parameter less than or equals to 0");
      }
      this.fetchSize = fetchSize;
   }

   private IndexSearcher buildSearcher(SearchFactoryImplementor searchFactoryImplementor)
   {
      Map<Class, DocumentBuilder<Object>> builders = searchFactoryImplementor.getDocumentBuilders();
      List<DirectoryProvider> directories = new ArrayList<DirectoryProvider>();

      Similarity searcherSimilarity = null;

      if (classes == null || classes.length == 0)
      {
         //no class means all classes
         for (DocumentBuilder builder : builders.values())
         {
            searcherSimilarity = checkSimilarity(searcherSimilarity, builder);
            final DirectoryProvider[] directoryProviders = builder.getDirectoryProviderSelectionStrategy().getDirectoryProvidersForAllShards();
            populateDirectories(directories, directoryProviders);
         }
         classesAndSubclasses = null;
      }
      else
      {
         Set<Class> involvedClasses = new HashSet<Class>(classes.length);
         Collections.addAll(involvedClasses, classes);
         for (Class clazz : classes)
         {
            DocumentBuilder builder = builders.get(clazz);
            if (builder != null) involvedClasses.addAll(builder.getMappedSubclasses());
         }

         for (Class clazz : involvedClasses)
         {
            DocumentBuilder builder = builders.get(clazz);
            if (builder == null)
            {
               throw new HibernateException("Not a mapped entity (don't forget to add @Indexed): " + clazz);
            }

            final DirectoryProvider[] directoryProviders = builder.getDirectoryProviderSelectionStrategy().getDirectoryProvidersForAllShards();
            searcherSimilarity = checkSimilarity(searcherSimilarity, builder);
            populateDirectories(directories, directoryProviders);
         }
         classesAndSubclasses = involvedClasses;
      }

      //compute optimization needClassFilterClause
      //if at least one DP contains one class that is not part of the targeted classesAndSubclasses we can't optimize
      if (classesAndSubclasses != null)
      {
         for (DirectoryProvider dp : directories)
         {
            final Set<Class> classesInDirectoryProvider = searchFactoryImplementor.getClassesInDirectoryProvider(dp);
            // if a DP contains only one class, we know for sure it's part of classesAndSubclasses
            if (classesInDirectoryProvider.size() > 1)
            {
               //risk of needClassFilterClause
               for (Class clazz : classesInDirectoryProvider)
               {
                  if (!classesAndSubclasses.contains(clazz))
                  {
                     this.needClassFilterClause = true;
                     break;
                  }
               }
            }
            if (this.needClassFilterClause) break;
         }
      }

      //set up the searcher
      final DirectoryProvider[] directoryProviders = directories.toArray(new DirectoryProvider[directories.size()]);
      IndexSearcher is = new IndexSearcher(searchFactoryImplementor.getReaderProvider().openReader(directoryProviders));
      is.setSimilarity(searcherSimilarity);
      return is;
   }

   private Similarity checkSimilarity(Similarity similarity, DocumentBuilder builder)
   {
      if (similarity == null)
      {
         similarity = builder.getSimilarity();
      }
      else if (!similarity.getClass().equals(builder.getSimilarity().getClass()))
      {
         throw new HibernateException("Cannot perform search on two entities with differing Similarity implementations (" + similarity.getClass().getName() + " & " + builder.getSimilarity().getClass().getName() + ")");
      }

      return similarity;
   }

   private void populateDirectories(List<DirectoryProvider> directories, DirectoryProvider[] directoryProviders)
   
   {
      for (DirectoryProvider provider : directoryProviders)
      {
         if (!directories.contains(provider))
         {
            directories.add(provider);
         }
      }
   }

   private Hits getHits(Searcher searcher) throws IOException
   {
      Hits hits;
      org.apache.lucene.search.Query query = filterQueryByClasses(luceneQuery);
      buildFilters();
      hits = searcher.search(query, filter, sort);
      setResultSize(hits);
      return hits;
   }

   private void setResultSize(Hits hits)
   {
      resultSize = hits.length();
   }

   private org.apache.lucene.search.Query filterQueryByClasses(org.apache.lucene.search.Query luceneQuery)
   {
      if (!needClassFilterClause)
      {
         return luceneQuery;
      }
      else
      {
         //A query filter is more practical than a manual class filtering post query (esp on scrollable resultsets)
         //it also probably minimise the memory footprint
         BooleanQuery classFilter = new BooleanQuery();
         //annihilate the scoring impact of DocumentBuilder.CLASS_FIELDNAME
         classFilter.setBoost(0);
         for (Class clazz : classesAndSubclasses)
         {
            Term t = new Term(DocumentBuilder.CLASS_FIELDNAME, clazz.getName());
            TermQuery termQuery = new TermQuery(t);
            classFilter.add(termQuery, BooleanClause.Occur.SHOULD);
         }
         BooleanQuery filteredQuery = new BooleanQuery();
         filteredQuery.add(luceneQuery, BooleanClause.Occur.MUST);
         filteredQuery.add(classFilter, BooleanClause.Occur.MUST);
         return filteredQuery;
      }
   }

   private void buildFilters()
   {
      if (filterDefinitions != null && filterDefinitions.size() > 0)
      {
         ChainedFilter chainedFilter = new ChainedFilter();
         for (FullTextFilterImpl filterDefinition : filterDefinitions.values())
         {
            FilterDef def = searchFactory.getFilterDefinition(filterDefinition.getName());
            Class implClass = def.getImpl();
            Object instance;
            try
            {
               instance = implClass.newInstance();
            }
            catch (Exception e)
            {
               throw new SearchException("Unable to create @FullTextFilterDef: " + def.getImpl(), e);
            }
            for (Map.Entry<String, Object> entry : filterDefinition.getParameters().entrySet())
            {
               def.invoke(entry.getKey(), instance, entry.getValue());
            }
            if (def.isCache() && def.getKeyMethod() == null && filterDefinition.getParameters().size() > 0)
            {
               throw new SearchException("Filter with parameters and no @Key method: " + filterDefinition.getName());
            }
            FilterKey key = null;
            if (def.isCache())
            {
               if (def.getKeyMethod() == null)
               {
                  key = new FilterKey()
                  {
                     public int hashCode()
                     {
                        return getImpl().hashCode();
                     }

                     public boolean equals(Object obj)
                     {
                        if (!(obj instanceof FilterKey)) return false;
                        FilterKey that = (FilterKey) obj;
                        return this.getImpl().equals(that.getImpl());
                     }
                  };
               }
               else
               {
                  try
                  {
                     key = (FilterKey) def.getKeyMethod().invoke(instance);
                  }
                  catch (IllegalAccessException e)
                  {
                     throw new SearchException("Unable to access @Key method: "
                             + def.getImpl().getName() + "." + def.getKeyMethod().getName());
                  }
                  catch (InvocationTargetException e)
                  {
                     throw new SearchException("Unable to access @Key method: "
                             + def.getImpl().getName() + "." + def.getKeyMethod().getName());
                  }
                  catch (ClassCastException e)
                  {
                     throw new SearchException("@Key method does not return FilterKey: "
                             + def.getImpl().getName() + "." + def.getKeyMethod().getName());
                  }
               }
               key.setImpl(def.getImpl());
            }

            Filter filter = def.isCache() ?
                    searchFactory.getFilterCachingStrategy().getCachedFilter(key) :
                    null;
            if (filter == null)
            {
               if (def.getFactoryMethod() != null)
               {
                  try
                  {
                     filter = (Filter) def.getFactoryMethod().invoke(instance);
                  }
                  catch (IllegalAccessException e)
                  {
                     throw new SearchException("Unable to access @Factory method: "
                             + def.getImpl().getName() + "." + def.getFactoryMethod().getName());
                  }
                  catch (InvocationTargetException e)
                  {
                     throw new SearchException("Unable to access @Factory method: "
                             + def.getImpl().getName() + "." + def.getFactoryMethod().getName());
                  }
                  catch (ClassCastException e)
                  {
                     throw new SearchException("@Key method does not return a org.apache.lucene.search.Filter class: "
                             + def.getImpl().getName() + "." + def.getFactoryMethod().getName());
                  }
               }
               else
               {
                  try
                  {
                     filter = (Filter) instance;
                  }
                  catch (ClassCastException e)
                  {
                     throw new SearchException("@Key method does not return a org.apache.lucene.search.Filter class: "
                             + def.getImpl().getName() + "." + def.getFactoryMethod().getName());
                  }
               }
               if (def.isCache())
               {
                  searchFactory.getFilterCachingStrategy().addCachedFilter(key, filter);
               }
            }
            chainedFilter.addFilter(filter);
         }
         if (filter != null) chainedFilter.addFilter(filter);
         filter = chainedFilter;
      }
   }
}
