/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.exoplatform.services.jcr.impl.core.query.lucene;

import org.exoplatform.services.jcr.access.AccessManager;
import org.exoplatform.services.jcr.access.PermissionType;
import org.exoplatform.services.jcr.datamodel.InternalQName;
import org.exoplatform.services.jcr.datamodel.NodeData;
import org.exoplatform.services.jcr.datamodel.QPath;
import org.exoplatform.services.jcr.impl.core.SessionDataManager;
import org.exoplatform.services.jcr.impl.core.SessionImpl;
import org.exoplatform.services.security.IdentityConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;

import javax.jcr.ItemNotFoundException;
import javax.jcr.NamespaceException;
import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.query.QueryResult;
import javax.jcr.query.RowIterator;

/**
 * Implements the <code>QueryResult</code> interface.
 */
public abstract class QueryResultImpl implements QueryResult
{

   /**
    * The logger instance for this class
    */
   private static final Logger LOG = LoggerFactory.getLogger("exo.jcr.component.core.QueryResultImpl");

   /**
    * The search index to execute the query.
    */
   protected final SearchIndex index;

   /**
    * The item manager of the session executing the query
    */
   protected final SessionDataManager itemMgr;

   /**
    * The session executing the query
    */
   protected final SessionImpl session;

   /**
    * The access manager of the session that executes the query.
    */
   protected final AccessManager accessMgr;

   /**
    * The query instance which created this query result.
    */
   protected final AbstractQueryImpl queryImpl;

   /**
    * The spell suggestion or <code>null</code> if not available.
    */
   protected final SpellSuggestion spellSuggestion;

   /**
    * The select properties
    */
   protected final InternalQName[] selectProps;

   /**
    * The relative paths of properties to use for ordering the result set.
    */
   protected final QPath[] orderProps;

   /**
    * The order specifier for each of the order properties.
    */
   protected final boolean[] orderSpecs;

   /**
    * The result nodes including their score. This list is populated on a lazy
    * basis while a client iterates through the results.
    * <br>
    * The exact type is: <code>List&lt;ScoreNode[]></code>
    */
   private final List<ScoreNode[]> resultNodes = new ArrayList<ScoreNode[]>();

   /**
    * This is the raw number of results that matched the query. This number
    * also includes matches which will not be returned due to access
    * restrictions. This value is set whenever hits are obtained.
    */
   private int numResults = -1;

   /**
    * The selector names associated with the score nodes. The selector names
    * are set when the query is executed via {@link #getResults(long)}.
    */
   private InternalQName[] selectorNames;

   /**
    * The number of results that are invalid, either because a node does not
    * exist anymore or because the session does not have access to the node.
    */
   private int invalid = 0;

   /**
    * If <code>true</code> nodes are returned in document order.
    */
   protected final boolean docOrder;

   /**
    * The excerpt provider or <code>null</code> if none was created yet.
    */
   private ExcerptProvider excerptProvider;

   /**
    * The offset in the total result set
    */
   private final long offset;

   /**
    * The maximum size of this result if limit > 0
    */
   private final long limit;

   /**
    * If <code>true</code>, it means we're using a System session.
    */
   private final boolean isSystemSession;

   /**
    * Creates a new query result. The concrete sub class is responsible for
    * calling {@link #getResults(long)} after this constructor had been called.
    *
    * @param index           the search index where the query is executed.
    * @param itemMgr         the item manager of the session executing the
    *                        query.
    * @param session         the session executing the query.
    * @param accessMgr       the access manager of the session executiong the
    *                        query.
    * @param queryImpl       the query instance which created this query
    *                        result.
    * @param spellSuggestion the spell suggestion or <code>null</code> if none
    *                        is available.
    * @param selectProps     the select properties of the query.
    * @param orderProps      the relative paths of the order properties.
    * @param orderSpecs      the order specs, one for each order property
    *                        name.
    * @param documentOrder   if <code>true</code> the result is returned in
    *                        document order.
    * @param limit           the maximum result size
    * @param offset          the offset in the total result set
    * @throws RepositoryException if an error occurs while reading from the
    *                             repository.
    */
   public QueryResultImpl(SearchIndex index, SessionDataManager itemMgr, SessionImpl session, AccessManager accessMgr,
      AbstractQueryImpl queryImpl, SpellSuggestion spellSuggestion, InternalQName[] selectProps, QPath[] orderProps,
      boolean[] orderSpecs, boolean documentOrder, long offset, long limit) throws RepositoryException
   {
      this.index = index;
      this.itemMgr = itemMgr;
      this.session = session;
      this.accessMgr = accessMgr;
      this.queryImpl = queryImpl;
      this.spellSuggestion = spellSuggestion;
      this.selectProps = selectProps;
      this.orderProps = orderProps;
      this.orderSpecs = orderSpecs;
      this.docOrder = orderProps.length == 0 && documentOrder;
      this.offset = offset;
      this.limit = limit;
      this.isSystemSession = IdentityConstants.SYSTEM.equals(session.getUserID());
   }

