package de.renew.plugin.load;

import java.beans.PropertyChangeEvent;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.Vector;
import java.util.function.Supplier;
import java.util.jar.JarFile;

import de.renew.plugin.CollectionLister;
import de.renew.plugin.DependencyCheckList;
import de.renew.plugin.DependencyCheckList.DependencyElement;
import de.renew.plugin.DependencyNotFulfilledException;
import de.renew.plugin.IPlugin;
import de.renew.plugin.PluginClassLoader;
import de.renew.plugin.PluginManager;
import de.renew.plugin.PluginProperties;
import de.renew.plugin.jpms.impl.ModuleManager;
import de.renew.plugin.locate.PluginLocationFinder;
import de.renew.plugin.locate.PluginLocationFinders;

/**
 * This Class is a Composition of PluginLoaders. When an instance's
 * {@link #loadPlugins} method is called, all contained PluginLoaders are
 * triggered.
 * <p>
 * In the current implementation, this class checks all dependencies and decides
 * when to add which plugin to the {@link PluginManager}. So this class
 * <i>must</i> be used as top level plugin loader, and it <i>may not</i> be
 * added as a tree node to the plugin loader hierarchy. However, the
 * implementation does not check for these conditions. In future versions,
 * dependency check and plugin addition can be moved somewhere else, where the
 * functions are more appropriate.
 * </p>
 *
 * @author Joern Schumacher
 * @author Michael Duvigneau
 */
public class PluginLoaderComposition implements PluginLoader {
    /**
     * Logger for logging purposes as specified by Apache Log4j
     */
    private static final org.apache.log4j.Logger LOGGER =
        org.apache.log4j.Logger.getLogger(PluginLoaderComposition.class);
    private final Collection<PluginLoader> _loaders = new Vector<>();
    private static ISplashscreenPlugin _splashscreen = null;
    private final ModuleManager _moduleManager;

    /**
     * Constructor for PluginLoaderComposition.
     * @param moduleManager this classes instance of the {@link ModuleManager}
     */
    public PluginLoaderComposition(ModuleManager moduleManager) {
        super();
        this._moduleManager = moduleManager;
    }

    /**
     * A simple progress splashscreen to be displayed while loading plugins
     * @param splashscreen Progressbar to be displayed
     */
    public static void setSplashScreen(ISplashscreenPlugin splashscreen) {
        PluginLoaderComposition._splashscreen = splashscreen;
    }

