package org.skywalking.apm.agent.core.plugin.interceptor.loader;

import org.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceConstructorInterceptor;
import org.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
import org.skywalking.apm.agent.core.plugin.interceptor.enhance.StaticMethodsAroundInterceptor;
import org.skywalking.apm.logging.ILog;
import org.skywalking.apm.logging.LogManager;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.ProtectionDomain;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

/**
 * The <code>InterceptorInstanceLoader</code> is a classes finder and container.
 * <p>
 * This is a very important class in sky-walking's auto-instrumentation mechanism. If you want to fully understand why
 * need this, and how it works, you need have knowledge about Classloader appointment mechanism.
 * <p>
 * The loader will load a class, and focus the target class loader (be intercepted class's classloader) loads it.
 * <p>
 * If the target class and target class loader are same, the loaded classes( {@link InstanceConstructorInterceptor},
 * {@link InstanceMethodsAroundInterceptor} and {@link StaticMethodsAroundInterceptor} implementations) stay in
 * singleton.
 * <p>
 * Created by wusheng on 16/8/2.
 */
public class InterceptorInstanceLoader {
    private static final ILog logger = LogManager.getLogger(InterceptorInstanceLoader.class);

    private static ConcurrentHashMap<String, Object> INSTANCE_CACHE = new ConcurrentHashMap<String, Object>();

    private static ReentrantLock INSTANCE_LOAD_LOCK = new ReentrantLock();

    public static <T> T load(String className, ClassLoader targetClassLoader)
        throws InvocationTargetException, IllegalAccessException, InstantiationException, ClassNotFoundException {
        String instanceKey = className + "_OF_" + targetClassLoader.getClass().getName() + "@" + Integer.toHexString(targetClassLoader.hashCode());
        Object inst = INSTANCE_CACHE.get(instanceKey);
        if (inst == null) {
            if (InterceptorInstanceLoader.class.getClassLoader().equals(targetClassLoader)) {
                inst = targetClassLoader.loadClass(className).newInstance();
            } else {
                INSTANCE_LOAD_LOCK.lock();
                try {
                    try {
                        inst = findLoadedClass(className, targetClassLoader);
                        if (inst == null) {
                            inst = loadBinary(className, targetClassLoader);
                        }
                        if (inst == null) {
                            throw new ClassNotFoundException(targetClassLoader.toString() + " load interceptor class:" + className + " failure.");
                        }
                    } catch (Exception e) {
                        throw new ClassNotFoundException(targetClassLoader.toString() + " load interceptor class:" + className + " failure.", e);
                    }
                } finally {
                    INSTANCE_LOAD_LOCK.unlock();
                }
            }
            if (inst != null) {
                INSTANCE_CACHE.put(instanceKey, inst);
            }
        }

        return (T) inst;
    }

    /**
     * load class from class binary files.
     * Most likely all the interceptor implementations should be loaded by this.
     *
     * @param className         interceptor class name.
     * @param targetClassLoader the classloader, which should load the interceptor.
     * @param <T>
     * @return interceptor instance.
     * @throws InvocationTargetException
     * @throws IllegalAccessException
     * @throws InstantiationException
     */
    private static <T> T loadBinary(String className,
                                    ClassLoader targetClassLoader) throws InvocationTargetException, IllegalAccessException, InstantiationException {
        String path = "/" + className.replace('.', '/').concat(".class");
        byte[] data = null;
        BufferedInputStream is = null;
        ByteArrayOutputStream baos = null;
        try {
            logger.debug("Read binary code of {} using classload {}", className, InterceptorInstanceLoader.class.getClassLoader());
            is = new BufferedInputStream(InterceptorInstanceLoader.class.getResourceAsStream(path));
            baos = new ByteArrayOutputStream();
            int ch = 0;
            while ((ch = is.read()) != -1) {
                baos.write(ch);
            }
            data = baos.toByteArray();
        } catch (IOException e) {
            logger.error(e.getMessage(), e);
        } finally {
            if (is != null)
                try {
                    is.close();
                } catch (IOException ignored) {
                }
            if (baos != null)
                try {
                    baos.close();
                } catch (IOException ignored) {
                }
        }

        Method defineClassMethod = null;
        Class<?> targetClassLoaderType = targetClassLoader.getClass();
        while (defineClassMethod == null && targetClassLoaderType != null) {
            try {
                defineClassMethod = targetClassLoaderType.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class, ProtectionDomain.class);
            } catch (NoSuchMethodException e) {
                targetClassLoaderType = targetClassLoaderType.getSuperclass();
            }
        }
        defineClassMethod.setAccessible(true);
        logger.debug("load binary code of {} to classloader {}", className, targetClassLoader);
        Class<?> type = (Class<?>) defineClassMethod.invoke(targetClassLoader, className, data, 0, data.length, null);
        return (T) type.newInstance();
    }

    /**
     * Find loaded class in the current classloader.
     * Just in case some classes have already been loaded for some reason.
     *
     * @param className         interceptor class name.
     * @param targetClassLoader the classloader, which should load the interceptor.
     * @param <T>
     * @return interceptor instance.
     */
    private static <T> T findLoadedClass(String className,
                                         ClassLoader targetClassLoader) throws InvocationTargetException, IllegalAccessException, InstantiationException {
        Method defineClassMethod = null;
        Class<?> targetClassLoaderType = targetClassLoader.getClass();
        while (defineClassMethod == null && targetClassLoaderType != null) {
            try {
                defineClassMethod = targetClassLoaderType.getDeclaredMethod("findLoadedClass", String.class);
            } catch (NoSuchMethodException e) {
                targetClassLoaderType = targetClassLoaderType.getSuperclass();
            }
        }
        defineClassMethod.setAccessible(true);
        Class<?> type = (Class<?>) defineClassMethod.invoke(targetClassLoader, className);
        if (type == null) {
            return null;
        }
        return (T) type.newInstance();
    }
}
