package de.renew.plugin.jpms.impl;

import java.io.IOException;
import java.lang.module.ModuleDescriptor;
import java.lang.module.ModuleFinder;
import java.lang.module.ModuleReference;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;

import de.renew.plugin.jpms.ComponentLayer;
import de.renew.plugin.jpms.ComponentLayerFactory;
import de.renew.plugin.jpms.ExportsStrategy;
import de.renew.plugin.jpms.ModuleConfigurationException;
import de.renew.plugin.jpms.ModuleLayerListener;
import de.renew.plugin.jpms.ModuleLayerRemoveException;


/**
 * Responsible for loading and unloading modules.
 * <br>
 * Before a {@link de.renew.plugin.IPlugin} is loaded, a {@link Module}
 * needs to be created for it, which will reside inside
 * a {@link ModuleLayer}. Each layer has a set of parents
 * that corresponds to the required modules.
 * <br>
 * For example, a plugin C which requires plugins A and B
 * will have the layer containing plugin A's module and
 * the layer containing plugin B's module as parents.
 * <br>
 * This class keeps track of the currently loaded layers
 * and also which layers have no children and could potentially
 * be removed using the {@link de.renew.plugin.command.UnloadCommand}
 */
public class ModuleManager {
    /**
     * Logger for logging purposes as specified by Apache Log4j
     */
    private static final org.apache.log4j.Logger LOGGER =
        org.apache.log4j.Logger.getLogger(ModuleManager.class);

    private final List<ComponentLayer> _loadedLayers = new ArrayList<>();
    private final List<ComponentLayer> _removableLayers = new CopyOnWriteArrayList<>();

    private final List<ModuleLayerListener> _layerListeners = new ArrayList<>();

    private final ComponentLayerFactory _pluginLayerFactory;
    private final ComponentLayerFactory _libLayerFactory;

    private final ExportsStrategy _resolveStrategy;

    /**
     * Creates a new ModuleManager. This class is only meant
     * to be instantiated by the {@link de.renew.plugin.PluginManager}.
     */
    public ModuleManager() {
        this(new ResolveExportsStrategy());
    }

    /**
     * Constructs a new {@code ModuleManager} with a given {@code strategy}.
     * @param strategy the given reified strategy.
     */
    public ModuleManager(final ExportsStrategy strategy) {
        _loadedLayers.add(ComponentLayer.BOOT);

        _pluginLayerFactory = new PluginLayerFactory();
        _libLayerFactory = new LibraryLayerFactory();

        _resolveStrategy = strategy;
        _resolveStrategy.putUnresolvedExportsFromBoot();
    }

    /**
     * Creates a ModuleLayer for a single plugin consisting of exactly one module.
     * <br>
     * The module is expected to be inside a jar file.
     * <br><br>
     * <b> Enforced: 1 plugin = 1 layer = 1 module </b>
     *
     * @param pluginName    The name of the plugin used as parent for this layer
     * @param moduleJarPath Path to Jar
     * @param parentCL      The plugin Class loader of the Module
     */
    public void createPluginLayer(String pluginName, Path moduleJarPath, ClassLoader parentCL) {
        createPluginLayer(pluginName, Set.of(moduleJarPath), parentCL);
    }

    /**
     * Creates a ModuleLayer for a single plugin consisting of multiple modules.
     * <br>
     * Each module is expected to be inside a different jar file, all located
     * <b>inside the same directory.</b> This directory's name will be used to
     * reference possible library dependencies for this layer.
     * <br><br>
     * <b> Enforced: 1 plugin = 1 layer = n modules </b>
     *
     * @param pluginName     The name of the plugin used as parent for this layer
     * @param moduleJarPaths Paths to Jars
     * @param parentCL       The plugin Class loader of the Modules
     */
    public void createPluginLayer(
        String pluginName, Set<Path> moduleJarPaths, ClassLoader parentCL)
    {
        Objects.requireNonNull(parentCL);
        // All jars must be located inside the same directory
        // Implicit null-check of moduleJarPaths
        checkSameOrigin(moduleJarPaths);

        ComponentLayer component = resolveComponentLayer(pluginName, moduleJarPaths, parentCL);

        ModuleLayer layer = component.getLayer();
        _removableLayers.removeAll(component.parents());

        for (Module m : layer.modules()) {
            // 1. Check if previous exports can be resolved now
            _resolveStrategy.tryResolveExportsTo(m);

            // 2. Check if there are new unresolved exports
            _resolveStrategy.putUnresolvedExportsFrom(m, component.getController());
        }

        layerAdded(layer);

        _loadedLayers.add(component);
        _removableLayers.add(component);
    }

