/**
 * 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.camel.builder.script;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.Message;
import org.apache.camel.Predicate;
import org.apache.camel.Processor;
import org.apache.camel.spi.ClassResolver;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.ResourceHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A builder class for creating {@link Processor}, {@link Expression} and
 * {@link Predicate} objects using the JSR 223 scripting engine.
 *
 * @version
 */
public class ScriptBuilder implements Expression, Predicate, Processor {

    /**
     * Additional arguments to {@link ScriptEngine} provided as a header on the IN {@link org.apache.camel.Message}
     * using the key {@link #ARGUMENTS}
     */
    public static final String ARGUMENTS = "CamelScriptArguments";

    private static final Logger LOG = LoggerFactory.getLogger(ScriptBuilder.class);

    private Map<String, Object> attributes;
    private final CamelContext camelContext;
    private final ScriptEngineFactory scriptEngineFactory;
    private final ScriptEngine scriptEngine;
    private final String scriptLanguage;
    private final String scriptResource;
    private final String scriptText;
    private CompiledScript compiledScript;

    /**
     * Constructor.
     *
     * @param scriptLanguage the name of the scripting language
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     */
    public ScriptBuilder(String scriptLanguage, String scriptText) {
        this(null, scriptLanguage, scriptText, null);
    }

    /**
     * Constructor.
     *
     * @param scriptLanguage the name of the scripting language
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     */
    public ScriptBuilder(CamelContext camelContext, String scriptLanguage, String scriptText) {
        this(camelContext, scriptLanguage, scriptText, null);
    }

    /**
     * Constructor.
     *
     * @param scriptLanguage the name of the scripting language
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @param scriptEngineFactory the script engine factory
     */
    public ScriptBuilder(CamelContext camelContext, String scriptLanguage, String scriptText, ScriptEngineFactory scriptEngineFactory) {
        this.camelContext = camelContext;
        this.scriptLanguage = scriptLanguage;
        if (ResourceHelper.hasScheme(scriptText)) {
            this.scriptResource = scriptText;
            this.scriptText = null;
        } else {
            this.scriptResource = null;
            this.scriptText = scriptText;
        }
        if (scriptEngineFactory == null) {
            this.scriptEngine = createScriptEngine(scriptLanguage);
            this.scriptEngineFactory = lookupScriptEngineFactory(scriptLanguage);
        } else {
            this.scriptEngineFactory = scriptEngineFactory;
            this.scriptEngine = scriptEngineFactory.getScriptEngine();
        }

        if (this.scriptEngineFactory == null) {
            throw new IllegalArgumentException("Cannot lookup ScriptEngineFactory for script language: " + scriptLanguage);
        }

        // bean shell is not compileable
        if (isBeanShell(scriptLanguage)) {
            return;
        }

        // pre-compile script which would execute faster if possible
        Reader reader = null;
        try {
            // if we have camel context then load resources
            if (camelContext != null && scriptResource != null) {
                reader = createScriptReader(camelContext.getClassResolver(), scriptResource);
            } else if (this.scriptText != null) {
                reader = new StringReader(this.scriptText);
            }

            // pre-compile script if we have it as text
            if (reader != null) {
                if (compileScripte(camelContext) && scriptEngine instanceof Compilable) {
                    Compilable compilable = (Compilable) scriptEngine;
                    this.compiledScript = compilable.compile(reader);
                    LOG.debug("Using compiled script: {}", this.compiledScript);
                }
            }
        } catch (IOException e) {
            throw new ScriptEvaluationException("Cannot load " + scriptLanguage + " script from resource: " + scriptResource, e);
        } catch (ScriptException e) {
            throw new ScriptEvaluationException("Error compiling " + scriptLanguage + " script: " + scriptText, e);
        } finally {
            IOHelper.close(reader);
        }
    }

    @Override
    public String toString() {
        return getScriptDescription();
    }

    public Object evaluate(Exchange exchange) {
        return evaluateScript(exchange);
    }

