/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.impl;

import java.io.IOException;
import java.lang.module.ModuleDescriptor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;

import org.apache.maven.api.Dependency;
import org.apache.maven.api.JavaPathType;
import org.apache.maven.api.Node;
import org.apache.maven.api.PathType;
import org.apache.maven.api.services.DependencyResolverException;
import org.apache.maven.api.services.DependencyResolverRequest;
import org.apache.maven.api.services.DependencyResolverResult;

/**
 * The result of collecting dependencies with a dependency resolver.
 * New instances are initially empty. Callers must populate with calls
 * to the following methods, in that order:
 *
 * <ul>
 *   <li>{@link #addOutputDirectory(Path, Path)} (optional)</li>
 *   <li>{@link #addDependency(Node, Dependency, Predicate, Path)}</li>
 * </ul>
 *
 * @see DefaultDependencyResolver#resolve(DependencyResolverRequest)
 */
public class DefaultDependencyResolverResult implements DependencyResolverResult {
    /**
     * The corresponding request.
     */
    private final DependencyResolverRequest request;

    /**
     * The exceptions that occurred while building the dependency graph.
     */
    private final List<Exception> exceptions;

    /**
     * The root node of the dependency graph.
     */
    private final Node root;

    /**
     * The ordered list of the flattened dependency nodes.
     */
    private final List<Node> nodes;

    /**
     * The file paths of all dependencies, regardless on which Java tool option those paths should be placed.
     */
    private final List<Path> paths;

    /**
     * The file paths of all dependencies, dispatched according the Java options where to place them.
     */
    private final Map<PathType, List<Path>> dispatchedPaths;

    /**
     * The dependencies together with the path to each dependency.
     */
    private final Map<Dependency, Path> dependencies;

    /**
     * Information about modules in the main output. This field is initially null and is set to a non-null
     * value when the output directories have been set, or when it is too late for setting them.
     */
    private PathModularization outputModules;

    /**
     * Cache of module information about each dependency.
     */
    private final PathModularizationCache cache;

    /**
     * Creates an initially empty result with a temporary cache.
     * Callers should add path elements by calls to {@link #addDependency(Node, Dependency, Predicate, Path)}.
     *
     * <p><b>WARNING: this constructor may be removed in a future Maven release.</b>
     * The reason is because {@code DefaultDependencyResolverResult} needs a cache, which should
     * preferably be session-wide. How to manage such caches has not yet been clarified.</p>
     *
     * @param request the corresponding request
     * @param exceptions the exceptions that occurred while building the dependency graph
     * @param root the root node of the dependency graph
     * @param count estimated number of dependencies
     */
    public DefaultDependencyResolverResult(
            DependencyResolverRequest request, List<Exception> exceptions, Node root, int count) {
        this(
                request,
                new PathModularizationCache(DefaultDependencyResolver.getTargetVersion(request)),
                exceptions,
                root,
                count);
    }

    /**
     * Creates an initially empty result. Callers should add path elements by calls
     * to {@link #addDependency(Node, Dependency, Predicate, Path)}.
     *
     * @param request the corresponding request
     * @param cache cache of module information about each dependency
     * @param exceptions the exceptions that occurred while building the dependency graph
     * @param root the root node of the dependency graph
     * @param count estimated number of dependencies
     */
    DefaultDependencyResolverResult(
            DependencyResolverRequest request,
            PathModularizationCache cache,
            List<Exception> exceptions,
            Node root,
            int count) {
        this.request = request;
        this.cache = Objects.requireNonNull(cache);
        this.exceptions = exceptions;
        this.root = root;
        nodes = new ArrayList<>(count);
        paths = new ArrayList<>(count);
        dispatchedPaths = new LinkedHashMap<>();
        dependencies = new LinkedHashMap<>(count + count / 3);
    }

    /**
     * Adds the given path element to the specified type of path.
     *
     * @param type the type of path (class-path, module-path, …)
     * @param path the path element to add
     */
    private void addPathElement(PathType type, Path path) {
        dispatchedPaths.computeIfAbsent(type, (t) -> new ArrayList<>()).add(path);
    }