    private ComponentLayer resolveComponentLayer(
        String name, Set<Path> moduleJarPaths, ClassLoader parentCL)
    {
        // Special case: Cloud Native, Reason: Spring
        // Don't create layers for the libraries
        if (name.equals("Renew Cloud Native Spring")) {
            // assume all modules have the same library location
            Path moduleJarPath = moduleJarPaths.stream().findFirst().orElseThrow();
            Set<Path> libraryLocations = getLibraryLocations(moduleJarPath);
            return new AIOPluginLayerFactory()
                .create(name, moduleJarPaths, libraryLocations, _loadedLayers, parentCL)
                .orElseThrow();
        }

        // Load the jar files as modules
        final Set<ModuleReference> modulesToLoad = moduleJarPaths.stream()
            .map(ModuleManager::getModuleReferenceFromPath).collect(Collectors.toUnmodifiableSet());

        // Merge all the dependencies for the individual modules
        final var requiredModules = getAllDependencies(modulesToLoad);

        // Extracts the relevant dependencies for the module layer
        List<String> libraryNames = getRequiredLibraryNames(requiredModules);

        // Check loaded layers for needed libraries
        updateLibraries(libraryNames);

        Path moduleJarPath = moduleJarPaths.stream().findFirst().orElseThrow();
        checkCreationOfMoreLibraryLayer(moduleJarPath, libraryNames, parentCL);

        return _pluginLayerFactory.create(name, moduleJarPaths, null, _loadedLayers, parentCL)
            .orElseThrow();
    }

    private void updateLibraries(List<String> libraryNames) {
        if (libraryNames.isEmpty()) {
            return;
        }

        List<String> previouslyLoadedLibraries = new ArrayList<>();
        for (String libraryName : libraryNames) {
            _loadedLayers.stream().map(ModuleManager::getModuleNamesInLayer)
                .filter(l -> l.contains(libraryName))
                .forEach(l -> previouslyLoadedLibraries.add(libraryName));
        }
        libraryNames.removeAll(previouslyLoadedLibraries);
    }

    /**
     * Creates a Module Layer for a library.
     * @param modulePath Path to Jar of plugin
     * @param libraryName Name of Library
     * @param parentClassLoader The parent Class loader
     */
    private void createLibraryLayer(
        Path modulePath, String libraryName, ClassLoader parentClassLoader)
    {
        Path pluginPath = modulePath;
        if (modulePath.resolveSibling("plugin.cfg").toFile().exists()) {
            pluginPath = modulePath.getParent(); // plugin is directory plugin
        }
        Set<Path> libraryLocations = getLibraryLocations(pluginPath);
        ModuleFinder moduleFinder = ModuleFinder.of(libraryLocations.toArray(Path[]::new));
        Set<ModuleReference> foundModules = moduleFinder.findAll();

        ModuleReference libraryModule = foundModules.stream()
            .filter(m -> m.descriptor().name().equals(libraryName)).findFirst().orElseThrow();

        // Check Dependencies (only works for modules)
        // A non-modular library is loaded in its own layer, with the own dependencies, which are not yet present in the system
        // Problem: libraryModule.descriptor().requires() only returns mandated java.base for non-modular libraries => No access on dependencies of non-modular library
        var requiredModules = libraryModule.descriptor().requires();
        List<String> libraryNames = getRequiredLibraryNames(requiredModules);

        // If Dependencies: first load dependencies
        // Check loaded layers for needed libraries
        updateLibraries(libraryNames);
        checkCreationOfMoreLibraryLayer(modulePath, libraryNames, parentClassLoader);

        ComponentLayer layer = _libLayerFactory.create(
            null, Set.of(Path.of(libraryModule.location().orElseThrow())), libraryLocations,
            _loadedLayers, parentClassLoader).orElseThrow();

        _removableLayers.removeAll(layer.parents());

        _loadedLayers.add(layer);
        _removableLayers.add(layer);
    }

