/**
 * Copyright (c) André Bargull
 * Alle Rechte vorbehalten / All Rights Reserved.  Use is subject to license terms.
 *
 * <https://github.com/anba/es6draft>
 */
package com.github.anba.es6draft.scripting;

import static com.github.anba.es6draft.runtime.AbstractOperations.IsCallable;
import static com.github.anba.es6draft.runtime.ExecutionContext.newScriptingExecutionContext;

import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Paths;
import java.util.EnumSet;
import java.util.Objects;

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 com.github.anba.es6draft.Script;
import com.github.anba.es6draft.compiler.CompilationException;
import com.github.anba.es6draft.parser.Parser;
import com.github.anba.es6draft.parser.ParserException;
import com.github.anba.es6draft.runtime.ExecutionContext;
import com.github.anba.es6draft.runtime.LexicalEnvironment;
import com.github.anba.es6draft.runtime.Realm;
import com.github.anba.es6draft.runtime.RealmData;
import com.github.anba.es6draft.runtime.World;
import com.github.anba.es6draft.runtime.internal.CompatibilityOption;
import com.github.anba.es6draft.runtime.internal.Console;
import com.github.anba.es6draft.runtime.internal.RuntimeContext;
import com.github.anba.es6draft.runtime.internal.ScriptException;
import com.github.anba.es6draft.runtime.internal.ScriptLoader;
import com.github.anba.es6draft.runtime.internal.Source;
import com.github.anba.es6draft.runtime.modules.loader.FileSourceIdentifier;
import com.github.anba.es6draft.runtime.types.Callable;
import com.github.anba.es6draft.runtime.types.ScriptObject;

/**
 * Concrete implementation of the {@link AbstractScriptEngine} abstract class.
 */
final class ScriptEngineImpl extends AbstractScriptEngine implements ScriptEngine, Compilable, Invocable {
    private final ScriptEngineFactoryImpl factory;
    // Scripting sources have an extra scope object before the global environment record, the
    // ScriptContext object. To ensure this extra scope is properly handled, we use the
    // 'scripting' parser-option when evaluating the source code.
    private final ScriptLoader scriptingLoader;
    private final World world;

    ScriptEngineImpl(ScriptEngineFactoryImpl factory) {
        this.factory = factory;

        /* @formatter:off */
        RuntimeContext context = new RuntimeContext.Builder()
                                                   .setBaseDirectory(Paths.get("").toAbsolutePath())
                                                   .setRealmData(ScriptingRealmData::new)
                                                   .setConsole(new ScriptingConsole(this.context))
                                                   .setOptions(CompatibilityOption.WebCompatibility())
                                                   .build();
        RuntimeContext scriptingContext = new RuntimeContext.Builder(context)
                                                            .setParserOptions(EnumSet.of(Parser.Option.Scripting))
                                                            .build();
        /* @formatter:on */

        this.world = new World(context);
        this.scriptingLoader = new ScriptLoader(scriptingContext);
        this.context.setBindings(createBindings(), ScriptContext.ENGINE_SCOPE);
    }

    private static final class ScriptingRealmData extends RealmData {
        public ScriptingRealmData(Realm realm) {
            super(realm);
        }

        @Override
        public void initializeExtensions() {
            getRealm().createGlobalProperties(new ScriptingFunctions(), ScriptingFunctions.class);
        }
    }

    private Realm newScriptingRealm() {
        try {
            return Realm.InitializeHostDefinedRealm(world);
        } catch (ParserException | CompilationException | IOException e) {
            throw new IllegalStateException(e);
        }
    }

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

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

    @Override
    public Object eval(String script, ScriptContext context) throws javax.script.ScriptException {
        return eval(script(script, context), context);
    }

    @Override
    public Object eval(Reader reader, ScriptContext context) throws javax.script.ScriptException {
        return eval(script(reader, context), context);
    }

    @Override
    public CompiledScript compile(String script) throws javax.script.ScriptException {
        return new CompiledScriptImpl(this, script(script, context));
    }

    @Override
    public CompiledScript compile(Reader reader) throws javax.script.ScriptException {
        return new CompiledScriptImpl(this, script(reader, context));
    }

    @Override
    public Object invokeFunction(String name, Object... args)
            throws javax.script.ScriptException, NoSuchMethodException {
        return invoke(null, Objects.requireNonNull(name), args);
    }

    @Override
    public Object invokeMethod(Object thisValue, String name, Object... args)
            throws javax.script.ScriptException, NoSuchMethodException {
        if (!(thisValue instanceof ScriptObject)) {
            throw new IllegalArgumentException();
        }
        return invoke((ScriptObject) thisValue, Objects.requireNonNull(name), args);
    }