    /**
     * Adds main and test output directories to the result. This method adds the main output directory
     * to the module path if it contains a {@code module-info.class}, or to the class path otherwise.
     * For the test output directory, the rules are more complex and are governed by the fact that
     * Java does not accept the placement of two modules of the same name on the module path.
     * So the modular test output directory usually needs to be placed in a {@code --path-module} option.
     *
     * <ul>
     *   <li>If the test output directory is modular, then:
     *     <ul>
     *       <li>If a test module name is identical to a main module name,
     *           place the test directory in a {@code --patch-module} option.</li>
     *       <li>Otherwise, place the test directory on the module path. However, this case
     *           (a module existing only in test output, not in main output) should be uncommon.</li>
     *     </ul>
     *   </li>
     *   <li>Otherwise (test output contains no module information), then:
     *     <ul>
     *       <li>If the main output is on the module path, place the test output
     *           on a {@code --patch-module} option.</li>
     *       <li>Otherwise (main output on the class path), place the test output on the class path too.</li>
     *     </ul>
     *   </li>
     * </ul>
     *
     * This method must be invoked before {@link #addDependency(Node, Dependency, Predicate, Path)}
     * if output directories are desired on the class path or module path.
     * This method can be invoked at most once.
     *
     * @param main the main output directory, or {@code null} if none
     * @param test the test output directory, or {@code null} if none
     * @throws IOException if an error occurred while reading module information
     *
     * TODO: this is currently not called. This is intended for use by Surefire and may move there.
     */
    void addOutputDirectory(Path main, Path test) throws IOException {
        if (outputModules != null) {
            throw new IllegalStateException("Output directories must be set first and only once.");
        }
        if (main != null) {
            outputModules = cache.getModuleInfo(main);
            addPathElement(outputModules.getPathType(), main);
        } else {
            outputModules = PathModularization.NONE;
        }
        if (test != null) {
            boolean addToClasspath = true;
            PathModularization testModules = cache.getModuleInfo(test);
            boolean isModuleHierarchy = outputModules.isModuleHierarchy || testModules.isModuleHierarchy;
            for (Object value : outputModules.descriptors.values()) {
                String moduleName = name(value);
                Path subdir = test;
                if (isModuleHierarchy) {
                    // If module hierarchy is used, the directory names shall be the module names.
                    Path path = test.resolve(moduleName);
                    if (!Files.isDirectory(path)) {
                        // Main module without tests. It is okay.
                        continue;
                    }
                    subdir = path;
                }
                // When the same module is found in main and test output, the latter is patching the former.
                addPathElement(JavaPathType.patchModule(moduleName), subdir);
                addToClasspath = false;
            }
            /*
             * If the test output directory provides some modules of its own, add them.
             * Except for this unusual case, tests should never be added to the module-path.
             */
            for (Map.Entry<Path, Object> entry : testModules.descriptors.entrySet()) {
                if (!outputModules.containsModule(name(entry.getValue()))) {
                    addPathElement(JavaPathType.MODULES, entry.getKey());
                    addToClasspath = false;
                }
            }
            if (addToClasspath) {
                addPathElement(JavaPathType.CLASSES, test);
            }
        }
    }

    /**
     * Adds a dependency node to the result.
     *
     * @param node the dependency node
     */
    void addNode(Node node) {
        nodes.add(node);
    }

    /**
     * Adds a dependency to the result. This method populates the {@link #nodes}, {@link #paths},
     * {@link #dispatchedPaths} and {@link #dependencies} collections with the given arguments.
     *
     * @param node the dependency node
     * @param dep the dependency for the given node, or {@code null} if none
     * @param filter filter the paths accepted by the tool which will consume the path.
     * @param path the path to the dependency, or {@code null} if the dependency was null
     * @throws IOException if an error occurred while reading module information
     */
    void addDependency(Node node, Dependency dep, Predicate<PathType> filter, Path path) throws IOException {
        nodes.add(node);
        if (dep == null) {
            return;
        }
        if (dependencies.put(dep, path) != null) {
            throw new IllegalStateException("Duplicated key: " + dep);
        }
        if (path == null) {
            return;
        }
        paths.add(path);
        /*
         * Dispatch the dependency to class path, module path, patch-module path, etc.
         * according the dependency properties. We need to process patch-module first,
         * because this type depends on whether a module of the same name has already
         * been added on the module-type.
         */
        // DependencyProperties properties = dep.getDependencyProperties();
        Set<PathType> pathTypes = dep.getType().getPathTypes();
        if (containsPatches(pathTypes)) {
            if (outputModules == null) {
                // For telling users that it is too late for setting the output directory.
                outputModules = PathModularization.NONE;
            }
            PathType type = null;
            for (Map.Entry<Path, Object> info :
                    cache.getModuleInfo(path).descriptors.entrySet()) {
                String moduleName = name(info.getValue());
                type = JavaPathType.patchModule(moduleName);
                if (!containsModule(moduleName)) {
                    /*
                     * Not patching an existing module. This case should be unusual. If it nevertheless
                     * happens, add to class path or module path if allowed, or keep patching otherwise.
                     * The latter case (keep patching) is okay if the main module will be defined later.
                     */
                    type = cache.selectPathType(pathTypes, filter, path).orElse(type);
                }
                addPathElement(type, info.getKey());
                // There is usually no more than one element, but nevertheless allow multi-modules.
            }
            /*
             * If the dependency has no module information, search for an artifact of the same groupId
             * and artifactId. If one is found, we are patching that module. If none is found, add the
             * dependency as a normal dependency.
             */
            if (type == null) {
                Path main = findArtifactPath(dep.getGroupId(), dep.getArtifactId());
                if (main != null) {
                    for (Map.Entry<Path, Object> info :
                            cache.getModuleInfo(main).descriptors.entrySet()) {
                        type = JavaPathType.patchModule(name(info.getValue()));
                        addPathElement(type, info.getKey());
                        // There is usually no more than one element, but nevertheless allow multi-modules.
                    }
                }
            }
            if (type != null) {
                return; // Dependency added, we are done.
            }
        }
        addPathElement(cache.selectPathType(pathTypes, filter, path).orElse(PathType.UNRESOLVED), path);
    }

