/*
 * 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.jackrabbit.user;

import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.Query;
import org.apache.jackrabbit.api.security.user.QueryBuilder;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.commons.json.JsonHandler;
import org.apache.jackrabbit.commons.json.JsonParser;

import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.ValueFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Stack;

/**
 * This class handles the translation of queries for users and groups from a
 * JSON format to the query model of Jackrabbit's user groups search
 * (see {@link org.apache.jackrabbit.api.security.user.UserManager#findAuthorizables(org.apache.jackrabbit.api.security.user.Query)
 * UserManager#findAuthorizables(Query)}).
 *
 * The JSON query format is defined as follows:
 * <pre>
{
  ( selector: "authorizable" | "user" | "group" )?        // Defaults to "authorizable", see QueryBuilder#setSelector()

  (
    scope:                                                // See QueryBuilder#setScope()
    {
      groupName: /* group name (String) * /
      ( declaredOnly: true | false )                      // Defaults to true
    }
  ) ?                                                     // Defaults to all

  ( condition: [ CONJUNCTION+ ] ) ?                       // Defaults to a 'true' condition, see QueryBuilder#setCondition()

  (
    order | sort:                                         // See QueryBuilder#setOrder()
    {
      property: /* relative path (String) * /
      ( direction: "asc" | "desc" )                       // Defaults to "asc"
    }
  ) ?                                                     // Defaults to document order

  (
    limit:                                                // See QueryBuilder#setLimit()
    {
      offset: /* Positive Integer * /                     // Takes precedence over bound if both are given
      bound:  /* String, Number, Boolean * /
      max:    /* Positive Integer or -1 * /               // Defaults to no limit (-1)
    }
  ) ?                                                     // Defaults to all
}

CONJUNCTION ::= COMPOUND | PRIMITIVE
COMPOUND    ::= [ PRIMITIVE+ ]
PRIMITIVE   ::= { ATOM | NEGATION }
NEGATION    ::= not: { ATOM }                             // See QueryBuilder#not()
ATOM        ::= named: /* pattern * /                     // Users, groups of that name. See QueryBuilder#nameMatches()
            |   exists: /* relative path * /              // See QueryBuilder#exists()
            |   impersonates: /* authorizable name * /    // See QueryBuilder#impersonates()
            |   RELOP:
                {
                  property: /* relative path * /
                  value: /* String, Number, Boolean * /   // According to the type of the property
                }
            |   like:                                     // See QueryBuilder#like()
                {
                  property: /* relative path * /
                  pattern: /* pattern * /
                }
            |   contains:                                 // See QueryBuilder#contains()
                {
                  property: /* relative path * /
                  expression: /* search expression * /
                }
RELOP       ::= neq | eq | lt | le | gt | ge              // See QueryBuilder#neq(), QueryBuilder#eq(), ...
</pre>
 *
 * <ul>
 * <li>A relative path refers to a property or a child node of an user or a group. Property names need to be
 * prefixed with the at (@) character. Invalid JCR characters need proper escaping. The current path is denoted
 * by a dot (.).</li>
 * <li>In a 'pattern' the percent character (%) represents any string of zero or more characters and the underscore
 * character (_) represents any single character. Any literal use of these characters and the backslash
 * character (\) must be escaped with a backslash character. The pattern is matched against
 * Authorizable#getID() and Authorizable#getPrincipal().</li>
 * <li>The syntax of 'expression' is [-]value { [OR] [-]value }.</li>
 * </ul>
 */
public class AuthorizableQueryManager {

    /**
     * Constant defining the default maximal size of the result set.
     */
    public static final int MAX_RESULT_COUNT = 2000;

    private final UserManager userManager;
    private final ValueFactory valueFactory;

    public AuthorizableQueryManager(UserManager userManager, ValueFactory valueFactory) {
        this.userManager = userManager;
        this.valueFactory = valueFactory;
    }

    public Iterator<Authorizable> execute(final String query) throws RepositoryException, IOException {
        try {
            return userManager.findAuthorizables(new Query() {
                public <T> void build(QueryBuilder<T> builder) {
                    try {
                        // Must request more than MAX_RESULT_COUNT records explicitly
                        builder.setLimit(0, MAX_RESULT_COUNT);
                        new QueryTranslator<T>(builder).translate(query);
                    } catch (IOException e) {
                        throw new IllegalArgumentException(e);
                    }
                }
            });
        } catch (IllegalArgumentException e) {
            Throwable cause = e.getCause();
            if (cause instanceof IOException) {
                throw (IOException) cause;
            } else {
                throw e;
            }
        }
    }

    //------------------------------------------------------------< private >---

