/*
 * 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.webbeans.proxy;

import static java.util.stream.Collectors.joining;
import static org.apache.xbean.asm9.ClassReader.SKIP_CODE;
import static org.apache.xbean.asm9.ClassReader.SKIP_DEBUG;
import static org.apache.xbean.asm9.ClassReader.SKIP_FRAMES;

import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.Stream;

import org.apache.webbeans.config.WebBeansContext;
import org.apache.webbeans.exception.ProxyGenerationException;
import org.apache.webbeans.exception.WebBeansException;
import org.apache.webbeans.hash.XxHash64;
import org.apache.webbeans.spi.DefiningClassService;
import org.apache.xbean.asm9.ClassReader;
import org.apache.xbean.asm9.ClassWriter;
import org.apache.xbean.asm9.MethodVisitor;
import org.apache.xbean.asm9.Opcodes;
import org.apache.xbean.asm9.Type;
import org.apache.xbean.asm9.shade.commons.EmptyVisitor;

import javax.enterprise.inject.Vetoed;

/**
 * Base class for all OWB Proxy factories
 */
public abstract class AbstractProxyFactory
{
    public static final int MAX_CLASSLOAD_TRIES = 10000;

    /**
     * This is needed as the Modifier#VARARGS is not (yet) public.
     * Note that the bitcode is the same as Modifier#TRANSIENT.
     * But 'varargs' is only for methods, whereas 'transient' is only for fields.
     */
    public static final int MODIFIER_VARARGS = 0x00000080;

    protected final Unsafe unsafe;

    private final DefiningClassService definingService;

    private final boolean useStaticNames;
    private final boolean useXXhash64;

    protected WebBeansContext webBeansContext;

    private final int javaVersion;


    /**
     * The name of the field which stores the passivationID of the Bean this proxy serves.
     * This is needed in case the proxy gets de-serialized back into a JVM
     * which didn't have this bean loaded yet.
     */
    public static final String FIELD_BEAN_PASSIVATION_ID = "owbBeanPassivationId";


    protected AbstractProxyFactory(WebBeansContext webBeansContext)
    {
        this.webBeansContext = webBeansContext;
        javaVersion = determineDefaultJavaVersion();
        definingService = webBeansContext.getService(DefiningClassService.class);
        useStaticNames = Boolean.parseBoolean(webBeansContext.getOpenWebBeansConfiguration()
                .getProperty("org.apache.webbeans.proxy.useStaticNames"));
        useXXhash64 = Boolean.parseBoolean(webBeansContext.getOpenWebBeansConfiguration()
                .getProperty("org.apache.webbeans.proxy.staticNames.useXxHash64"));
        unsafe = definingService == null ? new Unsafe() : null;
    }

    private int determineDefaultJavaVersion()
    {
        String javaVersionProp = webBeansContext.getOpenWebBeansConfiguration().getGeneratorJavaVersion();
        if (javaVersionProp == null)  // try to align on the runtime
        {
            javaVersionProp = System.getProperty("java.version");
        }
        if (javaVersionProp != null)
        {
            if (javaVersionProp.startsWith("1.8"))
            {
                return Opcodes.V1_8;
            }
            else if (javaVersionProp.startsWith("9") || javaVersionProp.startsWith("1.9"))
            {
                return Opcodes.V9;
            }
            else if (javaVersionProp.startsWith("10"))
            {
                return Opcodes.V10;
            }
            else if (javaVersionProp.startsWith("11"))
            {
                return Opcodes.V11;
            }
            else if (javaVersionProp.startsWith("12"))
            {
                return Opcodes.V12;
            }
            else if (javaVersionProp.startsWith("13"))
            {
                return Opcodes.V13;
            }
            else
            {
                try
                {
                    final int i = Integer.parseInt(javaVersionProp);
                    if (i > 13)
                    {
                        return Opcodes.V13 + (i - 13);
                    }
                }
                catch (final NumberFormatException nfe)
                {
                    // let's default
                }
            }
        }

        // the fallback is the lowest one to ensure it supports all possible classes of current environments
        return Opcodes.V1_8;
    }


