/*
 * 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.apache.jackrabbit.commons.query;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;

import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.RangeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.nodetype.NoSuchNodeTypeException;
import javax.jcr.nodetype.NodeDefinition;
import javax.jcr.nodetype.NodeType;
import javax.jcr.nodetype.NodeTypeIterator;
import javax.jcr.nodetype.NodeTypeManager;
import javax.jcr.nodetype.PropertyDefinition;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.Row;
import javax.jcr.query.RowIterator;

import org.apache.jackrabbit.commons.iterator.RowIteratorAdapter;
import org.apache.jackrabbit.util.ISO9075;
import org.apache.jackrabbit.util.Text;

/**
 * <code>GQL</code> is a simple fulltext query language, which supports field
 * prefixes similar to Lucene or Google queries.
 * <p/>
 * GQL basically consists of a list of query terms that are optionally prefixed
 * with a property name. E.g.: <code>title:jackrabbit</code>. When a property
 * prefix is omitted, GQL will perform a fulltext search on all indexed
 * properties of a node. There are a number of pseudo properties that have
 * special meaning:
 * <ul>
 * <li><code><b>path:</b></code> only search nodes below this path. If you
 * specify more than one term with a path prefix, only the last one will be
 * considered.</li>
 * <li><code><b>type:</b></code> only return nodes of the given node types. This
 * includes primary as well as mixin types. You may specify multiple comma
 * separated node types. GQL will return nodes that are of any of the specified
 * types.</li>
 * <li><code><b>order:</b></code> order the result by the given properties. You
 * may specify multiple comma separated property names. To order the result in
 * descending order simply prefix the property name with a minus. E.g.:
 * <code>order:-name</code>. Using a plus sign will return the result in
 * ascending order, which is also the default.</li>
 * <li><code><b>limit:</b></code> limits the number of results using an
 * interval. E.g.: <code>limit:10..20</code> Please note that the interval is
 * zero based, start is inclusive and end is exclusive. You may also specify an
 * open interval: <code>limit:10..</code> or <code>limit:..20</code> If the dots
 * are omitted and only one value is specified GQL will return at most this
 * number of results. E.g. <code>limit:10</code> (will return the first 10
 * results)</li>
 * <li><code><b>name:</b></code> a constraint on the name of the returned nodes.
 * The following wild cards are allowed: '*', matching any character sequence of
 * length 0..n; '?', matching any single character.</li>
 * </ul>
 * <p/>
 * <b>Property name</b>
 * <p/>
 * Instead of a property name you may also specify a relative path to a
 * property. E.g.: <code>"jcr:content/jcr:mimeType":text/plain</code>
 * <p/>
 * <b>Double quotes</b>
 * <p/>
 * The property name as well as the value may enclosed in double quotes. For
 * certain use cases this is required. E.g. if you want to search for a phrase:
 * <code>title:"apache jackrabbit"</code>. Similarly you need to enclose the
 * property name if it contains a colon: <code>"jcr:title":apache</code>,
 * otherwise the first colon is interpreted as the separator between the
 * property name and the value. This also means that a value that contains
 * a colon does not need to be enclosed in double quotes.
 * <p/>
 * <b>Auto prefixes</b>
 * <p/>
 * When a property, node or node type name does not have a namespace prefix GQL
 * will guess the prefix by looking up item and node type definitions in the
 * node type manager. If it finds a definition with a local name that matches
 * it will automatically assign the prefix that is in the definition. This means
 * that you can write: '<code>type:file</code>' and GQL will return nodes that are
 * of node type <code>nt:file</code>. Similarly you can write:
 * <code>order:lastModified</code> and your result nodes will be sorted by their
 * <code>jcr:lastModified</code> property value.
 * <p/>
 * <b>Common path prefix</b>
 * <p/>
 * For certain queries it is useful to specify a common path prefix for the
 * GQL query statement. See {@link #execute(String, Session, String)}. E.g. if
 * you are searching for file nodes with matches in their resource node. The
 * common path prefix is prepended to every term (except to pseudo properties)
 * before the query is executed. This means you can write:
 * '<code>type:file jackrabbit</code>' and call execute with three parameters,
 * where the third parameter is <code>jcr:content</code>. GQL will return
 * <code>nt:file</code> nodes with <code>jcr:content</code> nodes that contain
 * matches for <code>jackrabbit</code>.
 * <p/>
 * <b>Excerpts</b>
 * <p/>
 * To get an excerpt for the current row in the result set simply call
 * {@link Row#getValue(String) Row.getValue("rep:excerpt()");}. Please note
 * that this is feature is Jackrabbit specific and will not work with other
 * implementations!
 * <p/>
 * <b>Parser callbacks</b>
 * <p/>
 * You can get callbacks for each field and query term pair using the method
 * {@link #parse(String, Session, ParserCallback)}. This may be useful when you
 * want to do some transformation on the GQL before it is actually executed.
 */
