/*
 * 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.datamodel.InternalQName;
import org.exoplatform.services.jcr.datamodel.ItemData;
import org.exoplatform.services.jcr.impl.Constants;
import org.exoplatform.services.jcr.impl.core.LocationFactory;
import org.exoplatform.services.jcr.impl.core.NodeImpl;
import org.exoplatform.services.jcr.impl.core.PropertyImpl;
import org.exoplatform.services.jcr.impl.core.SessionDataManager;
import org.exoplatform.services.jcr.impl.core.value.ValueFactoryImpl;
import org.exoplatform.services.jcr.impl.util.ISO9075;
import org.exoplatform.services.jcr.impl.util.io.FileCleanerHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;

import javax.jcr.ItemNotFoundException;
import javax.jcr.NamespaceException;
import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.ValueFactory;
import javax.jcr.query.Row;
import javax.jcr.query.RowIterator;

/**
 * Implements the {@link javax.jcr.query.RowIterator} interface returned by a
 * {@link javax.jcr.query.QueryResult}.
 */
class RowIteratorImpl implements RowIterator
{

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

   /**
    * The name of the excerpt function without prefix but with left
    * parenthesis.
    */
   private static final String EXCERPT_FUNC_LPAR = "excerpt(";

   /**
    * The name of the spell check function without prefix but with left
    * parenthesis.
    */
   private static final String SPELLCHECK_FUNC_LPAR = "spellcheck(";

   /**
    * The start Name for the rep:excerpt function: rep:excerpt(
    */
   private static final InternalQName REP_EXCERPT_LPAR = new InternalQName(Constants.NS_REP_URI, EXCERPT_FUNC_LPAR);

   /**
    * Iterator over nodes, that constitute the result set.
    */
   private final ScoreNodeIterator scoreNodes;

   /**
    * Array of select property names
    */
   private final InternalQName[] properties;

   /**
    * Set of select property <code>Name</code>s.
    */
   private Set<InternalQName> propertySet;

   /**
    * List of valid selector {@link Name}s.
    */
   private final List<InternalQName> selectorNames = new ArrayList<InternalQName>();

   /**
    * The item manager of the session that executes the query.
    */
   private final SessionDataManager itemMgr;

   /**
    * The <code>NamePathResolver</code> of the user <code>Session</code>.
    */
   private final LocationFactory resolver;

   /**
    * The excerpt provider or <code>null</code> if none is available.
    */
   private final ExcerptProvider excerptProvider;

   /**
    * The spell suggestion or <code>null</code> if none is available.
    */
   private final SpellSuggestion spellSuggestion;

   /**
    * A value factory for the session that executes the query.
    */
   private final ValueFactory valueFactory;

   /**
    * Creates a new <code>RowIteratorImpl</code> that iterates over the result
    * nodes.
    * 
    * @param scoreNodes
    *            a <code>ScoreNodeIterator</code> that contains the nodes of
    *            the query result.
    * @param properties
    *            <code>Name</code> of the select properties.
    * @param selectorNames
    *            the selector names.
    * @param itemMgr
    *            the item manager of the session that executes the query.
    * @param hmgr
    *            the hierarchy manager of the workspace.
    * @param resolver
    *            <code>NamespaceResolver</code> of the user
    *            <code>Session</code>.
    * @param exProvider
    *            the excerpt provider associated with the query result that
    *            created this row iterator.
    * @param spellSuggestion
    *            the spell suggestion associated with the query result or
    *            <code>null</code> if none is available.
    * @param cleanerHolder 
    *             is used for {@link ValueFactoryImpl} creation
    */

   RowIteratorImpl(ScoreNodeIterator scoreNodes, InternalQName[] properties, InternalQName[] selectorNames,
      SessionDataManager itemMgr,

      LocationFactory resolver, ExcerptProvider exProvider, SpellSuggestion spellSuggestion,
      FileCleanerHolder cleanerHolder)
   {
      this.scoreNodes = scoreNodes;
      this.properties = properties;
      this.selectorNames.addAll(Arrays.asList(selectorNames));
      this.itemMgr = itemMgr;
      this.resolver = resolver;
      this.excerptProvider = exProvider;
      this.spellSuggestion = spellSuggestion;
      this.valueFactory = new ValueFactoryImpl(resolver, cleanerHolder);
   }

