/*
 *  Copyright 2004 Hannes Wallnoefer <hannes@helma.at>
 *
 *  Licensed 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.ringojs.engine;

import org.mozilla.javascript.*;
import org.mozilla.javascript.json.JsonParser;
import org.ringojs.repository.*;
import org.ringojs.tools.RingoDebugger;
import org.ringojs.tools.launcher.RingoClassLoader;
import org.ringojs.wrappers.*;
import org.ringojs.util.StringUtils;
import org.mozilla.javascript.tools.debugger.ScopeProvider;

import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.net.MalformedURLException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * This class provides methods to create JavaScript objects
 * from JavaScript files.
 *
 * @author Hannes Wallnoefer, hannes@helma.at
 */
public class RhinoEngine implements ScopeProvider {

    private final RingoConfig config;
    private final List<Repository> repositories;
    private final RingoGlobal globalScope;
    private List<String> commandLineArgs;
    private final Map<Trackable, ReloadableScript> compiledScripts;
    private final Map<Trackable, ReloadableScript> interpretedScripts;
    private final Map<Singleton, Singleton> singletons;
    private final AppClassLoader loader = new AppClassLoader();
    private final WrapFactory wrapFactory;
    private Set<Class<Scriptable>> hostClasses;
    private ModuleLoader[] loaders;
    private List<Callback> shutdownHooks;

    private final RingoContextFactory contextFactory;
    private ModuleScope mainScope = null;

    private final RingoWorker mainWorker;
    private final Deque<RingoWorker> workers;
    private final ThreadLocal<RingoWorker> currentWorker;
    private final AsyncTaskCounter asyncCounter = new AsyncTaskCounter();

    private static final Logger log = Logger.getLogger(RhinoEngine.class.getName());

    public static final List<Integer> VERSION =
            Collections.unmodifiableList(Arrays.asList(4, 0, 1));

    /**
     * Create a RhinoEngine with the given configuration. If <code>globals</code>
     * is not null, its contents are added as properties on the global object.
     *
     * @param config the configuration used to initialize the engine.
     * @param globals an optional map of global properties
     * @throws Exception if the engine can't be created
     */
    public RhinoEngine(RingoConfig config, Map<String, Object> globals)
            throws Exception {
        this.config = config;
        workers = new LinkedBlockingDeque<>();
        currentWorker = new ThreadLocal<>();
        mainWorker = new RingoWorker(this);
        compiledScripts = new ConcurrentHashMap<>();
        interpretedScripts = new ConcurrentHashMap<>();
        singletons = new HashMap<>();
        contextFactory = new RingoContextFactory(this, config);
        repositories = config.getRepositories();
        wrapFactory = config.getWrapFactory();

        loaders = new ModuleLoader[] {
            new JsModuleLoader(), new JsonModuleLoader(), new ClassModuleLoader()
        };

        RingoDebugger debugger = null;
        if (config.getDebug()) {
            debugger = new RingoDebugger(config);
            debugger.setScopeProvider(this);
            debugger.attachTo(contextFactory);
            debugger.setBreakOnExceptions(true);
        }

        // create and initialize global scope
        Context cx = contextFactory.enterContext();
        try {
            boolean sealed = config.isSealed();
            globalScope = new RingoGlobal(cx, this, sealed);
            Class<Scriptable>[] classes = config.getHostClasses();
            if (classes != null) {
                for (Class<Scriptable> clazz: classes) {
                    defineHostClass(clazz);
                }
            }
            ScriptableList.init(globalScope);
            ScriptableMap.init(globalScope);
            ScriptableObject.defineClass(globalScope, ScriptableWrapper.class);
            ScriptableObject.defineClass(globalScope, ModuleObject.class);
            if (globals != null) {
                for (Map.Entry<String, Object> entry : globals.entrySet()) {
                    ScriptableObject.defineProperty(globalScope, entry.getKey(),
                            entry.getValue(), ScriptableObject.DONTENUM);
                }
            }
            mainWorker.evaluateScript(cx, getScript("globals"), globalScope);
            evaluateBootstrapScripts(cx);
            if (sealed) {
                globalScope.sealObject();
            }
            if (debugger != null) {
                debugger.setBreak();
            }
            try {
                Runtime.getRuntime().addShutdownHook(new Thread() {
                    public void run() {
                        shutdown();
                    }
                });
            } catch (java.security.AccessControlException e) {
                log.log(Level.WARNING, "Could not register shutdown hook due to security exception", e);
            }
        } finally {
            Context.exit();
        }
    }