    private class QueryTranslator<T> implements JsonHandler {
        private final QueryBuilder<T> queryBuilder;
        private final Stack<JsonHandler> handlers = new Stack<JsonHandler>();

        public QueryTranslator(QueryBuilder<T> queryBuilder) {
            this.queryBuilder = queryBuilder;
            handlers.push(new HandlerBase() {
                @Override
                public void object() {
                    handlers.push(new ClausesHandler());
                }
            });
        }

        public void translate(String query) throws IOException {
            new JsonParser(this).parse(query);
            if (handlers.size() != 1) {
                throw new IOException("Missing closing parenthesis");
            }
        }

        public void object() throws IOException {
            handlers.peek().object();
        }

        public void endObject() throws IOException {
            handlers.peek().endObject();
        }

        public void array() throws IOException {
            handlers.peek().array();
        }

        public void endArray() throws IOException {
            handlers.peek().endArray();
        }

        public void key(String s) throws IOException {
            handlers.peek().key(s);
        }

        public void value(String s) throws IOException {
            handlers.peek().value(s);
        }

        public void value(boolean b) throws IOException {
            handlers.peek().value(b);
        }

        public void value(long l) throws IOException {
            handlers.peek().value(l);
        }

        public void value(double v) throws IOException {
            handlers.peek().value(v);
        }

        private Value valueFor(String s) {
            return valueFactory.createValue(s);
        }

        private Value valueFor(boolean b) {
            return valueFactory.createValue(b);
        }

        private Value valueFor(long l) {
            return valueFactory.createValue(l);
        }

        private Value valueFor(double v) {
            return valueFactory.createValue(v);
        }

        //----------------------------------------------------< HandlerBase >---

        private class HandlerBase implements JsonHandler {

            public void object() throws IOException {
                throw new IOException("Syntax error: '{'");
            }

            public void endObject() throws IOException {
                throw new IOException("Syntax error: '}'");
            }

            public void array() throws IOException {
                throw new IOException("Syntax error: '['");
            }

            public void endArray() throws IOException {
                throw new IOException("Syntax error: ']'");
            }

            public void key(String s) throws IOException {
                throw new IOException("Syntax error: key '" + s + '\'');
            }

            public void value(String s) throws IOException {
                throw new IOException("Syntax error: string '" + s + '\'');
            }

            public void value(boolean b) throws IOException {
                throw new IOException("Syntax error: boolean '" + b + '\'');
            }

            public void value(long l) throws IOException {
                throw new IOException("Syntax error: long '" + l + '\'');
            }

            public void value(double v) throws IOException {
                throw new IOException("Syntax error: double '" + v + '\'');
            }
        }

        //-------------------------------------------------< ClausesHandler >---

        private class ClausesHandler extends HandlerBase {
            private String currentKey;

            @Override
            public void object() throws IOException {
                handlers.push(handlerFor(currentKey));
            }

            @Override
            public void endObject() throws IOException {
                handlers.pop();
            }

            @Override
            public void array() throws IOException {
                handlers.push(handlerFor(currentKey));
            }

            @Override
            public void endArray() throws IOException {
                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if ("selector".equals(currentKey)) {
                    queryBuilder.setSelector(selectorFor(s));
                } else {
                    throw new IOException("String value '" + s + "' is invalid for '" + currentKey + '\'');
                }
            }

            private Class<? extends Authorizable> selectorFor(String selector) throws IOException {
                if ("user".equals(selector)) {
                    return User.class;
                } else if ("group".equals(selector)) {
                    return Group.class;
                } else if ("authorizable".equals(selector)) {
                    return Authorizable.class;
                } else {
                    throw new IOException("Invalid selector '" + selector + '\'');
                }
            }

            private JsonHandler handlerFor(String key) throws IOException {
                if ("scope".equals(key)) {
                    return new ScopeHandler();
                } else if ("condition".equals(key)) {
                    return new ConditionHandler();
                } else if ("order".equals(key) || "sort".equals(key)) {
                    return new OrderHandler();
                } else if ("limit".equals(key)) {
                    return new LimitHandler();
                } else {
                    throw new IOException("Invalid clause '" + key + '\'');
                }
            }
        }

        //---------------------------------------------------< ScopeHandler >---

        private class ScopeHandler extends HandlerBase {
            private String currentKey;
            private String groupName;
            private Boolean declaredOnly;

            @Override
            public void endObject() throws IOException {
                if (groupName == null) {
                    throw new IOException("Missing groupName");
                } else {
                    queryBuilder.setScope(groupName, declaredOnly == null ? true : declaredOnly);
                }
                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if ("groupName".equals(currentKey)) {
                    groupName = s;
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + s + '\'');
                }
            }

