/*
 * 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.tinkerpop.gremlin.jsr223;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.script.Bindings;
import javax.script.ScriptContext;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * The {@code ScriptEngineManager} implements a discovery, instantiation and configuration mechanism for
 * {@link GremlinScriptEngine} classes and also maintains a collection of key/value pairs storing state shared by all
 * engines created by it. This class uses the {@code ServiceProvider} mechanism to enumerate all the
 * implementations of <code>GremlinScriptEngineFactory</code>. The <code>ScriptEngineManager</code> provides a method
 * to return a list of all these factories as well as utility methods which look up factories on the basis of language
 * name, file extension and mime type.
 * <p/>
 * The {@code Bindings} of key/value pairs, referred to as the "Global Scope" maintained by the manager is available
 * to all instances of @code ScriptEngine} created by the {@code GremlinScriptEngineManager}. The values
 * in the {@code Bindings} are generally exposed in all scripts.
 * <p/>
 * This class is based quite heavily on the workings of the {@code ScriptEngineManager} supplied in the
 * {@code javax.script} packages, but adds some additional features that are specific to Gremlin and TinkerPop.
 * Unfortunately, it's not easily possible to extend {@code ScriptEngineManager} directly as there certain behaviors
 * don't appear to be be straightforward to implement and member variables are all private. It is important to note
 * that this class is designed to provide support for "Gremlin-enabled" {@code ScriptEngine} instances (i.e. those
 * that extend from {@link GremlinScriptEngine}) and is not meant to manage just any {@code ScriptEngine} instance
 * that may be on the path.
 * <p/>
 * As this is a "Gremlin" {@code ScriptEngine}, certain common imports are automatically applied when a
 * {@link GremlinScriptEngine} is instantiated via the {@link GremlinScriptEngineFactory}.. Initial imports from
 * gremlin-core come from the {@link CoreImports}.
 *
 * @author Stephen Mallette (http://stephen.genoprime.com)
 */
public class DefaultGremlinScriptEngineManager implements GremlinScriptEngineManager {

    private static final Logger logger = LoggerFactory.getLogger(DefaultGremlinScriptEngineManager.class);

    /**
     * Set of script engine factories discovered.
     */
    private final HashSet<GremlinScriptEngineFactory> engineSpis = new HashSet<>();

    /**
     * Map of engine name to script engine factory.
     */
    private final HashMap<String, GremlinScriptEngineFactory> nameAssociations = new HashMap<>();

    /**
     * Map of script file extension to script engine factory.
     */
    private final HashMap<String, GremlinScriptEngineFactory> extensionAssociations = new HashMap<>();

    /**
     *  Map of script script MIME type to script engine factory.
     */
    private final HashMap<String, GremlinScriptEngineFactory> mimeTypeAssociations = new HashMap<>();

    /**
     * Global bindings associated with script engines created by this manager.
     */
    private Bindings globalScope = new ConcurrentBindings();

    /**
     * List of extensions for the {@link GremlinScriptEngineManager} which will be used to supply
     * {@link Customizer} instances to {@link GremlinScriptEngineFactory} that are instantiated.
     */
    private List<GremlinPlugin> plugins = new ArrayList<>();

    /**
     * List of extensions for the {@link GremlinScriptEngineManager} which will be used to supply
     * {@link Customizer} instances to {@link GremlinScriptEngineFactory} that are instantiated.
     *
     * @deprecated As of release 3.2.4, replaced by {@link #plugins}.
     */
    private List<GremlinModule> modules = new ArrayList<>();

    /**
     * The effect of calling this constructor is the same as calling
     * {@code DefaultGremlinScriptEngineManager(Thread.currentThread().getContextClassLoader())}.
     */
    public DefaultGremlinScriptEngineManager() {
        final ClassLoader ctxtLoader = Thread.currentThread().getContextClassLoader();
        initEngines(ctxtLoader);
    }

    /**
     * This constructor loads the implementations of {@link GremlinScriptEngineFactory} visible to the given
     * {@code ClassLoader} using the {@code ServiceLoader} mechanism. If loader is <code>null</code>, the script
     * engine factories that are bundled with the platform and that are in the usual extension directories
     * (installed extensions) are loaded.
     */
    public DefaultGremlinScriptEngineManager(final ClassLoader loader) {
        initEngines(loader);
    }

    @Override
    public List<Customizer> getCustomizers(final String scriptEngineName) {
        final List<Customizer> pluginCustomizers = plugins.stream().flatMap(plugin -> {
            final Optional<Customizer[]> customizers = plugin.getCustomizers(scriptEngineName);
            return Stream.of(customizers.orElse(new Customizer[0]));
        }).collect(Collectors.toList());

        // modules are deprecated in favor of GremlinPlugin - this line will eventually be removed
        pluginCustomizers.addAll(modules.stream().flatMap(plugin -> {
            final Optional<Customizer[]> customizers = plugin.getCustomizers(scriptEngineName);
            return Stream.of(customizers.orElse(new Customizer[0]));
        }).collect(Collectors.toList()));


        return pluginCustomizers;
    }