    /**
     * Define a Javascript host object implemented by the given class.
     * @param clazz The Java class implementing the host object.
     * @exception IllegalAccessException if access is not available
     *            to a reflected class member
     * @exception InstantiationException if unable to instantiate
     *            the named class
     * @exception InvocationTargetException if an exception is thrown
     *            during execution of methods of the named class
     */
    public void defineHostClass(Class<Scriptable> clazz)
            throws InvocationTargetException, InstantiationException, IllegalAccessException {
        if (hostClasses != null && hostClasses.contains(clazz)) {
            return;
        }
        synchronized (this) {
            if (hostClasses == null) {
                hostClasses = new HashSet<>();
            }
            hostClasses.add(clazz);
            ScriptableObject.defineClass(globalScope, clazz);
        }
    }

    /**
     * Invoke a script from the command line.
     * @param scriptResource the script resource of path
     * @param scriptArgs an array of command line arguments
     * @return the return value
     * @throws IOException an I/O related error occurred
     * @throws JavaScriptException the script threw an error during
     *         compilation or execution
     */
    public Object runScript(Object scriptResource, String... scriptArgs)
            throws IOException, JavaScriptException {
        Resource resource;
        if (scriptResource instanceof Resource) {
            resource = (Resource) scriptResource;
        } else if (scriptResource instanceof String) {
            resource = findResource((String) scriptResource, null, null);
        } else {
            throw new IOException("Unsupported script resource: " + scriptResource);
        }
        if (!resource.exists()) {
            throw new FileNotFoundException(scriptResource.toString());
        }
        Context cx = contextFactory.enterContext();
        try {
            Object retval;
            Map<Trackable,ReloadableScript> scripts = getScriptCache(cx);
            commandLineArgs = Arrays.asList(scriptArgs);
            ReloadableScript script = new ReloadableScript(resource, this);
            scripts.put(resource, script);
            mainScope = new ModuleScope(resource.getModuleName(), resource,
                    globalScope, mainWorker);
            retval = mainWorker.evaluateScript(cx, script, mainScope);
            mainScope.updateExports();
            return retval instanceof Wrapper ? ((Wrapper) retval).unwrap() : retval;
        } finally {
            Context.exit();
        }
    }

    /**
     * Evaluate an expression from the command line.
     * @param expr the JavaScript expression to evaluate
     * @return the return value
     * @throws IOException an I/O related error occurred
     * @throws JavaScriptException the script threw an error during
     *         compilation or execution
     */
    public Object evaluateExpression(String expr)
            throws IOException, JavaScriptException {
        Context cx = contextFactory.enterContext();
        cx.setOptimizationLevel(-1);
        try {
            Object retval;
            Repository repository = repositories.get(0);
            Scriptable parentScope = mainScope != null ? mainScope : globalScope;
            ModuleScope scope = new ModuleScope("<expr>", repository,
                    parentScope, mainWorker);
            Resource res = new StringResource("<expr>", expr, 1);
            ReloadableScript script = new ReloadableScript(res, this);
            retval = mainWorker.evaluateScript(cx, script, scope);
            return retval instanceof Wrapper ? ((Wrapper) retval).unwrap() : retval;
        } finally {
            Context.exit();
        }
    }

