/*
 * 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.commons.jexl3.internal;

import org.apache.commons.jexl3.JexlArithmetic;
import org.apache.commons.jexl3.JexlBuilder;
import org.apache.commons.jexl3.JexlContext;
import org.apache.commons.jexl3.JexlEngine;
import org.apache.commons.jexl3.JexlException;
import org.apache.commons.jexl3.JexlInfo;
import org.apache.commons.jexl3.JexlScript;
import org.apache.commons.jexl3.internal.introspection.SandboxUberspect;
import org.apache.commons.jexl3.internal.introspection.Uberspect;
import org.apache.commons.jexl3.introspection.JexlMethod;
import org.apache.commons.jexl3.introspection.JexlSandbox;
import org.apache.commons.jexl3.introspection.JexlUberspect;
import org.apache.commons.jexl3.parser.ASTArrayAccess;
import org.apache.commons.jexl3.parser.ASTFunctionNode;
import org.apache.commons.jexl3.parser.ASTIdentifier;
import org.apache.commons.jexl3.parser.ASTIdentifierAccess;
import org.apache.commons.jexl3.parser.ASTJexlScript;
import org.apache.commons.jexl3.parser.ASTMethodNode;
import org.apache.commons.jexl3.parser.JexlNode;
import org.apache.commons.jexl3.parser.Parser;

import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import java.nio.charset.Charset;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * A JexlEngine implementation.
 * @since 2.0
 */
public class Engine extends JexlEngine {
    /**
     * Gets the default instance of Uberspect.
     * <p>This is lazily initialized to avoid building a default instance if there
     * is no use for it. The main reason for not using the default Uberspect instance is to
     * be able to use a (low level) introspector created with a given logger
     * instead of the default one.</p>
     * <p>Implemented as on demand holder idiom.</p>
     */
    private static final class UberspectHolder {
        /** The default uberspector that handles all introspection patterns. */
        private static final Uberspect UBERSPECT =
                new Uberspect(LogFactory.getLog(JexlEngine.class), JexlUberspect.JEXL_STRATEGY);

        /** Non-instantiable. */
        private UberspectHolder() {}
    }
    /**
     * The JexlUberspect instance.
     */
    protected final JexlUberspect uberspect;
    /**
     * The {@link JexlArithmetic} instance.
     */
    protected final JexlArithmetic arithmetic;
    /**
     * The Log to which all JexlEngine messages will be logged.
     */
    protected final Log logger;
    /**
     * The atomic parsing flag; true whilst parsing.
     */
    protected final AtomicBoolean parsing = new AtomicBoolean(false);
    /**
     * The {@link Parser}; when parsing expressions, this engine uses the parser if it
     * is not already in use otherwise it will create a new temporary one.
     */
    protected final Parser parser = new Parser(new StringReader(";")); //$NON-NLS-1$
    /**
     * Whether this engine considers unknown variables, methods and constructors as errors.
     */
    protected final boolean strict;
    /**
     * Whether expressions evaluated by this engine will throw exceptions (false) or return null (true) on errors.
     * Default is false.
     */
    protected final boolean silent;
    /**
     * Whether expressions evaluated by this engine will throw JexlException.Cancel (true) or return null (false) when
     * interrupted.
     * Default is true when not silent and strict.
     */
    protected final boolean cancellable;
    /**
     * Whether error messages will carry debugging information.
     */
    protected final boolean debug;
    /**
     * The map of 'prefix:function' to object implementing the namespaces.
     */
    protected final Map<String, Object> functions;
    /**
     * The expression cache.
     */
    protected final SoftCache<String, ASTJexlScript> cache;
    /**
     * The expression max length to hit the cache.
     */
    protected final int cacheThreshold;
    /**
     * The default charset.
     */
    protected final Charset charset;
    /**
     * The default jxlt engine.
     */
    protected volatile TemplateEngine jxlt = null;