   /**
    * Returns the next <code>Row</code> in the iteration.
    * 
    * @return the next <code>Row</code> in the iteration.
    * @throws NoSuchElementException
    *             if iteration has no more <code>Row</code>s.
    */
   public Row nextRow() throws NoSuchElementException
   {
      return new RowImpl(scoreNodes.nextScoreNodes());
   }

   /**
    * Skip a number of <code>Row</code>s in this iterator.
    * 
    * @param skipNum
    *            the non-negative number of <code>Row</code>s to skip
    * @throws NoSuchElementException
    *             if skipped past the last <code>Row</code> in this iterator.
    */
   public void skip(long skipNum) throws NoSuchElementException
   {
      scoreNodes.skip(skipNum);
   }

   /**
    * Returns the number of <code>Row</code>s in this iterator.
    * 
    * @return the number of <code>Row</code>s in this iterator.
    */
   public long getSize()
   {
      return scoreNodes.getSize();
   }

   /**
    * Returns the current position within this iterator. The number returned is
    * the 0-based index of the next <code>Row</code> in the iterator, i.e. the
    * one that will be returned on the subsequent <code>next</code> call.
    * <br>
    * Note that this method does not check if there is a next element, i.e. an
    * empty iterator will always return 0.
    * 
    * @return the current position withing this iterator.
    */
   public long getPosition()
   {
      return scoreNodes.getPosition();
   }

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

   /**
    * Returns <code>true</code> if the iteration has more <code>Row</code>s.
    * (In other words, returns <code>true</code> if <code>next</code> would
    * return an <code>Row</code> rather than throwing an exception.)
    * 
    * @return <code>true</code> if the iterator has more elements.
    */
   public boolean hasNext()
   {
      return scoreNodes.hasNext();
   }

   /**
    * Returns the next <code>Row</code> in the iteration.
    * 
    * @return the next <code>Row</code> in the iteration.
    * @throws NoSuchElementException
    *             if iteration has no more <code>Row</code>s.
    */
   public Object next() throws NoSuchElementException
   {
      return nextRow();
   }

   // ---------------------< class RowImpl
   // >------------------------------------

   /**
    * Implements the {@link javax.jcr.query.Row} interface, which represents a
    * row in the query result.
    */
   class RowImpl implements Row
   {

      /**
       * The score for this result row
       */
      private final float score;

      /**
       * The underlying <code>Node</code> of this result row.
       */
      private NodeImpl node;

      /**
       * The score nodes associated with this row.
       */
      private final ScoreNode[] sn;

      /**
       * Cached value array for returned by {@link #getValues()}.
       */
      private Value[] values;

      /**
       * Creates a new <code>RowImpl</code> instance based on
       * <code>node</code>.
       * 
       * @param sn
       *            the score nodes associated with this row.
       */
      RowImpl(ScoreNode[] sn)
      {
         this.sn = sn;
         this.score = sn[0].getScore();
      }

