package de.renew.plugin.jpms;

import java.lang.module.Configuration;
import java.lang.module.ModuleDescriptor;
import java.lang.module.ModuleFinder;
import java.lang.module.ModuleReference;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * The common supertype for all {@code ComponentLayerFactory}s.
 * <p>
 * Subclasses must implement the build steps specified in  {@link Facilitator}.
 * Furthermore,  it's possible to define custom parent determination and  layer
 * validation criteria by overriding the methods {@link #determineParentLayers}
 * and {@link #verify}, though this should generally not be necessary.
 *
 * @author Kjell Ehlers
 * @since Renew 4.2
 * @see ComponentLayerFactory
 */
public abstract non-sealed class AbstractLayerFactory
    implements ComponentLayerFactory, ComponentLayerFactory.Facilitator
{
    private static final String ROOT = "de.renew.loader";

    @Override
    public final Optional<ComponentLayer> create(
        String name, Set<Path> toLoad, Set<Path> dependencyLocations, List<ComponentLayer> loaded,
        ClassLoader parentCL)
    {
        Objects.requireNonNull(parentCL);
        // Implicit null-check of moduleJarPath
        Set<ModuleReference> modules = getModuleReferencesFrom(toLoad);

        Set<String> moduleNames = getModuleNames(modules);

        // Extracts the relevant dependencies for the module layer
        Set<String> requires = determineParentLayers(modules);
        ModuleFinder finder = createModuleFinder(toLoad, dependencyLocations);

        if (requires.isEmpty()) {
            requires.add(ROOT);
        }

        Set<ModuleLayer> layers =
            loaded.stream().map(ComponentLayer::getLayer).collect(Collectors.toSet());
        var ctrl = createLayer(moduleNames, layers, requires, parentCL, finder);

        // Loaded more (or less) modules than anticipated
        if (!verify(ctrl.layer(), moduleNames)) {
            throw new ModuleLayerCreationException(moduleNames, ctrl.layer().modules());
        }

        List<ComponentLayer> parents = getParentComponents(ctrl.layer(), loaded);

        return Optional.ofNullable(createComponentLayer(name, ctrl, parents));
    }

    /**
     * Merges all the dependencies for the given individual modules and returns them.
     * @param modules the given modules
     * @return the merged dependencies for the given modules
     */
    protected Set<String> determineParentLayers(Set<ModuleReference> modules) {
        // Merge all the dependencies for the individual modules
        var requiredModules = getAllDependencies(modules);
        return getRequiredModuleNames(requiredModules);
    }

    @Override
    public ModuleFinder createModuleFinder(Set<Path> modulesToLoad, Set<Path> dependencyLocations) {
        Set<Path> searchPaths = new HashSet<>(modulesToLoad);
        if (dependencyLocations != null) {
            searchPaths.addAll(dependencyLocations);
        }
        return ModuleFinder.of(searchPaths.toArray(Path[]::new));
    }

    /**
     * Checks if the size of the module layers are equivalent to the size of the loaded modules.
     * @param layer the module layers
     * @param modulesToLoad the modules to be loaded
     * @return true if the size of module layers are equivalent to the size of the loaded modules otherwise false
     */
    protected boolean verify(ModuleLayer layer, Set<String> modulesToLoad) {
        return layer.modules().size() == modulesToLoad.size();
    }

    private static ModuleLayer.Controller createLayer(
        Set<String> toLoad, Set<ModuleLayer> loaded, Set<String> parentNames, ClassLoader parentCL,
        ModuleFinder finder)
    {
        Set<ModuleLayer> parentLayers = new LinkedHashSet<>();
        Set<Configuration> parentConfigurations = new LinkedHashSet<>();

        for (ModuleLayer loadedLayer : loaded) {
            for (String parentLayerName : parentNames) {
                List<String> modulesInLoadedLayer = getModuleNamesInLayer(loadedLayer);
                if (modulesInLoadedLayer.contains(parentLayerName)
                    && !parentLayers.contains(loadedLayer)) {
                    parentLayers.add(loadedLayer);
                    parentConfigurations.add(loadedLayer.configuration());
                }
            }
        }

        if (parentConfigurations.isEmpty()) {
            // None of the required module layers could be located
            // TODO: How do we determine if >0 but not all layers could be located?
            throw new ModuleLayerCreationException(toLoad, parentNames, parentLayers);
        }

        Configuration newConfiguration = Configuration.resolve(
            ModuleFinder.of(), // No before-finder necessary, use empty finder
            new ArrayList<>(parentConfigurations), finder, toLoad);
        ModuleLayer.Controller control = ModuleLayer
            .defineModulesWithOneLoader(newConfiguration, new ArrayList<>(parentLayers), parentCL);

        ModuleLayer newLayer = control.layer();

        boolean rerun = false;
        for (Module m : newLayer.modules()) {
            // "," to determine next module
            String name = m.getName();
            for (ModuleLayer l : loaded) {
                if (l.modules().stream().map(Module::getName)
                    .anyMatch(module -> module.equals(name))) {
                    parentLayers.add(l);
                    parentConfigurations.add(l.configuration());
                    rerun = true;
                }
            }
        }

        if (rerun) {
            newConfiguration = Configuration.resolve(
                ModuleFinder.of(), // No before-finder necessary, use empty finder
                new ArrayList<>(parentConfigurations), finder, toLoad);
            control = ModuleLayer.defineModulesWithOneLoader(
                newConfiguration, new ArrayList<>(parentLayers), parentCL);
        }

        return control;
    }

    private static List<ComponentLayer> getParentComponents(
        ModuleLayer layer, List<ComponentLayer> from)
    {
        return from.stream().filter(c -> layer.parents().contains(c.getLayer()))
            .collect(Collectors.toList());
    }

    private static Set<ModuleReference> getModuleReferencesFrom(Set<Path> paths) {
        return paths.stream().map(AbstractLayerFactory::getModuleReferenceFromPath)
            .collect(Collectors.toUnmodifiableSet());
    }

    /**
     * Returns the reference to the module at the given path.
     *
     * @param  moduleJarPath a path to the modular JAR.
     * @return The reference to the module.
     */
    private static ModuleReference getModuleReferenceFromPath(Path moduleJarPath) {
        ModuleFinder jarModuleFinder = ModuleFinder.of(moduleJarPath);
        Set<ModuleReference> referencedModulesInJar = jarModuleFinder.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> modules) {
        final Set<String> moduleNames = getModuleNames(modules);
        return modules.parallelStream().map(ModuleReference::descriptor)
            .map(ModuleDescriptor::requires).flatMap(Set::stream)
            .filter(r -> !moduleNames.contains(r.name())).collect(Collectors.toUnmodifiableSet());
    }

    private static Set<String> getModuleNames(Set<ModuleReference> modules) {
        return modules.stream().map(ModuleReference::descriptor).map(ModuleDescriptor::name)
            .collect(Collectors.toUnmodifiableSet());
    }

    private static Set<String> getRequiredModuleNames(Set<ModuleDescriptor.Requires> requires) {
        Set<String> requiredModuleNames = new HashSet<>();
        for (ModuleDescriptor.Requires require : requires) {
            String requireString = require.name();
            boolean isStatic =
                require.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC);
            Supplier<Boolean> isInBoot =
                () -> ModuleLayer.boot().findModule(requireString).isPresent();

            if (!isStatic && !isInBoot.get()) {
                requiredModuleNames.add(requireString);
            }
        }

        return requiredModuleNames;
    }

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