    /**
     * Loads plugins.
     * @return the loaded plugins
     */
    public Collection<IPlugin> loadPlugins() {
        LOGGER.info("loading plugins...");
        DependencyCheckList<PluginProperties> dependencyList = new DependencyCheckList<>();

        Collection<PluginProperties> locations =
            PluginLocationFinders.getInstance().findPluginLocations();
        Collection<IPlugin> result = new Vector<>();

        PluginProperties splashscreenProps = null;

        List<String> loadedPluginMainClasses = new Vector<>();

        for (PluginProperties props : locations) {
            if (props.getName().equals("Renew PropertyManagement")) {
                loadPluginFromURL(props.getURL());
            }
            if (props.getName().equals("Renew Suite")
                && props.getBoolProperty("de.renew.suite.showAtStart")) {
                loadPluginFromURL(props.getURL());
                if (PluginProperties.getUserProperties()
                    .getBoolProperty("de.renew.suite.loadedSuite")) {
                    return result;
                } else {
                    dependencyList.addElement(DependencyElement.create(props));
                }
            } else if (props.getName().equals("Renew Splashscreen")) {
                splashscreenProps = props;
            } else {
                dependencyList.addElement(DependencyElement.create(props));
            }
        }

        List<PluginProperties> fulfilledDependencies = dependencyList.getFulfilledObjects();

        for (PluginProperties plugin : fulfilledDependencies) {
            LOGGER.debug(plugin);
        }
        int progress = 0;
        int start;
        if (splashscreenProps != null) {
            start = -1;
        } else {
            start = 0;
        }

        for (int i = start; i < fulfilledDependencies.size(); i++) {
            if (_splashscreen != null) {
                int old = progress;
                progress++;
                double count = i + 1;
                double size = fulfilledDependencies.size();
                int newValue = (int) ((count / size) * 100);
                try {
                    _splashscreen
                        .propertyChange(new PropertyChangeEvent(this, "progress", old, newValue));
                } catch (Exception e) {
                    _splashscreen = null;
                }
            }
            PluginProperties props;
            if (i == -1) {
                props = splashscreenProps;
            } else {
                props = fulfilledDependencies.get(i);
            }
            if (PluginManager.getInstance().getPluginByName(props.getName()) != null) {
                LOGGER.debug(
                    "PluginLoader: A plugin with the name " + props.getName()
                        + " has already been loaded. Skipping " + props.getURL());
                // do not load if this plugin has already been loaded
                continue;
            }

            final boolean isModularPlugin = resolvePluginJars(props);

            IPlugin loaded = loadPlugin(props);
            if (loaded != null) {
                try {
                    PluginManager.getInstance().addPlugin(loaded, isModularPlugin);
                    if (_splashscreen != null) {
                        try {
                            _splashscreen.propertyChange(
                                new PropertyChangeEvent(
                                    this, "pluginLoaded", null, loaded.getName()));
                        } catch (Exception e) {
                            _splashscreen = null;
                        }
                    }
                    Collection<String> mainClass = loaded.getProperties().getProvisions();
                    if (!new HashSet<>(loadedPluginMainClasses).containsAll(mainClass)) {
                        loadedPluginMainClasses.addAll(mainClass);
                    }
                    result.add(loaded);
                } catch (DependencyNotFulfilledException e) {
                    assert false : "The PluginManager doubts our dependency check for " + loaded;
                }
            } else {
                LOGGER.debug("PluginLoaderComposition: did not load plugin from " + props);
                Collection<DependencyElement<PluginProperties>> retracted =
                    dependencyList.removeElementWithDependencies(props);
                if (!retracted.isEmpty()) {
                    LOGGER.debug("PluginLoaderComposition: recalculated dependencies.");
                }

                // There is an important (dirty?) assumption here:
                // Because the dependencyList is ordered, the
                // fulfilledDependencies list should not differ in the
                // first part before the failed plugin. So we can just
                // continue to loop from the same point where we are.
                fulfilledDependencies = dependencyList.getFulfilledObjects();
                i--;
            }
        }

        Collection<DependencyElement<PluginProperties>> unfulfilled =
            dependencyList.getUnfulfilled();
        if (!unfulfilled.isEmpty()) {
            LOGGER.warn("\nThere are plugins with unfulfilled dependencies:");
            List<String> provisions = new Vector<>();
            Map<String, List<String>> missing = new HashMap<>();
            for (DependencyElement<PluginProperties> dependencyElement : unfulfilled) {
                LOGGER.warn(
                    "Plugin with unfulfilled dependencies: "
                        + dependencyElement.toStringExtended(loadedPluginMainClasses) + "\n");
                for (String provisionsByDE : dependencyElement.getProvisions()) {
                    if (!provisions.contains(provisionsByDE)) {
                        provisions.add(provisionsByDE);
                    }
                }
                for (String required : dependencyElement
                    .getMissingRequirements(loadedPluginMainClasses)) {
                    if (!provisions.contains(required)) {
                        List<String> requiredBy = new Vector<>();
                        if (missing.containsKey(required)) {
                            requiredBy = missing.get(required);
                        }
                        String pluginName = dependencyElement.getPluginName();
                        if (!requiredBy.contains(pluginName)) {
                            requiredBy.add(pluginName);
                        }
                        missing.put(required, requiredBy);
                    }
                }
                for (String provisionsByDE : dependencyElement.getProvisions()) {
                    missing.remove(provisionsByDE);
                }
            }
            List<String> erroneousPlugins = new Vector<>();
            for (String missingPlugin : missing.keySet()) {
                erroneousPlugins.add(
                    missingPlugin + " [Required by: "
                        + CollectionLister.toString(missing.get(missingPlugin), ", ") + "]");
            }
            LOGGER.error(
                "\nPLEASE add the plugin(s) with the following provisions in their plugin.cfg\n\n* "
                    + CollectionLister.toString(erroneousPlugins, "\n* ") + "\n");
        }

        if (_splashscreen != null) {
            _splashscreen.closeSplashScreen();
        }

        return result;
    }

