/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.javascript.engine;

import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import javax.script.AbstractScriptEngine;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptException;
import javax.script.SimpleBindings;
import org.mozilla.javascript.Callable;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ContextFactory;
import org.mozilla.javascript.RhinoException;
import org.mozilla.javascript.Script;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.ScriptableObject;

/**
 * This is the implementation of the standard ScriptEngine interface for Rhino.
 *
 * <p>An instance of the Rhino ScriptEngine is fully self-contained. Bindings at the GLOBAL_SCOPE
 * may be set, but there is nothing special about them -- if both global and ENGINE_SCOPE bindings
 * are set then the "engine" bindings override the global ones.
 *
 * <p>The Rhino engine is not thread safe. Rhino does no synchronization of ScriptEngine instances
 * and no synchronization of Bindings instances. It is up to the caller to ensure that the
 * ScriptEngine and all its Bindings are used by a single thread at a time.
 *
 * <p>The Rhino script engine includes some top-level built-in functions. See the Builtins class for
 * more documentation.
 *
 * <p>The engine supports a few configuration parameters that may be set at the "engine scope". Both
 * are numbers that may be set to a String or Number object.
 *
 * <ul>
 *   <li>javax.script.language_version: The version of the JavaScript language supported, which is
 *       an integer defined in the Context class. The default is the latest "ES6" version, defined
 *       as 200.
 *   <li>org.mozilla.javascript.optimization_level: The level of optimization Rhino performs on the
 *       generated bytecode. Default is 9, which is the most. Set to -1 to use interpreted mode.
 * </ul>
 */
public class RhinoScriptEngine extends AbstractScriptEngine implements Compilable, Invocable {

    /**
     * Reserved key for the Rhino optimization level. Default is "9," for optimized and compiled
     * code. Set this to "-1" to run Rhino in interpreted mode -- this is much much slower but the
     * only option on platforms like Android that don't support class files.
     */
    public static final String OPTIMIZATION_LEVEL = "org.mozilla.javascript.optimization_level";

    static final int DEFAULT_LANGUAGE_VERSION = Context.VERSION_ES6;
    private static final int DEFAULT_OPT = 9;
    private static final boolean DEFAULT_DEBUG = true;
    private static final String DEFAULT_FILENAME = "eval";

    private static final CtxFactory ctxFactory = new CtxFactory();

    private final RhinoScriptEngineFactory factory;
    private final Builtins builtins;
    private ScriptableObject topLevelScope = null;

    RhinoScriptEngine(RhinoScriptEngineFactory factory) {
        this.factory = factory;
        this.builtins = new Builtins();
    }

    private Scriptable initScope(Context cx, ScriptContext sc) throws ScriptException {
        configureContext(cx);

        if (topLevelScope == null) {
            topLevelScope = cx.initStandardObjects();
            // We need to stash this away so that the built in functions can find
            // this engine's specific stuff that they need to work.
            topLevelScope.associateValue(Builtins.BUILTIN_KEY, builtins);
            builtins.register(cx, topLevelScope, sc);
        }

        Scriptable engineScope = new BindingsObject(sc.getBindings(ScriptContext.ENGINE_SCOPE));
        engineScope.setParentScope(null);
        engineScope.setPrototype(topLevelScope);

        if (sc.getBindings(ScriptContext.GLOBAL_SCOPE) != null) {
            Scriptable globalScope = new BindingsObject(sc.getBindings(ScriptContext.GLOBAL_SCOPE));
            globalScope.setParentScope(null);
            globalScope.setPrototype(topLevelScope);
            engineScope.setPrototype(globalScope);
        }

        return engineScope;
    }