    /**
     * Creates an engine with default arguments.
     */
    public Engine() {
        this(new JexlBuilder());
    }

    /**
     * Creates a JEXL engine using the provided {@link JexlBuilder}.
     * @param conf the builder
     */
    public Engine(JexlBuilder conf) {
        JexlSandbox sandbox = conf.sandbox();
        JexlUberspect uber = conf.uberspect() == null ? getUberspect(conf.logger(), conf.strategy()) : conf.uberspect();
        ClassLoader loader = conf.loader();
        if (loader != null) {
            uber.setClassLoader(loader);
        }
        if (sandbox == null) {
            this.uberspect = uber;
        } else {
            this.uberspect = new SandboxUberspect(uber, sandbox);
        }
        this.logger = conf.logger() == null ? LogFactory.getLog(JexlEngine.class) : conf.logger();
        this.functions = conf.namespaces() == null ? Collections.<String, Object>emptyMap() : conf.namespaces();
        this.strict = conf.strict() == null ? true : conf.strict();
        this.silent = conf.silent() == null ? false : conf.silent();
        this.cancellable = conf.cancellable() == null ? !silent && strict : conf.cancellable();
        this.debug = conf.debug() == null ? true : conf.debug();
        this.arithmetic = conf.arithmetic() == null ? new JexlArithmetic(this.strict) : conf.arithmetic();
        this.cache = conf.cache() <= 0 ? null : new SoftCache<String, ASTJexlScript>(conf.cache());
        this.cacheThreshold = conf.cacheThreshold();
        this.charset = conf.charset();
        if (uberspect == null) {
            throw new IllegalArgumentException("uberspect can not be null");
        }
    }

    /**
     * Gets the default instance of Uberspect.
     * <p>This is lazily initialized to avoid building a default instance if there
     * is no use for it. The main reason for not using the default Uberspect instance is to
     * be able to use a (low level) introspector created with a given logger
     * instead of the default one.</p>
     * @param logger the logger to use for the underlying Uberspect
     * @param strategy the property resolver strategy
     * @return Uberspect the default uberspector instance.
     */
    public static Uberspect getUberspect(Log logger, JexlUberspect.ResolverStrategy strategy) {
        if ((logger == null || logger.equals(LogFactory.getLog(JexlEngine.class)))
            && (strategy == null || strategy == JexlUberspect.JEXL_STRATEGY)) {
            return UberspectHolder.UBERSPECT;
        }
        return new Uberspect(logger, strategy);
    }

    @Override
    public JexlUberspect getUberspect() {
        return uberspect;
    }

    @Override
    public JexlArithmetic getArithmetic() {
        return arithmetic;
    }

    @Override
    public boolean isDebug() {
        return this.debug;
    }

    @Override
    public boolean isSilent() {
        return this.silent;
    }

    @Override
    public boolean isStrict() {
        return strict;
    }

    @Override
    public boolean isCancellable() {
        return this.cancellable;
    }

    @Override
    public void setClassLoader(ClassLoader loader) {
        uberspect.setClassLoader(loader);
    }

    @Override
    public Charset getCharset() {
        return charset;
    }

    @Override
    public TemplateEngine createJxltEngine(boolean noScript, int cacheSize, char immediate, char deferred) {
        return new TemplateEngine(this, noScript, cacheSize, immediate, deferred);
    }

    @Override
    public void clearCache() {
        if (cache != null) {
            cache.clear();
        }
    }

    /**
     * Creates an interpreter.
     * @param context a JexlContext; if null, the empty context is used instead.
     * @param frame   the interpreter frame
     * @return an Interpreter
     */
    protected Interpreter createInterpreter(JexlContext context, Scope.Frame frame) {
        return new Interpreter(this, context, frame);
    }

    @Override
    public Script createScript(JexlInfo info, String scriptText, String[] names) {
        if (scriptText == null) {
            throw new NullPointerException("source is null");
        }
        String source = trimSource(scriptText);
        Scope scope = names == null ? null : new Scope(null, names);
        ASTJexlScript tree = parse(info, source, scope, false, false);
        return new Script(this, source, tree);
    }

