/*
 * Copyright (c) 2020. Red Hat, Inc. and/or its affiliates.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 *
 *       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.drools.mvel.asm;

import java.lang.reflect.Method;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.ProtectionDomain;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Map;

import org.drools.core.base.BaseClassFieldReader;
import org.drools.core.base.BaseClassFieldWriter;
import org.drools.core.base.ClassFieldAccessorCache.CacheEntry;
import org.drools.core.base.ClassFieldInspector;
import org.drools.core.base.FieldAccessorFactory;
import org.drools.core.base.ValueType;
import org.drools.core.base.extractors.BaseObjectClassFieldReader;
import org.drools.core.base.extractors.SelfReferenceClassFieldReader;
import org.drools.core.common.InternalWorkingMemory;
import org.drools.core.common.ReteEvaluator;
import org.drools.core.util.Drools;
import org.drools.mvel.accessors.BaseBooleanClassFieldReader;
import org.drools.mvel.accessors.BaseBooleanClassFieldWriter;
import org.drools.mvel.accessors.BaseByteClassFieldReader;
import org.drools.mvel.accessors.BaseByteClassFieldWriter;
import org.drools.mvel.accessors.BaseCharClassFieldReader;
import org.drools.mvel.accessors.BaseCharClassFieldWriter;
import org.drools.mvel.accessors.BaseDateClassFieldReader;
import org.drools.mvel.accessors.BaseDoubleClassFieldReader;
import org.drools.mvel.accessors.BaseDoubleClassFieldWriter;
import org.drools.mvel.accessors.BaseFloatClassFieldReader;
import org.drools.mvel.accessors.BaseFloatClassFieldWriter;
import org.drools.mvel.accessors.BaseIntClassFieldReader;
import org.drools.mvel.accessors.BaseIntClassFieldWriter;
import org.drools.mvel.accessors.BaseLocalDateClassFieldReader;
import org.drools.mvel.accessors.BaseLocalDateTimeClassFieldReader;
import org.drools.mvel.accessors.BaseLongClassFieldReader;
import org.drools.mvel.accessors.BaseLongClassFieldWriter;
import org.drools.mvel.accessors.BaseNumberClassFieldReader;
import org.drools.mvel.accessors.BaseObjectClassFieldWriter;
import org.drools.mvel.accessors.BaseShortClassFieldReader;
import org.drools.mvel.accessors.BaseShortClassFieldWriter;
import org.drools.mvel.accessors.BaseZonedDateTimeClassFieldReader;
import org.drools.wiring.api.util.ByteArrayClassLoader;
import org.mvel2.asm.ClassWriter;
import org.mvel2.asm.Label;
import org.mvel2.asm.MethodVisitor;
import org.mvel2.asm.Opcodes;
import org.mvel2.asm.Type;

import static org.drools.mvel.accessors.ClassFieldAccessorStore.getClassFieldInspector;
import static org.drools.mvel.asm.ClassGenerator.createClassWriter;

/**
 * This generates subclasses of BaseClassFieldExtractor to provide field extractors.
 * This should not be used directly, but via ClassFieldExtractor (which ensures that it is 
 * all nicely serializable).
 */

public class ClassFieldAccessorFactory implements FieldAccessorFactory {

    private static final String                        BASE_PACKAGE         = "org/drools/base";

    private static final String                        SELF_REFERENCE_FIELD = "this";

    private static final ProtectionDomain              PROTECTION_DOMAIN;

    static {
        PROTECTION_DOMAIN = Drools.isNativeImage() ? null : AccessController.doPrivileged((PrivilegedAction<ProtectionDomain>) ClassFieldAccessorFactory.class::getProtectionDomain);
    }