    private void checkCreationOfMoreLibraryLayer(
        Path moduleJarPath, List<String> libraryNames, ClassLoader parentCL)
    {
        if (libraryNames.isEmpty()) {
            return;
        }

        for (String lib : libraryNames) {
            boolean createLibLayer = _loadedLayers.stream()
                .map(ModuleManager::getModuleNamesInLayer).noneMatch(m -> m.contains(lib));

            if (createLibLayer) {
                createLibraryLayer(moduleJarPath, lib, parentCL);
            }
        }
    }

    private static void checkSameOrigin(Set<Path> dirs) {
        if (dirs.size() < 2)
            return;

        final boolean hasSameParent =
            dirs.stream().map(Path::getParent).distinct().limit(2).count() == 1;
        if (!hasSameParent) {
            throw new ModuleConfigurationException(
                "All jars need to have the " + "same origin. While trying to load from " + dirs
                    + " at least one jar was located in a different directory.");
        }
    }

    private static Set<Path> getLibraryLocations(Path modulePath) {
        Path pluginLibDir =
            Path.of(modulePath.toString().replace("plugins", "libs").replaceFirst("\\.jar$", ""));

        Set<Path> libraryLocations = new HashSet<>();
        //Check if there is a folder specific to this plugin, otherwise use the standard libs folder.
        if (!pluginLibDir.toFile().isDirectory()) {
            libraryLocations.add(pluginLibDir.getParent());
            return libraryLocations;
        }

        // add plugin lib dir
        libraryLocations.add(pluginLibDir);

        // add library locations from reference files
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(pluginLibDir, "*.txt")) {
            for (Path path : stream) {
                libraryLocations
                    .add(pluginLibDir.resolve(String.join("", Files.readAllLines(path))));
            }
        } catch (IOException e) {
            LOGGER.error(
                "Cannot open " + libraryLocations + " to search for text files referencing jars.");
        }