public final class GQL {

    /**
     * Constant for <code>path</code> keyword.
     */
    private static final String PATH = "path";

    /**
     * Constant for <code>type</code> keyword.
     */
    private static final String TYPE = "type";

    /**
     * Constant for <code>order</code> keyword.
     */
    private static final String ORDER = "order";

    /**
     * Constant for <code>limit</code> keyword.
     */
    private static final String LIMIT = "limit";

    /**
     * Constant for <code>name</code> keyword.
     */
    private static final String NAME = "name";

    /**
     * Constant for <code>OR</code> operator.
     */
    private static final String OR = "OR";

    /**
     * Constant for <code>jcr:mixinTypes</code> property name.
     */
    private static final String JCR_MIXIN_TYPES = "jcr:mixinTypes";

    /**
     * Constant for <code>jcr:primaryType</code> property name.
     */
    private static final String JCR_PRIMARY_TYPE = "jcr:primaryType";

    /**
     * Constant for <code>jcr:root</code>.
     */
    private static final String JCR_ROOT = "jcr:root";

    /**
     * Constant for <code>jcr:contains</code> function name.
     */
    private static final String JCR_CONTAINS = "jcr:contains";

    /**
     * Constant for <code>jcr:score</code> pseudo property name.
     */
    private static final String JCR_SCORE = "jcr:score";

    /**
     * Constant for <code>descending</code> order modifier.
     */
    private static final String DESCENDING = "descending";

    /**
     * Constant for <code>rep:excerpt</code> function name. (Jackrabbit
     * specific).
     */
    private static final String REP_EXCERPT = "rep:excerpt";
    
    /**
     * A pseudo-property for native xpath conditions.
     */
    private static final String NATIVE_XPATH = "jcr:nativeXPath";

    /**
     * The GQL query statement.
     */
    private final String statement;

    /**
     * The session that will exeucte the query.
     */
    private final Session session;

    /**
     * List that contains all {@link PropertyExpression}s.
     */
    private final List<Expression> conditions = new ArrayList<Expression>();

    /**
     * An optional common path prefix for the GQL query.
     */
    private final String commonPathPrefix;

    /**
     * An optional filter that may include/exclude result rows.
     */
    private final Filter filter;

    /**
     * Maps local names of node types to prefixed names.
     */
    private Map<String, String[]> ntNames;

    /**
     * Maps local names of child node definitions to prefixed child node names.
     */
    private Map<String, String> childNodeNames;

    /**
     * Maps local names of property definitions to prefixed property names.
     */
    private Map<String, String> propertyNames;

    /**
     * The path constraint. Defaults to: <code>//*</code>
     */
    private String pathConstraint = "//*";

    /**
     * The optional type constraints.
     */
    private OptionalExpression typeConstraints = null;

    /**
     * The order by expression. Defaults to: @jcr:score descending
     */
    private Expression orderBy = new OrderByExpression();

    /**
     * A result offset.
     */
    private int offset = 0;

    /**
     * The number of results to return at most.
     */
    private int numResults = Integer.MAX_VALUE;

    /**
     * Constructs a GQL.
     *
     * @param statement the GQL query.
     * @param session the session that will execute the query.
     * @param commonPathPrefix a common path prefix for the GQL query.
     * @param filter an optional filter that may include/exclude result rows.
     */
    private GQL(String statement, Session session,
                String commonPathPrefix, Filter filter) {
        this.statement = statement;
        this.session = session;
        this.commonPathPrefix = commonPathPrefix;
        this.filter = filter;
    }

    /**
     * Executes the GQL query and returns the result as a row iterator.
     *
     * @param statement the GQL query.
     * @param session the session that will execute the query.
     * @return the result.
     */
    public static RowIterator execute(String statement,
                                      Session session) {
        return execute(statement, session, null);
    }

    /**
     * Executes the GQL query and returns the result as a row iterator.
     *
     * @param statement the GQL query.
     * @param session the session that will execute the query.
     * @param commonPathPrefix a common path prefix for the GQL query.
     * @return the result.
     */
    public static RowIterator execute(String statement,
                                      Session session,
                                      String commonPathPrefix) {
        return execute(statement, session, commonPathPrefix, null);
    }

