package com.srbenoit.util;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.logging.Level;
import com.srbenoit.log.LoggedObject;

/**
 * A class with a static method that can locate the set of classes installed under a given package.
 * To use, pass the package name to <code>scanClasses</code>, which returns a list of the matching
 * classes.
 */
public final class ClassList extends LoggedObject {

    /**
     * Private constructor to prevent instantiation.
     */
    private ClassList() {

        super();
    }

    /**
     * Scans for all classes installed under a particular path.
     *
     * @param   root  the root path in which to search for classes
     * @return  the list of classes found, or <code>null</code> on any error
     */
    public static List<Class<?>> scanClasses(final String root) {

        List<Class<?>> list;
        ClassLoader loader;

        list = new ArrayList<Class<?>>(20);
        loader = Thread.currentThread().getContextClassLoader();

        if (loader instanceof java.net.URLClassLoader) {

            for (URL url : ((java.net.URLClassLoader) loader).getURLs()) {

                if (url.toString().startsWith("file:")) {
                    scanFilename(url.getFile(), root, list, loader);
                } else {
                    scanUrl(url, root, list, loader);
                }
            }
        } else {

            for (String path
                : System.getProperty("java.class.path", "").split(
                    (File.separatorChar == '\\') ? "\\\\" : File.separator)) {
                scanFilename(path, root, list, loader);
            }
        }

        return list;
    }

    /**
     * Scans for all classes installed under a particular URL.
     *
     * @param  url     the URL to scan
     * @param  path    the path in which to search for classes
     * @param  list    the list to which to accumulate results
     * @param  loader  the class loader to use to locate resources
     */
    private static void scanUrl(final URL url, final String path, final List<Class<?>> list,
        final ClassLoader loader) {

        byte[] buf;
        ByteArrayOutputStream baos;
        InputStream input;
        int size;
        JarInputStream jis;
        JarEntry entry;

        try {

            // Download the JAR file
            buf = new byte[1024];
            baos = new ByteArrayOutputStream();
            input = url.openStream();
            size = input.read(buf);

            while (size != -1) {
                baos.write(buf, 0, size);
                size = input.read(buf);
            }

            input.close();

            jis = new JarInputStream(new ByteArrayInputStream(baos.toByteArray()));

            entry = jis.getNextJarEntry();

            while (entry != null) {

                if (entry.getName().endsWith(".class")) {
                    processClass(entry.getName(), path, loader, list);
                }

                entry = jis.getNextJarEntry();
            }
        } catch (IOException e1) {
            LOG.log(Level.WARNING, "Could not download the JAR file ''{0}'': {1}",
                new Object[] { url, e1.getMessage() });
        }
    }

    /**
     * Scans for all classes installed under a particular path in a single JAR file.
     *
     * @param  fname   the filename to scan
     * @param  path    the path in which to search for classes
     * @param  list    the list to which to accumulate results
     * @param  loader  the class loader to use to locate resources
     */
    private static void scanFilename(final String fname, final String path,
        final List<Class<?>> list, final ClassLoader loader) {

        File file;

        if (fname.endsWith(".jar")) {
            scanJarFile(fname, path, loader, list);
        } else {
            file = new File(fname);

            if (file.isDirectory()) {
                recurseDirectory(file.getAbsolutePath(), file, path, loader, list);
            }
        }
    }

    /**
     * Recurses a directory searching for files whose names end in ".class".
     *
     * @param  root    the root of the directory being searched
     * @param  dir     the directory to search
     * @param  path    the path being searched for
     * @param  loader  the ClassLoader to use
     * @param  list    the list to which to add matches
     */
    private static void recurseDirectory(final String root, final File dir, final String path,
        final ClassLoader loader, final List<Class<?>> list) {

        String absPath;

        for (File f : dir.listFiles()) {

            if (f.isDirectory()) {
                recurseDirectory(root, f, path, loader, list);
            } else {
                absPath = f.getAbsolutePath();

                if (absPath.startsWith(root)) {
                    absPath = absPath.substring(root.length() + 1);
                }

                processClass(absPath.replace(File.separatorChar, '.'), path, loader, list);
            }
        }
    }

    /**
     * Scans a single JAR file for class entries matching the search path.
     *
     * @param  fname   the name of the Jar file to search
     * @param  path    the path being searched for
     * @param  loader  the ClassLoader to use
     * @param  list    the list to which to add matches
     */
    private static void scanJarFile(final String fname, final String path,
        final ClassLoader loader, final List<Class<?>> list) {

        JarFile jar;
        Enumeration<JarEntry> entries;
        String entry;

        try {
            jar = new JarFile(fname.replace("%20", " "));
            entries = jar.entries();

            while (entries.hasMoreElements()) {
                entry = entries.nextElement().getName();

                if (entry.endsWith(".class")) {
                    processClass(entry, path, loader, list);
                }
            }

            try {
                jar.close();
            } catch (IOException e) {
                LOG.log(Level.WARNING,
                    "The module jar file ''{0}'' could not be closed. Error: {1}",
                    new Object[] { fname, e.getMessage() });
            }
        } catch (Exception e) {
            LOG.log(Level.WARNING,
                "jar file ''{0}'' could not be instantiated from file path. Error: {1}",
                new Object[] { fname, e.getMessage() });
        }
    }

    /**
     * Given the filename of a single class, tries to load that class and checks it against the
     * search path.
     *
     * @param  fname   the class filename
     * @param  path    the path being searched for
     * @param  loader  the ClassLoader to use
     * @param  list    the list to which to add matches
     */
    private static void processClass(final String fname, final String path,
        final ClassLoader loader, final List<Class<?>> list) {

        String name;
        Class<?> theClass;
        int pos;

        name = fname.replace('/', '.').substring(0, fname.length() - 6);

        // Classes not in the search path are ignored.
        pos = name.indexOf(path);

        if (pos != -1) {

            // Ignore classes deeper under the search path
            pos = name.indexOf('.', pos + path.length() + 1);

            if (pos == -1) {

                try {
                    theClass = Class.forName(name, false, loader);

                    if (!theClass.isInterface()) {
                        list.add(theClass);
                    }
                } catch (ClassNotFoundException nfe) {
                    LOG.log(Level.WARNING, "Skipping class ''{0}'' for reason {1}",
                        new Object[] { name, nfe.getMessage() });
                } catch (NoClassDefFoundError e) {
                    LOG.log(Level.WARNING, "Skipping class ''{0}'' for reason {1}",
                        new Object[] { name, e.getMessage() });
                }
            }
        }
    }

    /**
     * Main method to exercise the <code>ClassList</code> class.
     *
     * @param  args  command-line arguments
     */
    public static void main(final String... args) {

        List<Class<?>> cls;

        cls = ClassList.scanClasses("com.bekenlearning.db.data");

        for (Class<?> clazz : cls) {
            LOG.info(clazz.getSimpleName());
        }
    }
}