   /**
    * {@inheritDoc}
    */
   public String[] getColumnNames() throws RepositoryException
   {

      try
      {
         String[] propNames = new String[selectProps.length];
         for (int i = 0; i < selectProps.length; i++)
         {
            propNames[i] = session.getLocationFactory().createJCRName(selectProps[i]).getAsString();
         }
         return propNames;
      }
      catch (NamespaceException npde)
      {
         String msg = "encountered invalid property name";
         LOG.debug(msg);
         throw new RepositoryException(msg, npde);
      }
   }

   /**
    * {@inheritDoc}
    */
   public NodeIterator getNodes() throws RepositoryException
   {
      return new NodeIteratorImpl(itemMgr, getScoreNodes(), 0);
   }

   /**
    * {@inheritDoc}
    */
   public RowIterator getRows() throws RepositoryException
   {
      if (excerptProvider == null)
      {
         try
         {
            excerptProvider = createExcerptProvider();
         }
         catch (IOException e)
         {
            throw new RepositoryException(e);
         }
      }
      return new RowIteratorImpl(getScoreNodes(), selectProps, selectorNames, itemMgr, session.getLocationFactory(),
         excerptProvider, spellSuggestion, index.getContext().getCleanerHolder());
   }

   /**
    * Executes the query for this result and returns hits. The caller must
    * close the query hits when he is done using it.
    *
    * @param resultFetchHint a hint on how many results should be fetched.
    * @return hits for this query result.
    * @throws IOException if an error occurs while executing the query.
    * @throws RepositoryException 
    */
   protected abstract MultiColumnQueryHits executeQuery(long resultFetchHint) throws IOException, RepositoryException;

   /**
    * Creates an excerpt provider for this result set.
    *
    * @return an excerpt provider.
    * @throws IOException if an error occurs.
    */
   protected abstract ExcerptProvider createExcerptProvider() throws IOException;

   //--------------------------------< internal >------------------------------

   /**
    * Creates a {@link ScoreNodeIterator} over the query result.
    *
    * @return a {@link ScoreNodeIterator} over the query result.
    */
   private ScoreNodeIterator getScoreNodes()
   {
      if (docOrder)
      {
         return new DocOrderScoreNodeIterator(itemMgr, resultNodes, 0);
      }
      else
      {
         return new LazyScoreNodeIteratorImpl();
      }
   }