    /**
     * Executes the GQL query and returns the result as a row iterator.
     *
     * @param statement the GQL query.
     * @param session the session that will execute the query.
     * @param commonPathPrefix a common path prefix for the GQL query.
     * @param filter an optional filter that may include/exclude result rows.
     * @return the result.
     */
    public static RowIterator execute(String statement,
                                      Session session,
                                      String commonPathPrefix,
                                      Filter filter) {
        GQL query = new GQL(statement, session, commonPathPrefix, filter);
        return query.execute();
    }
    
    /**
     * Executes the GQL query and returns the result as a row iterator.
     *
     * @param jcrQuery the native JCR query.
     * @param jcrQueryLanguage the JCR query language
     * @param session the session that will execute the query.
     * @param commonPathPrefix a common path prefix for the GQL query.
     * @param filter an optional filter that may include/exclude result rows.
     * @return the result.
     */
    public static RowIterator executeXPath(String jcrQuery,
                                      String jcrQueryLanguage,
                                      Session session,
                                      String commonPathPrefix,
                                      Filter filter) {
        GQL query = new GQL("", session, commonPathPrefix, filter);
        return query.executeJcrQuery(jcrQuery, jcrQueryLanguage);
    }
    
    /**
     * Translate the GQL query to XPath.
     *
     * @param statement the GQL query.
     * @param session the session that will execute the query.
     * @param commonPathPrefix a common path prefix for the GQL query.
     * @return the xpath statement.
     */
    public static String translateToXPath(String statement,
            Session session,
            String commonPathPrefix) throws RepositoryException {
        GQL query = new GQL(statement, session, commonPathPrefix, null);
        return query.translateStatement();
    }

    /**
     * Parses the given <code>statement</code> and generates callbacks for each
     * GQL term parsed.
     *
     * @param statement the GQL statement.
     * @param session   the current session to resolve namespace prefixes.
     * @param callback  the callback handler.
     * @throws RepositoryException if an error occurs while parsing.
     */
    public static void parse(String statement,
                             Session session,
                             ParserCallback callback)
            throws RepositoryException {
        GQL query = new GQL(statement, session, null, null);
        query.parse(callback);
    }

    /**
     * Defines a filter for query result rows.
     */
    public interface Filter {

        /**
         * Returns <code>true</code> if the given <code>row</code> should be
         * included in the result.
         *
         * @param row the row to check.
         * @return <code>true</code> if the row should be included,
         *         <code>false</code> otherwise.
         * @throws RepositoryException if an error occurs while reading from the
         *                             repository.
         */
        public boolean include(Row row) throws RepositoryException;
    }

    /**
     * Defines a callback interface that may be implemented by client code to
     * get a callback for each GQL term that is parsed.
     */
    public interface ParserCallback {

        /**
         * A GQL term was parsed.
         *
         * @param property the name of the property or an empty string if the
         *                 term is not prefixed.
         * @param value    the value of the term.
         * @param optional whether this term is prefixed with an OR operator.
         * @throws RepositoryException if an error occurs while processing the
         *                             term.
         */
        public void term(String property, String value, boolean optional)
                throws RepositoryException;
    }

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

    /**
     * Executes the GQL query and returns the result as a row iterator.
     *
     * @return the result.
     */
    private RowIterator execute() {
        String xpath;
        try {
            xpath = translateStatement();
        } catch (RepositoryException e) {
            // in case of error return empty result
            return RowIteratorAdapter.EMPTY;
        }
        return executeJcrQuery(xpath, Query.XPATH);
    }
    
    private RowIterator executeJcrQuery(String jcrQuery, String jcrQueryLanguage) {
        try {
            QueryManager qm = session.getWorkspace().getQueryManager();
            RowIterator nodes = qm.createQuery(jcrQuery, jcrQueryLanguage).execute().getRows();
            if (filter != null) {
                nodes = new FilteredRowIterator(nodes);
            }
            if (offset > 0) {
                try {
                    nodes.skip(offset);
                } catch (NoSuchElementException e) {
                    return RowIteratorAdapter.EMPTY;
                }
            }
            if (numResults == Integer.MAX_VALUE) {
                return new RowIterAdapter(nodes, nodes.getSize());
            }
            List<Row> resultRows = new ArrayList<Row>();
            while (numResults-- > 0 && nodes.hasNext()) {
                resultRows.add(nodes.nextRow());
            }
            return new RowIterAdapter(resultRows, resultRows.size());
        } catch (RepositoryException e) {
            // in case of error return empty result
            return RowIteratorAdapter.EMPTY;
        }        
    }