    /**
     * @deprecated As of release 3.2.4, replaced by {@link #addPlugin(GremlinPlugin)}.
     */
    @Override
    @Deprecated
    public void addModule(final GremlinModule module) {
        // TODO: should modules be a set based on "name" to ensure uniqueness? not sure what bad stuff can happen with dupes
        if (module != null) modules.add(module);
    }

    @Override
    public void addPlugin(final GremlinPlugin plugin) {
        // TODO: should modules be a set based on "name" to ensure uniqueness? not sure what bad stuff can happen with dupes
        if (plugin != null) plugins.add(plugin);
    }

    /**
     * Stores the specified {@code Bindings} as a global for all {@link GremlinScriptEngine} objects created by it.
     * If the bindings are to be updated by multiple threads it is recommended that a {@link ConcurrentBindings}
     * instance is supplied.
     *
     * @throws IllegalArgumentException if bindings is null.
     */
    @Override
    public synchronized void setBindings(final Bindings bindings) {
        if (null == bindings) throw new IllegalArgumentException("Global scope cannot be null.");
        globalScope = bindings;
    }

    /**
     * Gets the bindings of the {@code Bindings} in global scope.
     */
    @Override
    public Bindings getBindings() {
        return globalScope;
    }

    /**
     * Sets the specified key/value pair in the global scope. The key may not be null or empty.
     *
     * @throws IllegalArgumentException if key is null or empty.
     */
    @Override
    public void put(final String key, final Object value) {
        if (null == key) throw new IllegalArgumentException("key may not be null");
        if (key.isEmpty()) throw new IllegalArgumentException("key may not be empty");
        globalScope.put(key, value);
    }

    /**
     * Gets the value for the specified key in the global scope.
     */
    @Override
    public Object get(final String key) {
        return globalScope.get(key);
    }

    /**
     * Looks up and creates a {@link GremlinScriptEngine} for a given name. The algorithm first searches for a
     * {@link GremlinScriptEngineFactory} that has been registered as a handler for the specified name using the
     * {@link #registerEngineExtension(String, GremlinScriptEngineFactory)} method. If one is not found, it searches
     * the set of {@code GremlinScriptEngineFactory} instances stored by the constructor for one with the specified
     * name.  If a {@code ScriptEngineFactory} is found by either method, it is used to create instance of
     * {@link GremlinScriptEngine}.
     *
     * @param shortName The short name of the {@link GremlinScriptEngine} implementation returned by the
     * {@link GremlinScriptEngineFactory#getNames} method.
     * @return A {@link GremlinScriptEngine} created by the factory located in the search.  Returns {@code null}
     * if no such factory was found.  The global scope of this manager is applied to the newly created
     * {@link GremlinScriptEngine}
     * @throws NullPointerException if shortName is {@code null}.
     */
    @Override
    public GremlinScriptEngine getEngineByName(final String shortName) {
        if (null == shortName) throw new NullPointerException();
        //look for registered name first
        Object obj;
        if (null != (obj = nameAssociations.get(shortName))) {
            final GremlinScriptEngineFactory spi = (GremlinScriptEngineFactory) obj;
            try {
                return createGremlinScriptEngine(spi);
            } catch (Exception exp) {
                logger.error(String.format("Could not create GremlinScriptEngine for %s", shortName), exp);
            }
        }

        for (GremlinScriptEngineFactory spi : engineSpis) {
            List<String> names = null;
            try {
                names = spi.getNames();
            } catch (Exception exp) {
                logger.error("Could not get GremlinScriptEngine names", exp);
            }

            if (names != null) {
                for (String name : names) {
                    if (shortName.equals(name)) {
                        try {
                            return createGremlinScriptEngine(spi);
                        } catch (Exception exp) {
                            logger.error(String.format("Could not create GremlinScriptEngine for %s", shortName), exp);
                        }
                    }
                }
            }
        }

        return null;
    }