    @Override
    public Script createExpression(JexlInfo info, String expression) {
        if (expression == null) {
            throw new NullPointerException("source is null");
        }
        String source = trimSource(expression);
        ASTJexlScript tree = parse(info, source, null, false, true);
        return new Script(this, source, tree);
    }

    @Override
    public Object getProperty(Object bean, String expr) {
        return getProperty(null, bean, expr);
    }

    @Override
    public Object getProperty(JexlContext context, Object bean, String expr) {
        if (context == null) {
            context = EMPTY_CONTEXT;
        }
        // synthetize expr using register
        String src = trimSource(expr);
        src = "#0" + (src.charAt(0) == '[' ? "" : ".") + src;
        try {
            final Scope scope = new Scope(null, "#0");
            final ASTJexlScript script = parse(null, src, scope, true, true);
            final JexlNode node = script.jjtGetChild(0);
            final Scope.Frame frame = script.createFrame(bean);
            final Interpreter interpreter = createInterpreter(context, frame);
            return node.jjtAccept(interpreter, null);
        } catch (JexlException xjexl) {
            if (silent) {
                logger.warn(xjexl.getMessage(), xjexl.getCause());
                return null;
            }
            throw xjexl.clean();
        }
    }

    @Override
    public void setProperty(Object bean, String expr, Object value) {
        setProperty(null, bean, expr, value);
    }

    @Override
    public void setProperty(JexlContext context, Object bean, String expr, Object value) {
        if (context == null) {
            context = EMPTY_CONTEXT;
        }
        // synthetize expr using registers
        String src = trimSource(expr);
        src = "#0" + (src.charAt(0) == '[' ? "" : ".") + src + "=" + "#1";
        try {
            final Scope scope = new Scope(null, "#0", "#1");
            final ASTJexlScript script = parse(null, src, scope, true, true);
            final JexlNode node = script.jjtGetChild(0);
            final Scope.Frame frame = script.createFrame(bean, value);
            final Interpreter interpreter = createInterpreter(context, frame);
            node.jjtAccept(interpreter, null);
        } catch (JexlException xjexl) {
            if (silent) {
                logger.warn(xjexl.getMessage(), xjexl.getCause());
                return;
            }
            throw xjexl.clean();
        }
    }

    @Override
    public Object invokeMethod(Object obj, String meth, Object... args) {
        JexlException xjexl = null;
        Object result = null;
        final JexlInfo info = debug ? createInfo() : null;
        try {
            JexlMethod method = uberspect.getMethod(obj, meth, args);
            if (method == null && arithmetic.narrowArguments(args)) {
                method = uberspect.getMethod(obj, meth, args);
            }
            if (method != null) {
                result = method.invoke(obj, args);
            } else {
                xjexl = new JexlException.Method(info, meth, null);
            }
        } catch (JexlException xany) {
            xjexl = xany;
        } catch (Exception xany) {
            xjexl = new JexlException.Method(info, meth, xany);
        }
        if (xjexl != null) {
            if (silent) {
                logger.warn(xjexl.getMessage(), xjexl.getCause());
                result = null;
            } else {
                throw xjexl.clean();
            }
        }
        return result;
    }

    @Override
    public <T> T newInstance(Class<? extends T> clazz, Object... args) {
        return clazz.cast(doCreateInstance(clazz, args));
    }

    @Override
    public Object newInstance(String clazz, Object... args) {
        return doCreateInstance(clazz, args);
    }