    /**
     * Translates the GQL query into a XPath statement.
     *
     * @return the XPath equivalent.
     * @throws RepositoryException if an error occurs while translating the query.
     */
    private String translateStatement() throws RepositoryException {
        parse(new ParserCallback() {
            public void term(String property, String value, boolean optional)
                    throws RepositoryException {
                pushExpression(property, value, optional);
            }
        });
        StringBuffer stmt = new StringBuffer();
        // path constraint
        stmt.append(pathConstraint);
        // predicate
        RequiredExpression predicate = new RequiredExpression();
        if (typeConstraints != null) {
            predicate.addOperand(typeConstraints);
        }
        for (Expression condition : conditions) {
            predicate.addOperand(condition);
        }
        if (predicate.getSize() > 0) {
            stmt.append("[");
        }
        predicate.toString(stmt);
        if (predicate.getSize() > 0) {
            stmt.append("]");
        }
        stmt.append(" ");
        // order by
        orderBy.toString(stmt);
        return stmt.toString();
    }

    /**
     * Resolves and collects all node types that match <code>ntName</code>.
     *
     * @param ntName the name of a node type (optionally without prefix).
     * @throws RepositoryException if an error occurs while reading from the
     *                             node type manager.
     */
    private void collectNodeTypes(String ntName)
            throws RepositoryException {
        NodeTypeManager ntMgr = session.getWorkspace().getNodeTypeManager();

        String[] resolvedNames = resolveNodeTypeName(ntName);

        // now resolve node type hierarchy
        for (String resolvedName : resolvedNames) {
            try {
                NodeType base = ntMgr.getNodeType(resolvedName);
                if (base.isMixin()) {
                    // search for nodes where jcr:mixinTypes is set to this mixin
                    addTypeConstraint(new MixinComparision(resolvedName));
                } else {
                    // search for nodes where jcr:primaryType is set to this type
                    addTypeConstraint(new PrimaryTypeComparision(resolvedName));
                }

                // now search for all node types that are derived from base
                NodeTypeIterator allTypes = ntMgr.getAllNodeTypes();
                while (allTypes.hasNext()) {
                    NodeType nt = allTypes.nextNodeType();
                    NodeType[] superTypes = nt.getSupertypes();
                    if (Arrays.asList(superTypes).contains(base)) {
                        if (nt.isMixin()) {
                            addTypeConstraint(new MixinComparision(nt.getName()));
                        } else {
                            addTypeConstraint(new PrimaryTypeComparision(nt.getName()));
                        }
                    }
                }
            } catch (NoSuchNodeTypeException e) {
                // add anyway -> will not match anything
                addTypeConstraint(new PrimaryTypeComparision(resolvedName));
            }
        }
    }

    /**
     * Adds an expression to the {@link #typeConstraints}.
     *
     * @param expr the expression to add.
     */
    private void addTypeConstraint(Expression expr) {
        if (typeConstraints == null) {
            typeConstraints = new OptionalExpression();
        }
        typeConstraints.addOperand(expr);
    }

    /**
     * Resolves the given <code>ntName</code> and returns all node type names
     * where the local name matches <code>ntName</code>.
     *
     * @param ntName the name of a node type (optionally without prefix).
     * @return the matching node type names.
     * @throws RepositoryException if an error occurs while reading from the
     *                             node type manager.
     */
    private String[] resolveNodeTypeName(String ntName)
            throws RepositoryException {
        String[] names;
        if (isPrefixed(ntName)) {
            names = new String[]{ntName};
        } else {
            if (ntNames == null) {
                NodeTypeManager ntMgr = session.getWorkspace().getNodeTypeManager();
                ntNames = new HashMap<String, String[]>();
                NodeTypeIterator it = ntMgr.getAllNodeTypes();
                while (it.hasNext()) {
                    String name = it.nextNodeType().getName();
                    String localName = name;
                    int idx = name.indexOf(':');
                    if (idx != -1) {
                        localName = name.substring(idx + 1);
                    }
                    String[] nts = ntNames.get(localName);
                    if (nts == null) {
                        nts = new String[]{name};
                    } else {
                        String[] tmp = new String[nts.length + 1];
                        System.arraycopy(nts, 0, tmp, 0, nts.length);
                        tmp[nts.length] = name;
                        nts = tmp;
                    }
                    ntNames.put(localName, nts);
                }
            }
            names = ntNames.get(ntName);
            if (names == null) {
                names = new String[]{ntName};
            }
        }
        return names;
    }