    /**
     * Invoke a javascript function. This enters a JavaScript context, creates
     * a new per-thread scope, calls the function, exits the context and returns
     * the return value of the invocation.
     *
     * @param module the module name or object, or null for the main module
     * @param method the method name to call in the script
     * @param args the arguments to pass to the method
     * @return the return value of the invocation
     * @throws NoSuchMethodException the method is not defined
     * @throws IOException an I/O related error occurred
     */
    public Object invoke(Object module, String method, Object... args)
            throws IOException, NoSuchMethodException {
        return mainWorker.invoke(module, method, args);
    }

    /**
     * Associate a worker with the current thread and return the worker
     * that was previously associated with it, or null.
     *
     * @param worker the new worker associated with the current thread
     * @return the worker previously associated with the current thread, or null
     */
    protected RingoWorker setCurrentWorker(RingoWorker worker) {
        RingoWorker previousWorker = currentWorker.get();
        currentWorker.set(worker);
        return previousWorker;
    }

    /**
     * Get the worker associated with the current thread, or the given scope or function argument if provided.
     * An {@code IllegalStateException} is thrown if no worker could be found or if different workers are
     * associated with the current thread and the argument object.
     *
     * @param obj a scope or function object
     * @throws IllegalStateException if no worker could be found, or if different workers are associates with
     * the current thread and the argument object
     * @return the current worker
     */
    public RingoWorker getCurrentWorker(Scriptable obj) {
        RingoWorker worker = currentWorker.get();  // Get worker associated with current thread
        Scriptable scriptable = obj;

        while (scriptable != null) {
            if (scriptable instanceof ModuleScope) {
                RingoWorker scopeWorker = ((ModuleScope) scriptable).getWorker();
                if (worker == null) {
                    worker = scopeWorker;
                } else if (worker != scopeWorker) {
                    throw new IllegalStateException("Current thread worker differs from scope worker");
                }
                break;
            }
            scriptable = scriptable.getParentScope();
        }
        if (worker == null) {
            throw new IllegalStateException("No worker associated with current thread or scope");
        }
        return worker;
    }

    /**
     * Get the main worker running the main script.
     * @return the main worker
     */
    public RingoWorker getMainWorker() {
        return mainWorker;
    }

    /**
     * Get a new {@link RingoWorker}.
     * @return a worker instance.
     */
    public RingoWorker getWorker() {
        RingoWorker worker = workers.pollFirst();
        if (worker == null) {
            worker = new RingoWorker(this);
        }
        return worker;
    }

    /**
     * Return a worker, returning it to the worker pool.
     * @param worker the worker to be released
     */
    void returnWorker(RingoWorker worker) {
        if (!workers.offerFirst(worker)) {
            worker.shutdown();
        }
    }

    synchronized void shutdown() {
        List<Callback> hooks = shutdownHooks;
        if (hooks != null) {
            for (Callback callback: hooks) {
                try {
                    Object result = callback.invoke();
                    if (!callback.sync && result instanceof Future) {
                        ((Future)result).get();
                    }
                } catch (Exception x) {
                    log.log(Level.WARNING, "Error in shutdown hook", x);
                }
            }
            shutdownHooks = null;
        }
    }

    /**
     * Add a callback to be invoked on shutdown.
     * @param callback a callback function wrapper
     * @param sync whether to invoke the callback synchronously (on the main
     *             shutdown thread) or asynchronously (on the worker's event
     *             loop thread)
     */
    public synchronized void addShutdownHook(Scriptable callback, boolean sync) {
        List<Callback> hooks = shutdownHooks;
        if (hooks == null) {
            hooks = shutdownHooks = new ArrayList<>();
        }
        hooks.add(new Callback(callback, this, sync));
    }

    /**
     * Get the list of errors encountered by the main worker.
     * @return a list of errors, may be null.
     */
    public List<ScriptError> getMainErrors() {
        return mainWorker.getErrors();
    }

    /**
     * Return a shell scope for interactive evaluation
     * @return a shell scope
     * @throws IOException an I/O related exception occurred
     */
    public Scriptable getShellScope(RingoWorker worker) throws IOException {
        Repository repository = new FileRepository("");
        repository.setAbsolute(true);
        Scriptable protoScope = mainScope != null ? mainScope : globalScope;
        return contextFactory.call(cx -> new ModuleScope("<shell>", repository, protoScope, worker));
    }