    public <T> T evaluate(Exchange exchange, Class<T> type) {
        Object result = evaluate(exchange);
        return exchange.getContext().getTypeConverter().convertTo(type, result);
    }

    public boolean matches(Exchange exchange) {
        Object scriptValue = evaluateScript(exchange);
        return matches(exchange, scriptValue);
    }

    public void assertMatches(String text, Exchange exchange) throws AssertionError {
        Object scriptValue = evaluateScript(exchange);
        if (!matches(exchange, scriptValue)) {
            throw new AssertionError(this + " failed on " + exchange + " as script returned <" + scriptValue + ">");
        }
    }

    public void process(Exchange exchange) {
        evaluateScript(exchange);
    }

    // Builder API
    // -------------------------------------------------------------------------

    /**
     * Sets the attribute on the context so that it is available to the script
     * as a variable in the {@link ScriptContext#ENGINE_SCOPE}
     *
     * @param name the name of the attribute
     * @param value the attribute value
     * @return this builder
     */
    public ScriptBuilder attribute(String name, Object value) {
        if (attributes == null) {
            attributes = new HashMap<String, Object>();
        }
        attributes.put(name, value);
        return this;
    }

    /**
     * Creates a script builder for the named language and script contents
     *
     * @param language the language to use for the script
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder script(String language, String scriptText) {
        return new ScriptBuilder(language, scriptText);
    }

    /**
     * Creates a script builder for the groovy script contents
     *
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder groovy(String scriptText) {
        return new ScriptBuilder("groovy", scriptText);
    }

    /**
     * Creates a script builder for the JavaScript/ECMAScript script contents
     *
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder javaScript(String scriptText) {
        return new ScriptBuilder("js", scriptText);
    }

    /**
     * Creates a script builder for the PHP script contents
     *
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder php(String scriptText) {
        return new ScriptBuilder("php", scriptText);
    }

    /**
     * Creates a script builder for the Python script contents
     *
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder python(String scriptText) {
        return new ScriptBuilder("python", scriptText);
    }

    /**
     * Creates a script builder for the Ruby/JRuby script contents
     *
     * @param scriptText the script text to be evaluated, or a reference to a script resource
     * @return the builder
     */
    public static ScriptBuilder ruby(String scriptText) {
        return new ScriptBuilder("jruby", scriptText);
    }

    // Properties
    // -------------------------------------------------------------------------

    public CompiledScript getCompiledScript() {
        return compiledScript;
    }

    public String getScriptLanguage() {
        return scriptLanguage;
    }

    /**
     * Returns a description of the script
     *
     * @return the script description
     */
    public String getScriptDescription() {
        if (scriptText != null) {
            return scriptLanguage + ": " + scriptText;
        } else if (scriptResource != null) {
            return scriptLanguage + ": " + scriptResource;
        } else {
            return scriptLanguage + ": null script";
        }
    }

    // Implementation methods
    // -------------------------------------------------------------------------

    protected boolean matches(Exchange exchange, Object scriptValue) {
        return exchange.getContext().getTypeConverter().convertTo(boolean.class, scriptValue);
    }

    private static String[] getScriptNames(String name) {
        if (name.equals("js")) {
            return new String[]{"js", "javaScript", "ECMAScript"};
        } else if (name.equals("javaScript")) {
            return new String[]{"javaScript", "js", "ECMAScript"};
        } else if (name.equals("ECMAScript")) {
            return new String[]{"ECMAScript", "javaScript", "js"};
        }
        return new String[]{name};
    }

    protected static ScriptEngineFactory lookupScriptEngineFactory(String language) {
        ScriptEngineManager manager = new ScriptEngineManager();
        for (ScriptEngineFactory factory : manager.getEngineFactories()) {
            // some script names has alias
            String[] names = getScriptNames(language);
            for (String name : names) {
                if (factory.getLanguageName().equals(name)) {
                    return factory;
                }
            }
        }

        // fallback to get engine by name
        ScriptEngine engine = createScriptEngine(language);
        if (engine != null) {
            return engine.getFactory();
        }
        return null;
    }