    @Override
    public BaseClassFieldReader getClassFieldReader( Class< ? > clazz, String fieldName, CacheEntry cache) {
        try {
            // if it is a self reference
            if ( SELF_REFERENCE_FIELD.equals( fieldName ) ) {
                // then just create an instance of the special class field extractor
                return new SelfReferenceClassFieldReader( clazz );
            } else {
                // otherwise, bytecode generate a specific extractor
                ClassFieldInspector inspector = getClassFieldInspector( clazz, cache );
                Method getterMethod = inspector.getGetterMethods().get( fieldName );
                Integer index = inspector.getFieldNames().get( fieldName );

                Class< ? > fieldType = inspector.getFieldType( fieldName );
                if ( fieldType == null && fieldName.length() > 1 && Character.isLowerCase( fieldName.charAt( 0 ) ) && Character.isUpperCase( fieldName.charAt(1) ) ) {
                    // it might be that odd case of javabeans naming conventions that does not use lower case first letters if the second is uppercase
                    String altFieldName = Character.toUpperCase( fieldName.charAt( 0 ) ) + fieldName.substring( 1 );
                    fieldType = inspector.getFieldType( altFieldName );
                    if( fieldType != null ) {
                        // it seems it is the corner case indeed.
                        getterMethod = inspector.getGetterMethods().get( altFieldName );
                        index = inspector.getFieldNames().get( altFieldName );
                    }
                }
                if ( fieldType != null && getterMethod != null ) {
                    final String className = ClassFieldAccessorFactory.BASE_PACKAGE + "/" + Type.getInternalName( clazz ) + Math.abs( (long) System.identityHashCode( clazz ) ) + "$" + getterMethod.getName();

                    // generating byte array to create target class
                    final byte[] bytes = dumpReader( clazz,
                                                     className,
                                                     getterMethod,
                                                     fieldType );
                    // use bytes to get a class 
                    ByteArrayClassLoader byteArrayClassLoader = cache.getByteArrayClassLoader();
                    final Class< ? > newClass = byteArrayClassLoader.defineClass( className.replace( '/',
                                                                                                     '.' ),
                                                                                  bytes,
                                                                                  PROTECTION_DOMAIN );
                    // instantiating target class
                    final ValueType valueType = ValueType.determineValueType( fieldType );
                    final Object[] params = {index, fieldType, valueType};
                    return (BaseClassFieldReader) newClass.getConstructors()[0].newInstance( params );
                } else {
                    // must be a public field
                    return null;
                }
            }
        } catch ( final RuntimeException e ) {
            throw e;
        } catch ( final Exception e ) {
            throw new RuntimeException( e );
        }
    }

    @Override
    public BaseClassFieldWriter getClassFieldWriter( Class< ? > clazz, String fieldName, CacheEntry cache) {
        ByteArrayClassLoader byteArrayClassLoader = cache.getByteArrayClassLoader();
        Map<Class< ? >, ClassFieldInspector> inspectors = cache.getInspectors();
        
        try {
            // otherwise, bytecode generate a specific extractor
            ClassFieldInspector inspector = inspectors.get( clazz );
            if ( inspector == null ) {
                inspector = new ClassFieldInspectorImpl( clazz );
                inspectors.put( clazz,
                                inspector );
            }
            Method setterMethod = inspector.getSetterMethods().get( fieldName );
            Integer index = inspector.getFieldNames().get( fieldName );
            if ( setterMethod == null && fieldName.length() > 1 && Character.isLowerCase( fieldName.charAt( 0 ) ) && Character.isUpperCase( fieldName.charAt(1) ) ) {
                // it might be that odd case of javabeans naming conventions that does not use lower case first letters if the second is uppercase
                String altFieldName = Character.toUpperCase( fieldName.charAt( 0 ) ) + fieldName.substring( 1 );
                setterMethod = inspector.getSetterMethods().get( altFieldName );
                index = inspector.getFieldNames().get( altFieldName );
            }
            if ( setterMethod != null ) {
                final Class< ? > fieldType = setterMethod.getParameterTypes()[0];
                final String className = ClassFieldAccessorFactory.BASE_PACKAGE + "/" + Type.getInternalName( clazz ) + Math.abs( (long) System.identityHashCode( clazz ) ) + "$" + setterMethod.getName();

                // generating byte array to create target class
                final byte[] bytes = dumpWriter( clazz,
                                                 className,
                                                 setterMethod,
                                                 fieldType );
                // use bytes to get a class 

                final Class< ? > newClass = byteArrayClassLoader.defineClass( className.replace( '/',
                                                                                                 '.' ),
                                                                              bytes,
                                                                              PROTECTION_DOMAIN );
                // instantiating target class
                final ValueType valueType = ValueType.determineValueType( fieldType );
                final Object[] params = {index, fieldType, valueType};
                return (BaseClassFieldWriter) newClass.getConstructors()[0].newInstance( params );
            } else {
                if ( inspector.getFieldNames().containsKey( fieldName ) ) {
                    return null;
                } else {
                    throw new RuntimeException( "Field/method '" + fieldName + "' not found for class '" + clazz.getName() + "'" );
                }
            }
        } catch ( final RuntimeException e ) {
            throw e;
        } catch ( final Exception e ) {
            throw new RuntimeException( e );
        }
    }