    @Override
    public Object eval(String script, ScriptContext context) throws ScriptException {
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, context);
            Object ret = cx.evaluateString(scope, script, getFilename(), 0, null);
            return Context.jsToJava(ret, Object.class);
        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        }
    }

    @Override
    public Object eval(Reader reader, ScriptContext context) throws ScriptException {
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, context);
            Object ret = cx.evaluateReader(scope, reader, getFilename(), 0, null);
            return Context.jsToJava(ret, Object.class);
        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        } catch (IOException ioe) {
            throw new ScriptException(ioe);
        }
    }

    @Override
    public CompiledScript compile(String script) throws ScriptException {
        try (Context cx = ctxFactory.enterContext()) {
            configureContext(cx);
            Script s = cx.compileString(script, getFilename(), 1, null);
            return new RhinoCompiledScript(this, s);
        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        }
    }

    @Override
    public CompiledScript compile(Reader script) throws ScriptException {
        try (Context cx = ctxFactory.enterContext()) {
            configureContext(cx);
            Script s = cx.compileReader(script, getFilename(), 1, null);
            return new RhinoCompiledScript(this, s);
        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        } catch (IOException ioe) {
            throw new ScriptException(ioe);
        }
    }

    Object eval(Script script, ScriptContext sc) throws ScriptException {
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, sc);
            Object ret = script.exec(cx, scope);
            return Context.jsToJava(ret, Object.class);
        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        }
    }

    @Override
    public Object invokeFunction(String name, Object... args)
            throws ScriptException, NoSuchMethodException {
        return invokeMethod(null, name, args);
    }

    @Override
    public Object invokeMethod(Object thiz, String name, Object... args)
            throws ScriptException, NoSuchMethodException {
        return invokeMethodRaw(thiz, name, Object.class, args);
    }

    Object invokeMethodRaw(Object thiz, String name, Class<?> returnType, Object... args)
            throws ScriptException, NoSuchMethodException {
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, context);

            Scriptable localThis;
            if (thiz == null) {
                localThis = scope;
            } else {
                localThis = Context.toObject(thiz, scope);
            }

            Object f = ScriptableObject.getProperty(localThis, name);
            if (f == Scriptable.NOT_FOUND) {
                throw new NoSuchMethodException(name);
            }
            if (!(f instanceof Callable)) {
                throw new ScriptException("\"" + name + "\" is not a function");
            }
            Callable func = (Callable) f;

            if (args != null) {
                for (int i = 0; i < args.length; i++) {
                    args[i] = Context.javaToJS(args[i], scope);
                }
            }

            Object ret = func.call(cx, scope, localThis, args);
            if (returnType == Void.TYPE) {
                return null;
            }
            return Context.jsToJava(ret, returnType);

        } catch (RhinoException re) {
            throw new ScriptException(
                    re.getMessage(), re.sourceName(), re.lineNumber(), re.columnNumber());
        }
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> T getInterface(Class<T> clasz) {
        if ((clasz == null) || !clasz.isInterface()) {
            throw new IllegalArgumentException("Not an interface");
        }
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, context);
            if (methodsMissing(scope, clasz)) {
                return null;
            }
        } catch (ScriptException se) {
            return null;
        }
        return (T)
                Proxy.newProxyInstance(
                        Thread.currentThread().getContextClassLoader(),
                        new Class<?>[] {clasz},
                        new RhinoInvocationHandler(this, null));
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> T getInterface(Object thiz, Class<T> clasz) {
        if ((clasz == null) || !clasz.isInterface()) {
            throw new IllegalArgumentException("Not an interface");
        }
        try (Context cx = ctxFactory.enterContext()) {
            Scriptable scope = initScope(cx, context);
            Scriptable thisObj = Context.toObject(thiz, scope);
            if (methodsMissing(thisObj, clasz)) {
                return null;
            }
        } catch (ScriptException se) {
            return null;
        }
        return (T)
                Proxy.newProxyInstance(
                        Thread.currentThread().getContextClassLoader(),
                        new Class<?>[] {clasz},
                        new RhinoInvocationHandler(this, thiz));
    }

    @Override
    public Bindings createBindings() {
        return new SimpleBindings();
    }

    @Override
    public ScriptEngineFactory getFactory() {
        return factory;
    }

    private void configureContext(Context cx) throws ScriptException {
        Object lv = get(ScriptEngine.LANGUAGE_VERSION);
        if (lv != null) {
            cx.setLanguageVersion(parseInteger(lv));
        }
        Object ol = get(OPTIMIZATION_LEVEL);
        if (ol != null) {
            cx.setOptimizationLevel(parseInteger(ol));
        }
    }

    private static int parseInteger(Object v) throws ScriptException {
        if (v instanceof String) {
            try {
                return Integer.parseInt((String) v);
            } catch (NumberFormatException nfe) {
                throw new ScriptException("Invalid number " + v);
            }
        } else if (v instanceof Integer) {
            return ((Integer) v).intValue();
        } else {
            throw new ScriptException("Value must be a string or number");
        }
    }

    private String getFilename() {
        Object fn = get(ScriptEngine.FILENAME);
        if (fn instanceof String) {
            return (String) fn;
        }
        return DEFAULT_FILENAME;
    }

    private static boolean methodsMissing(Scriptable scope, Class<?> clasz) {
        for (Method m : clasz.getMethods()) {
            if (m.getDeclaringClass() == Object.class) {
                continue;
            }
            Object methodObj = ScriptableObject.getProperty(scope, m.getName());
            if (!(methodObj instanceof Callable)) {
                return true;
            }
        }
        return false;
    }

    private static final class CtxFactory extends ContextFactory {

        @Override
        protected boolean hasFeature(Context cx, int featureIndex) {
            if (featureIndex == Context.FEATURE_INTEGER_WITHOUT_DECIMAL_PLACE) {
                return true;
            }
            return super.hasFeature(cx, featureIndex);
        }

        @Override
        protected void onContextCreated(Context cx) {
            cx.setLanguageVersion(Context.VERSION_ES6);
            cx.setOptimizationLevel(DEFAULT_OPT);
            cx.setGeneratingDebug(DEFAULT_DEBUG);
        }
    }
}