    protected ClassLoader getProxyClassLoader(Class<?> beanClass)
    {
        if (definingService != null)
        {
            return definingService.getProxyClassLoader(beanClass);
        }
        return webBeansContext.getApplicationBoundaryService().getBoundaryClassLoader(beanClass);
    }

    /**
     * @return the marker interface which should be used for this proxy.
     */
    protected abstract Class<?> getMarkerInterface();

    /**
     * generate the bytecode for creating the instance variables of the class
     */
    protected abstract void createInstanceVariables(ClassWriter cw, Class<?> classToProxy, String classFileName);

    /**
     * generate the bytecode for serialization.
     */
    protected abstract void createSerialisation(ClassWriter cw, String proxyClassFileName, Class<?> classToProxy, String classFileName);

    /**
     * Each of our interceptor/decorator proxies has exactly 1 constructor
     * which invokes the super ct + sets the delegation field.
     *
     * @param cw
     * @param classToProxy
     * @param classFileName
     * @throws org.apache.webbeans.exception.ProxyGenerationException
     */
    protected abstract void createConstructor(ClassWriter cw, String proxyClassFileName, Class<?> classToProxy, String classFileName, Constructor<?> injectConstructor)
            throws ProxyGenerationException;

    /**
     * generate the bytecode for invoking all intercepted methods
     */
    protected abstract void delegateInterceptedMethods(ClassLoader classLoader, ClassWriter cw, String proxyClassFileName, Class<?> classToProxy, Method[] interceptedMethods)
            throws ProxyGenerationException;

    /**
     * generate the bytecode for invoking all non-intercepted methods
     */
    protected abstract void delegateNonInterceptedMethods(ClassLoader classLoader, ClassWriter cw, String proxyClassFileName, Class<?> classToProxy, Method[] noninterceptedMethods)
            throws ProxyGenerationException;

    /**
     * Detect a free classname based on the given one
     * @param proxyClassName
     * @return
     * @deprecated use {@link #getUnusedProxyClassName(ClassLoader, String, Method[], Method[])}.
     */
    @Deprecated
    protected String getUnusedProxyClassName(ClassLoader classLoader, String proxyClassName)
    {
        proxyClassName = fixPreservedPackages(proxyClassName);

        String finalName = proxyClassName;
        if (useStaticNames)  // todo: refine
        {
            return proxyClassName + 0;
        }

        for (int i = 0; i < MAX_CLASSLOAD_TRIES; i++)
        {
            try
            {
                finalName = proxyClassName + i;
                Class.forName(finalName, true, classLoader);
            }
            catch (ClassNotFoundException cnfe)
            {
                // this is exactly what we need!
                return finalName;
            }
            // otherwise we continue ;)
        }

        throw new WebBeansException("Unable to detect a free proxy class name based on: " + proxyClassName);
    }

    protected String getUnusedProxyClassName(ClassLoader classLoader, String proxyClassName,
                                             Method[] proxiedMethods, Method[] notProxiedMethods)
    {
        proxyClassName = fixPreservedPackages(proxyClassName);


        if (useStaticNames)
        {
            return proxyClassName + uniqueHash(proxiedMethods, notProxiedMethods);
        }
        return getUnusedProxyClassName(classLoader, proxyClassName);
    }

    protected String uniqueHash(Method[] proxiedMethods, Method[] notProxiedMethods)
    {
        if (useXXhash64)
        {
            // xxhash64 has very low collision so for this kind of has it is safe enough
            // and enables to avoid a big concatenation for names
            return Long.toString(Math.abs(XxHash64.apply(Stream.concat(
                    proxiedMethods == null ? Stream.empty() :
                            Stream.of(proxiedMethods).map(Method::toGenericString).sorted(),
                    notProxiedMethods == null ? Stream.empty() :
                            Stream.of(notProxiedMethods)
                                    .filter(it -> it.getDeclaringClass() != Object.class)
                                    .map(Method::toGenericString)
                                    .map(it -> "<NOT>" + it).sorted()
            ).collect(joining("_")))));
        }
        // else unsafe - 1 proxy per class max!
        return "0";
    }

