/*
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 * @copyright Ranides Atterwim
 * @license WTFPL
 * @url http://ranides.net/projects/assira
 */
package net.ranides.assira.reflection.util;

import lombok.experimental.UtilityClass;

import net.ranides.assira.generic.Ref;
import net.ranides.assira.io.IOStreams;
import net.ranides.assira.io.PathUtils;
import net.ranides.assira.io.ResourceUtils;
import net.ranides.assira.reflection.*;
import net.ranides.assira.system.RuntimeUtils;
import net.ranides.assira.text.Charsets;
import net.ranides.assira.text.IOStrings;
import net.ranides.assira.trace.LoggerUtils;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Modifier;
import java.nio.file.Path;
import java.security.CodeSource;
import java.util.MissingResourceException;

/**
 * Helper methods used for very specific, unsafe or non-portable reflective operations.
 *
 * @author ranides
 */
@UtilityClass
public final class ReflectUtils {

    interface IReflectSupport {

        boolean isStable();

        boolean isAccessible(Member object);

        <T extends AccessibleObject> T access(T object);

    }

    private static final IReflectSupport SUPPORT = ServiceScanner
        .scanner(IReflectSupport.class)
        .resolver(name -> Ref.of(IReflectSupport.class.cast(Class.forName(name).newInstance())))
        .services()
        .first()
        .orElse(null);

    static {
        if(!SUPPORT.isStable()) {
            String message = String.format(
                "%s is unstable on JDK %s. Please use correct version of assira.core for your JDK.",
                SUPPORT.getClass().getName(),
                RuntimeUtils.getJavaVersion()
            );

            LoggerFactory.getLogger(ReflectUtils.class).warn(message);
        }
    }

    public static boolean isAccessible(Member object) {
        return SUPPORT.isAccessible(object);
    }

    /**
     * Forces  reflective access to provided non-public object (field, method, etc).
     * It can throw exception, if it is not allowed because of SecurityException.
     *
     * @param <T> T
     * @param object object
     * @return object
     */
    public static <T extends AccessibleObject> T access(T object) {
        return SUPPORT.access(object);
    }

    /**
     * Makes final field writable.
     * It can throw exception, if it is not allowed because of SecurityException.
     *
     * Please note, that bypassing "final" modifier is undefined behaviour.
     *
     * @param field field
     * @return field
     */
    public static Field rewritable(Field field) {
        if(0 == (field.getModifiers() & Modifier.FINAL)) {
            return field;
        }
        try {
            RuntimeUtils.doPrivileged(() -> {
                try {
                    field.setAccessible(true);
                    Field modifiers = field.getClass().getDeclaredField("modifiers");
                    modifiers.setAccessible(true);
                    modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);
                } catch (ReflectiveOperationException cause) {
                    throw new IReflectiveException(cause);
                }
            });
        } catch(SecurityException cause) {
            throw new IReflectiveException(cause);
        }
        return field;
    }

    /**
     * Returns bytecode of specified class.
     * It uses default classloader to resolve class name.
     * It throws exception if it was impossible (for example because class is not loaded by default classloader).
     *
     * @param classname classname
     * @return bytecode
     */
    public static byte[] getByteCode(String classname) {
        return readByteCode(ClassLoaderUtils.getDefault(), classname);
    }

    /**
     * Returns bytecode of specified class.
     * It throws exception if it was impossible (for example class wasn't loaded from .class file)
     *
     * @param clazz clazz
     * @return bytecode
     */
    public static byte[] getByteCode(Class<?> clazz) {
        return readByteCode(clazz.getClassLoader(), clazz.getName());
    }

    private static byte[] readByteCode(ClassLoader loader, String classname){
        try {
            String name = classname.replace('.', '/') + ".class";
            try (InputStream istream = ResourceUtils.stream(loader, name)) {
                return IOStreams.read(istream);
            }
        } catch (IOException cause) {
            throw new IReflectiveException(cause);
        }
    }

    /**
     * Returns content of source file which defined provided class.
     * It doesn't work if source code is not accessible from classpath.
     * It returns whole file content, which must be taken into account if work for inner classes.
     *
     * @param classname classname
     * @return source code
     * @throws IOException IOException
     */
    public static String getSource(String classname) throws IOException {
        return readSourceCode(ClassLoaderUtils.getDefault(), classname);
    }

    /**
     * Returns content of source file which defined provided class.
     * It doesn't work if source code is not accessible from classpath.
     * It returns whole file content, which must be taken into account if work for inner classes.
     *
     * @param clazz clazz
     * @return source code
     * @throws IOException IOException
     */
    public static String getSource(Class<?> clazz) throws IOException {
        return readSourceCode(clazz.getClassLoader(), clazz.getName());
    }

    private static String readSourceCode(ClassLoader loader, String classname) throws IOException {
        String name = classname.replace('.', '/').replaceAll("\\$.+", "") + ".java";
        InputStream istream = loader.getResourceAsStream(name);
        if(istream != null) {
            return IOStrings.read(istream, Charsets.UTF8);
        }

        File file;
        if((file = new File(name)).exists()) {
            return IOStrings.read(file, Charsets.UTF8);
        }
        if((file = new File("src/test/java/" + name)).exists()) {
            return IOStrings.read(file, Charsets.UTF8);
        }
        if((file = new File("src/main/java/" + name)).exists()) {
            return IOStrings.read(file, Charsets.UTF8);
        }
        throw new IOException("unknown location of file: " + name);
    }

    /**
     * Returns location used to find ".class" file used to load provided class.
     *
     * Please note, that it does not return path to ".class" file, but path to
     * directory/JAR used for classpath search.
     *
     * If you want to obtain specific ".class" file, you should use getFQLocation
     *
     * @param clazz clazz
     * @return Path
     */
    public static Path getLocation(Class<?> clazz) {
        CodeSource cs = clazz.getProtectionDomain().getCodeSource();
        if(null == cs) {
            throw new IReflectiveException("Unknown class location: "+ clazz);
        }
        return PathUtils.fromURL(cs.getLocation());
    }

    /**
     * Returns location to ".class" file used to load provided class.
     *
     * Please note, that it does not return classpath.
     * If you want to obtain directory used to search for a ".class" file, you should use getLocation
     *
     * @param clazz clazz
     * @return Path
     */
    public static Path getFQLocation(Class<?> clazz) {
        ClassLoader cr = clazz.getClassLoader();
        if(null == cr) {
            throw new IReflectiveException("Unknown class location: "+ clazz);
        }
        try {
            String name = clazz.getName().replace('.', '/').replace('\\','/') + ".class";
            return PathUtils.fromURL(ResourceUtils.url(cr, name));
        } catch (MissingResourceException cause) {
            throw new IReflectiveException(cause);
        }
    }

}