    /**
     * Returns whether the given set of path types contains at least one patch for a module.
     */
    private boolean containsPatches(Set<PathType> types) {
        for (PathType type : types) {
            if (type instanceof JavaPathType.Modular modular) {
                type = modular.rawType();
            }
            if (JavaPathType.PATCH_MODULE.equals(type)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns whether at least one previously added modular dependency contains a module of the given name.
     *
     * @param moduleName name of the module to search
     */
    private boolean containsModule(String moduleName) throws IOException {
        for (Path path : dispatchedPaths.getOrDefault(JavaPathType.MODULES, Collections.emptyList())) {
            if (cache.getModuleInfo(path).containsModule(moduleName)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Searches an artifact of the given group and artifact identifiers, and returns its path
     *
     * @param group the group identifier to search
     * @param artifact the artifact identifier to search
     * @return path to the desired artifact, or {@code null} if not found
     */
    private Path findArtifactPath(String group, String artifact) throws IOException {
        for (Map.Entry<Dependency, Path> entry : dependencies.entrySet()) {
            Dependency dep = entry.getKey();
            if (group.equals(dep.getGroupId()) && artifact.equals(dep.getArtifactId())) {
                return entry.getValue();
            }
        }
        return null;
    }

    @Override
    public DependencyResolverRequest getRequest() {
        return request;
    }

    @Override
    public List<Exception> getExceptions() {
        return Collections.unmodifiableList(exceptions);
    }

    @Override
    public Node getRoot() {
        return root;
    }

    @Override
    public List<Node> getNodes() {
        return Collections.unmodifiableList(nodes);
    }

    @Override
    public List<Path> getPaths() {
        return Collections.unmodifiableList(paths);
    }

    @Override
    public Map<PathType, List<Path>> getDispatchedPaths() {
        return Collections.unmodifiableMap(dispatchedPaths);
    }

    @Override
    public Map<Dependency, Path> getDependencies() {
        return Collections.unmodifiableMap(dependencies);
    }

    @Override
    public Optional<ModuleDescriptor> getModuleDescriptor(Path dependency) throws IOException {
        Object value = cache.getModuleInfo(dependency).descriptors.get(dependency);
        return (value instanceof ModuleDescriptor moduleDescriptor) ? Optional.of(moduleDescriptor) : Optional.empty();
    }

    @Override
    public Optional<String> getModuleName(Path dependency) throws IOException {
        return Optional.ofNullable(
                name(cache.getModuleInfo(dependency).descriptors.get(dependency)));
    }

    /**
     * Returns the module name for the given value of the {@link PathModularization#descriptors} map.
     */
    private static String name(final Object value) {
        if (value instanceof String string) {
            return string;
        } else if (value instanceof ModuleDescriptor moduleDescriptor) {
            return moduleDescriptor.name();
        } else {
            return null;
        }
    }

    @Override
    public Optional<String> warningForFilenameBasedAutomodules() {
        try {
            return cache.warningForFilenameBasedAutomodules(dispatchedPaths.get(JavaPathType.MODULES));
        } catch (IOException e) {
            throw new DependencyResolverException("Cannot read module information.", e);
        }
    }
}