    /**
     * Resolves the given property name. If the name has a prefix then the name
     * is returned immediately as is. Otherwise the node type manager is
     * searched for a property definition that defines a named property with
     * a local name that matches the provided <code>name</code>. If such a match
     * is found the name of the property definition is returned.
     *
     * @param name the name of a property (optionally without a prefix).
     * @return the resolved property name.
     * @throws RepositoryException if an error occurs while reading from the
     *                             node type manager.
     */
    private String resolvePropertyName(String name)
            throws RepositoryException {
        if (isPrefixed(name)) {
            return name;
        }
        if (propertyNames == null) {
            propertyNames = new HashMap<String, String>();
            if (session != null) {
                NodeTypeManager ntMgr = session.getWorkspace().getNodeTypeManager();
                NodeTypeIterator it = ntMgr.getAllNodeTypes();
                while (it.hasNext()) {
                    NodeType nt = it.nextNodeType();
                    PropertyDefinition[] defs = nt.getDeclaredPropertyDefinitions();
                    for (PropertyDefinition def : defs) {
                        String pn = def.getName();
                        if (!pn.equals("*")) {
                            String localName = pn;
                            int idx = pn.indexOf(':');
                            if (idx != -1) {
                                localName = pn.substring(idx + 1);
                            }
                            propertyNames.put(localName, pn);
                        }
                    }
                }
            }
        }
        String pn = propertyNames.get(name);
        if (pn != null) {
            return pn;
        } else {
            return name;
        }
    }

    /**
     * Resolves the given node name. If the name has a prefix then the name
     * is returned immediately as is. Otherwise the node type manager is
     * searched for a node definition that defines a named child node with
     * a local name that matches the provided <code>name</code>. If such a match
     * is found the name of the node definition is returned.
     *
     * @param name the name of a node (optionally without a prefix).
     * @return the resolved node name.
     * @throws RepositoryException if an error occurs while reading from the
     *                             node type manager.
     */
    private String resolveChildNodeName(String name)
            throws RepositoryException {
        if (isPrefixed(name)) {
            return name;
        }
        if (childNodeNames == null) {
            childNodeNames = new HashMap<String, String>();
            NodeTypeManager ntMgr = session.getWorkspace().getNodeTypeManager();
            NodeTypeIterator it = ntMgr.getAllNodeTypes();
            while (it.hasNext()) {
                NodeType nt = it.nextNodeType();
                NodeDefinition[] defs = nt.getDeclaredChildNodeDefinitions();
                for (NodeDefinition def : defs) {
                    String cnn = def.getName();
                    if (!cnn.equals("*")) {
                        String localName = cnn;
                        int idx = cnn.indexOf(':');
                        if (idx != -1) {
                            localName = cnn.substring(idx + 1);
                        }
                        childNodeNames.put(localName, cnn);
                    }
                }
            }
        }
        String cnn = childNodeNames.get(name);
        if (cnn != null) {
            return cnn;
        } else {
            return name;
        }
    }

    /**
     * @param name a JCR name.
     * @return <code>true</code> if <code>name</code> contains a namespace
     *         prefix; <code>false</code> otherwise.
     */
    private static boolean isPrefixed(String name) {
        return name.indexOf(':') != -1;
    }

    /**
     * Parses the GQL query statement.
     *
     * @param callback the parser callback.
     * @throws RepositoryException if an error occurs while reading from the
     *                             repository.
     */
    private void parse(ParserCallback callback) throws RepositoryException {
        char[] stmt = new char[statement.length() + 1];
        statement.getChars(0, statement.length(), stmt, 0);
        stmt[statement.length()] = ' ';
        StringBuffer property = new StringBuffer();
        StringBuffer value = new StringBuffer();
        boolean quoted = false;
        boolean optional = false;
        for (char c : stmt) {
            switch (c) {
                case ' ':
                    if (quoted) {
                        value.append(c);
                    } else {
                        if (value.length() > 0) {
                            String p = property.toString();
                            String v = value.toString();
                            if (v.equals(OR) && p.length() == 0) {
                                optional = true;
                            } else {
                                callback.term(p, v, optional);
                                optional = false;
                            }
                            property.setLength(0);
                            value.setLength(0);
                        }
                    }
                    break;
                case ':':
                    if (quoted) {
                        value.append(c);
                    } else {
                        if (property.length() == 0) {
                            // turn value into property name
                            property.append(value);
                            value.setLength(0);
                        } else {
                            // colon in value
                            value.append(c);
                        }
                    }
                    break;
                case '"':
                    quoted = !quoted;
                    break;
                    // noise
                case '*':
                case '?':
                    if (property.toString().equals(NAME)) {
                        // allow wild cards in name
                        value.append(c);
                        break;
                    }
                case '~':
                case '^':
                case '[':
                case ']':
                case '{':
                case '}':
                case '!':
                case '\\':
                    break;
                default:
                    value.append(c);
            }
        }
    }