    /**
     * Creates a new instance of an object using the most appropriate constructor
     * based on the arguments.
     * @param clazz the class to instantiate
     * @param args  the constructor arguments
     * @return the created object instance or null on failure when silent
     */
    protected Object doCreateInstance(Object clazz, Object... args) {
        JexlException xjexl = null;
        Object result = null;
        final JexlInfo info = debug ? createInfo() : null;
        try {
            JexlMethod ctor = uberspect.getConstructor(clazz, args);
            if (ctor == null && arithmetic.narrowArguments(args)) {
                ctor = uberspect.getConstructor(clazz, args);
            }
            if (ctor != null) {
                result = ctor.invoke(clazz, args);
            } else {
                xjexl = new JexlException.Method(info, clazz.toString(), null);
            }
        } catch (JexlException xany) {
            xjexl = xany;
        } catch (Exception xany) {
            xjexl = new JexlException.Method(info, clazz.toString(), xany);
        }
        if (xjexl != null) {
            if (silent) {
                logger.warn(xjexl.getMessage(), xjexl.getCause());
                return null;
            }
            throw xjexl.clean();
        }
        return result;
    }

    /**
     * Swaps the current thread local context.
     * @param tls the context or null
     * @return the previous thread local context
     */
    protected JexlContext.ThreadLocal putThreadLocal(JexlContext.ThreadLocal tls) {
        JexlContext.ThreadLocal local = CONTEXT.get();
        CONTEXT.set(tls);
        return local;
    }

    /**
     * Gets the list of variables accessed by a script.
     * <p>This method will visit all nodes of a script and extract all variables whether they
     * are written in 'dot' or 'bracketed' notation. (a.b is equivalent to a['b']).</p>
     * @param script the script
     * @return the set of variables, each as a list of strings (ant-ish variables use more than 1 string)
     *         or the empty set if no variables are used
     */
    protected Set<List<String>> getVariables(ASTJexlScript script) {
        VarCollector collector = new VarCollector();
        getVariables(script, script, collector);
        return collector.collected();
    }

    /**
     * Utility class to collect variables.
     */
    protected static class VarCollector {
        /**
         * The collected variables represented as a set of list of strings.
         */
        private final Set<List<String>> refs = new LinkedHashSet<List<String>>();
        /**
         * The current variable being collected.
         */
        private List<String> ref = new ArrayList<String>();
        /**
         * The node that started the collect.
         */
        private JexlNode root = null;

        /**
         * Starts/stops a variable collect.
         * @param node starts if not null, stop if null
         */
        public void collect(JexlNode node) {
            if (!ref.isEmpty()) {
                refs.add(ref);
                ref = new ArrayList<String>();
            }
            root = node;
        }

        /**
         * @return true if currently collecting a variable, false otherwise
         */
        public boolean isCollecting() {
            return root instanceof ASTIdentifier;
        }

        /**
         * Adds a 'segment' to the variable being collected.
         * @param name the name
         */
        public void add(String name) {
            ref.add(name);
        }

        /**
         *@return the collected variables
         */
        public Set<List<String>> collected() {
            return refs;
        }
    }

    /**
     * Fills up the list of variables accessed by a node.
     * @param script the owning script
     * @param node the node
     * @param collector the variable collector
     */
    protected void getVariables(final ASTJexlScript script, JexlNode node, VarCollector collector) {
        if (node instanceof ASTIdentifier) {
            JexlNode parent = node.jjtGetParent();
            if (parent instanceof ASTMethodNode || parent instanceof ASTFunctionNode) {
                // skip identifiers for methods and functions
                collector.collect(null);
                return;
            }
            ASTIdentifier identifier = (ASTIdentifier) node;
            int symbol = identifier.getSymbol();
            // symbols that are hoisted are considered "global" variables
            if (symbol >= 0 && script != null && !script.isHoistedSymbol(symbol)) {
                collector.collect(null);
            } else {
                // start collecting from identifier
                collector.collect(identifier);
                collector.add(identifier.getName());
            }
        } else if (node instanceof ASTIdentifierAccess) {
            JexlNode parent = node.jjtGetParent();
            if (parent instanceof ASTMethodNode || parent instanceof ASTFunctionNode) {
                // skip identifiers for methods and functions
                collector.collect(null);
                return;
            }
            // belt and suspender since an identifier should have been seen first
            if (collector.isCollecting()) {
                collector.add(((ASTIdentifierAccess) node).getName());
            }
        } else if (node instanceof ASTArrayAccess) {
            int num = node.jjtGetNumChildren();
            // collect only if array access is const and follows an identifier
            boolean collecting = collector.isCollecting();
            for (int i = 0; i < num; ++i) {
                JexlNode child = node.jjtGetChild(i);
                if (collecting && child.isConstant()) {
                    String image = child.toString();
                    collector.add(image);
                } else {
                    collecting = false;
                    collector.collect(null);
                    getVariables(script, child, collector);
                }
            }
        } else {
            int num = node.jjtGetNumChildren();
            for (int i = 0; i < num; ++i) {
                getVariables(script, node.jjtGetChild(i), collector);
            }
            collector.collect(null);
        }
    }