    protected  <T> String getSignedClassProxyName(final Class<T> classToProxy)
    {
        // avoid java.lang.SecurityException: class's signer information
        // does not match signer information of other classes in the same package
        return "org.apache.webbeans.custom.signed." + classToProxy.getName();
    }

    protected String fixPreservedPackages(String proxyClassName)
    {
        proxyClassName = fixPreservedPackage(proxyClassName, "java.");
        proxyClassName = fixPreservedPackage(proxyClassName, "javax.");
        proxyClassName = fixPreservedPackage(proxyClassName, "sun.misc.");

        return proxyClassName;
    }
    /**
     * Detect if the provided className is in the forbidden package.
     * If so, move it to org.apache.webbeans.custom.
     * @param forbiddenPackagePrefix including the '.', e.g. 'javax.'
     */
    private String fixPreservedPackage(String className, String forbiddenPackagePrefix)
    {
        String fixedClassName = className;

        if (className.startsWith(forbiddenPackagePrefix))
        {
            fixedClassName = "org.apache.webbeans.custom." + className.substring(forbiddenPackagePrefix.length());
        }

        return fixedClassName;
    }

    protected <T> Class<T> createProxyClass(ClassLoader classLoader, String proxyClassName, Class<T> classToProxy,
                                            Method[] interceptedMethods, Method[] nonInterceptedMethods)
            throws ProxyGenerationException
    {
        return createProxyClass(classLoader, proxyClassName, classToProxy, interceptedMethods, nonInterceptedMethods, null);
    }

    /**
     * @param classLoader to use for creating the class in
     * @param classToProxy the class for which a subclass will get generated
     * @param interceptedMethods the list of intercepted or decorated business methods.
     * @param nonInterceptedMethods all methods which are <b>not</b> intercepted nor decorated and shall get delegated directly
     * @param <T>
     * @return the proxy class
     */
    protected <T> Class<T> createProxyClass(ClassLoader classLoader, String proxyClassName, Class<T> classToProxy,
                                                      Method[] interceptedMethods, Method[] nonInterceptedMethods,
                                                      Constructor<T> constructor)
            throws ProxyGenerationException
    {
        String proxyClassFileName = proxyClassName.replace('.', '/');

        byte[] proxyBytes = generateProxy(classLoader,
                classToProxy,
                proxyClassName,
                proxyClassFileName,
                sortOutDuplicateMethods(interceptedMethods),
                sortOutDuplicateMethods(nonInterceptedMethods),
                constructor);

        if (definingService != null)
        {
            return definingService.defineAndLoad(proxyClassName, proxyBytes, classToProxy);
        }
        return unsafe.defineAndLoadClass(classLoader, proxyClassName, proxyBytes);
    }

    protected  <T> T newInstance(final Class<? extends T> proxyClass)
    {
        if (unsafe != null)
        {
            return unsafe.unsafeNewInstance(proxyClass);
        }
        else
        {
            try
            {
                return proxyClass.getConstructor().newInstance();
            }
            catch (final Exception e)
            {
                throw new IllegalStateException("Failed to allocateInstance of Proxy class " + proxyClass.getName(), e);
            }
        }
    }

    private Method[] sortOutDuplicateMethods(Method[] methods)
    {
        if (methods == null || methods.length == 0)
        {
            return null;
        }
        
        ArrayList<Method> duplicates = new ArrayList<>();
        
        for (Method outer : methods)
        {
            for (Method inner : methods)
            {
                if (inner != outer
                        && hasSameSignature(outer, inner)
                        && !(duplicates.contains(outer) || duplicates.contains(inner)))
                {
                    duplicates.add(inner);
                }
            }
        }

        ArrayList<Method> outsorted = new ArrayList<>(Arrays.asList(methods));
        outsorted.removeAll(duplicates);
        return outsorted.toArray(new Method[outsorted.size()]);
    }
     