    private static byte[] dumpReader(final Class< ? > originalClass,
                                     final String className,
                                     final Method getterMethod,
                                     final Class< ? > fieldType) throws Exception {

        final Class< ? > superClass = getReaderSuperClassFor( fieldType );
        final ClassWriter cw = buildClassHeader( superClass, className );

        build3ArgConstructor( superClass,
                              className,
                              cw );

        buildGetMethod( originalClass,
                        className,
                        superClass,
                        getterMethod,
                        cw );

        cw.visitEnd();

        return cw.toByteArray();
    }

    private static byte[] dumpWriter(final Class< ? > originalClass,
                                     final String className,
                                     final Method getterMethod,
                                     final Class< ? > fieldType) throws Exception {


        final Class< ? > superClass = getWriterSuperClassFor( fieldType );
        final ClassWriter cw = buildClassHeader( superClass, className );

        build3ArgConstructor( superClass,
                              className,
                              cw );

        buildSetMethod( originalClass,
                        className,
                        superClass,
                        getterMethod,
                        fieldType,
                        cw );

        cw.visitEnd();

        return cw.toByteArray();
    }

    /**
     * Builds the class header
     */
    protected static ClassWriter buildClassHeader(Class< ? > superClass, String className) {

        ClassWriter cw = createClassWriter( superClass.getClassLoader(),
                                            Opcodes.ACC_PUBLIC + Opcodes.ACC_SUPER,
                                            className,
                                            null,
                                            Type.getInternalName( superClass ),
                                            null );

        cw.visitSource( null,
                        null );

        return cw;
    }

    /**
     * Creates a constructor for the field extractor receiving
     * the index, field type and value type
     */
    private static void build3ArgConstructor(final Class< ? > superClazz,
                                             final String className,
                                             final ClassWriter cw) {
        MethodVisitor mv;

        mv = cw.visitMethod(Opcodes.ACC_PUBLIC,
                            "<init>",
                            Type.getMethodDescriptor(Type.VOID_TYPE,
                                                     Type.getType(int.class), Type.getType(Class.class), Type.getType(ValueType.class)),
                            null,
                            null);
        mv.visitCode();
        final Label l0 = new Label();
        mv.visitLabel(l0);
        mv.visitVarInsn(Opcodes.ALOAD,
                        0);
        mv.visitVarInsn(Opcodes.ILOAD,
                        1);
        mv.visitVarInsn(Opcodes.ALOAD,
                        2);
        mv.visitVarInsn(Opcodes.ALOAD,
                        3);
        mv.visitMethodInsn(Opcodes.INVOKESPECIAL,
                           Type.getInternalName(superClazz),
                           "<init>",
                           Type.getMethodDescriptor(Type.VOID_TYPE,
                                                    Type.getType(int.class), Type.getType(Class.class), Type.getType(ValueType.class)));
        final Label l1 = new Label();
        mv.visitLabel(l1);
        mv.visitInsn(Opcodes.RETURN);
        final Label l2 = new Label();
        mv.visitLabel(l2);
        mv.visitLocalVariable("this",
                              "L" + className + ";",
                              null,
                              l0,
                              l2,
                              0);
        mv.visitLocalVariable("index",
                              Type.getDescriptor(int.class),
                              null,
                              l0,
                              l2,
                              1);
        mv.visitLocalVariable("fieldType",
                              Type.getDescriptor(Class.class),
                              null,
                              l0,
                              l2,
                              2);
        mv.visitLocalVariable("valueType",
                              Type.getDescriptor(ValueType.class),
                              null,
                              l0,
                              l2,
                              3);
        mv.visitMaxs(0,
                     0);
        mv.visitEnd();

    }