            @Override
            public void value(boolean b) throws IOException {
                if ("declaredOnly".equals(currentKey)) {
                    declaredOnly = b;
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + b + '\'');
                }
            }
        }

        //-----------------------------------------------< ConditionHandler >---

        private class ConditionHandler extends HandlerBase {
            private final List<ConditionBase> memberHandlers = new ArrayList<ConditionBase>();

            @Override
            public void object() throws IOException {
                PrimitiveHandler memberHandler = new PrimitiveHandler();
                memberHandlers.add(memberHandler);
                handlers.push(memberHandler);
            }

            @Override
            public void array() throws IOException {
                CompoundHandler memberHandler = new CompoundHandler();
                memberHandlers.add(memberHandler);
                handlers.push(memberHandler);
            }

            @Override
            public void endArray() throws IOException {
                if (memberHandlers.isEmpty()) {
                    throw new IOException("Empty search term");
                }

                Iterator<ConditionBase> memberHandler = memberHandlers.iterator();
                T condition = memberHandler.next().getCondition();
                while (memberHandler.hasNext()) {
                    condition = queryBuilder.and(condition, memberHandler.next().getCondition());
                }

                queryBuilder.setCondition(condition);

                handlers.pop();
            }

        }

        //--------------------------------------------------< ConditionBase >---

        private abstract class ConditionBase extends HandlerBase {
            public abstract T getCondition();
        }

        //------------------------------------------------< CompoundHandler >---

        private class CompoundHandler extends ConditionBase {
            private final List<ConditionBase> memberHandlers = new ArrayList<ConditionBase>();

            @Override
            public void object() throws IOException {
                PrimitiveHandler memberHandler = new PrimitiveHandler();
                memberHandlers.add(memberHandler);
                handlers.push(memberHandler);
            }

            @Override
            public void endArray() throws IOException {
                if (memberHandlers.isEmpty()) {
                    throw new IOException("Empty search term");
                }

                handlers.pop();
            }

            @Override
            public T getCondition() {
                Iterator<ConditionBase> memberHandler = memberHandlers.iterator();
                T condition = memberHandler.next().getCondition();
                while (memberHandler.hasNext()) {
                    condition = queryBuilder.or(condition, memberHandler.next().getCondition());
                }

                return condition;
            }
        }

        //-----------------------------------------------< PrimitiveHandler >---

        private class PrimitiveHandler extends ConditionBase {
            private String currentKey;
            private ConditionBase relOp;
            private ConditionBase not;
            private T condition;

            @Override
            public void object() throws IOException {
                if (hasCondition()) {
                    throw new IOException("Condition on '" + currentKey + "' not allowed since another " +
                            "condition is already set");
                }

                if ("not".equals(currentKey)) {
                    not = new PrimitiveHandler();
                    handlers.push(not);
                } else {
                    relOp = new RelOpHandler(currentKey);
                    handlers.push(relOp);
                }
            }

            @Override
            public void endObject() throws IOException {
                if (!hasCondition()) {
                    throw new IOException("Missing term");
                }

                if (relOp != null) {
                    condition = relOp.getCondition();
                } else if (condition == null) {
                    condition = queryBuilder.not(not.getCondition());
                }

                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if (hasCondition()) {
                    throw new IOException("Condition on '" + currentKey + "' not allowed since another " +
                            "condition is already set");
                }

                if ("named".equals(currentKey)) {
                    condition = queryBuilder.nameMatches(s);
                } else if ("exists".equals(currentKey)) {
                    condition = queryBuilder.exists(s);
                } else if ("impersonates".equals(currentKey)) {
                    condition = queryBuilder.impersonates(s);
                } else {
                    throw new IOException("Invalid condition '" + currentKey + '\'');
                }
            }

            private boolean hasCondition() {
                return condition != null || relOp != null || not != null;
            }

            @Override
            public T getCondition() {
                return condition;
            }
        }

        //---------------------------------------------------< RelOpHandler >---

        private class RelOpHandler extends ConditionBase {
            private final String op;

            private String currentKey;
            private String property;
            private String pattern;
            private String expression;
            private Value value;
            private T condition;

            public RelOpHandler(String op) {
                this.op = op;
            }

            @Override
            public void endObject() throws IOException {
                if (property == null) {
                    throw new IOException("Property not set for condition '" + op + '\'');
                }

                if ("like".equals(op)) {
                    if (pattern == null) {
                        throw new IOException("Pattern not set for 'like' condition");
                    }
                    condition = queryBuilder.like(property, pattern);
                } else if ("contains".equals(op)) {
                    if (expression == null) {
                        throw new IOException("Expression not set for 'contains' condition");
                    }
                    condition = queryBuilder.contains(property, expression);
                } else {
                    if (value == null) {
                        throw new IOException("Value not set for '" + op + "' condition");
                    }

                    if ("eq".equals(op)) {
                        condition = queryBuilder.eq(property, value);
                    } else if ("neq".equals(op)) {
                        condition = queryBuilder.neq(property, value);
                    } else if ("lt".equals(op)) {
                        condition = queryBuilder.lt(property, value);
                    } else if ("le".equals(op)) {
                        condition = queryBuilder.le(property, value);
                    } else if ("ge".equals(op)) {
                        condition = queryBuilder.ge(property, value);
                    } else if ("gt".equals(op)) {
                        condition = queryBuilder.gt(property, value);
                    } else {
                        throw new IOException("Invalid condition: '" + op + '\'');
                    }
                }

                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if ("property".equals(currentKey)) {
                    property = s;
                } else if ("pattern".equals(currentKey)) {
                    pattern = s;
                } else if ("expression".equals(currentKey)) {
                    expression = s;
                } else if ("value".equals(currentKey)) {
                    value = valueFor(s);
                } else {
                    throw new IOException("Expected one of 'property', 'pattern', 'expression', 'value' " +
                            "but found '" + currentKey + '\'');
                }
            }

            @Override
            public void value(boolean b) throws IOException {
                if ("value".equals(currentKey)) {
                    value = valueFor(b);
                } else {
                    throw new IOException("Expected 'value', found '" + currentKey + '\'');
                }
            }

            @Override
            public void value(long l) throws IOException {
                if ("value".equals(currentKey)) {
                    value = valueFor(l);
                } else {
                    throw new IOException("Expected 'value', found '" + currentKey + '\'');
                }
            }

            @Override
            public void value(double v) throws IOException {
                if ("value".equals(currentKey)) {
                    value = valueFor(v);
                } else {
                    throw new IOException("Expected 'value', found '" + currentKey + '\'');
                }
            }

            @Override
            public T getCondition() {
                return condition;
            }
        }

        //---------------------------------------------------< OrderHandler >---

        private class OrderHandler extends HandlerBase {
            private String currentKey;
            private String property;
            private QueryBuilder.Direction direction;

            @Override
            public void endObject() throws IOException {
                if (property == null) {
                    throw new IOException("Missing property");
                } else {
                    queryBuilder.setSortOrder(property, direction == null
                            ? QueryBuilder.Direction.ASCENDING
                            : direction, true);
                }
                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if ("property".equals(currentKey)) {
                    property = s;
                } else if ("direction".equals(currentKey)) {
                    direction = directionFor(s);
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + s + '\'');
                }
            }

            private QueryBuilder.Direction directionFor(String direction) throws IOException {
                if ("asc".equals(direction)) {
                    return QueryBuilder.Direction.ASCENDING;
                } else if ("desc".equals(direction)) {
                    return QueryBuilder.Direction.DESCENDING;
                } else {
                    throw new IOException("Invalid direction '" + direction + '\'');
                }
            }
        }

        //---------------------------------------------------< LimitHandler >---

        private class LimitHandler extends HandlerBase {
            private String currentKey;
            private Long offset;
            private Value bound;
            private Long max;

            @Override
            public void endObject() throws IOException {
                if (offset != null) {
                    queryBuilder.setLimit(offset, max == null ? -1 : max);
                } else if (bound != null) {
                    queryBuilder.setLimit(bound, max == null ? -1 : max);
                } else {
                    throw new IOException("Missing bound or offset");
                }
                handlers.pop();
            }

            @Override
            public void key(String s) throws IOException {
                currentKey = s;
            }

            @Override
            public void value(String s) throws IOException {
                if ("bound".equals(currentKey)) {
                    bound = valueFor(s);
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + s + '\'');
                }
            }

            @Override
            public void value(boolean b) throws IOException {
                if ("bound".equals(currentKey)) {
                    bound = valueFor(b);
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + b + '\'');
                }
            }

            @Override
            public void value(long l) throws IOException {
                if ("bound".equals(currentKey)) {
                    bound = valueFor(l);
                } else if ("offset".equals(currentKey)) {
                    offset = l;
                } else if ("max".equals(currentKey)) {
                    max = l;
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + l + '\'');
                }
            }

            @Override
            public void value(double v) throws IOException {
                if ("bound".equals(currentKey)) {
                    bound = valueFor(v);
                } else {
                    throw new IOException("Unexpected: '" + currentKey + ':' + v + '\'');
                }
            }
        }
    }
}