    /**
     * Resolves a plugin's properties' collection of jars to load them at runtime.
     * <p>
     * If all located jars are modular then the {@link ModuleManager} is invoked,
     * and it is guaranteed they're loaded as such. If not, no guarantee is given
     * as to how these jars will be loaded, but an effort is made to ensure legacy
     * plugins get loaded as non-modular and added to the {@link PluginClassLoader}.
     * <p>
     * Note: It is strongly discouraged to have a plugin consist of both modular
     * and non-modular jars, as the functionality of such a configuration can't
     * be ensured.
     *
     * @param props The properties of the plugin
     * @return true, if the resolved jars are all modular, false otherwise.
     */
    private boolean resolvePluginJars(PluginProperties props) {
        // Account for multi-jar plugins and perform implicit null-check
        final URL[] urlsToPluginJars = AbstractPluginLoader.unifyURL(props.getURL());
        final Path[] pluginJars = new Path[urlsToPluginJars.length];
        boolean isModularPlugin = true;
        int c = 0;
        for (URL url : urlsToPluginJars) {
            try {
                pluginJars[c++] = Path.of(url.toURI());
                isModularPlugin &= isModularJar(pluginJars[c - 1].toFile());
            } catch (URISyntaxException e) {
                LOGGER.error(
                    "Could not create plugin file of " + props.getName()
                        + " due to syntax exception.",
                    e);
                // Abort resolving this plugin, loading it will fail
                return false;
            }
        }

        if (isModularPlugin) {
            /* If the plugin is modular, we expect pluginJars to reference a
             * single modular Jar. Special case: Plugins inside folders. Here
             * one or more Jars would be located inside a subdirectory, which
             * then constitutes the plugin.
             * (props.getURL() would reference a plugin.cfg instead of a Jar) */
            Supplier<Boolean> isPluginFolder =
                () -> props.getURL().getPath().toLowerCase(Locale.ROOT).endsWith("plugin.cfg");
            if (pluginJars.length == 1 && !isPluginFolder.get()) {
                // Default: A single modular Jar
                _moduleManager.createPluginLayer(
                    props.getName(), pluginJars[0], ClassLoader.getSystemClassLoader());
            } else {
                // N modular Jars possibly located inside a subdirectory
                _moduleManager.createPluginLayer(
                    props.getName(), Set.of(pluginJars), ClassLoader.getSystemClassLoader());
            }
            return true;
        }
        // At least one jar is not modular (legacy)
        addPluginJarsToClassLoader(urlsToPluginJars);
        if (LOGGER.isInfoEnabled()) {
            LOGGER.info(
                "PluginLoaderComposition: Adding files of plugin " + props.getName()
                    + " to plugin class loader. At least one "
                    + "jar was found to be non-modular.");
        }
        return false;
    }

    /**
     * Adds the Jar files belonging to a plugin to the {@link PluginClassLoader}.
     * @param urlsToPluginJars An array of URLs referencing JARs
     */
    private void addPluginJarsToClassLoader(URL[] urlsToPluginJars) {
        PluginClassLoader pluginCL = PluginManager.getInstance().getPluginClassLoader();
        for (URL url : urlsToPluginJars) {
            pluginCL.addURL(url);
        }
    }

    /**
     * Determines whether the provided File is a modular Jar file.
     * @param jarfile the Jar to be checked
     * @return true if the file is a modular Jar, false otherwise
     */
    private boolean isModularJar(File jarfile) {
        try (JarFile jarToCheck = new JarFile(jarfile)) {
            if (jarToCheck.getEntry("module-info.class") != null) {
                return true;
            }
        } catch (IOException e) {
            LOGGER
                .warn("Error while checking Jar file  " + jarfile.toPath() + " " + e.getMessage());
        }
        return false;
    }

    /**
     * Adds a PluginLoader to the LoaderComposition
     * @param l The PluginLoader to be added
     */
    public void addLoader(PluginLoader l) {
        _loaders.add(l);
    }

    /**
     * Removes PluginLoader from LoaderComposition
     * @param l The PluginLoader to be removed
     */
    public void removeLoader(PluginLoader l) {
        _loaders.remove(l);
    }