    /**
     * Get the engine's global shared scope
     * @return the global scope
     */
    public Scriptable getScope() {
        return globalScope;
    }

    /**
     * Initialize and normalize the global variables and arguments on a thread scope.
     * @param args the arguments
     */
    protected void initArguments(Object[] args) {
        if (args != null) {
            for (int i = 0; i < args.length; i++) {
                args[i] = wrapArgument(args[i], globalScope);
            }
        }
    }

    /**
     * Prepare a single property or argument value for use within rhino.
     * @param value the property or argument value
     * @param scope the scope
     * @return the object wrapped and wired for rhino
     */
    public static Object wrapArgument(Object value, Scriptable scope) {
        if (value instanceof ScriptableObject) {
            ScriptableObject scriptable = ((ScriptableObject) value);
            // Avoid overwriting prototype and parent scope as this would break closures
            if (scriptable.getPrototype() == null) {
                scriptable.setPrototype(ScriptableObject.getClassPrototype(
                        scope, scriptable.getClassName()));
            }
            if (scriptable.getParentScope() == null) {
                scriptable.setParentScope(scope);
            }
            return scriptable;
        } else {
            return Context.javaToJS(value, scope);
        }
    }

    /**
     * Get the current Rhino optimization level
     * @return the current optimization level
     */
    public int getOptimizationLevel() {
        Context cx = Context.getCurrentContext();
        if (cx != null) {
            return cx.getOptimizationLevel();
        }
        return 0;
    }

    /**
     * Set Rhino optimization level
     * @param level the new optimization level
     */
    public void setOptimizationLevel(int level) {
        Context cx = Context.getCurrentContext();
        if (cx != null && cx.getOptimizationLevel() != level) {
            cx.setOptimizationLevel(level);
        }
    }

    /**
     * Resolves a type name to a script file within our script directory
     * and returns a Scriptable evaluated to the file.
     *
     * @param moduleName the name of the module to load
     * @return The raw compiled script for the module
     * @throws JavaScriptException if an error occurred evaluating the script file
     * @throws IOException if an error occurred reading the script file
     */
    public ReloadableScript getScript(String moduleName)
            throws JavaScriptException, IOException {
        return getScript(moduleName, null);
    }

    /**
     * Resolves a type name to a script file within our script directory
     * and returns a Scriptable evaluated to the file.
     *
     * @param moduleName the name of the module to load
     * @param localPath the path of the resource issuing this call
     * @return The raw compiled script for the module
     * @throws JavaScriptException if an error occurred evaluating the script file
     * @throws IOException if an error occurred reading the script file
     */
    public ReloadableScript getScript(String moduleName, Repository localPath)
            throws JavaScriptException, IOException {
        ReloadableScript script;
        Resource source = findResource(moduleName, loaders, localPath);
        if (!source.exists()) {
            source = loadPackage(moduleName, localPath);
            if (!source.exists()) {
                source = findResource(moduleName, null, localPath);
            }
        }
        Context cx = Context.getCurrentContext();
        Map<Trackable,ReloadableScript> scripts = getScriptCache(cx);
        if (scripts.containsKey(source)) {
            script = scripts.get(source);
        } else {
            script = new ReloadableScript(source, this);
            if (source.exists()) {
                scripts.put(source, script);
            }
        }
        return script;
    }

