/**
 *
 * 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.openejb.client;

import javax.naming.Context;
import javax.naming.NamingException;
import java.beans.PropertyEditor;
import java.beans.PropertyEditorManager;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

public class ClientInjectionProcessor<T> {

    private static final Logger logger = Logger.getLogger("OpenEJB.client");

    private final Class<? extends T> beanClass;
    private final ClassLoader classLoader;
    private final List<Injection> injections;
    private final List<CallbackMetaData> postConstructCallbacks;
    private final List<CallbackMetaData> preDestroyCallbacks;
    private final Context context;
    private T instance;
    private boolean allowStatic;

    public ClientInjectionProcessor(final Class<? extends T> beanClass,
                                    final List<Injection> injections,
                                    final List<CallbackMetaData> postConstructMethods,
                                    final List<CallbackMetaData> preDestroyMethods,
                                    final Context context) {
        this.beanClass = beanClass;
        classLoader = beanClass.getClassLoader();
        this.injections = injections;
        this.postConstructCallbacks = postConstructMethods;
        this.preDestroyCallbacks = preDestroyMethods;
        this.context = context;
    }

    public void allowStatic() {
        allowStatic = true;
    }

    public T createInstance() throws Exception {
        if (instance == null) {
            construct();
        }
        return instance;
    }

    public T getInstance() {
        return instance;
    }

    private void construct() {
        final Map<Injection, Object> values = new HashMap<>();
        for (final Injection injection : injections) {
            // only process injections for this class
            final Class<?> targetClass = loadClass(injection.getTargetClass());
            if (targetClass == null) {
                continue;
            }
            if (!targetClass.isAssignableFrom(beanClass)) {
                continue;
            }

            try {
                final String jndiName = injection.getJndiName();
                final Object object = context.lookup("java:comp/env/" + jndiName);
                values.put(injection, object);
            } catch (NamingException e) {
                logger.warning("Injection data not found in JNDI context: jndiName='" +
                    injection.getJndiName() +
                    "', target=" +
                    injection.getTargetClass() +
                    "/" +
                    injection.getName());
            }
        }

        try {
            instance = beanClass.newInstance();
        } catch (Exception e) {
            throw new IllegalStateException("Error while creating bean " + beanClass.getName(), e);
        }

        final List<String> unsetProperties = new ArrayList<>();
        for (final Map.Entry<Injection, Object> entry : values.entrySet()) {
            final Injection injection = entry.getKey();
            final Object value = entry.getValue();

            final Class<?> targetClass = loadClass(injection.getTargetClass());
            if (targetClass == null || !targetClass.isAssignableFrom(beanClass)) {
                continue;
            }

            if (!setProperty(targetClass, injection.getName(), value)) {
                unsetProperties.add(injection.getName());
            }
        }

        if (unsetProperties.size() > 0) {
            for (final Object property : unsetProperties) {
                logger.warning("Injection: Unable to set property '" + property + "' in class " + beanClass.getName());
            }
        }
    }

    public void postConstruct() throws Exception {
        if (instance == null) {
            throw new IllegalStateException("Instance has not been constructed");
        }
        if (postConstructCallbacks == null) {
            return;
        }

        for (final Method postConstruct : toMethod(postConstructCallbacks)) {
            try {
                postConstruct.invoke(instance);
            } catch (Exception e) {
                e = unwrap(e);
                throw new Exception("Error while calling post construct method", e);
            }
        }
    }

    public void preDestroy() {
        if (instance == null) {
            return;
        }
        if (preDestroyCallbacks == null) {
            return;
        }
        for (final Method preDestroy : toMethod(preDestroyCallbacks)) {
            try {
                preDestroy.invoke(instance);
            } catch (Exception e) {
                e = unwrap(e);
                logger.log(Level.SEVERE, "Error while calling pre destroy method", e);
            }
        }
    }

    private List<Method> toMethod(final List<CallbackMetaData> callbacks) {
        final List<String> methodsNotFound = new ArrayList<>(1);
        final List<Method> methods = new ArrayList<>(callbacks.size());
        for (final CallbackMetaData callback : callbacks) {
            final Method method = toMethod(callback);
            if (method != null) {
                methods.add(method);
            } else {
                methodsNotFound.add(callback.toString());
            }
        }
        if (!methodsNotFound.isEmpty()) {
            throw new IllegalStateException("Callback methods not found " + methodsNotFound);
        }
        return methods;
    }

    private Method toMethod(final CallbackMetaData callback) {
        try {
            final String className = callback.getClassName();
            final Class<?> clazz = classLoader.loadClass(className);
            return clazz.getDeclaredMethod(callback.getMethod());
        } catch (Exception e) {
            return null;
        }
    }

    private boolean setProperty(final Class clazz, final String name, Object propertyValue) {
        final Method method = findSetter(clazz, name, propertyValue);
        if (method != null) {
            try {
                propertyValue = convert(method.getParameterTypes()[0], propertyValue);
                method.invoke(instance, propertyValue);
                return true;
            } catch (Exception e) {
                return false;
            }
        }

        final Field field = findField(clazz, name, propertyValue);
        if (field != null) {
            try {
                propertyValue = convert(field.getType(), propertyValue);
                field.set(instance, propertyValue);
                return true;
            } catch (Exception e) {
                return false;
            }
        }

        return false;
    }

    public Method findSetter(final Class typeClass, final String propertyName, final Object propertyValue) {
        if (propertyName == null) {
            throw new NullPointerException("name is null");
        }
        if (propertyName.length() == 0) {
            throw new IllegalArgumentException("name is an empty string");
        }

        String setterName = "set" + Character.toUpperCase(propertyName.charAt(0));
        if (propertyName.length() > 0) {
            setterName += propertyName.substring(1);
        }

        final List<Method> methods = new ArrayList<>(Arrays.asList(typeClass.getMethods()));
        methods.addAll(Arrays.asList(typeClass.getDeclaredMethods()));
        for (final Method method : methods) {
            if (method.getName().equals(setterName)) {
                if (method.getParameterTypes().length == 0) {
                    continue;
                }

                if (method.getParameterTypes().length > 1) {
                    continue;
                }

                if (method.getReturnType() != Void.TYPE) {
                    continue;
                }

                if (Modifier.isAbstract(method.getModifiers())) {
                    continue;
                }

                if (!allowStatic && Modifier.isStatic(method.getModifiers())) {
                    continue;
                }

                final Class methodParameterType = method.getParameterTypes()[0];
                if (methodParameterType.isPrimitive() && propertyValue == null) {
                    continue;
                }

                if (!isInstance(methodParameterType, propertyValue) && !isConvertable(methodParameterType, propertyValue)) {
                    continue;
                }

                if (!Modifier.isPublic(method.getModifiers())) {
                    setAccessible(method);
                }

                return method;
            }

        }
        return null;
    }

    public Field findField(final Class typeClass, final String propertyName, final Object propertyValue) {
        if (propertyName == null) {
            throw new NullPointerException("name is null");
        }
        if (propertyName.length() == 0) {
            throw new IllegalArgumentException("name is an empty string");
        }

        final List<Field> fields = new ArrayList<>(Arrays.asList(typeClass.getDeclaredFields()));
        Class parent = typeClass.getSuperclass();
        while (parent != null) {
            fields.addAll(Arrays.asList(parent.getDeclaredFields()));
            parent = parent.getSuperclass();
        }

        for (final Field field : fields) {
            if (field.getName().equals(propertyName)) {

                if (!allowStatic && Modifier.isStatic(field.getModifiers())) {
                    continue;
                }

                final Class fieldType = field.getType();
                if (fieldType.isPrimitive() && propertyValue == null) {
                    continue;
                }

                if (!isInstance(fieldType, propertyValue) && !isConvertable(fieldType, propertyValue)) {
                    continue;
                }

                if (!Modifier.isPublic(field.getModifiers())) {
                    setAccessible(field);
                }

                return field;
            }

        }
        return null;
    }

    private static void setAccessible(final AccessibleObject accessibleObject) {
        AccessController.doPrivileged(new PrivilegedAction<Object>() {
            @Override
            public Object run() {
                accessibleObject.setAccessible(true);
                return null;
            }
        });
    }

    private static boolean isInstance(final Class type, final Object instance) {
        if (type.isPrimitive()) {
            // for primitives the instance can't be null
            if (instance == null) {
                return false;
            }

            // verify instance is the correct wrapper type
            if (type.equals(boolean.class)) {
                return instance instanceof Boolean;
            } else if (type.equals(char.class)) {
                return instance instanceof Character;
            } else if (type.equals(byte.class)) {
                return instance instanceof Byte;
            } else if (type.equals(short.class)) {
                return instance instanceof Short;
            } else if (type.equals(int.class)) {
                return instance instanceof Integer;
            } else if (type.equals(long.class)) {
                return instance instanceof Long;
            } else if (type.equals(float.class)) {
                return instance instanceof Float;
            } else if (type.equals(double.class)) {
                return instance instanceof Double;
            } else {
                throw new AssertionError("Invalid primitive type: " + type);
            }
        }

        return instance == null || type.isInstance(instance);
    }

    private static boolean isConvertable(final Class type, final Object propertyValue) {
        return (propertyValue instanceof String && findEditor(type) != null);
    }

    private Object convert(final Class type, Object value) {
        if (type == Object.class || !(value instanceof String stringValue)) {
            return value;
        }

        final PropertyEditor editor = findEditor(type);
        if (editor != null) {
            editor.setAsText(stringValue);
            value = editor.getValue();
        }
        return value;
    }

    /**
     * Locate a property editor for qiven class of object.
     *
     * @param type The target object class of the property.
     * @return The resolved editor, if any.  Returns null if a suitable editor
     * could not be located.
     */
    private static PropertyEditor findEditor(final Class type) {
        if (type == null) {
            throw new NullPointerException("type is null");
        }

        // try to locate this directly from the editor manager first.
        final PropertyEditor editor = PropertyEditorManager.findEditor(type);

        // we're outta here if we got one.
        if (editor != null) {
            return editor;
        }

        // nothing found
        return null;
    }

    private Class<?> loadClass(final String targetClass) {
        try {
            return classLoader.loadClass(targetClass);
        } catch (ClassNotFoundException e) {
            return null;
        }
    }

    private static Exception unwrap(Exception e) {
        if (e instanceof InvocationTargetException && e.getCause() instanceof Exception) {
            e = (Exception) e.getCause();
        }
        return e;
    }
}