    private boolean hasSameSignature(Method a, Method b)
    {
        return a.getName().equals(b.getName())
                && a.getReturnType().equals(b.getReturnType())
                && Arrays.equals(a.getParameterTypes(), b.getParameterTypes());
    }
     
    private byte[] generateProxy(ClassLoader classLoader, Class<?> classToProxy, String proxyClassName, String proxyClassFileName,
                                 Method[] interceptedMethods, Method[] nonInterceptedMethods, Constructor<?> constructor)
            throws ProxyGenerationException
    {
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        String classFileName = classToProxy.getName().replace('.', '/');

        String[] interfaceNames = {Type.getInternalName(getMarkerInterface())};
        String superClassName = classFileName;

        if (classToProxy.isInterface())
        {
            interfaceNames = new String[]{Type.getInternalName(classToProxy), interfaceNames[0]};
            superClassName = Type.getInternalName(Object.class);
        }

        cw.visit(findJavaVersion(classToProxy), Opcodes.ACC_PUBLIC + Opcodes.ACC_SUPER + Opcodes.ACC_SYNTHETIC, proxyClassFileName, null, superClassName, interfaceNames);
        cw.visitSource(classFileName + ".java", null);

        // proxies are already available in a native environment when scanning is enabled -> lets skip proxies
        cw.visitAnnotation(Type.getDescriptor(Vetoed.class), true).visitEnd();

        createInstanceVariables(cw, classToProxy, classFileName);
        createSerialisation(cw, proxyClassFileName, classToProxy, classFileName);



        // create a static String Field which contains the passivationId of the Bean or null if not PassivationCapable
        cw.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC,
                FIELD_BEAN_PASSIVATION_ID, Type.getDescriptor(String.class), null, null).visitEnd();

        createConstructor(cw, proxyClassFileName, classToProxy, classFileName, constructor);


        if (nonInterceptedMethods != null)
        {
            delegateNonInterceptedMethods(classLoader, cw, proxyClassFileName, classToProxy, nonInterceptedMethods);
        }

        if (interceptedMethods != null)
        {
            delegateInterceptedMethods(classLoader, cw, proxyClassFileName, classToProxy, interceptedMethods);
        }