      /**
       * Returns an array of all the values in the same order as the property
       * names (column names) returned by
       * {@link javax.jcr.query.QueryResult#getColumnNames()}.
       * 
       * @return a <code>Value</code> array.
       * @throws RepositoryException
       *             if an error occurs while retrieving the values from the
       *             <code>Node</code>.
       */
      public Value[] getValues() throws RepositoryException
      {
         if (values == null)
         {
            Value[] tmp = new Value[properties.length];
            for (int i = 0; i < properties.length; i++)
            {
               String propertyName = resolver.createJCRName(properties[i]).getAsString();
               node = (NodeImpl)getNode();
               if (node.hasProperty(propertyName))
               {
                  PropertyImpl prop = (PropertyImpl)node.getProperty(propertyName);
                  if (!prop.getDefinition().isMultiple())
                  {
                     if (prop.getDefinition().getRequiredType() == PropertyType.UNDEFINED)
                     {
                        tmp[i] = valueFactory.createValue(prop.getString());
                     }
                     else
                     {
                        tmp[i] = prop.getValue();
                     }
                  }
                  else
                  {
                     // mvp values cannot be returned
                     tmp[i] = null;
                  }
               }
               else
               {
                  // property not set or one of the following:
                  // jcr:path / jcr:score / rep:excerpt / rep:spellcheck
                  if (Constants.JCR_PATH.equals(properties[i]))
                  {
                     tmp[i] = valueFactory.createValue(node.getPath(), PropertyType.PATH);
                  }
                  else if (Constants.JCR_SCORE.equals(properties[i]))
                  {
                     tmp[i] = valueFactory.createValue(Math.round(score * 1000f));
                  }
                  else if (isExcerptFunction(properties[i]))
                  {
                     tmp[i] = getExcerpt();
                  }
                  else if (isSpellCheckFunction(properties[i]))
                  {
                     tmp[i] = getSpellCheckedStatement();
                  }
                  else
                  {
                     tmp[i] = null;
                  }
               }
            }
            values = tmp;
         }
         // return a copy of the array
         Value[] ret = new Value[values.length];
         System.arraycopy(values, 0, ret, 0, values.length);
         return ret;
      }

      /**
       * Returns the value of the indicated property in this <code>Row</code>.
       * <br>
       * If <code>propertyName</code> is not among the column names of the
       * query result table, an <code>ItemNotFoundException</code> is thrown.
       * 
       * @return a <code>Value</code>
       * @throws ItemNotFoundException
       *             if <code>propertyName</code> is not among the column
       *             names of the query result table.
       * @throws RepositoryException
       *             if <code>propertyName</code> is not a valid property
       *             name.
       */
      public Value getValue(String propertyName) throws ItemNotFoundException, RepositoryException
      {
         if (propertySet == null)
         {
            // create the set first
            Set<InternalQName> tmp = new HashSet<InternalQName>();
            tmp.addAll(Arrays.asList(properties));
            propertySet = tmp;
         }
         try
         {
            InternalQName prop = resolver.parseJCRName(propertyName).getInternalName();
            if (!propertySet.contains(prop))
            {
               if (isExcerptFunction(propertyName))
               {
                  // excerpt function with parameter
                  return getExcerpt(propertyName);
               }
               else
               {
                  throw new ItemNotFoundException(propertyName);
               }
            }
            if (Constants.JCR_PATH.equals(prop))
            {
               // QValue p =
               // QVALUE_FACTORY.create(hmgr.getPath(sn[0].getNodeId()));
               ItemData item = itemMgr.getItemData(sn[0].getNodeId());
               if (item == null)
                  throw new ItemNotFoundException(sn[0].getNodeId());
               String path = resolver.createJCRPath(item.getQPath()).getAsString(false);
               return valueFactory.createValue(path, PropertyType.PATH);
            }
            else if (getNodeImpl().hasProperty(propertyName))
            {
               Property p = getNodeImpl().getProperty(propertyName);
               if (p.getDefinition().getRequiredType() == PropertyType.UNDEFINED)
               {
                  return valueFactory.createValue(p.getString());
               }
               else
               {
                  return p.getValue();
               }
            }
            else
            {

               // either jcr:score, rep:excerpt,
               // rep:spellcheck or not set
               if (Constants.JCR_SCORE.equals(prop))
               {
                  return valueFactory.createValue(Math.round(score * 1000f));
               }
               else if (isExcerptFunction(prop))
               {
                  return getExcerpt();
               }
               else if (isSpellCheckFunction(prop))
               {
                  return getSpellCheckedStatement();
               }
               else
               {
                  return null;
               }
            }
         }
         catch (RepositoryException e)
         {
            if (isExcerptFunction(propertyName))
            {
               // excerpt function with parameter
               return getExcerpt(propertyName);
            }
            else
            {
               throw new RepositoryException(e.getMessage(), e);
            }
         }
      }