    /**
     * Creates the proxy reader method for the given method
     */
    protected static void buildGetMethod(final Class< ? > originalClass,
                                         final String className,
                                         final Class< ? > superClass,
                                         final Method getterMethod,
                                         final ClassWriter cw) {

        final Class< ? > fieldType = getterMethod.getReturnType();
        Method overridingMethod;
        try {
            overridingMethod = superClass.getMethod( getOverridingGetMethodName( fieldType ),
                                                     ReteEvaluator.class, Object.class );
        } catch ( final Exception e ) {
            throw new RuntimeException( "This is a bug. Please report back to JBoss Rules team.",
                                        e );
        }
        final MethodVisitor mv = cw.visitMethod( Opcodes.ACC_PUBLIC,
                                                 overridingMethod.getName(),
                                                 Type.getMethodDescriptor( overridingMethod ),
                                                 null,
                                                 null );

        mv.visitCode();

        final Label l0 = new Label();
        mv.visitLabel( l0 );
        mv.visitVarInsn( Opcodes.ALOAD,
                         2 );
        mv.visitTypeInsn( Opcodes.CHECKCAST,
                          Type.getInternalName( originalClass ) );

        if ( originalClass.isInterface() ) {
            mv.visitMethodInsn( Opcodes.INVOKEINTERFACE,
                                Type.getInternalName( originalClass ),
                                getterMethod.getName(),
                                Type.getMethodDescriptor( getterMethod ),
                                true);
        } else {
            mv.visitMethodInsn( Opcodes.INVOKEVIRTUAL,
                                Type.getInternalName( originalClass ),
                                getterMethod.getName(),
                                Type.getMethodDescriptor( getterMethod ),
                                false);
        }
        mv.visitInsn( Type.getType( fieldType ).getOpcode( Opcodes.IRETURN ) );
        final Label l1 = new Label();
        mv.visitLabel( l1 );
        mv.visitLocalVariable( "this",
                               "L" + className + ";",
                               null,
                               l0,
                               l1,
                               0 );
        mv.visitLocalVariable( "workingMemory",
                               Type.getDescriptor( InternalWorkingMemory.class ),
                               null,
                               l0,
                               l1,
                               1 );
        mv.visitLocalVariable( "object",
                               Type.getDescriptor( Object.class ),
                               null,
                               l0,
                               l1,
                               2 );
        mv.visitMaxs( 0,
                      0 );
        mv.visitEnd();
    }

    /**
     * Creates the set method for the given field definition
     */
    protected static void buildSetMethod(final Class< ? > originalClass,
                                         final String className,
                                         final Class< ? > superClass,
                                         final Method setterMethod,
                                         final Class< ? > fieldType,
                                         final ClassWriter cw) {
        MethodVisitor mv;
        // set method
        Method overridingMethod;
        try {
            overridingMethod = superClass.getMethod( getOverridingSetMethodName( fieldType ),
                                                     Object.class, fieldType.isPrimitive() ? fieldType : Object.class );
        } catch ( final Exception e ) {
            throw new RuntimeException( "This is a bug. Please report back to JBoss Rules team.",
                                        e );
        }

        mv = cw.visitMethod( Opcodes.ACC_PUBLIC,
                             overridingMethod.getName(),
                             Type.getMethodDescriptor( overridingMethod ),
                             null,
                             null );

        mv.visitCode();
        final Label l0 = new Label();
        mv.visitLabel( l0 );

        mv.visitVarInsn( Opcodes.ALOAD,
                         1 );
        mv.visitTypeInsn( Opcodes.CHECKCAST,
                          Type.getInternalName( originalClass ) );

        mv.visitVarInsn( Type.getType( fieldType ).getOpcode( Opcodes.ILOAD ),
                         2 );

        if ( !fieldType.isPrimitive() ) {
            mv.visitTypeInsn( Opcodes.CHECKCAST,
                              Type.getInternalName( fieldType ) );
        }

        if ( originalClass.isInterface() ) {
            mv.visitMethodInsn( Opcodes.INVOKEINTERFACE,
                                Type.getInternalName( originalClass ),
                                setterMethod.getName(),
                                Type.getMethodDescriptor( setterMethod ),
                                true);
        } else {
            mv.visitMethodInsn( Opcodes.INVOKEVIRTUAL,
                                Type.getInternalName( originalClass ),
                                setterMethod.getName(),
                                Type.getMethodDescriptor( setterMethod ),
                                false);
        }

        mv.visitInsn( Opcodes.RETURN );

        final Label l1 = new Label();
        mv.visitLabel( l1 );
        mv.visitLocalVariable( "this",
                               "L" + className + ";",
                               null,
                               l0,
                               l1,
                               0 );
        mv.visitLocalVariable( "bean",
                               Type.getDescriptor( Object.class ),
                               null,
                               l0,
                               l1,
                               1 );
        mv.visitLocalVariable( "value",
                               Type.getDescriptor( fieldType ),
                               null,
                               l0,
                               l1,
                               2 );
        mv.visitMaxs( 0,
                      0 );
        mv.visitEnd();
    }