    @Override
    public <T> T getInterface(Class<T> clazz) {
        return getInterface((ScriptObject) null, clazz);
    }

    @Override
    public <T> T getInterface(Object thisValue, Class<T> clazz) {
        if (!(thisValue instanceof ScriptObject)) {
            throw new IllegalArgumentException();
        }
        return getInterface((ScriptObject) thisValue, clazz);
    }

    private Source createSource(ScriptContext context) {
        Object fileName = context.getAttribute(FILENAME);
        if (fileName != null) {
            String file = fileName.toString();
            return new Source(new FileSourceIdentifier(Paths.get(file)), file, 1);
        }
        return new Source(new FileSourceIdentifier(Paths.get("")), "<eval>", 1);
    }

    private Script script(String sourceCode, ScriptContext context) throws javax.script.ScriptException {
        Source source = createSource(context);
        try {
            return scriptingLoader.script(source, sourceCode);
        } catch (ParserException e) {
            throw new javax.script.ScriptException(e.getMessage(), e.getFile(), e.getLine(), e.getColumn());
        } catch (CompilationException e) {
            throw new javax.script.ScriptException(e);
        }
    }

    private Script script(Reader reader, ScriptContext context) throws javax.script.ScriptException {
        Source source = createSource(context);
        try {
            return scriptingLoader.script(source, reader);
        } catch (ParserException e) {
            throw new javax.script.ScriptException(e.getMessage(), e.getFile(), e.getLine(), e.getColumn());
        } catch (CompilationException | IOException e) {
            throw new javax.script.ScriptException(e);
        }
    }

    Object eval(Script script, ScriptContext context) throws javax.script.ScriptException {
        Realm realm = getEvalRealm(context);
        RuntimeContext runtimeContext = realm.getRuntimeContext();
        Console console = runtimeContext.getConsole();
        runtimeContext.setConsole(new ScriptingConsole(context));
        try {
            // Prepare a new execution context before calling the generated code.
            ExecutionContext evalCxt = newScriptingExecutionContext(realm, script, new LexicalEnvironment<>(
                    realm.getGlobalEnv(), new ScriptContextEnvironmentRecord(realm.defaultContext(), context)));
            Object result = script.evaluate(evalCxt);
            realm.getWorld().runEventLoop();
            return TypeConverter.toJava(result);
        } catch (ScriptException e) {
            throw new javax.script.ScriptException(e);
        } finally {
            runtimeContext.setConsole(console);
        }
    }

    private Object invoke(ScriptObject thisValue, String name, Object... args)
            throws javax.script.ScriptException, NoSuchMethodException {
        Realm realm = getEvalRealm(context);
        RuntimeContext runtimeContext = realm.getRuntimeContext();
        Console console = runtimeContext.getConsole();
        runtimeContext.setConsole(new ScriptingConsole(context));
        try {
            Object[] arguments = TypeConverter.fromJava(args);
            if (thisValue == null) {
                thisValue = realm.getGlobalThis();
            }
            ExecutionContext cx = realm.defaultContext();
            Object func = thisValue.get(cx, (Object) name, thisValue);
            if (!IsCallable(func)) {
                throw new NoSuchMethodException(name);
            }
            Object result = ((Callable) func).call(cx, thisValue, arguments);
            realm.getWorld().runEventLoop();
            return TypeConverter.toJava(result);
        } catch (ScriptException e) {
            throw new javax.script.ScriptException(e);
        } finally {
            runtimeContext.setConsole(console);
        }
    }

    private <T> T getInterface(ScriptObject thisValue, Class<T> clazz) {
        if (clazz == null || !clazz.isInterface()) {
            throw new IllegalArgumentException();
        }
        Object instance = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[] { clazz },
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        Object[] arguments = args != null ? args : new Object[] {};
                        return ScriptEngineImpl.this.invoke(thisValue, method.getName(), arguments);
                    }
                });
        return clazz.cast(instance);
    }

    private Realm getEvalRealm(ScriptContext context) {
        Bindings bindings = context.getBindings(ScriptContext.ENGINE_SCOPE);
        if (bindings instanceof GlobalBindings) {
            // Return realm from engine scope bindings if compatible, i.e. from the same world instance.
            Realm realm = ((GlobalBindings) bindings).getRealm();
            if (realm.getWorld() == world) {
                return realm;
            }
        }
        // Otherwise create a new realm.
        return newScriptingRealm();
    }
}