    /**
     * Pushes an expression.
     *
     * @param property the property name of the currently parsed expression.
     * @param value the value of the currently parsed expression.
     * @param optional whether the previous token was the <code>OR</code> operator.
     * @throws RepositoryException if an error occurs while reading from the
     *                             node type manager.
     */
    private void pushExpression(String property,
                                String value,
                                boolean optional) throws RepositoryException {
        if (property.equals(PATH)) {
            String path;
            if (value.startsWith("/")) {
                path = "/" + JCR_ROOT + value;
            } else {
                path = value;
            }
            pathConstraint = ISO9075.encodePath(path) + "//*";
        } else if (property.equals(TYPE)) {
            String[] nts = Text.explode(value, ',');
            if (nts.length > 0) {
                for (String nt : nts) {
                    collectNodeTypes(nt);
                }
            }
        } else if (property.equals(ORDER)) {
            orderBy = new OrderByExpression(value);
        } else if (property.equals(LIMIT)) {
            int idx = value.indexOf("..");
            if (idx != -1) {
                String lower = value.substring(0, idx);
                String uppper = value.substring(idx + "..".length());
                if (lower.length() > 0) {
                    try {
                        offset = Integer.parseInt(lower);
                    } catch (NumberFormatException e) {
                        // ignore
                    }
                }
                if (uppper.length() > 0) {
                    try {
                        numResults = Integer.parseInt(uppper) - offset;
                        if (numResults < 0) {
                            numResults = Integer.MAX_VALUE;
                        }
                    } catch (NumberFormatException e) {
                        // ignore
                    }
                }
            } else {
                // numResults only?
                try {
                    numResults = Integer.parseInt(value);
                } catch (NumberFormatException e) {
                    // ignore
                }
            }
        } else {
            Expression expr;
            if (property.equals(NAME)) {
                expr = new NameExpression(value);
            } else {
                expr = new ContainsExpression(property, value);
            }

            if (optional) {
                Expression last = conditions.get(conditions.size() - 1);
                if (last instanceof OptionalExpression) {
                    ((OptionalExpression) last).addOperand(expr);
                } else {
                    OptionalExpression op = new OptionalExpression();
                    op.addOperand(last);
                    op.addOperand(expr);
                    conditions.set(conditions.size() - 1, op);
                }
            } else {
                conditions.add(expr);
            }
        }
    }

    /**
     * Checks if the <code>value</code> is prohibited (prefixed with a dash)
     * and returns the value without the prefix.
     *
     * @param value the value to check.
     * @return the un-prefixed value.
     */
    private static String checkProhibited(String value) {
        if (value.startsWith("-")) {
            return value.substring(1);
        } else {
            return value;
        }
    }

    /**
     * An expression in GQL.
     */
    private interface Expression {

        void toString(StringBuffer buffer) throws RepositoryException;
    }

    /**
     * Base class for all property expressions.
     */
    private abstract class PropertyExpression implements Expression {

        protected final String property;

        protected final String value;

        PropertyExpression(String property, String value) {
            this.property = property;
            this.value = value;
        }
    }

    /**
     * Base class for mixin and primary type expressions.
     */
    private abstract class ValueComparison extends PropertyExpression {

        ValueComparison(String property, String value) {
            super(property, value);
        }

        public void toString(StringBuffer buffer)
                throws RepositoryException {
            buffer.append("@");
            buffer.append(ISO9075.encode(resolvePropertyName(property)));
            buffer.append("='").append(value).append("'");
        }
    }

    /**
     * A mixin type comparison.
     */
    private class MixinComparision extends ValueComparison {

        MixinComparision(String value) {
            super(JCR_MIXIN_TYPES, value);
        }
    }

    /**
     * A primary type comparision.
     */
    private class PrimaryTypeComparision extends ValueComparison {