    private static String getOverridingGetMethodName(final Class< ? > fieldType) {
        String ret = null;
        if ( fieldType.isPrimitive() ) {
            if ( fieldType == char.class ) {
                ret = "getCharValue";
            } else if ( fieldType == byte.class ) {
                ret = "getByteValue";
            } else if ( fieldType == short.class ) {
                ret = "getShortValue";
            } else if ( fieldType == int.class ) {
                ret = "getIntValue";
            } else if ( fieldType == long.class ) {
                ret = "getLongValue";
            } else if ( fieldType == float.class ) {
                ret = "getFloatValue";
            } else if ( fieldType == double.class ) {
                ret = "getDoubleValue";
            } else if ( fieldType == boolean.class ) {
                ret = "getBooleanValue";
            }
        } else {
            ret = "getValue";
        }
        return ret;
    }

    private static String getOverridingSetMethodName(final Class< ? > fieldType) {
        String ret = null;
        if ( fieldType.isPrimitive() ) {
            if ( fieldType == char.class ) {
                ret = "setCharValue";
            } else if ( fieldType == byte.class ) {
                ret = "setByteValue";
            } else if ( fieldType == short.class ) {
                ret = "setShortValue";
            } else if ( fieldType == int.class ) {
                ret = "setIntValue";
            } else if ( fieldType == long.class ) {
                ret = "setLongValue";
            } else if ( fieldType == float.class ) {
                ret = "setFloatValue";
            } else if ( fieldType == double.class ) {
                ret = "setDoubleValue";
            } else if ( fieldType == boolean.class ) {
                ret = "setBooleanValue";
            }
        } else {
            ret = "setValue";
        }
        return ret;
    }

    /**
     * Returns the appropriate Base class field extractor class
     * for the given fieldType
     */
    private static Class< ? > getReaderSuperClassFor(final Class< ? > fieldType) {
        Class< ? > ret = null;
        if ( fieldType.isPrimitive() ) {
            if ( fieldType == char.class ) {
                ret = BaseCharClassFieldReader.class;
            } else if ( fieldType == byte.class ) {
                ret = BaseByteClassFieldReader.class;
            } else if ( fieldType == short.class ) {
                ret = BaseShortClassFieldReader.class;
            } else if ( fieldType == int.class ) {
                ret = BaseIntClassFieldReader.class;
            } else if ( fieldType == long.class ) {
                ret = BaseLongClassFieldReader.class;
            } else if ( fieldType == float.class ) {
                ret = BaseFloatClassFieldReader.class;
            } else if ( fieldType == double.class ) {
                ret = BaseDoubleClassFieldReader.class;
            } else if ( fieldType == boolean.class ) {
                ret = BaseBooleanClassFieldReader.class;
            }
        } else if ( Number.class.isAssignableFrom( fieldType ) ) {
            ret = BaseNumberClassFieldReader.class;
        } else if ( Date.class.isAssignableFrom( fieldType ) ) {
            ret = BaseDateClassFieldReader.class;
        } else if ( LocalDate.class.isAssignableFrom( fieldType ) ) {
            ret = BaseLocalDateClassFieldReader.class;
        } else if ( LocalDateTime.class.isAssignableFrom( fieldType ) ) {
            ret = BaseLocalDateTimeClassFieldReader.class;
        } else if ( ZonedDateTime.class.isAssignableFrom( fieldType ) ) {
            ret = BaseZonedDateTimeClassFieldReader.class;
        } else {
            ret = BaseObjectClassFieldReader.class;
        }
        return ret;
    }

    /**
     * Returns the appropriate Base class field extractor class
     * for the given fieldType
     */
    private static Class< ? > getWriterSuperClassFor(final Class< ? > fieldType) {
        Class< ? > ret = null;
        if ( fieldType.isPrimitive() ) {
            if ( fieldType == char.class ) {
                ret = BaseCharClassFieldWriter.class;
            } else if ( fieldType == byte.class ) {
                ret = BaseByteClassFieldWriter.class;
            } else if ( fieldType == short.class ) {
                ret = BaseShortClassFieldWriter.class;
            } else if ( fieldType == int.class ) {
                ret = BaseIntClassFieldWriter.class;
            } else if ( fieldType == long.class ) {
                ret = BaseLongClassFieldWriter.class;
            } else if ( fieldType == float.class ) {
                ret = BaseFloatClassFieldWriter.class;
            } else if ( fieldType == double.class ) {
                ret = BaseDoubleClassFieldWriter.class;
            } else if ( fieldType == boolean.class ) {
                ret = BaseBooleanClassFieldWriter.class;
            }
        } else {
            ret = BaseObjectClassFieldWriter.class;
        }
        return ret;
    }
}