    /**
     * Resolves a module id to a package resource. If module id consists of
     * just one term and resolves to a package directory, the main module of
     * the package is returned. If the module id consists of several terms
     * and the first term resolves to a package directory, the remaining part
     * of the module id is resolved against the "lib" directory of the package.
     *
     * @see <a href="http://nodejs.org/docs/v0.4.4/api/modules.html#folders_as_Modules">NodeJS Modules</a>
     * @param moduleName the name of the package to load
     * @param localPath the path of the resource issuing this call
     * @return the location of the package's main module
     * @throws IOException an unrecoverable I/O exception occurred while
     * reading the package
     */
    protected Resource loadPackage(String moduleName, Repository localPath)
            throws IOException {

        int slash = 0;
        String packageName, remainingName;

        do {
            slash = moduleName.indexOf('/', slash + 1);
            if (slash == -1) {
                packageName = moduleName;
                remainingName = null;
            } else {
                packageName = moduleName.substring(0, slash);
                if (".".equals(packageName) || "..".equals(packageName))
                    continue;
                remainingName = moduleName.substring(slash + 1);
            }

            Resource json = findResource(packageName + "/package.json", null, localPath);

            if (json != null && json.exists()) {

                Scriptable obj = parseJsonResource(json);
                Repository parent = json.getParentRepository();
                String moduleId;
                Resource res;

                // Load JAR dependencies
                Object jars = ScriptableObject.getProperty(obj, "jars");
                if (jars instanceof Scriptable && ScriptRuntime.isArrayObject(jars)) {
                    Object[] jarsToLoad = ScriptRuntime.getArrayElements((Scriptable) jars);
                    for (Object jar : jarsToLoad) {
                        Resource jarResource = parent.getResource(jar.toString());
                        if (!jarResource.exists()) {
                            log.warning("Skipping non-existing JAR resource in package.json descriptor for " +
                                    packageName + ": " + jar.toString() + " - " + jarResource.getUrl());
                        } else {
                            log.config("Adding JAR resource from " + packageName +
                                    " package.json descriptor to classpath: " + jarResource.getUrl());
                            loader.addURL(jarResource.getUrl());
                        }
                    }
                }

                if (remainingName == null) {
                    // get the main module of this package
                    moduleId = getStringProperty(obj, "main", null);
                    if (moduleId != null) {
                        // optimize for the common case where main module
                        // property links to the exact file name
                        res = parent.getResource(moduleId);
                        if (res != null && res.exists()) return res;
                    }
                } else {
                    // map remaining name to libs directory
                    String lib = "lib";
                    Object dirs = ScriptableObject.getProperty(obj, "directories");
                    if (dirs instanceof Scriptable) {
                        lib = getStringProperty((Scriptable)dirs, "lib", "lib");
                    }
                    moduleId = lib + "/" + remainingName;
                }

                if (moduleId != null) {
                    for (ModuleLoader loader: loaders) {
                        res = parent.getResource(moduleId + loader.getExtension());
                        if (res != null && res.exists()) return res;
                    }
                    if (remainingName != null) {
                        res = parent.getResource(moduleId);
                        if (res != null && res.exists()) return res;
                    }
                }

            }

        } while (slash != -1);

        return findResource(moduleName + "/index", loaders, localPath);
    }

    private Scriptable parseJsonResource(Resource resource) throws IOException {
        JsonParser parser = new JsonParser(Context.getCurrentContext(), globalScope);
        try {
            Object result = parser.parseValue(resource.getContent());
            if (!(result instanceof Scriptable)) {
                throw new RuntimeException(
                        "Expected Object from package.json, got " + result);
            }
            return (Scriptable) result;
        } catch (JsonParser.ParseException px) {
            log.severe("Could not parse package.json " + resource.getUrl());
            throw new RuntimeException(px);
        }
    }

    private String getStringProperty(Scriptable obj, String name, String defaultValue) {
        Object value = ScriptableObject.getProperty(obj, name);
        if (value != null && value != ScriptableObject.NOT_FOUND) {
            return ScriptRuntime.toString(value);
        }
        return defaultValue;
    }


    /**
     * Load a Javascript module into a module scope. This checks if the module has already
     * been loaded in the current context and if so returns the existing module scope.
     * @param cx the current context
     * @param moduleName the module name
     * @param loadingScope the scope requesting the module
     * @return the loaded module's scope
     * @throws IOException indicates that in input/output related error occurred
     */
    public Scriptable loadModule(Context cx, String moduleName,
                                  Scriptable loadingScope)
            throws IOException {
        return mainWorker.loadModule(cx, moduleName, loadingScope);
    }