        PrimaryTypeComparision(String value) {
            super(JCR_PRIMARY_TYPE, value);
        }
    }

    /**
     * A name expression.
     */
    private static class NameExpression implements Expression {

        private final String value;

        NameExpression(String value) {
            String tmp = value;
            tmp = tmp.replaceAll("'", "''");
            tmp = tmp.replaceAll("\\*", "\\%");
            tmp = tmp.replaceAll("\\?", "\\_");
            tmp = tmp.toLowerCase();
            this.value = tmp;
        }

        public void toString(StringBuffer buffer)
                throws RepositoryException {
            buffer.append("jcr:like(fn:lower-case(fn:name()), '");
            buffer.append(value);
            buffer.append("')");
        }
    }

    /**
     * A single contains expression.
     */
    private final class ContainsExpression extends PropertyExpression {

        private boolean prohibited = false;

        ContainsExpression(String property, String value) {
            super(property, checkProhibited(value.toLowerCase()));
            this.prohibited = value.startsWith("-");
        }

        public void toString(StringBuffer buffer)
                throws RepositoryException {
            if (property.equals(NATIVE_XPATH)) {
                buffer.append(value);
                return;
            }        
            if (prohibited) {
                buffer.append("not(");
            }
            buffer.append(JCR_CONTAINS).append("(");
            if (property.length() == 0) {
                // node scope
                if (commonPathPrefix == null) {
                    buffer.append(".");
                } else {
                    buffer.append(ISO9075.encodePath(commonPathPrefix));
                }
            } else {
                // property scope
                String[] parts = Text.explode(property, '/');
                if (commonPathPrefix != null) {
                    buffer.append(ISO9075.encodePath(commonPathPrefix));
                    buffer.append("/");
                }
                String slash = "";
                for (int i = 0; i < parts.length; i++) {
                    if (i == parts.length - 1) {
                        if (!parts[i].equals(".")) {
                            // last part
                            buffer.append(slash);
                            buffer.append("@");
                            buffer.append(ISO9075.encode(
                                    resolvePropertyName(parts[i])));
                        }
                    } else {
                        buffer.append(slash);
                        buffer.append(ISO9075.encode(
                                resolveChildNodeName(parts[i])));
                    }
                    slash = "/";
                }
            }
            buffer.append(", '");
            // properly escape apostrophe. See JCR-3157
            String escapedValue = value.replaceAll("'", "\\\\''");
            if (value.indexOf(' ') != -1) {
                // phrase
                buffer.append('"').append(escapedValue).append('"');
            } else {
                buffer.append(escapedValue);
            }
            buffer.append("')");
            if (prohibited) {
                buffer.append(")");
            }
        }
    }

    /**
     * Base class for n-ary expression.
     */
    private abstract class NAryExpression implements Expression {

        private final List<Expression> operands = new ArrayList<Expression>();

        public void toString(StringBuffer buffer)
                throws RepositoryException {
            if (operands.size() > 1) {
                buffer.append("(");
            }
            String op = "";
            for (Expression expr : operands) {
                buffer.append(op);
                expr.toString(buffer);
                op = getOperation();
            }
            if (operands.size() > 1) {
                buffer.append(")");
            }
        }

        void addOperand(Expression expr) {
            operands.add(expr);
        }

        int getSize() {
            return operands.size();
        }

        protected abstract String getOperation();
    }

    /**
     * An expression that requires all its operands to match.
     */
    private class RequiredExpression extends NAryExpression {

        protected String getOperation() {
            return " and ";
        }
    }

    /**
     * An expression where at least one operand must match.
     */
    private class OptionalExpression extends NAryExpression {

        protected String getOperation() {
            return " or ";
        }
    }

    /**
     * An order by expression.
     */
    private class OrderByExpression implements Expression {

        private final String value;

        OrderByExpression() {
            this.value = "";
        }

        OrderByExpression(String value) {
            this.value = value;
        }

        public void toString(StringBuffer buffer)
                throws RepositoryException {
            int start = buffer.length();
            buffer.append("order by ");
            List<String> names = new ArrayList<String>(Arrays.asList(Text.explode(value, ',')));
            int length = buffer.length();
            String comma = "";
            for (String name : names) {
                boolean asc;
                if (name.equals("-")) {
                    // no order by at all
                    buffer.delete(start, buffer.length());
                    return;
                }
                if (name.startsWith("-")) {
                    name = name.substring(1);
                    asc = false;
                } else if (name.startsWith("+")) {
                    name = name.substring(1);
                    asc = true;
                } else {
                    // default is ascending
                    asc = true;
                }
                if (name.length() > 0) {
                    buffer.append(comma);
                    name = createPropertyName(resolvePropertyName(name));
                    buffer.append(name);
                    if (!asc) {
                        buffer.append(" ").append(DESCENDING);
                    }
                    comma = ", ";
                }
            }
            if (buffer.length() == length) {
                // no order by
                defaultOrderBy(buffer);
            }
        }