      /**
       * Returns the <code>Node</code> corresponding to this <code>Row</code>.
       * <br>
       * A <code>RepositoryException</code> is thrown if this <code>Row</code>
       * contains values from more than one node. This will be the case when
       * more than one selector is included among the columns specified for
       * the query.
       * 
       * @return a <code>Node</code>
       * @throws RepositoryException
       *             if this query has more than one selector (and therefore,
       *             this <code>Row</code> corresponds to more than one
       *             <code>Node</code>) or if another error occurs.
       * @since JCR 2.0
       */
      public Node getNode() throws RepositoryException
      {
         checkSingleSelector("Use getNode(String) instead.");
         return getNodeImpl();
      }

      /**
       * Returns the <code>Node</code> corresponding to this <code>Row</code>
       * and the specified selector.
       * 
       * @param selectorName
       *            a <code>String</code>
       * @return a <code>Node</code>
       * @throws RepositoryException
       *             if <code>selectorName</code> is not the alias of a
       *             selector in this query or if another error occurs.
       * @since JCR 2.0
       */
      public Node getNode(String selectorName) throws RepositoryException
      {
         ScoreNode s = sn[getSelectorIndex(selectorName)];
         if (s == null)
         {
            return null;
         }
         return (Node)itemMgr.getItemByIdentifier(s.getNodeId(), true);
      }

      /**
       * Equivalent to <code>Row.getNode().getPath()</code>. However, some
       * implementations may be able gain efficiency by not resolving the
       * actual <code>Node</code>.
       * 
       * @return a <code>String</code>
       * @throws RepositoryException
       *             if this query has more than one selector (and therefore,
       *             this <code>Row</code> corresponds to more than one
       *             <code>Node</code>) or if another error occurs.
       * @since JCR 2.0
       */
      public String getPath() throws RepositoryException
      {
         checkSingleSelector("Use getPath(String) instead.");
         ItemData item = itemMgr.getItemData(sn[0].getNodeId());
         if (item == null)
            throw new ItemNotFoundException("Item not found " + sn[0].getNodeId());
         return resolver.createJCRPath(item.getQPath()).getAsString(false);
      }

      /**
       * Equivalent to <code>Row.getNode(selectorName).getPath()</code>.
       * However, some implementations may be able gain efficiency by not
       * resolving the actual <code>Node</code>.
       * 
       * @param selectorName
       *            a <code>String</code>
       * @return a <code>String</code>
       * @throws RepositoryException
       *             if <code>selectorName</code> is not the alias of a
       *             selector in this query or if another error occurs.
       * @since JCR 2.0
       */
      public String getPath(String selectorName) throws RepositoryException
      {
         Node n = getNode(selectorName);
         if (n != null)
         {
            return n.getPath();
         }
         else
         {
            return null;
         }
      }

      /**
       * Returns the full text search score for this row associated with the
       * default selector. This corresponds to the score of a particular node.
       * <br>
       * If no <code>FullTextSearchScore</code> AQM object is associated with
       * the default selector this method will still return a value. However,
       * in that case the returned value may not be meaningful or may simply
       * reflect the minimum possible relevance level (for example, in some
       * systems this might be a score of 0).
       * <br>
       * Note, in JCR-SQL2 a <code>FullTextSearchScore</code> AQM object is
       * represented by a <code>SCORE()</code> function. In JCR-JQOM it is
       * represented by a Java object of type
       * <code>javax.jcr.query.qom.FullTextSearchScore</code>.
       * 
       * @return a <code>double</code>
       * @throws RepositoryException
       *             if this query has more than one selector (and therefore,
       *             this <code>Row</code> corresponds to more than one
       *             <code>Node</code>) or if another error occurs.
       * @since JCR 2.0
       */
      public double getScore() throws RepositoryException
      {
         checkSingleSelector("Use getScore(String) instead.");
         return score;
      }