    /**
     * Gets the array of parameters from a script.
     * @param script the script
     * @return the parameters which may be empty (but not null) if no parameters were defined
     * @since 3.0
     */
    protected String[] getParameters(JexlScript script) {
        return script.getParameters();
    }

    /**
     * Gets the array of local variable from a script.
     * @param script the script
     * @return the local variables array which may be empty (but not null) if no local variables were defined
     * @since 3.0
     */
    protected String[] getLocalVariables(JexlScript script) {
        return script.getLocalVariables();
    }

    /**
     * Parses an expression.
     *
     * @param info      information structure
     * @param src      the expression to parse
     * @param scope     the script frame
     * @param registers whether the parser should allow the unnamed '#number' syntax for 'registers'
     * @param expression whether the parser allows scripts or only expressions
     * @return the parsed tree
     * @throws JexlException if any error occurred during parsing
     */
    protected ASTJexlScript parse(JexlInfo info, String src, Scope scope, boolean registers, boolean expression) {
        final boolean cached = src.length() < cacheThreshold && cache != null;
        ASTJexlScript script = null;
        if (cached) {
            script = cache.get(src);
            if (script != null) {
                Scope f = script.getScope();
                if ((f == null && scope == null) || (f != null && f.equals(scope))) {
                    return script;
                }
            }
        }
        final JexlInfo ninfo = info == null && debug ? createInfo() : info;
        // if parser not in use...
        if (parsing.compareAndSet(false, true)) {
            try {
                // lets parse
                script = parser.parse(ninfo, src, scope, registers, expression);
            } finally {
                // no longer in use
                parsing.set(false);
            }
        } else {
            // ...otherwise parser was in use, create a new temporary one
            Parser lparser = new Parser(new StringReader(";"));
            script = lparser.parse(ninfo, src, scope, registers, expression);
        }
        if (cached) {
            cache.put(src, script);
        }
        return script;
    }

    /**
     * Trims the source from front and ending spaces.
     * @param str expression to clean
     * @return trimmed expression ending in a semi-colon
     */
    protected String trimSource(CharSequence str) {
        if (str != null) {
            int start = 0;
            int end = str.length();
            if (end > 0) {
                // trim front spaces
                while (start < end && Character.isSpaceChar(str.charAt(start))) {
                    ++start;
                }
                // trim ending spaces
                while (end > 0 && Character.isSpaceChar(str.charAt(end - 1))) {
                    --end;
                }
                return str.subSequence(start, end).toString();
            }
            return "";
        }
        return null;
    }

    /**
     * Gets and/or creates a default template engine.
     * @return a template engine
     */
    protected TemplateEngine jxlt() {
        TemplateEngine e = jxlt;
        if (e == null) {
            synchronized(this) {
                if (jxlt == null) {
                    e = new TemplateEngine(this, true, 0, '$', '#');
                    jxlt = e;
                }
            }
        }
        return e;
    }
}