   /**
    * Attempts to get <code>size</code> results and puts them into {@link
    * #resultNodes}. If the size of {@link #resultNodes} is less than
    * <code>size</code> then there are no more than <code>resultNodes.size()</code>
    * results for this query.
    *
    * @param size the number of results to fetch for the query.
    * @throws RepositoryException if an error occurs while executing the
    *                             query.
    */
   protected void getResults(long size) throws RepositoryException
   {
      if (LOG.isDebugEnabled())
      {
         LOG.debug("getResults({}) limit={}", new Long(size), new Long(limit));
      }

      long maxResultSize = size;

      // is there any limit?
      if (limit > 0)
      {
         maxResultSize = limit;
      }

      if (resultNodes.size() >= maxResultSize)
      {
         // we already have them all
         return;
      }

      // execute it
      MultiColumnQueryHits result = null;
      try
      {
         long time = 0;
         if (LOG.isDebugEnabled())
         {
            time = System.currentTimeMillis();
         }
         result = executeQuery(maxResultSize);
         if (LOG.isDebugEnabled())
         {
            LOG.debug("query executed in {} ms", new Long(System.currentTimeMillis() - time));
         }
         // set selector names
         selectorNames = result.getSelectorNames();

         if (resultNodes.isEmpty() && offset > 0)
         {
            // collect result offset into dummy list
            collectScoreNodes(result, new ArrayList<ScoreNode[]>(), offset, true);
         }
         else
         {
            int start = resultNodes.size() + invalid + (int)offset;
            result.skip(start);
         }

         if (LOG.isDebugEnabled())
         {
            time = System.currentTimeMillis();
         }
         collectScoreNodes(result, resultNodes, maxResultSize,false);
         if (LOG.isDebugEnabled())
         {
            LOG.debug("retrieved ScoreNodes in {} ms", new Long(System.currentTimeMillis() - time));
         }

         // update numResults
         numResults = result.getSize();
      }
      catch (IndexOfflineIOException e)
      {
         throw new IndexOfflineRepositoryException(e.getMessage(), e);
      }
      catch (IOException e)
      {
         LOG.error("Exception while executing query: ", e);
         // todo throw?
      }
      finally
      {
         if (result != null)
         {
            try
            {
               result.close();
            }
            catch (IOException e)
            {
               LOG.warn("Unable to close query result: " + e);
            }
         }
      }
   }

   /**
    * Collect score nodes from <code>hits</code> into the <code>collector</code>
    * list until the size of <code>collector</code> reaches <code>maxResults</code>
    * or there are not more results.
    *
    * @param hits the raw hits.
    * @param collector where the access checked score nodes are collected.
    * @param maxResults the maximum number of results in the collector.
    * @param isOffset if true the access is checked for the offset result.
    * @throws IOException if an error occurs while reading from hits.
    * @throws RepositoryException if an error occurs while checking access rights.
    */
   private void collectScoreNodes(MultiColumnQueryHits hits, List<ScoreNode[]> collector, long maxResults, boolean isOffset) throws IOException,
      RepositoryException
   {
      while (collector.size() < maxResults)
      {
         ScoreNode[] sn = hits.nextScoreNodes();
         if (sn == null)
         {
            // no more results
            break;
         }
         // check access
         if ((!docOrder && !isOffset ) || isAccessGranted(sn))
         {
            collector.add(sn);
         }
         else
         {
            invalid++;
         }
      }
   }

   /**
    * Checks if access is granted to all <code>nodes</code>.
    *
    * @param nodes the nodes to check.
    * @return <code>true</code> if read access is granted to all
    *         <code>nodes</code>.
    * @throws RepositoryException if an error occurs while checking access
    *                             rights.
    */
   private boolean isAccessGranted(ScoreNode[] nodes) throws RepositoryException
   {
      if (isSystemSession)
      {
         return true;
      }
      for (int i = 0; i < nodes.length; i++)
      {
         try
         {
            //if (nodes[i] != null && !accessMgr.isGranted(nodes[i].getNodeId(), PermissionType.READ)) {
            if (nodes[i] != null)
            {
               NodeData nodeData = (NodeData)itemMgr.getItemData(nodes[i].getNodeId());
               if (nodeData == null
                  || !accessMgr.hasPermission(nodeData.getACL(), PermissionType.READ, session.getUserState()
                     .getIdentity()))
               {
                  return false;
               }
            }
         }
         catch (ItemNotFoundException e)
         {
            if (LOG.isTraceEnabled())
            {
               LOG.trace("An exception occurred: " + e.getMessage());
            }
         }
      }
      return true;
   }

   /**
    * Returns the total number of hits. This is the number of results you
    * will get get if you don't set any limit or offset. Keep in mind that this
    * number may get smaller if nodes are found in the result set which the
    * current session has no permission to access. This method may return
    * <code>-1</code> if the total size is unknown.
    *
    * @return the total number of hits.
    */
   public int getTotalSize()
   {
      if (numResults == -1)
      {
         return -1;
      }
      else
      {
         return numResults - invalid;
      }
   }