    protected static ScriptEngine createScriptEngine(String language) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = null;

        // some script names has alias
        String[] names = getScriptNames(language);
        for (String name : names) {
            try {
                engine = manager.getEngineByName(name);
                if (engine != null) {
                    break;
                }
            } catch (NoClassDefFoundError ex) {
                LOG.error("Cannot load the scriptEngine for " + name + ", the exception is " + ex
                          + ", please ensure correct JARs is provided on classpath.");
            }
        }
        if (engine == null) {
            engine = checkForOSGiEngine(language);
        }
        if (engine == null) {
            throw new IllegalArgumentException("No script engine could be created for: " + language);
        }
        if (isPython(language)) {
            ScriptContext context = engine.getContext();
            context.setAttribute("com.sun.script.jython.comp.mode", "eval", ScriptContext.ENGINE_SCOPE);
        }
        return engine;
    }

    private static ScriptEngine checkForOSGiEngine(String language) {
        LOG.debug("No script engine found for " + language + " using standard javax.script auto-registration. Checking OSGi registry...");
        try {
            // Test the OSGi environment with the Activator
            Class<?> c = Class.forName("org.apache.camel.script.osgi.Activator");
            Method mth = c.getDeclaredMethod("getBundleContext");
            Object ctx = mth.invoke(null);
            LOG.debug("Found OSGi BundleContext " + ctx);
            if (ctx != null) {
                Method resolveScriptEngine = c.getDeclaredMethod("resolveScriptEngine", String.class);
                return (ScriptEngine)resolveScriptEngine.invoke(null, language);
            }
        } catch (Throwable t) {
            LOG.debug("Unable to load OSGi, script engine cannot be found", t);
        }
        return null;
    }

    protected Object evaluateScript(Exchange exchange) {
        try {
            if (reuseScriptEngine(exchange)) {
                // It's not safe to do the evaluation with a single scriptEngine
                synchronized (this) {
                    LOG.debug("Calling doEvaluateScript without creating a new scriptEngine");
                    return doEvaluateScript(exchange, scriptEngine);
                }
            } else {
                LOG.debug("Calling doEvaluateScript with a new scriptEngine");
                // get a new engine which we must for each exchange
                ScriptEngine engine = scriptEngineFactory.getScriptEngine();
                return doEvaluateScript(exchange, engine);
            }
        } catch (ScriptException e) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Script evaluation failed: " + e.getMessage(), e);
            }
            if (e.getCause() != null) {
                throw createScriptEvaluationException(e.getCause());
            } else {
                throw createScriptEvaluationException(e);
            }
        } catch (IOException e) {
            throw createScriptEvaluationException(e);
        }
    }

    protected Object doEvaluateScript(Exchange exchange, ScriptEngine scriptEngine) throws ScriptException, IOException {
        ScriptContext context = populateBindings(scriptEngine, exchange, attributes);
        addScriptEngineArguments(scriptEngine, exchange);
        Object result = runScript(scriptEngine, exchange, context);
        LOG.debug("The script evaluation result is: {}", result);
        return result;
    }

    // To check the camel context property to decide if we need to reuse the ScriptEngine
    private boolean reuseScriptEngine(Exchange exchange) {
        CamelContext camelContext = exchange.getContext();
        if (camelContext != null) {
            return getCamelContextProperty(camelContext, Exchange.REUSE_SCRIPT_ENGINE);
        } else {
            // default value is false
            return false;
        }
    }

    private boolean compileScripte(CamelContext camelContext) {
        if (camelContext != null) {
            return getCamelContextProperty(camelContext, Exchange.COMPILE_SCRIPT);
        } else {
            return false;
        }
    }

    private boolean getCamelContextProperty(CamelContext camelContext, String propertyKey) {
        String propertyValue =  camelContext.getProperty(propertyKey);
        if (propertyValue != null) {
            return camelContext.getTypeConverter().convertTo(boolean.class, propertyValue);
        } else {
            return false;
        }
    }

    protected Object runScript(ScriptEngine engine, Exchange exchange, ScriptContext context) throws ScriptException, IOException {
        Object result = null;
        if (compiledScript != null) {
            LOG.trace("Evaluate using compiled script for context: {} on exchange: {}", context, exchange);
            result = compiledScript.eval(context);
        } else {
            if (scriptText != null) {
                LOG.trace("Evaluate script for context: {} on exchange: {}", context, exchange);
                result = engine.eval(scriptText, context);
            } else if (scriptResource != null) {
                Reader reader = createScriptReader(exchange.getContext().getClassResolver(), scriptResource);
                try {
                    LOG.trace("Evaluate script for context: {} on exchange: {}", context, exchange);
                    result = engine.eval(reader, context);
                } finally {
                    IOHelper.close(reader);
                }
            }
        }
        return result;
    }

    protected ScriptContext populateBindings(ScriptEngine engine, Exchange exchange, Map<String, Object> attributes) {
        ScriptContext context = engine.getContext();
        int scope = ScriptContext.ENGINE_SCOPE;
        context.setAttribute("context", exchange.getContext(), scope);
        context.setAttribute("camelContext", exchange.getContext(), scope);
        context.setAttribute("exchange", exchange, scope);
        Message in = exchange.getIn();
        context.setAttribute("request", in, scope);
        context.setAttribute("headers", in.getHeaders(), scope);
        context.setAttribute("body", in.getBody(), scope);
        if (exchange.hasOut()) {
            Message out = exchange.getOut();
            context.setAttribute("out", out , scope);
            context.setAttribute("response", out, scope);
        }
        // to make using properties component easier
        context.setAttribute("properties", new ScriptPropertiesFunction(exchange.getContext()), scope);
        // any additional attributes
        if (attributes != null) {
            for (Map.Entry<String, Object> entry : attributes.entrySet()) {
                context.setAttribute(entry.getKey(), entry.getValue(), scope);
            }
        }
        return context;
    }

    @SuppressWarnings("unchecked")
    protected void addScriptEngineArguments(ScriptEngine engine, Exchange exchange) {
        if (!exchange.getIn().hasHeaders()) {
            return;
        }

        Map<Object, Object> args = exchange.getIn().getHeader(ARGUMENTS, Map.class);
        if (args != null) {
            for (Map.Entry<Object, Object> entry : args.entrySet()) {
                String key = exchange.getContext().getTypeConverter().convertTo(String.class, entry.getKey());
                Object value = entry.getValue();
                if (!ObjectHelper.isEmpty(key) && value != null) {
                    LOG.trace("Putting {} -> {} on ScriptEngine", key, value);
                    engine.put(key, value);
                }
            }
        }
    }

    protected static InputStreamReader createScriptReader(ClassResolver classResolver, String resource) throws IOException {
        InputStream is = ResourceHelper.resolveMandatoryResourceAsInputStream(classResolver, resource);
        return new InputStreamReader(is);
    }

    protected ScriptEvaluationException createScriptEvaluationException(Throwable e) {
        if (e.getClass().getName().equals("org.jruby.exceptions.RaiseException")) {
            // Only the nested exception has the specific problem
            try {
                Object ex = e.getClass().getMethod("getException").invoke(e);
                return new ScriptEvaluationException("Failed to evaluate: " + getScriptDescription() + ".  Error: " + ex + ". Cause: " + e, e);
            } catch (Exception e1) {
                // do nothing here
            }
        }
        return new ScriptEvaluationException("Failed to evaluate: " + getScriptDescription() + ". Cause: " + e, e);
    }

    private static boolean isPython(String language) {
        return "python".equals(language) || "jython".equals(language);
    }

    private static boolean isBeanShell(String language) {
        return "beanshell".equals(language) || "bsh".equals(language);
    }

}