      /**
       * Returns the full text search score for this row associated with the
       * specified selector. This corresponds to the score of a particular
       * node.
       * <br>
       * If no <code>FullTextSearchScore</code> AQM object is associated with
       * the selector <code>selectorName</code> this method will still return
       * a value. However, in that case the returned value may not be
       * meaningful or may simply reflect the minimum possible relevance level
       * (for example, in some systems this might be a score of 0).
       * <br>
       * Note, in JCR-SQL2 a <code>FullTextSearchScore</code> AQM object is
       * represented by a <code>SCORE()</code> function. In JCR-JQOM it is
       * represented by a Java object of type
       * <code>javax.jcr.query.qom.FullTextSearchScore</code>.
       * 
       * @param selectorName
       *            a <code>String</code>
       * @return a <code>String</code>
       * @throws RepositoryException
       *             if <code>selectorName</code> is not the alias of a
       *             selector in this query or if another error occurs.
       * @since JCR 2.0
       */
      public double getScore(String selectorName) throws RepositoryException
      {
         ScoreNode s = sn[getSelectorIndex(selectorName)];
         if (s == null)
         {
            return Double.NaN;
         }
         return s.getScore();
      }

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

      /**
       * Returns the node corresponding to this row.
       * 
       * @return the node.
       * @throws RepositoryException
       *             if an error occurs while retrieving the node. e.g. node
       *             does not exist anymore.
       */
      private NodeImpl getNodeImpl() throws RepositoryException
      {
         if (node == null)
         {
            node = (NodeImpl)itemMgr.getItemByIdentifier(sn[0].getNodeId(), true);
         }
         return node;
      }

      /**
       * Checks if there is a single selector and otherwise throws a
       * RepositoryException.
       * 
       * @param useInstead
       *            message telling, which method to use instead.
       * @throws RepositoryException
       *             if there is more than one selector.
       */
      private void checkSingleSelector(String useInstead) throws RepositoryException
      {
         if (sn.length > 1)
         {
            String msg = "More than one selector. " + useInstead;
            throw new RepositoryException(msg);
         }
      }

      /**
       * Gets the selector index for the given <code>selectorName</code>.
       * 
       * @param selectorName
       *            the selector name.
       * @return the selector index.
       * @throws RepositoryException
       *             if the selector name is not a valid JCR name or the
       *             selector name is not the alias of a selector in this
       *             query.
       */
      private int getSelectorIndex(String selectorName) throws RepositoryException
      {
         int idx = selectorNames.indexOf(resolver.parseJCRName(selectorName));
         if (idx == -1)
         {
            throw new RepositoryException("Unknown selector name: " + selectorName);
         }
         return idx;
      }

      /**
       * @param name
       *            a Name.
       * @return <code>true</code> if <code>name</code> is the rep:excerpt
       *         function, <code>false</code> otherwise.
       */
      private boolean isExcerptFunction(InternalQName name)
      {
         return name.getNamespace().equals(Constants.NS_REP_URI) && name.getName().startsWith(EXCERPT_FUNC_LPAR);
      }

      /**
       * @param name
       *            a String.
       * @return <code>true</code> if <code>name</code> is the rep:excerpt
       *         function, <code>false</code> otherwise.
       */
      private boolean isExcerptFunction(String name)
      {
         try
         {
            return name.startsWith(resolver.createJCRName(REP_EXCERPT_LPAR).getAsString());
         }
         catch (NamespaceException e)
         {
            // will never happen
            return false;
         }
         catch (RepositoryException e)
         {
            log.error(e.getLocalizedMessage(), e);
            return false;
         }
      }

      /**
       * Returns an excerpt for the node associated with this row.
       * 
       * @return a StringValue or <code>null</code> if the excerpt cannot be
       *         created or an error occurs.
       */
      private Value getExcerpt()
      {
         return createExcerpt(sn[0].getNodeId());
      }