    /**
     * This method is called to trigger the loading of a plugin with the
     * previously loaded properties. All previously registered loaders are
     * called to try to load a plugin; the first successfully loaded is
     * returned.
     * <p>
     * In contrast to {@link #loadPluginFromURL} and {@link #loadPlugins}, this
     * method does neither check dependencies nor add the plugin to the
     * <code>PluginManager</code> automatically.
     * </p>
     */
    @Override
    public IPlugin loadPlugin(PluginProperties props) {
        IPlugin result;
        for (PluginLoader loader : _loaders) {
            try {
                result = loader.loadPlugin(props);
                if (result != null) {
                    return result;
                }
            } catch (Exception e) {
                LOGGER.error(
                    "PluginLoaderComposition: " + e + " occurred when " + loader
                        + " tried to load plugin from " + props.getURL());
                LOGGER.error(e.getMessage(), e);
            }
        }

        LOGGER.error("No loader was able to load " + props.getName() + "!");
        return null;
    }

    /**
     * This method is called to trigger the loading of a plugin at the given location.
     * If there are missing dependencies, the required plugins are loaded recursively.
     * <p>
     * There are two ways of loading the plugin, they are tried in the given
     * order until a plugin has been loaded.
     * </p>
     * <ol>
     * <li><code>PluginLocationFinders</code> are asked to transform the given
     * <code>source</code> URL into <code>PluginProperties</code>. If the
     * finders are successful, the plugin is loaded according to the properties
     * found.</li>
     * <li>All registered <code>PluginLoader</code>s are called to try to load
     * the plugin without the assistance of a finder; the first successfully
     * loaded plugin is returned.</li>
     * </ol>
     * <p>
     * If a plugin has been loaded, it is automatically added to the
     * <code>PluginManager</code>.
     * </p>
     **/
    @Override
    public IPlugin loadPluginFromURL(URL source) {
        IPlugin result = null;
        PluginManager mgr = PluginManager.getInstance();
        PluginLocationFinder pluginLocationFinder = PluginLocationFinders.getInstance();
        PluginProperties props = pluginLocationFinder.checkPluginLocation(source);
        boolean isModularPlugin = false;
        if (props != null) {
            if (!mgr.checkDependenciesFulfilled(props)) {
                List<String> missingDependencies = mgr.getMissingDependencies(props);
                for (String missingDependency : missingDependencies) {
                    /*
                     * Loaded plugin should not be loaded again.
                     * We use recursion, so some plugins may
                     * be found as missing dependency multiple times.
                     * The if-statement checks whether the plugin was already loaded on another recursion-path
                     */
                    if (mgr.getPluginsProviding(missingDependency).isEmpty()) {
                        PluginProperties pluginProperties =
                            pluginLocationFinder.findPluginByProvides(missingDependency);
                        if (pluginProperties != null) {
                            loadPluginFromURL(pluginProperties.getURL());
                        } else {
                            LOGGER.error(
                                "Could not find the Plugin which provides " + missingDependency
                                    + "; another Plugin depends on it");
                        }
                    }
                }
            }
            if (mgr.checkDependenciesFulfilled(props)) {
                isModularPlugin = resolvePluginJars(props);
                result = loadPlugin(props);

                LOGGER.info("loaded plugin: " + result.getName());
            } else {
                LOGGER.error(
                    "Dependencies are not fulfilled for plugin \n\t" + props.getName()
                        + "\nlocated at \n\t" + props.getURL());
            }
        } else {
            for (PluginLoader loader : _loaders) {
                try {
                    result = loader.loadPluginFromURL(source);
                    if (result != null) {
                        break;
                    }
                } catch (Exception e) {
                    LOGGER.error(
                        "PluginLoaderComposition: " + e + " occurred when " + loader
                            + " tried to load plugin from " + source);
                    LOGGER.debug(e.getMessage(), e);
                }
            }
        }

        if (result != null) {
            try {
                mgr.addPlugin(result, isModularPlugin);
                result.startUpComplete(false);
            } catch (DependencyNotFulfilledException e) {
                LOGGER.error(
                    "Dependencies are not fulfilled for " + result + " loaded from " + source);
                return null;
            }
        }
        return result;
    }
}