        return libraryLocations;
    }

    /**
     * Returns the reference to the module at the given path.
     *
     * @param moduleJarPath Path to the modular JAR.
     * @return The reference to the module
     */
    private static ModuleReference getModuleReferenceFromPath(Path moduleJarPath) {
        Set<ModuleReference> referencedModulesInJar = ModuleFinder.of(moduleJarPath).findAll();

        if (referencedModulesInJar.size() != 1) {
            throw new ModuleConfigurationException(
                "A modular jar must contain exactly one module!");
        }

        return referencedModulesInJar.stream().findFirst().get();
    }

    private static Set<ModuleDescriptor.Requires> getAllDependencies(
        Set<ModuleReference> modulesToLoad)
    {
        return modulesToLoad.stream().map(ModuleReference::descriptor)
            .map(ModuleDescriptor::requires).flatMap(Set::stream)
            .collect(Collectors.toUnmodifiableSet());
    }

    /**
     * Converts the ModuleDescriptor's requires into a list of libraries.
     *
     * @param requires The requirements of the module to load.
     * @return The list of required libraries, this list can be empty
     */
    private static List<String> getRequiredLibraryNames(Set<ModuleDescriptor.Requires> requires) {
        List<String> libraryNames = new ArrayList<>();
        for (ModuleDescriptor.Requires require : requires) {
            String requireString = require.name();
            boolean isStatic =
                require.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC);

            if (isStatic // compile time dependency (optional at runtime)
                || requireString.startsWith("java.") // platform modules
                    && !requireString.equals("java.mail")
                || requireString.startsWith("jdk.") // platform modules
                || isRenewModule(requireString)) // plugin
                continue;

            libraryNames.add(requireString);
        }

        return libraryNames;
    }

    /**
     * Remove the module layer of a single plugin.
     *
     * @param name The name of the plugin to remove
     * @throws ModuleLayerRemoveException Thrown if the layer isn't removable
     */
    public void removeLayerOfPlugin(String name) throws ModuleLayerRemoveException {
        var layersOfPlugin = _loadedLayers.stream().filter(l -> l.getName().equals(name))
            .collect(Collectors.toSet());

        if (layersOfPlugin.size() != 1) {
            throw new ModuleConfigurationException(
                "Plugin " + name + " must have exactly one layer!");
        }

        ComponentLayer toRemove = layersOfPlugin.stream().findFirst().get();

        if (!_removableLayers.contains(toRemove)) {
            throwRemoveException(toRemove);
        }

        layerRemoved(toRemove.getLayer());

        removeLayer(toRemove);
        removeUnnecessaryLibraryLayers();
    }

    private void removeUnnecessaryLibraryLayers() {
        for (ComponentLayer removableLayer : _removableLayers) {
            if (removableLayer instanceof LibraryLayer) {
                removeLayer(removableLayer);
            }
        }
    }

    private void removeLayer(ComponentLayer layerToRemove) {
        _loadedLayers.remove(layerToRemove);
        _removableLayers.remove(layerToRemove);
        updateRemovableLayers();
    }

    private void throwRemoveException(ComponentLayer toRemove) {
        String layerName = toRemove.getName();
        List<String> childrenList = new ArrayList<>();
        for (ComponentLayer l : _loadedLayers) {
            if (l.parents().contains(toRemove)) {
                childrenList.add(l.getLayer().toString());
            }
        }
        throw new ModuleLayerRemoveException(layerName, childrenList);
    }

    /**
     * Checks the currently loaded layers for elements that
     * are not parents to any other layer, i.e., all layers
     * without any children.
     */
    private void updateRemovableLayers() {
        List<ModuleLayer> allParents = new ArrayList<>();
        for (ComponentLayer layer : _loadedLayers) {
            allParents.addAll(layer.getLayer().parents());
        }
        for (ComponentLayer layer : _loadedLayers) {
            if (!_removableLayers.contains(layer) && !allParents.contains(layer.getLayer())) {
                _removableLayers.add(layer);
            }
        }
    }

    /**
     * Returns the names of all modules contained in the layer
     *
     * @param layer The layer containing the modules.
     * @return A list of Strings representing the module names contained in the layer.
     */
    private static List<String> getModuleNamesInLayer(ComponentLayer layer) {
        return layer.getLayer().modules().stream().map(Module::getName)
            .collect(Collectors.toList());
    }

    /**
     * This method is called to find which module a class belongs to.
     * The name is extracted from the className by cutting the string.
     *
     * @param className Name of the class, that you need to find the module's ClassLoader for.
     * @return the ClassLoader of the Module, that your class is part of
     * or the classloader of this class if this class is in the unnamed module
     */
    public ClassLoader getModuleClassLoaderForClass(String className) {

        if (!this.getClass().getModule().isNamed()) {
            // This class is in the unnamed module.
            // This means Renew was not started as a modular application, for example by an ant task
            return this.getClass().getClassLoader();
        }

        var noStartingSlash = className.startsWith("/") ? className.substring(1) : className;
        var normalizedClassName = noStartingSlash.replace('/', '.');

        int packageEndIndex = normalizedClassName.lastIndexOf('.');

        if (packageEndIndex == -1) {
            LOGGER.debug(
                "The class " + className + "is not fully qualified. Can be ignored if "
                    + "the call originated from an InscriptionParser.");
            return null;
        }

        String packageName = normalizedClassName.substring(0, packageEndIndex);

        for (ComponentLayer layer : _loadedLayers) {
            for (Module module : layer.getLayer().modules()) {
                if (module.getPackages().contains(packageName)) {
                    return module.getClassLoader();
                }
            }
        }

        return null;
    }

    private void layerAdded(ModuleLayer layer) {
        for (ModuleLayerListener l : _layerListeners) {
            l.layerAdded(layer.modules());
        }
    }

    private void layerRemoved(ModuleLayer layer) {
        for (ModuleLayerListener l : _layerListeners) {
            l.layerRemoved(layer.modules());
        }
    }

    /**
     * Registers the layer listener.
     * @param l the module layer listener
     */

    public void registerLayerListener(ModuleLayerListener l) {
        _layerListeners.add(l);
    }

    private static boolean isRenewModule(String name) {
        return name.startsWith("de.renew.") || name.equals("CH.ifa.draw")
            || name.startsWith("net.paose.");
    }
}