      /**
       * Returns an excerpt for the node indicated by the relative path
       * parameter of the rep:excerpt function. The relative path is resolved
       * against the node associated with this row.
       * 
       * @param excerptCall
       *            the rep:excerpt function with the parameter as string.
       * @return a StringValue or <code>null</code> if the excerpt cannot be
       *         created or an error occurs.
       * @throws RepositoryException
       *             if the function call is not well-formed.
       */
      private Value getExcerpt(String excerptCall) throws RepositoryException
      {
         int idx = excerptCall.indexOf(EXCERPT_FUNC_LPAR);
         int end = excerptCall.lastIndexOf(')');
         if (end == -1)
         {
            throw new RepositoryException("Missing right parenthesis");
         }
         String pathStr = excerptCall.substring(idx + EXCERPT_FUNC_LPAR.length(), end).trim();
         String decodedPath = ISO9075.decode(pathStr);
         try
         {
            NodeImpl n = (NodeImpl)getNodeImpl().getNode(decodedPath);
            return createExcerpt(n.getData().getIdentifier());
         }
         catch (PathNotFoundException e)
         {
            // does not exist or references a property
            try
            {
               Property p = getNode().getProperty(decodedPath);
               return highlight(p.getValue().getString());
            }
            catch (PathNotFoundException e1)
            {
               // does not exist
               return null;
            }
         }
      }

      /**
       * Creates an excerpt for node with the given <code>id</code>.
       * 
       * @return a StringValue or <code>null</code> if the excerpt cannot be
       *         created or an error occurs.
       */
      private Value createExcerpt(String id)
      {
         if (excerptProvider == null)
         {
            return null;
         }
         try
         {
            long time = 0;
            if (log.isDebugEnabled())
            {
               time = System.currentTimeMillis();
            }
            String excerpt = excerptProvider.getExcerpt(id, 3, 150);
            
            if (log.isDebugEnabled())
            {
               time = System.currentTimeMillis() - time;
               log.debug("Created excerpt in {} ms.", new Long(time));               
            }
            if (excerpt != null)
            {
               return valueFactory.createValue(excerpt);
            }
            else
            {
               return null;
            }
         }
         catch (IOException e)
         {
            return null;
         }
      }

      /**
       * Highlights the matching terms in the passed <code>text</code>.
       * 
       * @return a StringValue or <code>null</code> if highlighting fails.
       */
      private Value highlight(String text)
      {
         if (!(excerptProvider instanceof HighlightingExcerptProvider))
         {
            return null;
         }
         HighlightingExcerptProvider hep = (HighlightingExcerptProvider)excerptProvider;
         try
         {
            long time = 0;
            if (log.isDebugEnabled())
            {
               time = System.currentTimeMillis();
            }
            text = hep.highlight(text);
            if (log.isDebugEnabled())
            {
               time = System.currentTimeMillis() - time;
               log.debug("Highlighted text in {} ms.", new Long(time));               
            }
            return valueFactory.createValue(text);
         }
         catch (IOException e)
         {
            return null;
         }
      }

      /**
       * @param name
       *            a Name.
       * @return <code>true</code> if <code>name</code> is the rep:spellcheck
       *         function, <code>false</code> otherwise.
       */
      private boolean isSpellCheckFunction(InternalQName name)
      {
         return name.getNamespace().equals(Constants.NS_REP_URI) && name.getName().startsWith(SPELLCHECK_FUNC_LPAR);
      }

      /**
       * Returns the spell checked string of the first relation query node
       * with a spellcheck operation.
       * 
       * @return a StringValue or <code>null</code> if the spell checker
       *         thinks the words are spelled correctly. This method also
       *         returns <code>null</code> if no spell checker is configured.
       */
      private Value getSpellCheckedStatement()
      {
         String v = null;
         if (spellSuggestion != null)
         {
            try
            {
               v = spellSuggestion.getSuggestion();
            }
            catch (IOException e)
            {
               log.warn("Spell checking failed", e);
            }
            catch (RepositoryException e)
            {
               log.warn("Spell checking failed", e);
            }
         }
         if (v != null)
         {
            return valueFactory.createValue(v);
         }
         else
         {
            return null;
         }
      }
   }
}