    /**
     * Look up and create a {@link GremlinScriptEngine} for a given extension.  The algorithm
     * used by {@link #getEngineByName(String)} is used except that the search starts by looking for a
     * {@link GremlinScriptEngineFactory} registered to handle the given extension using
     * {@link #registerEngineExtension(String, GremlinScriptEngineFactory)}.
     *
     * @return The engine to handle scripts with this extension.  Returns {@code null} if not found.
     * @throws NullPointerException if extension is {@code null}.
     */
    @Override
    public GremlinScriptEngine getEngineByExtension(final String extension) {
        if (null == extension) throw new NullPointerException();
        //look for registered extension first
        Object obj;
        if (null != (obj = extensionAssociations.get(extension))) {
            final GremlinScriptEngineFactory spi = (GremlinScriptEngineFactory) obj;
            try {
                return createGremlinScriptEngine(spi);
            } catch (Exception exp) {
                logger.error(String.format("Could not create GremlinScriptEngine for %s", extension), exp);
            }
        }

        for (GremlinScriptEngineFactory spi : engineSpis) {
            List<String> exts = null;
            try {
                exts = spi.getExtensions();
            } catch (Exception exp) {
                logger.error("Could not get GremlinScriptEngine extensions", exp);
            }
            if (exts == null) continue;
            for (String ext : exts) {
                if (extension.equals(ext)) {
                    try {
                        return createGremlinScriptEngine(spi);
                    } catch (Exception exp) {
                        logger.error(String.format("Could not create GremlinScriptEngine for %s", extension), exp);
                    }
                }
            }
        }
        return null;
    }

    /**
     * Look up and create a {@link GremlinScriptEngine} for a given mime type.  The algorithm used by
     * {@link #getEngineByName(String)} is used except that the search starts by looking for a
     * {@link GremlinScriptEngineFactory} registered to handle the given mime type using
     * {@link #registerEngineMimeType(String, GremlinScriptEngineFactory)}.
     *
     * @param mimeType The given mime type
     * @return The engine to handle scripts with this mime type.  Returns {@code null} if not found.
     * @throws NullPointerException if mime-type is {@code null}.
     */
    @Override
    public GremlinScriptEngine getEngineByMimeType(final String mimeType) {
        if (null == mimeType) throw new NullPointerException();
        //look for registered types first
        Object obj;
        if (null != (obj = mimeTypeAssociations.get(mimeType))) {
            final GremlinScriptEngineFactory spi = (GremlinScriptEngineFactory) obj;
            try {
                return createGremlinScriptEngine(spi);
            } catch (Exception exp) {
                logger.error(String.format("Could not create GremlinScriptEngine for %s", mimeType), exp);
            }
        }

        for (GremlinScriptEngineFactory spi : engineSpis) {
            List<String> types = null;
            try {
                types = spi.getMimeTypes();
            } catch (Exception exp) {
                logger.error("Could not get GremlinScriptEngine mimetypes", exp);
            }
            if (types == null) continue;
            for (String type : types) {
                if (mimeType.equals(type)) {
                    try {
                        return createGremlinScriptEngine(spi);
                    } catch (Exception exp) {
                        logger.error(String.format("Could not create GremlinScriptEngine for %s", mimeType), exp);
                    }
                }
            }
        }
        return null;
    }

    /**
     * Returns a list whose elements are instances of all the {@link GremlinScriptEngineFactory} classes
     * found by the discovery mechanism.
     *
     * @return List of all discovered {@link GremlinScriptEngineFactory} objects.
     */
    @Override
    public List<GremlinScriptEngineFactory> getEngineFactories() {
        final List<GremlinScriptEngineFactory> res = new ArrayList<>(engineSpis.size());
        res.addAll(engineSpis.stream().collect(Collectors.toList()));
        return Collections.unmodifiableList(res);
    }

    /**
     * Registers a {@link GremlinScriptEngineFactory} to handle a language name.  Overrides any such association found
     * using the discovery mechanism.
     *
     * @param name The name to be associated with the {@link GremlinScriptEngineFactory}
     * @param factory The class to associate with the given name.
     * @throws NullPointerException if any of the parameters is null.
     */
    @Override
    public void registerEngineName(final String name, final GremlinScriptEngineFactory factory) {
        if (null == name || null == factory) throw new NullPointerException();
        nameAssociations.put(name, factory);
    }

    /**
     * Registers a {@link GremlinScriptEngineFactory} to handle a mime type. Overrides any such association found using
     * the discovery mechanism.
     *
     * @param type The mime type  to be associated with the {@link GremlinScriptEngineFactory}.
     * @param factory The class to associate with the given mime type.
     * @throws NullPointerException if any of the parameters is null.
     */
    @Override
    public void registerEngineMimeType(final String type, final GremlinScriptEngineFactory factory) {
        if (null == type || null == factory) throw new NullPointerException();
        mimeTypeAssociations.put(type, factory);
    }

    /**
     * Registers a {@link GremlinScriptEngineFactory} to handle an extension. Overrides any such association found
     * using the discovery mechanism.
     *
     * @param extension The extension type to be associated with the {@link GremlinScriptEngineFactory}
     * @param factory The class to associate with the given extension.
     * @throws NullPointerException if any of the parameters is null.
     */
    @Override
    public void registerEngineExtension(final String extension, final GremlinScriptEngineFactory factory) {
        if (null == extension || null == factory) throw new NullPointerException();
        extensionAssociations.put(extension, factory);
    }