    /**
     * Get the name of the main script as module name, if any
     * @return the main module name, or null
     */
    public String getMainModule() {
        return config.getMainModule();
    }

    /**
     * Get the main scrip's module scope, if any
     * @return the main module scope, or null
     */
    public ModuleScope getMainModuleScope() {
        return mainScope;
    }

    /**
     * Get the script arguments as object array suitable for use with Context.newArray().
     * @return the script arguments
     */
    public Object[] getArguments() {
        String[] args = config.getArguments();
        if (args == null) {
            return ScriptRuntime.emptyArgs;
        } else {
            Object[] array = new Object[args.length];
            System.arraycopy(args, 0, array, 0, args.length);
            return array;
        }
    }

    public String getCharset() {
        return config.getCharset();
    }

    /**
     * Get the currently active RhinoEngine instance.
     * @param scope the global scope or a top level module scope
     * @return the current RhinoEngine
     */
    public static RhinoEngine getEngine(Scriptable scope) {
        if (scope instanceof ModuleScope) {
            scope = scope.getPrototype();
        }
        if (scope instanceof RingoGlobal) {
            return ((RingoGlobal) scope).getEngine();
        }
        throw new IllegalArgumentException("Unsupported scope");
    }

    /**
     * Create a sandboxed scripting engine with the same install directory as this and the
     * given module paths, global properties, class shutter and sealing
     * @param config the sandbox configuration
     * @param globals a map of predefined global properties, may be null
     * @return a sandboxed RhinoEngine instance
     * @throws FileNotFoundException if any part of the module paths does not exist
     */
    public RhinoEngine createSandbox(RingoConfig config, Map<String,Object> globals)
            throws Exception {
        return new RhinoEngine(config, globals);
    }

    /**
     * Wait until all daemon threads running in this engine have terminated.
     * @throws InterruptedException if the current thread has been interrupted
     */
    public void waitForAsyncTasks() throws InterruptedException {
        asyncCounter.waitTillDone();
    }

    protected void enterAsyncTask() {
        asyncCounter.increase();
    }

    protected void exitAsyncTask() {
        asyncCounter.decrease();
    }

    private Map<Trackable,ReloadableScript> getScriptCache(Context cx) {
        return cx.getOptimizationLevel() == -1 ?
                interpretedScripts : compiledScripts;
    }

    private void evaluateBootstrapScripts(Context cx)
            throws IOException {
        List<String> bootstrapScripts = config.getBootstrapScripts();
        if (bootstrapScripts != null) {
            for(String script : bootstrapScripts) {
                Resource resource = new FileResource(script);
                // not found, attempt to resolve the file relative to ringo home
                if (!resource.exists()) {
                    resource = getRingoHome().getResource(script);
                }
                if (resource == null || !resource.exists()) {
                    throw new FileNotFoundException(
                            "Bootstrap script " + script + " not found");
                }
                mainWorker.evaluateScript(cx, new ReloadableScript(resource, this),
                        globalScope);
            }
        }
    }

    /**
     * Get the list of command line arguments
     * @return the command line arguments passed to this engine
     */
    public List<String> getCommandLineArguments() {
        if (commandLineArgs == null) {
            commandLineArgs = Collections.emptyList();
        }
        return Collections.unmodifiableList(commandLineArgs);
    }

    /**
     * Get the engine's module search path as a list of repositories
     * @return the module repositories
     */
    public List<Repository> getRepositories() {
        return repositories;
    }

    /**
     * Get the our installation directory.
     * @return the RingoJS installation directory
     */
    public Repository getRingoHome() {
        return config.getRingoHome();
    }

    /**
     * Get the repository associated with the scope or one of its prototypes
     *
     * @param scope the scope to get the repository from
     * @return the repository, or null
     */
    public Repository getParentRepository(Scriptable scope) {
        while (scope != null) {
            if (scope instanceof ModuleScope) {
                return ((ModuleScope) scope).getRepository();
            }
            scope = scope.getPrototype();
        }
        return null;
    }