        return cw.toByteArray();
    }

    private int findJavaVersion(final Class<?> from)
    {
        final String resource = from.getName().replace('.', '/') + ".class";
        try (final InputStream stream = from.getClassLoader().getResourceAsStream(resource))
        {
            if (stream == null)
            {
                return javaVersion;
            }
            final ClassReader reader = new ClassReader(stream);
            final VersionVisitor visitor = new VersionVisitor();
            reader.accept(visitor, SKIP_DEBUG + SKIP_CODE + SKIP_FRAMES);
            if (visitor.version != 0)
            {
                return visitor.version;
            }
        }
        catch (final Exception e)
        {
            // no-op
        }
        // mainly for JVM classes - outside the classloader, find to fallback on the JVM version
        return javaVersion;
    }



    protected boolean unproxyableMethod(Method delegatedMethod)
    {
        int modifiers = delegatedMethod.getModifiers();

        return (modifiers & (Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL | Modifier.NATIVE)) > 0 ||
               "finalize".equals(delegatedMethod.getName()) || delegatedMethod.isBridge();
    }

    /**
     * @return the wrapper type for a primitive, e.g. java.lang.Integer for int
     */
    protected String getWrapperType(Class<?> type)
    {
        if (Integer.TYPE.equals(type))
        {
            return Integer.class.getCanonicalName().replace('.', '/');
        }
        else if (Boolean.TYPE.equals(type))
        {
            return Boolean.class.getCanonicalName().replace('.', '/');
        }
        else if (Character.TYPE.equals(type))
        {
            return Character.class.getCanonicalName().replace('.', '/');
        }
        else if (Byte.TYPE.equals(type))
        {
            return Byte.class.getCanonicalName().replace('.', '/');
        }
        else if (Short.TYPE.equals(type))
        {
            return Short.class.getCanonicalName().replace('.', '/');
        }
        else if (Float.TYPE.equals(type))
        {
            return Float.class.getCanonicalName().replace('.', '/');
        }
        else if (Long.TYPE.equals(type))
        {
            return Long.class.getCanonicalName().replace('.', '/');
        }
        else if (Double.TYPE.equals(type))
        {
            return Double.class.getCanonicalName().replace('.', '/');
        }
        else if (Void.TYPE.equals(type))
        {
            return Void.class.getCanonicalName().replace('.', '/');
        }

        throw new IllegalStateException("Type: " + type.getCanonicalName() + " is not a primitive type");
    }

    /**
     * Returns the appropriate bytecode instruction to load a value from a variable to the stack
     *
     * @param type Type to load
     * @return Bytecode instruction to use
     */
    protected int getVarInsn(Class<?> type)
    {
        if (type.isPrimitive())
        {
            if (Integer.TYPE.equals(type))
            {
                return Opcodes.ILOAD;
            }
            else if (Boolean.TYPE.equals(type))
            {
                return Opcodes.ILOAD;
            }
            else if (Character.TYPE.equals(type))
            {
                return Opcodes.ILOAD;
            }
            else if (Byte.TYPE.equals(type))
            {
                return Opcodes.ILOAD;
            }
            else if (Short.TYPE.equals(type))
            {
                return Opcodes.ILOAD;
            }
            else if (Float.TYPE.equals(type))
            {
                return Opcodes.FLOAD;
            }
            else if (Long.TYPE.equals(type))
            {
                return Opcodes.LLOAD;
            }
            else if (Double.TYPE.equals(type))
            {
                return Opcodes.DLOAD;
            }
        }

        throw new IllegalStateException("Type: " + type.getCanonicalName() + " is not a primitive type");
    }

    /**
     * Invokes the most appropriate bytecode instruction to put a number on the stack
     *
     * @param mv
     * @param i
     */
    protected void pushIntOntoStack(MethodVisitor mv, int i)
    {
        if (i == 0)
        {
            mv.visitInsn(Opcodes.ICONST_0);
        }
        else if (i == 1)
        {
            mv.visitInsn(Opcodes.ICONST_1);
        }
        else if (i == 2)
        {
            mv.visitInsn(Opcodes.ICONST_2);
        }
        else if (i == 3)
        {
            mv.visitInsn(Opcodes.ICONST_3);
        }
        else if (i == 4)
        {
            mv.visitInsn(Opcodes.ICONST_4);
        }
        else if (i == 5)
        {
            mv.visitInsn(Opcodes.ICONST_5);
        }
        else if (i > 5 && i <= 255)
        {
            mv.visitIntInsn(Opcodes.BIPUSH, i);
        }
        else
        {
            mv.visitIntInsn(Opcodes.SIPUSH, i);
        }
    }

    /**
     * Gets the appropriate bytecode instruction for RETURN, according to what type we need to return
     *
     * @param type Type the needs to be returned
     * @return The matching bytecode instruction
     */
    protected int getReturnInsn(Class<?> type)
    {
        if (type.isPrimitive())
        {
            if (Void.TYPE.equals(type))
            {
                return Opcodes.RETURN;
            }
            if (Integer.TYPE.equals(type))
            {
                return Opcodes.IRETURN;
            }
            else if (Boolean.TYPE.equals(type))
            {
                return Opcodes.IRETURN;
            }
            else if (Character.TYPE.equals(type))
            {
                return Opcodes.IRETURN;
            }
            else if (Byte.TYPE.equals(type))
            {
                return Opcodes.IRETURN;
            }
            else if (Short.TYPE.equals(type))
            {
                return Opcodes.IRETURN;
            }
            else if (Float.TYPE.equals(type))
            {
                return Opcodes.FRETURN;
            }
            else if (Long.TYPE.equals(type))
            {
                return Opcodes.LRETURN;
            }
            else if (Double.TYPE.equals(type))
            {
                return Opcodes.DRETURN;
            }
        }

        return Opcodes.ARETURN;
    }

    /**
     * Gets the string to use for CHECKCAST instruction, returning the correct value for any type, including primitives and arrays
     *
     * @param returnType The type to cast to with CHECKCAST
     * @return CHECKCAST parameter
     */
    protected String getCastType(Class<?> returnType)
    {
        if (returnType.isPrimitive())
        {
            return getWrapperType(returnType);
        }
        else
        {
            return Type.getInternalName(returnType);
        }
    }

    /**
     * Returns the name of the Java method to call to get the primitive value from an Object - e.g. intValue for java.lang.Integer
     *
     * @param type Type whose primitive method we want to lookup
     * @return The name of the method to use
     */
    protected String getPrimitiveMethod(Class<?> type)
    {
        if (Integer.TYPE.equals(type))
        {
            return "intValue";
        }
        else if (Boolean.TYPE.equals(type))
        {
            return "booleanValue";
        }
        else if (Character.TYPE.equals(type))
        {
            return "charValue";
        }
        else if (Byte.TYPE.equals(type))
        {
            return "byteValue";
        }
        else if (Short.TYPE.equals(type))
        {
            return "shortValue";
        }
        else if (Float.TYPE.equals(type))
        {
            return "floatValue";
        }
        else if (Long.TYPE.equals(type))
        {
            return "longValue";
        }
        else if (Double.TYPE.equals(type))
        {
            return "doubleValue";
        }

        throw new IllegalStateException("Type: " + type.getCanonicalName() + " is not a primitive type");
    }

    protected void generateReturn(MethodVisitor mv, Method delegatedMethod)
    {
        Class<?> returnType = delegatedMethod.getReturnType();
        mv.visitInsn(getReturnInsn(returnType));
    }

    /**
     * Create an Object[] parameter which contains all the parameters of the currently invoked method
     * and store this array for use in the call stack.
     * @param mv
     * @param parameterTypes
     */
    protected void pushMethodParameterArray(MethodVisitor mv, Class<?>[] parameterTypes)
    {
        // need to construct the array of objects passed in
        // create the Object[]
        createArrayDefinition(mv, parameterTypes.length, Object.class);

        int index = 1;
        // push parameters into array
        for (int i = 0; i < parameterTypes.length; i++)
        {
            // keep copy of array on stack
            mv.visitInsn(Opcodes.DUP);

            Class<?> parameterType = parameterTypes[i];

            // push number onto stack
            pushIntOntoStack(mv, i);

            if (parameterType.isPrimitive())
            {
                String wrapperType = getWrapperType(parameterType);
                mv.visitVarInsn(getVarInsn(parameterType), index);

                mv.visitMethodInsn(Opcodes.INVOKESTATIC, wrapperType, "valueOf",
                        "(" + Type.getDescriptor(parameterType) + ")L" + wrapperType + ";", false);
                mv.visitInsn(Opcodes.AASTORE);

                if (Long.TYPE.equals(parameterType) || Double.TYPE.equals(parameterType))
                {
                    index += 2;
                }
                else
                {
                    index++;
                }
            }
            else
            {
                mv.visitVarInsn(Opcodes.ALOAD, index);
                mv.visitInsn(Opcodes.AASTORE);
                index++;
            }
        }
    }

    /**
     * pushes an array of the specified size to the method visitor. The generated bytecode will leave
     * the new array at the top of the stack.
     *
     * @param mv   MethodVisitor to use
     * @param size Size of the array to create
     * @param type Type of array to create
     * @throws ProxyGenerationException
     */
    protected void createArrayDefinition(MethodVisitor mv, int size, Class<?> type)
            throws ProxyGenerationException
    {
        // create a new array of java.lang.class (2)

        if (size < 0)
        {
            throw new ProxyGenerationException("Array size cannot be less than zero");
        }

        pushIntOntoStack(mv, size);

        mv.visitTypeInsn(Opcodes.ANEWARRAY, type.getCanonicalName().replace('.', '/'));
    }


    private static class VersionVisitor extends EmptyVisitor
    {
        private int version;

        @Override
        public void visit(final int version, final int access, final String name,
                          final String signature, final String superName, final String[] interfaces)
        {
            this.version = version;
        }
    }
}