    private ServiceLoader<GremlinScriptEngineFactory> getServiceLoader(final ClassLoader loader) {
        if (loader != null) {
            return ServiceLoader.load(GremlinScriptEngineFactory.class, loader);
        } else {
            return ServiceLoader.loadInstalled(GremlinScriptEngineFactory.class);
        }
    }

    private void initEngines(final ClassLoader loader) {
        Iterator<GremlinScriptEngineFactory> itty;
        try {
            final ServiceLoader<GremlinScriptEngineFactory> sl = AccessController.doPrivileged(
                    (PrivilegedAction<ServiceLoader<GremlinScriptEngineFactory>>) () -> getServiceLoader(loader));
            itty = sl.iterator();
        } catch (ServiceConfigurationError err) {
            logger.error("Can't find GremlinScriptEngineFactory providers: " + err.getMessage(), err);

            // do not throw any exception here. user may want to manager their own factories using this manager
            // by explicit registration (by registerXXX) methods.
            return;
        }

        try {
            while (itty.hasNext()) {
                try {
                    final GremlinScriptEngineFactory factory = itty.next();
                    factory.setCustomizerManager(this);
                    engineSpis.add(factory);
                } catch (ServiceConfigurationError err) {
                    logger.error("GremlinScriptEngineManager providers.next(): " + err.getMessage(), err);
                }
            }
        } catch (ServiceConfigurationError err) {
            logger.error("GremlinScriptEngineManager providers.hasNext(): " + err.getMessage(), err);
            // do not throw any exception here. user may want to manage their own factories using this manager
            // by explicit registration (by registerXXX) methods.
        }
    }

    private GremlinScriptEngine createGremlinScriptEngine(final GremlinScriptEngineFactory spi) {
        final GremlinScriptEngine engine = spi.getScriptEngine();

        // merge in bindings that are marked with global scope. these get applied to all GremlinScriptEngine instances
        getCustomizers(spi.getEngineName()).stream()
                .filter(p -> p instanceof BindingsCustomizer)
                .map(p -> ((BindingsCustomizer) p))
                .filter(bc -> bc.getScope() == ScriptContext.GLOBAL_SCOPE)
                .flatMap(bc -> bc.getBindings().entrySet().stream())
                .forEach(kv -> {
                    if (globalScope.containsKey(kv.getKey())) {
                        logger.warn("Overriding the global binding [{}] - was [{}] and is now [{}]",
                                kv.getKey(), globalScope.get(kv.getKey()), kv.getValue());
                    }

                    globalScope.put(kv.getKey(), kv.getValue());
                });
        engine.setBindings(getBindings(), ScriptContext.GLOBAL_SCOPE);

        // merge in bindings that are marked with engine scope. there typically won't be any of these but it's just
        // here for completeness. bindings will typically apply with global scope only as engine scope will generally
        // be overridden at the time of eval() with the bindings that are supplied to it
        getCustomizers(spi.getEngineName()).stream()
                .filter(p -> p instanceof BindingsCustomizer)
                .map(p -> ((BindingsCustomizer) p))
                .filter(bc -> bc.getScope() == ScriptContext.ENGINE_SCOPE)
                .forEach(bc -> engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(bc.getBindings()));

        final List<ScriptCustomizer> scriptCustomizers = getCustomizers(spi.getEngineName()).stream()
                .filter(p -> p instanceof ScriptCustomizer)
                .map(p -> ((ScriptCustomizer) p))
                .collect(Collectors.toList());

        // since the bindings aren't added until after the ScriptEngine is constructed, running init scripts that
        // require bindings creates a problem. as a result, init scripts are applied here
        scriptCustomizers.stream().flatMap(sc -> sc.getScripts().stream()).
                map(l -> String.join(System.lineSeparator(), l)).forEach(initScript -> {
            try {
                // need to apply global bindings here as part of the engine or else they don't get their binding types
                // registered by certain GremlinScriptEngine instances (pretty much talking about gremlin-groovy here)
                // where type checking is made important. this may not be a good generic way to handled this in the
                // long run, but for now we only have two GremlinScriptEngines to be concerned about so thus far it
                // presents no real pains. global bindings are applied automatically to the context via
                // AbstractScriptEngine.getScriptContext() - passing them again here to eval() will just make the
                // global bindings behave as engine bindings and then you get weird things happening (like local vars
                // becoming global).
                final Object initializedBindings = engine.eval(initScript);
                if (initializedBindings != null && initializedBindings instanceof Map)
                    ((Map<String,Object>) initializedBindings).forEach((k,v) -> put(k,v));
            } catch (Exception ex) {
                throw new IllegalStateException(ex);
            }
        });

        return engine;
    }
}
