/*
 * Copyright 2007 Rickard Öberg
 * 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.qi4j.lang.javascript;

import java.io.*;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.HashMap;
import java.util.StringTokenizer;
import org.mozilla.javascript.*;
import org.qi4j.api.common.AppliesTo;
import org.qi4j.api.common.AppliesToFilter;
import org.qi4j.api.composite.Composite;
import org.qi4j.api.composite.TransientBuilderFactory;
import org.qi4j.api.injection.scope.Structure;
import org.qi4j.api.injection.scope.This;
import org.qi4j.library.scripting.ScriptException;
import org.qi4j.library.scripting.ScriptReloadable;

/**
 * Generic mixin that implements interfaces by delegating to JavaScript functions
 * using Rhino. Each method in an interface is declared as a JS function
 * in a file located in classpath with the name "&lt;interface&gt;.&lt;method&gt;.js",
 * where the interface name includes the package, and has "." replaced with "/".
 * <p>
 * Example:
 * </p>
 * <pre><code>
 * org/qi4j/samples/hello/domain/HelloWorldSpeaker.say.js
 * </code></pre>
 */
@AppliesTo( JavaScriptMixin.AppliesTo.class )
public class JavaScriptMixin
    implements InvocationHandler, ScriptReloadable
{
    @This private Composite me;

    static private Scriptable standardScope;

    private HashMap<String, Function> cachedScripts;

    @Structure private TransientBuilderFactory factory;
    private Scriptable instanceScope;
    
    static
    {
        Context cx = Context.enter();
        standardScope = cx.initStandardObjects();
        Context.exit();
    }

    public JavaScriptMixin()
    {
        cachedScripts = new HashMap<String, Function>();
        Context cx = Context.enter();
        instanceScope = cx.newObject( standardScope );
        instanceScope.setPrototype( standardScope );
        Context.exit();

    }

    @Override
    public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable
    {
        Context cx = Context.enter();
        try
        {
            Scriptable proxyScope = Context.toObject( proxy, instanceScope );
            proxyScope.setPrototype( instanceScope );
            proxyScope.put( "compositeBuilderFactory", proxyScope, factory );
            proxyScope.put( "This", proxyScope, me );
            Function fn = getFunction( cx, proxyScope, method );
            Object result = fn.call( cx, instanceScope, proxyScope, args );

            if( result instanceof Undefined )
            {
                return null;
            }
            else if( result instanceof Wrapper )
            {
                return ( (Wrapper) result ).unwrap();
            }
            else
            {
                return result;
            }
        }
        finally
        {
            Context.exit();
        }
    }

    @Override
    public void reloadScripts()
    {
        cachedScripts.clear();
    }

    private Function getFunction( Context cx, Scriptable scope, Method method )
        throws IOException
    {
        Class<?> declaringClass = method.getDeclaringClass();
        String classname = declaringClass.getName();
        String methodName = method.getName();
        String requestedFunctionName = classname + ":" + methodName;

        Function fx = cachedScripts.get( requestedFunctionName );
        if( fx != null )
        {
            return fx;
        }
        compileScripts( cx, scope, method );
        fx = cachedScripts.get( requestedFunctionName );
        return fx;
    }

    private void compileScripts( Context cx, Scriptable scope, Method method )
        throws IOException
    {
        URL scriptUrl = getFunctionResource( method );
        if( scriptUrl == null )
        {
            throw new IOException( "No script found for method " + method.getName() );
        }

        InputStream in = scriptUrl.openStream();
        BufferedReader scriptReader = new BufferedReader( new InputStreamReader( in ) );
        int lineNo = 1;
        String classname = method.getDeclaringClass().getName();
        while( true )
        {
            ScriptFragment fragment = extractFunction( scriptReader );
            if( "".equals( fragment.script.trim() ) )
            {
                break;
            }
            String functionName = parseFunctionName( fragment.script, scriptUrl.toString() );
            Function function = cx.compileFunction( scope, fragment.script, "<" + scriptUrl.toString() + ">", lineNo, null );
            cachedScripts.put( classname + ":" + functionName, function );
            lineNo = lineNo + fragment.numberOfLines;
        }
    }

    /**
     * Extracts the function name.
     * <p>
     * Since the fragment has been stripped of all comments, the first non-whitespace word to appear
     * should be "function" and the word after that should be the function name.
     * </p>
     *
     * @param script     The script snippet.
     * @param scriptName The name of the script being parsed.
     * @return the name of the function declared in this snippet.
     */
    private String parseFunctionName( String script, String scriptName )
    {
        // TODO optimize with hardcoded parser??
        StringTokenizer st = new StringTokenizer( script, " \t\n\r\f(){}", false );
        if( !st.hasMoreTokens() )
        {
            throw new ScriptException( "The word \"function\" was not found in script: " + scriptName );
        }
        String fx = st.nextToken();
        if( !"function".equals( fx ) )
        {
            throw new ScriptException( "The word \"function\" was not found in script: " + scriptName );
        }
        if( !st.hasMoreTokens() )
        {
            throw new ScriptException( "Invalid syntax in: " + scriptName + "\n No function name." );
        }
        return st.nextToken();
    }

    /**
     * Returns ONE function, minus comments.
     *
     * @param scriptReader The Reader of the script
     * @return A ScriptFragment containing the Script text for the function, and how many lines it is.
     * @throws IOException If a problem in the Reader occurs.
     */
    private ScriptFragment extractFunction( Reader scriptReader )
        throws IOException
    {
        ScriptFragment fragment = new ScriptFragment();
        boolean inString = false;
        boolean inChar = false;
        boolean escaped = false;
        boolean lineComment = false;
        boolean blockComment = false;
        char lastCh = '\0';
        int braceCounter = 0;
        boolean notStarted = true;
        int b = scriptReader.read();
        int skip = 0;
        while( b != -1 && ( notStarted || braceCounter > 0 ) )
        {
            char ch = (char) b;
            if( !blockComment && !lineComment )
            {
                fragment.script = fragment.script + ch;
                if( !escaped )
                {
                    if( !inString && !inChar )
                    {
                        if( ch == '{' )
                        {
                            braceCounter++;
                            notStarted = false;
                        }
                        if( ch == '}' )
                        {
                            braceCounter--;
                        }
                    }
                    if( ch == '\"' )
                    {
                        inString = !inString;
                    }
                    if( ch == '\'' )
                    {
                        inChar = !inChar;
                    }
                    if( ch == '\\' )
                    {
                        escaped = true;
                    }
                    if( ch == '\n' )
                    {
                        fragment.numberOfLines++;
                    }
                    if( ch == '/' && lastCh == '/' )
                    {
                        lineComment = true;
                        fragment.script = fragment.script.substring( 0, fragment.script.length() - 2 );
                    }
                    if( ch == '*' && lastCh == '/' )
                    {
                        blockComment = true;
                        fragment.script = fragment.script.substring( 0, fragment.script.length() - 2 );
                    }
                }
                else
                {
                    if( ch == 'u' )
                    {
                        skip = 4;
                    }
                    else if( skip > 0 )
                    {
                        skip--;
                    }
                    else
                    {
                        escaped = false;
                    }
                }
            }
            else
            {
                if( lineComment )
                {
                    if( ch == '\n' )
                    {
                        lineComment = false;
                    }
                }
                if( blockComment )
                {
                    if( ch == '/' && lastCh == '*' )
                    {
                        blockComment = false;
                    }
                }
            }
            lastCh = ch;
            b = scriptReader.read();
        }
        return fragment;
    }

    private static URL getFunctionResource( Method method )
    {
        String scriptName = getScriptName( method );
        Class<?> declaringClass = method.getDeclaringClass();
        ClassLoader loader = declaringClass.getClassLoader();
        return loader.getResource( scriptName );
    }

    private static String getScriptName( Method method )
    {
        Class<?> declaringClass = method.getDeclaringClass();
        String classname = declaringClass.getName();
        return classname.replace( '.', '/' ) + ".js";
    }

    private static class ScriptFragment
    {
        String script = "";
        int numberOfLines = 0;
    }

    public static class AppliesTo
        implements AppliesToFilter
    {

        public boolean appliesTo( Method method, Class compositeType, Class mixin, Class modelClass )
        {
            return getFunctionResource( method ) != null;
        }

    }
}