    /**
     * Get a list of all child resources for the given path relative to
     * our script repository.
     * @param path the repository path
     * @param recursive whether to include nested resources
     * @return a list of all contained child resources
     */
    public List<Resource> findResources(String path, boolean recursive) throws IOException {
        return config.getResources(path, recursive);
    }

    /**
     * Try to resolve path to a resource or repository relative to a local path,
     * or the engine's repository path.
     * @param path the resource name
     * @param localRoot a repository to look first
     * @return the resource or repository
     * @throws IOException if an I/O error occurred
     */
    public Trackable resolve(String path, Repository localRoot) throws IOException {
        Trackable t = findResource(path, null, localRoot);
        if (t == null || !t.exists()) {
            t = findRepository(path, localRoot);
        }
        return t;
    }

    /**
     * Search for a resource in a local path, or the main repository path.
     * @param path the resource name
     * @param loaders optional list of module loaders
     * @param localRoot a repository to look first
     * @return the resource
     * @throws IOException if an I/O error occurred
     */
    public Resource findResource(String path, ModuleLoader[] loaders,
                                 Repository localRoot)
            throws IOException {
        // Note: as an extension to the CommonJS modules API
        // we allow absolute module paths for resources
        File file = new File(path);
        if (file.isAbsolute()) {
            Resource res;
            outer: if (loaders != null) {
                // loaders must contain at least one loader
                assert loaders.length > 0 && loaders[0] != null;
                for (ModuleLoader loader: loaders) {
                    res = new FileResource(path + loader.getExtension());
                    if (res.exists()) {
                        break outer;
                    }
                }
                res = new FileResource(path + loaders[0].getExtension());
            } else {
                res = new FileResource(file);
            }
            res.setAbsolute(true);
            return res;
        } else if (localRoot != null &&
                (path.startsWith("./") || path.startsWith("../"))) {
            String newpath = localRoot.getRelativePath() + path;
            return findResource(newpath, loaders, null);
        } else {
            return config.getResource(normalizePath(path), loaders);
        }
    }

    /**
     * Search for a repository in the local path, or the main repository path.
     * @param path the repository name
     * @param localPath a repository to look first
     * @return the repository
     * @throws IOException if an I/O error occurred
     */
    public Repository findRepository(String path, Repository localPath) throws IOException {
        // To be consistent, always return absolute repository if path is absolute
        // if we make this dependent on whether files exist we introduce a lot of
        // vague and undetermined behaviour.
        File file = new File(path);
        if (file.isAbsolute()) {
            return new FileRepository(file);
        }
        if (localPath != null) {
            Repository repository = localPath.getChildRepository(path);
            if (repository != null && repository.exists()) {
                return repository;
            }
        }
        return config.getRepository(normalizePath(path));
    }

    public ModuleLoader getModuleLoader(Resource resource) {
        String name = resource.getName();
        for (ModuleLoader loader : loaders) {
            if (name.endsWith(loader.getExtension())) {
                return loader;
            }
        }
        return loaders[0];
    }

    public synchronized void addModuleLoader(String extension, Object value) {
        if (value == null || value == Undefined.instance) {
            removeModuleLoader(extension);
        } else if (!(value instanceof Function)) {
            throw Context.reportRuntimeError("Module loader must be a function");
        }
        Function function = (Function) value;
        int length = loaders.length;
        for (int i = 0; i < length; i++) {
            if (extension.equals(loaders[i].getExtension())) {
                // replace existing loader
                loaders[i] = new ScriptedModuleLoader(extension, function);
                return;
            }
        }
        ModuleLoader[] newLoaders = new ModuleLoader[length + 1];
        System.arraycopy(loaders, 0, newLoaders, 0, length);
        newLoaders[length] = new ScriptedModuleLoader(extension, function);
        loaders = newLoaders;
    }