   private final class LazyScoreNodeIteratorImpl implements ScoreNodeIterator
   {

      private int position = -1;

      private boolean initialized = false;

      private ScoreNode[] next;

      public ScoreNode[] nextScoreNodes()
      {
         initialize();
         if (next == null)
         {
            throw new NoSuchElementException();
         }
         ScoreNode[] sn = next;
         fetchNext();
         return sn;
      }

      /**
       * {@inheritDoc}
       */
      public void skip(long skipNum)
      {
         initialize();
         if (skipNum < 0)
         {
            throw new IllegalArgumentException("skipNum must not be negative");
         }
         if (skipNum == 0)
         {
            // do nothing
         }
         else
         {
            // attempt to get enough results
            long expectedPosition = position + skipNum;
            while (position < expectedPosition)
            {
               fetchNext();
               if (next == null)
               {
                  // not enough results after getResults()
                  throw new NoSuchElementException();
               }
            }
         }
      }

      /**
       * 
       * @see org.exoplatform.services.jcr.impl.core.query.lucene.TwoWayRangeIterator#skipBack(long)
       */
      public void skipBack(long skipNum)
      {
         initialize();
         if (skipNum < 0)
         {
            throw new IllegalArgumentException("skipNum must not be negative");
         }
         if ((position - skipNum) < 0)
         {
            throw new NoSuchElementException();
         }
         if (skipNum == 0)
         {
            // do nothing
         }
         else
         {
            position -= skipNum + 1;
            fetchNext();
         }

      }

      /**
       * {@inheritDoc}
       * <br>
       * This value may shrink when the query result encounters non-existing
       * nodes or the session does not have access to a node.
       */
      public long getSize()
      {
         int total = getTotalSize();
         if (total == -1)
         {
            return -1;
         }
         long size = total - offset;
         if (limit > 0 && size > limit)
         {
            return limit;
         }
         else
         {
            return size;
         }
      }

      /**
       * {@inheritDoc}
       */
      public long getPosition()
      {
         initialize();
         return position;
      }

      /**
       * @throws UnsupportedOperationException always.
       */
      public void remove()
      {
         throw new UnsupportedOperationException("remove");
      }

      /**
       * {@inheritDoc}
       */
      public boolean hasNext()
      {
         initialize();
         return next != null;
      }

      /**
       * {@inheritDoc}
       */
      public Object next()
      {
         return nextScoreNodes();
      }

      /**
       * Initializes this iterator but only if it is not yet initialized.
       */
      private void initialize()
      {
         if (!initialized)
         {
            fetchNext();
            initialized = true;
         }
      }

      /**
       * Fetches the next node to return by this iterator. If this method
       * returns and {@link #next} is <code>null</code> then there is no next
       * node.
       */
      private void fetchNext()
      {
         next = null;
         int nextPos = position + 1;
         while (next == null)
         {
            if (nextPos >= resultNodes.size())
            {
               // quick check if there are more results at all
               // this check is only possible if we have numResults
               if (numResults != -1 && (nextPos + invalid) >= numResults)
               {
                  break;
               }

               // fetch more results
               try
               {
                  int num;
                  if (resultNodes.size() == 0)
                  {
                     num = index.getResultFetchSize();
                  }
                  else
                  {
                     num = resultNodes.size() * 2;
                  }
                  getResults(num);
               }
               catch (RepositoryException e)
               {
                  LOG.warn("Exception getting more results: " + e);
               }
               // check again
               if (nextPos >= resultNodes.size())
               {
                  // no more valid results
                  break;
               }
            }
            next = (ScoreNode[])resultNodes.get(nextPos);
            try
            {
               if (!isAccessGranted(next))
               {
                  next = null;
                  invalid++;
                  resultNodes.remove(nextPos);
                  if (LOG.isDebugEnabled())
                  {
                     LOG.debug("The node is invalid since we don't have sufficient rights to access it, "
                        + "it will be removed from the results set");
                  }
               }
            }
            catch (RepositoryException e)
            {
               LOG.error("Could not check access permission", e);
               break;
            }
         }
         position++;
      }
   }
}
