/* 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;

/**
 * <p>
 * This is the implementation of the standard ScriptEngine interface for Rhino.
 * </p>
 * <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>
 * <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>
 * <p>
 * The Rhino script engine includes some top-level built-in functions. See the Builtins class for
 * more documentation.
 * </p>
 * <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.
 * </p>
 * <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>
 * <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.</li>
 * </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);
    }
  }
}