        private String createPropertyName(String name) {
            if (name.contains("/")) {
                String[] labels = name.split("/");

                name = "";
                for (int i = 0; i < labels.length; i++) {
                    String label = ISO9075.encode(labels[i]);
                    if (i < (labels.length - 1)) {
                        name += label + "/";
                    } else {
                        name += "@" + label;
                    }
                }
                return name;
            } else {
                return "@" + ISO9075.encode(name);
            }
        }

        private void defaultOrderBy(StringBuffer buffer) {
            buffer.append("@").append(JCR_SCORE).append(" ").append(DESCENDING);
        }
    }

    /**
     * A row adapter for rep:excerpt values in the result.
     */
    private static final class RowAdapter implements Row {

        private final Row row;

        private final String excerptPath;

        private RowAdapter(Row row, String excerptPath) {
            this.row = row;
            this.excerptPath = excerptPath;
        }

        public Value[] getValues() throws RepositoryException {
            return row.getValues();
        }

        public Value getValue(String propertyName) throws ItemNotFoundException, RepositoryException {
            if (propertyName.startsWith(REP_EXCERPT)) {
                propertyName = REP_EXCERPT + "(" + excerptPath + ")";
            }
            return row.getValue(propertyName);
        }

        public Node getNode() throws RepositoryException {
            return row.getNode();
        }

        public Node getNode(String selectorName) throws RepositoryException {
            return row.getNode(selectorName);
        }

        public String getPath() throws RepositoryException {
            return row.getPath();
        }

        public String getPath(String selectorName) throws RepositoryException {
            return row.getPath(selectorName);
        }

        public double getScore() throws RepositoryException {
            return row.getScore();
        }

        public double getScore(String selectorName) throws RepositoryException {
            return row.getScore(selectorName);
        }
    }

    /**
     * Customized row iterator adapter, which returns {@link RowAdapter}.
     */
    private final class RowIterAdapter extends RowIteratorAdapter {

        private final long size;

        public RowIterAdapter(RangeIterator rangeIterator, long size) {
            super(rangeIterator);
            this.size = size;
        }

        public RowIterAdapter(Collection collection, long size) {
            super(collection);
            this.size = size;
        }

        public Row nextRow() {
            Row next = super.nextRow();
            if (commonPathPrefix != null) {
                next = new RowAdapter(next, commonPathPrefix);
            } else {
                next = new RowAdapter(next, ".");
            }
            return next;
        }

        public long getSize() {
            return size;
        }
    }

    /**
     * A row iterator that applies a {@link GQL#filter} on the underlying rows.
     */
    private final class FilteredRowIterator implements RowIterator {

        /**
         * The underlying rows.
         */
        private final RowIterator rows;

        /**
         * The next row to return or <code>null</code> if there is none.
         */
        private Row next;

        /**
         * The current position.
         */
        private long position = 0;

        /**
         * Filters the given <code>rows</code>.
         *
         * @param rows the rows to filter.
         */
        public FilteredRowIterator(RowIterator rows) {
            this.rows = rows;
            fetchNext();
        }

        /**
         * {@inheritDoc}
         */
        public void skip(long skipNum) {
            while (skipNum-- > 0 && hasNext()) {
                nextRow();
            }
        }

        /**
         * {@inheritDoc}
         */
        public long getSize() {
            return -1;
        }

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

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

        /**
         * {@inheritDoc}
         */
        public void remove() {
            throw new UnsupportedOperationException();
        }

        /**
         * {@inheritDoc}
         */
        public Row nextRow() {
            if (next == null) {
                throw new NoSuchElementException();
            } else {
                try {
                    return next;
                } finally {
                    position++;
                    fetchNext();
                }
            }
        }

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

        /**
         * Fetches the next {@link Row}.
         */
        private void fetchNext() {
            next = null;
            while (next == null && rows.hasNext()) {
                Row r = rows.nextRow();
                try {
                    if (filter.include(r)) {
                        next = r;
                        return;
                    }
                } catch (RepositoryException e) {
                    // ignore
                }
            }
        }
    }
}