    public synchronized void removeModuleLoader(String extension) {
        int length = loaders.length;
        for (int i = 0; i < length; i++) {
            if (loaders[i] instanceof ScriptedModuleLoader &&
                    extension.equals(loaders[i].getExtension())) {
                ModuleLoader[] newLoaders = new ModuleLoader[length - 1];
                if (i > 0)
                    System.arraycopy(loaders, 0, newLoaders, 0, i);
                if (i < length - 1)
                    System.arraycopy(loaders, i + 1, newLoaders, i, length - i - 1);
                loaders = newLoaders;
                return;
            }
        }
    }

    public static String normalizePath(String path) {
        if (!path.contains("./")) {
            return path;
        }
        boolean absolute = path.startsWith("/");
        String[] elements = StringUtils.split(path, Repository.SEPARATOR);
        LinkedList<String> list = new LinkedList<>();
        for (String e : elements) {
            if ("..".equals(e)) {
                if (list.isEmpty() || "..".equals(list.getLast())) {
                    list.add(e);
                } else {
                    list.removeLast();
                }
            } else if (!".".equals(e) && e.length() > 0) {
                list.add(e);
            }
        }
        StringBuilder sb = new StringBuilder(path.length());
        if (absolute) {
            sb.append("/");
        }
        int count = 0, last = list.size() - 1;
        for (String e : list) {
            sb.append(e);
            if (count++ < last)
                sb.append("/");
        }
        return sb.toString();
    }

    public void addToClasspath(Trackable path) throws MalformedURLException {
        loader.addURL(path.getUrl());
    }

    public RingoContextFactory getContextFactory() {
        return contextFactory;
    }

    public RingoClassLoader getClassLoader() {
        return loader;
    }

    public RingoConfig getConfig() {
        return config;
    }

    Singleton getSingleton(Singleton singleton) {
        synchronized (singletons) {
            Singleton st = singletons.get(singleton);
            if (st == null) {
                st = singleton;
                singletons.put(singleton, singleton);
            }
            return st;
        }
    }

    /**
     * Get a wrapper for a string that exposes the java.lang.String methods to JavaScript
     * This is useful for accessing strings as java.lang.String without the cost of
     * creating a new instance.
     * @param object an object
     * @return the object converted to a string and wrapped as native java object
     */
    public Object asJavaString(Object object) {
        if (!(object instanceof String)) {
            object = object.toString();
        }
        Context cx = Context.getCurrentContext();
        return wrapFactory.wrapAsJavaObject(cx, globalScope, object, null);
    }

    /**
     * Get a wrapper for an object that exposes it as Java object to JavaScript.
     * @param object an object
     * @return the object wrapped as native java object
     */
    public Object asJavaObject(Object object) {
        if (object instanceof Wrapper) {
            object = ((Wrapper) object).unwrap();
        }
        Context cx = Context.getCurrentContext();
        return wrapFactory.wrapAsJavaObject(cx, globalScope, object, null);
    }

    /**
     * Get the engine's WrapFactory.
     * @return the engine's WrapFactory instance
     */
    public WrapFactory getWrapFactory() {
        return wrapFactory;
    }

    static class AsyncTaskCounter {

        int count = 0;

        synchronized void waitTillDone() throws InterruptedException {
            while(count > 0) {
                wait();
            }
        }

        synchronized void increase() {
            ++count;
        }

        synchronized void decrease() {
            if (--count <= 0) {
                notifyAll();
            }
        }
    }

}

class AppClassLoader extends RingoClassLoader {

    final HashSet<URL> urls = new HashSet<>();

    public AppClassLoader() {
        super(new URL[0], RhinoEngine.class.getClassLoader());
    }

    /**
     * Overrides addURL to make it accessable to RingoGlobal.addToClasspath()
     * @param url the url to add to the classpath
     */
    protected synchronized void addURL(URL url) {
        if (!urls.contains(url)) {
            urls.add(url);
            super.addURL(url);
        }
    }
}


